Skip to content

Redis 进阶工程实践问题

大 Key 问题

moreKey 问题

模拟 redis 中有 100 万条数据

shell
# 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 *flushdbflushall 等危险命令,避免误操作

shell
# redis.conf 配置文件

# 禁用 危险命令
rename-command flushall ""
rename-command keys ""
rename-command flushdb ""

禁用危险命令.png

使用 scan 命令遍历大量数据

shell
SCAN cursor [MATCH pattern] [COUNT count]

基于游标的迭代器,不保证每次执行都返回某个给定数量的元素,支持模糊查询,一次返回的数量不可控,大概率符合 count 参数

scan遍历.png

多大的 key 算大 key

大的内容其实不是 key 本身,而是 key 对应的 value

参考《阿里云 Redis 开发规范》

  • String 类型控制在 10KB 以内
  • hashlistsetzset 元素个数不要超过 5000

大 Key 的危害

  • 网络阻塞:读取大 key 占用高带宽
  • 慢查询:复杂结构的遍历操作非常耗时
  • 内存不均:拥有大 key 的集群节点会提前满载,影响负载均衡
  • 持久化故障AOFRDB 持久化时可能触发超时中断
  • 主从同步阻塞:大 key 写入或删除耗时过长,导致主从延迟

如何发现 大 Key

使用 redis-cli --bigkeys 命令可以查找大 key

shell
redis-cli -h 127.0.0.1 -p 6379 -a 123456 --bigkeys

大 key 排查.png

如何删除 大 Key

非字符串的 bigkey 不要使用 del 删除,使用 hscansscanzscan 方式渐进式删除

同时要防止 bigkey 过期时间自动删除问题 (例如一个 200 万的 zset 过期后,会触发 del 操作,造成阻塞,而且该操作不会出现在慢查询中)

最佳实践

redis 4.0 引入了 Lazy Free(惰性删除)异步删除机制,将耗时删除操作交给后台线程执行,避免阻塞主线程

shell
# redis.conf 配置文件
# 启用所有Lazy Free场景(生产环境推荐)
lazyfree-lazy-eviction yes    # 内存淘汰时异步删除
lazyfree-lazy-expire yes      # 过期Key异步删除
lazyfree-lazy-server-del yes  # 命令隐式删除时异步处理
replica-lazy-flush yes        # 从节点接受FLUSHALL时异步清空

缓存双写一致性

缓存双写一致性.png

当数据同时存在于 数据库(持久层) 和 **缓存(内存层)**时,两种存储的更新顺序或策略不当会导致:

  • 不一致问题:客户都安可能读取到过期的旧值
  • 数据污染问题:高并发情况下,错误顺序更新甚至会导致持久化数据错误

并发写冲突示例

text
时间点 | 操作线程A                  | 操作线程B
-----------------------------------------------------------
T1     | 写DB(订单金额 → 120)     | 
T2     |                           | 写DB(订单金额 → 80)
T3     | 更新缓存失败(网络抖动)     |
T4     |                           | 更新缓存成功(缓存=80)
T5     | 缓存自动重试更新 → 120      |

最终结果:

  • DB = 80 (B线程覆盖了A线程的DB写)
  • 缓存 = 120 (A线程的缓存重新覆盖了B线程的缓存写)
  • 数据永久不一致 💥

解决方案

延迟双删

  1. 线程 A 来就去删除缓存旧数据,避免旧数据被读取
  2. 线程 A 更新数据库
  3. 假如与此同时线程 B 又来更新数据库并回写缓存
  4. 线程 A 延迟一段时间后再去删除 B 回写缓存的数据

这样一来线程 A 延迟这段时间就得大于线程B更新数据库并回写缓存的时间,这个时间不好确定(看门狗监控程序就是为了解决这个问题)

延时双删只是减少了脏数据风险,在高并发场景下,仍然有可能在线程 B 回写完成和删除前这个时间段发生脏读情况。

即分布式的核心问题不保证强一致性,只保证最终一致性。

java
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 主从复制步骤

  1. 当 master 上的数据发生改变时,会将其改变写入到二进制事件日志文件中,也就是 binlog
  2. slave 会监听 binlog 文件,如果 binlog 发生改变会发送一个 I/O Thead 请求二进制事件日志
  3. 同时 master 为每个 I/O Thread 启动一个 dump Thread ,用于向其发送二进制事件日志
  4. slave 根据二进制事件日志,更新数据,使得数据和 master 一致

canal 工作原理

canal工作原理.png

  1. canal 模拟 Mysql 的 slave ,向 Mysql master 发送 dump 协议
  2. Mysql master 收到 dump 请求,开始推送 binlog 给 canal
  3. canal 解析 binlog 对象(byte流)

canal 监听 binlog 更新缓存伪代码

java
// 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 位的数组,能高效地插入和查询,占用空间少,但返回的结果具有不确定性

一个元素判断结果:

  • 存在时:元素不一定存在
  • 不存在时:元素一定不存在

布隆过滤器.png

obj1obj2hash 冲突,比如都存放在 1 3 5 中时,这个时候会误判 obj1obk2 都存在,哪怕实际只有一个存在

  • 布隆过滤器可以添加元素,但是不能删除元素,删掉元素会导致误判率增加
  • 使用时最好不要让实际元素数量远大于初始化数量,尽量一次给够容量,避免扩容
  • 当实际元素数量超过初始化数量时,应该对布隆过滤器进行重建,重新分配一个 size 更大的过滤器,再将所有的历史元素批量 add

缓存预热

Mysql 中有 1000 条基底数据,正常情况下第一个查询的用户会去查 mysql 然后回写给 redis

为了提升初次访问的用户体验可以提前将基底数据存入 redis ,这称为缓存预热

缓存雪崩

缓存雪崩大量缓存在同一时间过期或失效,导致大量请求同时发起到数据库,导致数据库压力过大被压垮

解决方法

  • 永不过期:热点数据设置为永不过期 或 随机过期(加上一个随机时间)
  • 熔断降级:Hystrix 或 Sentinel 限流降级,数据库压力过大时拒绝请求
  • 多级缓存:本地缓存 + 分布式缓存

缓存穿透

缓存穿透.png

缓存穿透指查询根本不存在的数据,数据永远都不会回写到缓存层,缓存形同虚设

解决方法

  • 回写增强:即使数据库查询为空,也回写一个缺省值(零 或 null 等)到缓存层
  • 布隆过滤器:布隆过滤器可以判断缓存中是否包含该元素(如果有,可能有;如果没有,一定没有)

缓存击穿

热点Key(单一)过期失效,导致大量请求直接发起到数据库,称为缓存击穿

解决方法

  • 永不过期:热点数据设置为永不过期
  • 互斥锁:当热点 Key 过期时,只允许一个线程去查询数据库,然后回写回缓存,其他线程就又可以继续从缓存查询数据了
  • 热点预热:在 Redis 访问高峰期,提前调整热点 Key 的过期时间

Redis 分布式锁

Lua 脚本

Lua (葡萄牙语月亮的意思)是一种轻量级、高性能的嵌入式脚本语言。其设计目标是 简洁性可嵌入性高效率 ,常用于对性能要求较高的场景。

Redis 调用 Lua 脚本通过 eval 命令保证代码执行的原子性,直接用 return 返回脚本执行结果

使用案例

Lua 脚本将三个操作拼接为一个原子操作

shell
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 次红包

拆分算法

二倍均值法保证了每个人抢到的金额概率是相等的,与先后顺序没有关系

text
金额 = 随机区间(0,(剩余红包金额 M / 剩余人数) * 2)

假如有 10 个人,红包总额 100

第一次 100 / 10 * 2 = 20 ,第一个人的随机范围为 (0,20),平均可以抢到 10 元,假设第一个人抢到 10 元
第二次 90 / 9 * 2 = 20 ,第二个人的随机范围为 (0,20),平均可以抢到 10 元
...