Redis 进阶工程实践问题
大 Key 问题
moreKey 问题
模拟 redis 中有 100 万条数据
# shell 脚本
for (( i = 0; i <= 100 * 10000; i++ )); do
echo "set k$i v$i" >> /tmp/redisTest.txt;
done;
# 使用 pipe 管道批量插入 100 万数据
cat /tmp/redisTest.txt | redis-cli -h 127.0.0.1 -p 6379 -a 123456 --pipe
如何遍历 100 万条数据,可以用 keys *
吗
不可以!💥 keys *
遍历大量数据会耗时非常久,导致线程阻塞
生产上配置进行禁用 keys *
、 flushdb
、 flushall
等危险命令,避免误操作
# redis.conf 配置文件
# 禁用 危险命令
rename-command flushall ""
rename-command keys ""
rename-command flushdb ""
使用 scan
命令遍历大量数据
SCAN cursor [MATCH pattern] [COUNT count]
基于游标的迭代器,不保证每次执行都返回某个给定数量的元素,支持模糊查询,一次返回的数量不可控,大概率符合 count
参数
多大的 key 算大 key
大的内容其实不是 key
本身,而是 key
对应的 value
参考《阿里云 Redis 开发规范》
String
类型控制在 10KB 以内hash
、list
、set
、zset
元素个数不要超过 5000
大 Key 的危害
- 网络阻塞:读取大 key 占用高带宽
- 慢查询:复杂结构的遍历操作非常耗时
- 内存不均:拥有大 key 的集群节点会提前满载,影响负载均衡
- 持久化故障:
AOF
或RDB
持久化时可能触发超时中断 - 主从同步阻塞:大 key 写入或删除耗时过长,导致主从延迟
如何发现 大 Key
使用 redis-cli --bigkeys
命令可以查找大 key
redis-cli -h 127.0.0.1 -p 6379 -a 123456 --bigkeys
如何删除 大 Key
非字符串的 bigkey
不要使用 del
删除,使用 hscan
、sscan
、zscan
方式渐进式删除
同时要防止 bigkey
过期时间自动删除问题 (例如一个 200 万的 zset
过期后,会触发 del
操作,造成阻塞,而且该操作不会出现在慢查询中)
最佳实践
redis 4.0 引入了 Lazy Free(惰性删除)异步删除机制,将耗时删除操作交给后台线程执行,避免阻塞主线程
# redis.conf 配置文件
# 启用所有Lazy Free场景(生产环境推荐)
lazyfree-lazy-eviction yes # 内存淘汰时异步删除
lazyfree-lazy-expire yes # 过期Key异步删除
lazyfree-lazy-server-del yes # 命令隐式删除时异步处理
replica-lazy-flush yes # 从节点接受FLUSHALL时异步清空
缓存双写一致性
当数据同时存在于 数据库(持久层) 和 **缓存(内存层)**时,两种存储的更新顺序或策略不当会导致:
- 不一致问题:客户都安可能读取到过期的旧值
- 数据污染问题:高并发情况下,错误顺序更新甚至会导致持久化数据错误
并发写冲突示例
时间点 | 操作线程A | 操作线程B
-----------------------------------------------------------
T1 | 写DB(订单金额 → 120) |
T2 | | 写DB(订单金额 → 80)
T3 | 更新缓存失败(网络抖动) |
T4 | | 更新缓存成功(缓存=80)
T5 | 缓存自动重试更新 → 120 |
最终结果:
- DB = 80 (B线程覆盖了A线程的DB写)
- 缓存 = 120 (A线程的缓存重新覆盖了B线程的缓存写)
- 数据永久不一致 💥
解决方案
延迟双删
- 线程 A 来就去删除缓存旧数据,避免旧数据被读取
- 线程 A 更新数据库
- 假如与此同时线程 B 又来更新数据库并回写缓存
- 线程 A 延迟一段时间后再去删除 B 回写缓存的数据
这样一来线程 A 延迟这段时间就得大于线程B更新数据库并回写缓存的时间,这个时间不好确定(看门狗监控程序就是为了解决这个问题)
延时双删只是减少了脏数据风险,在高并发场景下,仍然有可能在线程 B 回写完成和删除前这个时间段发生脏读情况。
即分布式的核心问题不保证强一致性,只保证最终一致性。
public void updateUserWithDelayDelete(User user) {
// Step 0. 第一次删除(防止旧数据被读取)
redis.del("user:" + user.getId());
// Step 1. 更新数据库
userDAO.update(user);
// Step 2. 延迟删除
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(500); // 可动态调整睡眠时间
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
redis.del("user:" + user.getId());
}, retryExecutor);
}
canal 监听 Mysql 更新缓存
Mysql 主从复制步骤
- 当 master 上的数据发生改变时,会将其改变写入到二进制事件日志文件中,也就是 binlog
- slave 会监听 binlog 文件,如果 binlog 发生改变会发送一个 I/O Thead 请求二进制事件日志
- 同时 master 为每个 I/O Thread 启动一个 dump Thread ,用于向其发送二进制事件日志
- slave 根据二进制事件日志,更新数据,使得数据和 master 一致
canal 工作原理
- canal 模拟 Mysql 的 slave ,向 Mysql master 发送 dump 协议
- Mysql master 收到 dump 请求,开始推送 binlog 给 canal
- canal 解析 binlog 对象(byte流)
canal 监听 binlog 更新缓存伪代码
// Canal监听数据库变更事件(伪代码)
@CanalEventListener
public class BinlogListener {
private RedisClient redis;
// 监听用户表更新事件
@ListenPoint(
table = {"user_table"},
eventType = {EventType.UPDATE}
)
public void onUserUpdate(EventData eventData) {
Map<String, String> afterData = eventData.getAfterColumnsMap();
String userId = afterData.get("id");
// 异步更新缓存(例如放入消息队列)
mqProducer.send("cache_update_queue", "user:" + userId);
}
}
布隆过滤器
布隆过滤器是 bit 位的数组,能高效地插入和查询,占用空间少,但返回的结果具有不确定性
一个元素判断结果:
- 存在时:元素不一定存在
- 不存在时:元素一定不存在
当 obj1
和 obj2
的 hash
冲突,比如都存放在 1 3 5
中时,这个时候会误判 obj1
和 obk2
都存在,哪怕实际只有一个存在
- 布隆过滤器可以添加元素,但是不能删除元素,删掉元素会导致误判率增加
- 使用时最好不要让实际元素数量远大于初始化数量,尽量一次给够容量,避免扩容
- 当实际元素数量超过初始化数量时,应该对布隆过滤器进行重建,重新分配一个 size 更大的过滤器,再将所有的历史元素批量 add
缓存预热
Mysql 中有 1000 条基底数据,正常情况下第一个查询的用户会去查 mysql 然后回写给 redis
为了提升初次访问的用户体验可以提前将基底数据存入 redis ,这称为缓存预热
缓存雪崩
缓存雪崩指大量缓存在同一时间过期或失效,导致大量请求同时发起到数据库,导致数据库压力过大被压垮
解决方法:
- 永不过期:热点数据设置为永不过期 或 随机过期(加上一个随机时间)
- 熔断降级:Hystrix 或 Sentinel 限流降级,数据库压力过大时拒绝请求
- 多级缓存:本地缓存 + 分布式缓存
缓存穿透
缓存穿透指查询根本不存在的数据,数据永远都不会回写到缓存层,缓存形同虚设
解决方法:
- 回写增强:即使数据库查询为空,也回写一个缺省值(零 或 null 等)到缓存层
- 布隆过滤器:布隆过滤器可以判断缓存中是否包含该元素(如果有,可能有;如果没有,一定没有)
缓存击穿
热点Key(单一)过期失效,导致大量请求直接发起到数据库,称为缓存击穿
解决方法:
- 永不过期:热点数据设置为永不过期
- 互斥锁:当热点 Key 过期时,只允许一个线程去查询数据库,然后回写回缓存,其他线程就又可以继续从缓存查询数据了
- 热点预热:在 Redis 访问高峰期,提前调整热点 Key 的过期时间
Redis 分布式锁
Lua 脚本
Lua (葡萄牙语月亮
的意思)是一种轻量级、高性能的嵌入式脚本语言。其设计目标是 简洁性、可嵌入性 和 高效率 ,常用于对性能要求较高的场景。
Redis 调用 Lua 脚本通过 eval
命令保证代码执行的原子性,直接用 return
返回脚本执行结果
使用案例
Lua 脚本将三个操作拼接为一个原子操作
EVAL "脚本内容" [keys数量] [keys...] [argv...]
# 示例:实现原子性递增并设置过期时间
eval "
local current = redis.call('get', KEYS[1])
current = tonumber(current) or 0
current = current + tonumber(ARGV[1])
redis.call('set', KEYS[1], current)
redis.call('expire', KEYS[1], ARGV[2])
return current
" 1 counter 5 60
简易版微信抢红包案例
架构分析
- 发红包:
- 抢红包:类似减少库存操作,高并发场景下不加锁又要保证原子性
- 记红包:记录红包信息,汇总并且保证同一个用户不可以抢夺 2 次红包
拆分算法
二倍均值法保证了每个人抢到的金额概率是相等的,与先后顺序没有关系
金额 = 随机区间(0,(剩余红包金额 M / 剩余人数) * 2)
假如有 10 个人,红包总额 100
第一次 100 / 10 * 2 = 20 ,第一个人的随机范围为 (0,20),平均可以抢到 10 元,假设第一个人抢到 10 元
第二次 90 / 9 * 2 = 20 ,第二个人的随机范围为 (0,20),平均可以抢到 10 元
...