Redis的Set操作居然能把我的服务整挂了?

文章来源声明: 原文作者:阿橙的百宝箱; 来源站点:掘金; 原文链接:https://juejin.cn/post/7684478794612752384; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

排查思路与压测数据齐备,适合后端与SRE团队自查Redis隐患,尤其提醒同步客户端在慢命令下的级联风险,可直接用于团队避坑清单。

"服务突然响应超时,监控大盘一片红,最后发现是Redis的`SADD`操作卡了5秒——你敢信?" 上周压测时,我们一个日均千万级请求的推荐服务突然出现间歇性超时。经过长达6小时的排查,发现罪魁祸首竟是一个看似无害的Redis Set操作。今天我就把这次踩坑的完整复盘分享给你。

现象:延迟毛刺与超时风暴

当时我们的场景是这样的:

  • 服务用Redis Set存储用户兴趣标签(每个用户约200-500个标签)
  • 核心路径会调用SADD user:123:tags "new_tag"更新标签
  • Redis集群版本5.x,单个Set平均大小300元素,最大不超过1k

压测时观察到:99%的请求都在2ms内完成,但总有1%的请求突然卡顿3-5秒。更诡异的是,这种卡顿会像传染病一样,导致整个服务线程池被占满,引发级联超时。

你可能会说:"Redis单命令不是号称10万QPS吗?Set插入能有多慢?" 这就是最反直觉的地方——问题根本不是出在Redis的处理能力上。

根因:协议解析遇上大Set

通过抓包和Redis慢日志,我们终于锁定了问题:

  • 当Set元素超过哈希表扩容阈值时,Redis会阻塞执行rehash*。而我们的客户端用的是"一发一收"的同步模式,在等待服务器返回时,整个连接会被卡住。

这里有个关键细节:Redis的Set底层用哈希表存储,当元素数超过ht[0].size且未触发渐进式rehash时,会强制完成扩容操作。对于包含512个元素的Set,扩容到1024需要:

  1. 分配新内存
  2. 迁移所有元素
  3. 释放旧内存

在我们的案例中,300元素的Set扩容平均耗时1.8ms,但极端情况下(如内存碎片化严重)会暴涨到5秒。更致命的是,Redis的单线程模型会让后续所有命令排队等待。

对照实验:错误写法 vs 正确写法

来看这段问题代码(Java + Jedis):

<span>// 错误写法:同步阻塞 + 大Set</span>
<span>public</span> <span>void</span> <span>addUserTag</span><span>(<span>long</span> userId, String tag)</span> {
    <span>try</span> (<span>Jedis</span> <span>jedis</span> <span>=</span> jedisPool.getResource()) {
        <span>// 当userTags的哈希表正在扩容时,整个连接会被阻塞</span>
        jedis.sadd(<span>"user:"</span> + userId + <span>":tags"</span>, tag);
    }
}

优化后的版本:

<span>// 正确写法:异步化 + 连接隔离</span>
<span>public</span> CompletableFuture<Void> <span>addUserTagAsync</span><span>(<span>long</span> userId, String tag)</span> {
    <span>return</span> CompletableFuture.runAsync(() -> {
        <span>try</span> (<span>Jedis</span> <span>jedis</span> <span>=</span> nonBlockingPool.getResource()) {
            <span>// 使用专门配置的非阻塞连接池</span>
            jedis.sadd(<span>"user:"</span> + userId + <span>":tags"</span>, tag);
        }
    }, asyncExecutor); <span>// 用独立线程池执行</span>
}

关键改进点:

  1. 为可能阻塞的操作分配独立连接池(nonBlockingPool
  2. 通过异步执行避免拖累主线程
  3. 使用client-output-buffer-limit防止异步堆积

性能对比与数据

我们在相同环境下的压测结果:

方案P99延迟超时请求占比CPU利用率
同步阻塞5200ms1.2%65%
异步+连接隔离15ms0%72%
预扩容(最优)8ms0%68%

最优解其实是预分配足够大的Set

<span># 在创建Key时预先设置足够大的容量</span>
redis-cli --<span>eval</span> prepopulate_set.lua user:123:tags 1024

- - prepopulate_set.lua
<span>local</span> key = KEYS[<span>1</span>]
<span>local</span> size = <span>tonumber</span>(ARGV[<span>1</span>])
<span>for</span> i=<span>1</span>,size <span>do</span>
    redis.call(<span>'SADD'</span>, key, <span>'__padding__'</span>..i)
<span>end</span>
redis.call(<span>'DEL'</span>, key)  <span>-- 清空预留空间</span>

避坑清单

  1. 连接隔离:将可能阻塞的命令(如KEYS、大Set操作)放到独立连接池
  2. 监控扩容事件:通过redis-cli --latency-history观察延迟周期峰值
  3. 预分配策略:提前创建足够大的数据结构,避免线上动态扩容
  4. 版本检查:Redis 6+对渐进式rehash有优化,但4.x版本尤其危险
  5. 警惕集合陷阱:不只是Set,Hash/ZSet在元素数超过hash-max-ziplist-entries时也会突变存储结构

结语

Redis的"慢操作"从来不只是那些明面上的KEYSFLUSHALL——真正的杀手往往藏在那些你以为绝对安全的日常操作里。下次当你看到监控曲线出现周期性毛刺时,不妨先查查是不是某个Set正在偷偷扩容。

你们团队遇到过哪些反直觉的Redis性能陷阱?欢迎在评论区分享你的 war story。