Redis与MySQL的数据一致性
在实际项目中,一般用MySQL来做数据持久化,用Redis来做数据缓存。 由于Redis和MySQL的读写性能差异较大(Redis的QPS在几万上下,MySQL的QPS在几百上下),故Redis与MySQL之间会存在数据不一致问题,一般需要确保两者数据的最终一致性。 以下是四种更新策略及其存在的问题:
- 先更新数据库再更新缓存:多线程场景下,更新数据库和redis的顺序不一致,导致redis中存在脏数据,不使用。
- 先更新缓存再更新数据库:同样有多线程更新顺序问题,不使用。
- 先删除缓存再更新数据库:Redis中仍然可能存在脏数据。A线程先删除了缓存再去更新数据库,还未更新完,B线程来读取数据,redis是空的,直接到数据库中读取到旧值,又把旧值写回缓存,等A线程更新完,redis和mysql中的数据不一致。本质是数据库更新速度慢,而redis删除速度快。
- 先更新数据库再删除缓存:也存在更新数据库成功但是删除缓存失败的问题。
一般使用"先删除缓存再更新数据库"或"先更新数据库再删除缓存"策略。
-
对于先删除缓存再更新数据库的问题,有以下方式解决:
- 延时双删
- 更新数据库前先删除一次缓存,等更新完后延迟一段时间再删除一次缓存(把可能存在的脏数据删除)。
- 但是删除时间比较难确定,需要确保更新期间的读请求已经完成(包括将旧数据重新加载到缓存中)。
- 另外,即使使用了延时双删,如果mysql使用主从架构,主从同步还会有一定的延时,线程A删除redis缓存后去更新主库,然后又删除redis,线程B到来读取不到redis去读从库,读到的还是旧数据,更新缓存还是脏数据。这本质上是主从延迟的问题,解决方法是遇到要填充redis的操作,强制去主库读。
def update_data(key, new_value): // 第一次删除缓存 cache.delete(key) // 更新数据库 db.update(key, new_value) // 延时(如 500 毫秒) sleep(500) // 第二次删除缓存 cache.delete(key) - 更新与读取操作异步串行化
- redis中产生脏数据本质是MySQL更新操作和Redis写回操作是异步的,所以可以想办法将其变为同步。
- 维护一些队列,更新数据时,把MySQL更新操作放到队列中,读取数据时,若发现缓存中无数据,但是队列中有更新数据操作,则将读取数据操作发送到队列中,读请求在写请求后面,需要等数据更新完成再去读取数据。另外如果请求等待较长时间还没有执行,则直接返回旧值。
- 延时双删
-
对于先更新数据库再删除缓存的问题,有以下方式解决;
- 使用消息队列进行删除的补偿,将redis中需要删除的key作为消息体发送到消息队列,重试删除操作。
- 直接用cannel订阅binlog获取数据库操作再执行删除逻辑(重试直到成功),以减少对业务代码的侵入。
大key问题
bigkey是指key对应的value很大,例如String的值大于10kb,list等集合的元素大于5000。 bigkey会导致主线程阻塞,网络带宽压力,内存分布不均,所以需要找出bigkey并拆分。
// 查找bigkey
redis-cli --bigkeys // 该命令统计每个数据类型的最大key,对于集合类型只能统计元素个数而不是占用内存
// 查找bigkey
SCAN 0 MATCH * COUNT 100 // 使用SCAN命令遍历所有key
MEMORY USAGE key_name // 对SCAN返回的key使用MEMORY命令统计大小
// SCAN为渐进式遍历命令,避免阻塞,使用格式为SCAN 游标 MATCH 匹配模式 COUNT 返回数量
// 也可以使用Redis监控工具查找bigkey
// 删除bigkey
定时任务+SCAN命令 // 定期扫描
unlink key_name // 异步删除清理bigkey
// 对bigkey进行拆分
对string类型的数据使用序列化对其压缩
对集合类型数据进行分片
热key问题
短时间内被频繁访问的key就是热key,例如QPS/带宽使用率/CPU使用率集中在特定的key上。 热key会造成redis集群流量不均衡。
- 处理热key关键是对热key的监控
- 使用redis-cli monitor命令统计热key。
- 使用专门监控热key的中间件。
- 找到热key后
- 给热key加上前后缀,将其打散到不同的服务器,降低压力。
- 将热key加入本地缓存,如JVM缓存。
- 另外还存在热key重建问题
- 热key失效时有大量线程来重建缓存,且重建缓存是一个高消耗操作,这会导致服务器压力飙升。
- 可以使用互斥锁,仅允许一个线程重建缓存。
- 热key可以不设置过期时间或快要过期时自动续期。
缓存穿透
大量不存在的key查询,未命中缓存,全部打到数据库上。 与缓存击穿的区别是缓存穿透的key在redis中是不存在的。 解决方案如下:
- 对不存在的key也做缓存,设置较短过期时间,查询这个key时直接返回null,但若这些key是随机的,则缓存也没用。
- 使用布隆过滤器。数据写入数据库时使用布隆过滤器做个标记,当缓存缺失后要查询数据库时,先查询布隆过滤器快速判断数据是否存在数据库中,存在才去查询。这样即使有大量不存在的key请求,也只会打到Redis和布隆过滤器上,不会影响数据库。布隆过滤器使用redis实现,本身就能承担较大的并发访问压力。
- 布隆过滤器由一个bit数组和多个哈希函数组成,用于快速判断集合中是否存在某个元素。
- 元素到来时将其hash到数组多个位置上,bit数组上对应位置值改为1。
- 当要检查某元素是否存在时,检查其hash到的所有位置,若所有位置都为1说明该元素极大可能存在(也可能刚好这些位置是被其他元素映射的,这是误差所在);若其中一个位置为0说明该元素绝对不存在。
- 布隆过滤器优点在于可高效地插入和查询,占用空间少;缺点在于存在误差,且无法删除。因为删除元素会增大误差,因为可能bit数组中一个位置代表了多个元素映射),另外使用布隆过滤器时尽量一次给够空间,避免扩容。
缓存击穿
大量请求查询热点key,但是这个key刚好失效,导致请求全部打到数据库上。
- 对热点key不设置过期时间,或者每次要过期了就去续期。
- 双检加锁:多个线程查询数据库时,让它们竞争锁,第一个拿到锁的线程去数据库查到数据,做好redis缓存后释放锁,后续请求拿到锁后走redis缓存。双检加锁策略在加锁前查询一次redis,加锁后再查询一次redis。
// 双检加锁
public String getData(String key) {
// 1. 第一次检查:从缓存中获取数据
String value = jedis.get(key);
if (value != null) {
return value;
}
// 2. 缓存未命中,尝试获取锁
String lockValue = UUID.randomUUID().toString();
boolean locked = jedis.setnx(LOCK_KEY, lockValue) == 1;
if (locked) {
// 设置锁的过期时间
jedis.expire(LOCK_KEY, LOCK_EXPIRE);
try {
// 3. 第二次检查:再次从缓存中获取数据
value = jedis.get(key);
if (value == null) {
// 4. 从数据库中获取数据
value = fetchDataFromDB(key);
// 5. 将数据写入缓存
if (value != null) {
jedis.setex(CACHE_KEY, CACHE_EXPIRE, value);
}
}
} finally {
// 6. 释放锁
if (lockValue.equals(jedis.get(LOCK_KEY))) {
jedis.del(LOCK_KEY);
}
}
} else {
// 7. 未获取到锁,等待并重试
try {
Thread.sleep(100); // 等待 100ms
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getData(key); // 重试
}
return value;
}
缓存雪崩
大量key同时过期,导致大量请求打到数据库上。
- 将key的过期时间错开,让缓存失效时间尽量均匀。
- 设置分级缓存,例如Redis作为主缓存,Guava Cache作为本地缓存,若从主缓存获取数据失败则尝试从本地缓存获取数据。
也可能是redis宕机,导致请求全打在数据库上。
- 主从+哨兵或者redis集群避免redis全盘崩溃。
- 发生雪崩时可以使用互斥锁或队列来控制访问线程数量,或启动熔断机制,限流机制,确保数据库不会被打爆。
缓存预热
系统上线后提前将相关缓存数据直接加载到缓存系统,避免用户首次查询走数据库。
多级缓存
一般Redis作为分布式缓存,Caffeine或Guava Cache作为本地缓存。 对于访问频繁但不经常更改的数据放到本地缓存中,对于需要共享或一致性要求较高的数据放在redis中。 但是分布式缓存和本地缓存之间也要确保数据一致性:
- 本地缓存设置过期时间,过期后去找redis同步。
- 使用redis的发布订阅机制,本地缓存订阅redis,redis数据变化时本地缓存进行同步。
- 引入消息队列,redis数据变化时通过消息队列通知本地缓存。
redis阻塞排查
- API或数据结构使用不合理:找出慢查询语句,修改操作(如阻塞式命令)或数据结构,将大对象拆分成小对象(删除bigkey)。
- CPU饱和问题: 判断Redis的并发量是否到达极限,如果QPS到达几万了,应该做水平扩展来分摊压力,如果QPS较小,应该检查命令的执行或内存的使用情况。
- 持久化相关的阻塞
- fork阻塞:生成RDB文件或重写AOF文件时主线程都fork了子线程,若fork操作本身耗时较长可能导致主线程阻塞。
- AOF刷盘阻塞:命令从缓冲区刷到磁盘时,若磁盘压力较大则需要等待,如果主线程发现缓存刷盘了很久还没成功,为了数据安全它会阻塞直到刷盘完成。
redis变慢排查
- 使用了复杂度较高的命令或阻塞命令或一次查询大量数据。
- 操作了bigkey。
- 内存不足,redis经常需要到磁盘交换区中读取数据,此时增加redis可用内存或增加机器。
- redis中存在大量数据并且执行了生产RDB快照或AOF重写操作,fork子进程时耗时严重。
- AOF设置了always刷盘策略。
- 数据量太大,网卡压力大。
如何往redis中插入大量数据
使用管道。管道就是redis一次性获取并执行多条命令并将结果一次性返回给客户端,减少客户端与服务端的网络调用次数,减少读取数据时OS的上下文切换。redis提供三种将客户端多条命令打包给服务端执行的方式:管道,事务,Lua脚本。