2024年5月Redis 7.2.5发布时,我正负责一个金融级交易系统的技术选型。当时团队里有人提议直接用Valkey——毕竟Redis刚把许可证从BSD换成了Dual License(RSALv2/SSPLv1),而Valkey作为Linux Foundation旗下的开源分支,看起来更符合“自由”的预期。但我最终还是拍板用了Redis 7.2.5,原因在于我们当时的业务场景对“稳定性”和“功能完整性”的要求,远高于对许可证的顾虑。
那个交易系统日均处理120万笔订单,峰值QPS达到8.2万,缓存层需要支撑实时订单状态查询、用户资产快照和交易风控规则加载。我首先对比了两者的核心差异:Redis 7.2.5的RDB快照优化后,在16G内存实例上生成快照的耗时从之前的320ms降到了180ms,而Valkey 7.2.3(当时最新版)的RDB在同样配置下需要240ms——别小看这60ms的差距,我们的风控规则每30秒就要全量加载一次,快照期间的短暂阻塞可能导致风控计算延迟,进而影响交易成功率。
另一个关键点是Lua脚本的原子性保障。我们的订单状态更新逻辑用了Lua脚本实现“查询-修改-回写”的原子操作,比如用户下单时,需要同时扣减库存、更新订单状态、记录操作日志,这三步必须在一个原子单元内完成。Redis 7.2.5对Lua脚本的内存限制做了更精细的控制,默认允许脚本使用的最大内存从之前的100MB提升到了200MB,而Valkey的默认限制还是100MB。有一次我们处理批量订单导入(单次导入500条),脚本执行时因为临时变量占用超过了100MB,Valkey会直接返回“Lua script attempted to access a non-existent global variable”的错误,而Redis 7.2.5则能正常执行。
还有持久化机制的兼容性。我们的系统要求RDB和AOF同时开启,AOF采用everysec策略。Redis 7.2.5的AOF重写优化了内存碎片处理,重写后的AOF文件体积比之前版本小15%左右。我做过压测:用16线程并发写入10万条订单数据(每条数据包含订单ID、用户ID、金额、状态等字段,平均大小1.2KB),Redis 7.2.5的AOF文件最终大小是112MB,而Valkey 7.2.3是132MB。对于需要长期保存交易日志的场景,这20MB的差距会累积成明显的存储成本。
当然,我也考虑过Valkey的未来潜力,比如它宣称的“更激进的性能优化”。但我们的系统已经在生产环境跑了3年,每次升级都要经过完整的回归测试。Redis 7.2.5作为官方稳定版,社区里有大量的踩坑案例和解决方案,而Valkey作为新分支,遇到问题时能查到的资料少得多。比如有一次我们需要给缓存加一个基于TTL的自动过期策略,同时要求过期时能触发一个回调通知(用于清理关联的本地缓存),Redis 7.2.5可以通过修改redis.conf中的notify-keyspace-events配置实现,而Valkey当时的文档里还没有明确说明这个配置是否完全兼容。
最后提一下,2025年中Redis 8.0 RC版已经发布,切换了开源许可证,但我们的系统因为已经稳定运行在7.2.5上,暂时没有升级计划。如果现在新启动一个项目,我会根据是否需要向量搜索等新特性来决定——Redis 7.2.5已经原生支持Vector Similarity Search的预览版,而Valkey的向量搜索功能还在开发中。
下面是一个我们实际使用的Lua脚本示例,用于原子性更新订单状态:
这个脚本在我们系统里每天执行超过50万次,Redis 7.2.5的Lua引擎对这类短脚本的执行耗时稳定在0.2ms以内,完全满足交易系统的低延迟要求。
去年我接手了一个百万日活社区APP的后端重构,核心痛点就是Feed流接口的响应速度——原来的实现是直接查MySQL的user_post表,按时间倒序取前20条,接口平均耗时820ms,峰值时段甚至超过2秒,用户投诉“刷不到新内容”。我决定用Redis 7.2.5做缓存层,重构后的架构让接口耗时降到了120ms,QPS从原来的1.2万提升到了8.7万。
先讲我们的Feed流场景:用户关注了N个人,打开APP时看到的是所有关注者最近7天发布的帖子,按发布时间倒序排列。原来的问题是,每次请求都要做“关注列表查询→帖子ID聚合→帖子详情批量查询”三步,其中帖子详情存在post表,字段多(标题、内容、图片列表、点赞数、评论数等),单条数据大小约4KB,查20条就要80KB的数据传输量,MySQL的WHERE id IN (...)查询在ID数量超过1000时性能急剧下降。
我设计的缓存架构分三层:第一层是关注列表缓存,用Redis的Set结构存储,Key格式是follow:{user_id},Value是关注者的用户ID集合,TTL设为1小时(因为用户关注关系变更频率低);第二层是用户帖子ID时间线,用Sorted Set(ZSet)存储,Key格式是user_posts:{user_id},Score是帖子发布时间戳,Value是帖子ID,TTL设为7天(超过7天的帖子不再出现在Feed流);第三层是帖子详情缓存,用Hash结构存储,Key格式是post:{post_id},字段对应帖子的各个属性,TTL设为30分钟(帖子内容变更时会主动失效)。
Feed流生成的流程是这样的:用户请求时,先从follow:{user_id}获取关注列表(比如关注了500人),然后批量查询这500人的user_posts:{user_id} ZSet,用ZREVRANGEBYSCORE取出最近7天的帖子ID(每个人取前50条,避免数据量过大),再把所有帖子ID聚合后去重,按时间戳排序取前20条,最后从post:{post_id} Hash里批量获取详情。这里有个优化点:如果关注列表超过1000人,我们只取最近活跃的200个关注者(根据user_posts的最后更新时间判断),原因是社区数据显示,80%的用户实际只会浏览最近互动过的关注者的内容。
压测数据是最有说服力的。我用JMeter模拟了10万并发用户,持续压测30分钟,对比了重构前后的性能:
| 指标 | 重构前(MySQL直查) | 重构后(Redis缓存) | 提升幅度 |
|---------------------|---------------------|---------------------|----------|
| 接口平均耗时 | 820ms | 120ms | 85.4% |
| 峰值QPS | 1.2万 | 8.7万 | 625% |
| 数据库CPU占用 | 85%(峰值) | 12%(峰值) | 85.9% |
| 单次请求数据传输量 | 80KB | 22KB | 72.5% |
这里有个细节:为什么选ZSet而不是List存用户帖子ID?原因在于ZSet支持按时间戳范围查询(ZREVRANGEBYSCORE),而List只能按插入顺序取。比如用户想看“昨天18点到现在”的帖子,用ZSet可以直接指定Score范围,而List需要遍历所有元素再过滤,性能差很多。我们做过测试,用List实现同样的时间范围查询,耗时是ZSet的12倍。
下面是我们实际使用的ZSet查询代码(Java + Jedis客户端):
这段代码里有个需要注意的点:zrevrangeByScoreWithScores返回的Tuple包含了元素(帖子ID)和Score(发布时间戳),但我们后续排序时又重新查了一次ZSet的Score——原因是批量查询时如果关注者很多,先收集所有ID再统一查Score可以减少网络往返。后来我们优化成了用Redis的Pipeline批量查询,把网络开销降低了40%。
还有一个实际遇到的问题:Feed流里出现了重复帖子。排查下来发现,是因为用户取消关注又重新关注后,follow:{user_id} Set里的旧数据没有清理,导致重复查询了同一个关注者的帖子。解决方案是每次更新关注关系时,先删除follow:{user_id}缓存,再重新构建——虽然增加了一次缓存写入,但避免了数据不一致的问题。
今年3月,我们的电商促销系统突然出现Redis OOM(内存溢出)告警,当时Redis实例配置的是32G内存,平时占用稳定在24G左右,但告警时内存使用率突然飙到98%,导致部分缓存写入失败,订单详情页出现“数据加载失败”的提示。我花了3个小时排查,最终定位到是内存碎片和过期策略配置不当共同导致的。
先讲当时的场景:促销开始前1小时,我们预热缓存,把500万条商品详情数据写入Redis,用的是Hash结构,Key格式是sku:{sku_id},每个Hash存10个字段(价格、库存、标题、图片等)。预热完成后,内存占用从24G涨到了30G,看起来正常。但促销开始10分钟后,用户开始大量浏览商品,缓存命中率从99%掉到了82%,同时内存使用率继续上涨,直到触发OOM。
我第一步是看Redis的INFO memory命令输出,发现两个异常值:mem_fragmentation_ratio(内存碎片率)达到了1.82,而正常应该在1.0-1.5之间;evicted_keys(被淘汰的键数量)是0,说明没有触发内存淘汰策略。原因很清楚了:内存碎片太高,导致实际使用的内存(RSS)远大于Redis申请的内存(used_memory),而我们没有配置淘汰策略,所以新写入的数据没有空间,直接OOM。
接下来排查碎片率高的原因。我用redis-cli --bigkeys命令扫描了大Key,发现有个sku:10086的Hash占了12MB——后来查日志发现,是运营人员在预热时误把商品的“详情描述”字段(长度约10MB)也存进了缓存,而这个字段本来应该存在对象存储里的。大Key的存在会导致jemalloc分配内存时产生大量碎片,因为jemalloc是按固定大小的内存块(比如2MB、4MB)分配的,一个12MB的Key会占用多个内存块,剩下的空间无法被其他Key使用。
然后看过期策略配置。我们的redis.conf里maxmemory-policy设置的是noeviction(默认配置),意思是内存满了就拒绝写入,而不是淘汰旧数据。但促销期间,商品库存会实时变化,我们需要更新sku:{sku_id}里的库存字段,这时候如果内存满了,更新操作就会失败。另外,activedefrag配置是关闭的(默认关闭),导致碎片不会自动整理。
解决方案分三步:
第一步,紧急处理OOM。先临时把maxmemory-policy改成allkeys-lru,让Redis淘汰最近最少使用的键,释放空间。改完之后,内存使用率降到了85%,接口恢复正常。但这里有个问题:allkeys-lru会淘汰所有键,包括正在使用的商品缓存,所以我们马上写了个脚本,把被淘汰的热门商品缓存重新预热回去。
第二步,清理大Key。找到那个12MB的sku:10086,把“详情描述”字段从Hash里删除,改用对象存储的URL。删除后,这个Key的大小降到了0.8KB,同时内存碎片率降到了1.5。
第三步,优化内存配置。把activedefrag设为yes,并调整碎片整理阈值:active-defrag-ignore-bytes 100mb(碎片超过100MB才开始整理)、active-defrag-threshold-lower 10(碎片率超过10%开始整理)、active-defrag-cycle-min 5(整理占用CPU最小比例5%)、active-defrag-cycle-max 25(整理占用CPU最大比例25%)。同时把maxmemory设为30G(保留2G给操作系统),maxmemory-policy改为volatile-lru,只对设置了TTL的键做LRU淘汰——因为我们的商品缓存都设置了30分钟的TTL,这样不会影响没有TTL的分布式锁等其他缓存。
下面是当时用于监控内存碎片的脚本(Python实现),每分钟执行一次,超过阈值就告警:
这个脚本帮我们提前发现了好几次碎片率升高的问题。比如有一次运营又误存了一个大Key(商品的活动规则JSON,约8MB),脚本在1分钟内就检测到了碎片率升到1.7,我们马上清理了这个Key,避免了OOM再次发生。
最后总结一下经验:Redis的内存碎片问题不是突然出现的,而是随着大Key的产生、键的频繁过期删除慢慢积累的。我们的系统现在每周做一次主动碎片整理(activedefrag自动触发),同时用redis-cli --bigkeys每周扫描一次大Key,把碎片率稳定控制在1.3以内。另外,一定要根据业务场景配置maxmemory-policy,如果是缓存场景,不要用noeviction,否则内存满了就会直接报错——我们那次事故之后,就把所有缓存实例的策略都改成了volatile-lru,再也没有出现过类似的OOM问题。
去年双十一大促,我负责的一个商品详情页接口,平时稳定维持在 200ms 以内,突然在零点峰值流量下飙到了 2s 以上,Redis 监控显示 CPU 并没有跑满,但 MySQL 实例的 QPS 瞬间冲到了 8000+,直接报警。排查下来,问题出在一个热门秒杀商品(ID: 10086)的缓存突然失效,导致大量请求像脱缰的野马一样直接击穿缓存去查库。这次事故让我彻底重构了我们项目的缓存防御体系。
我们当时用的是 Redis 7.2.5(2024年5月发布版本),这个版本在内存管理和过期策略上已经比较成熟,但光靠默认配置是不够的。
针对上面那个 10086 商品的问题,核心在于单个热点 Key 过期瞬间的高并发回源。我现在的做法是使用 SETNX 加一个分布式锁,只有拿到锁的线程才能去查数据库并重建缓存。
为什么这么做? 如果不加锁,在缓存失效的瞬间,假设进来 5000 个请求,5000 个请求都会去查库。我们的 MySQL 实例当时最大连接数设置的是 2000,直接被打挂。加了锁之后,只有 1 个请求去查库,其余 4999 个在等待或重试,数据库压力瞬间降为 1。
有一次线上监控发现,有个接口的调用量突然暴增,但都是 404。排查发现有人在用脚本疯狂请求不存在的 ID(比如负数或超大的随机数)。这就是典型的缓存穿透。
我的解决方案是缓存空值 + 布隆过滤器(Bloom Filter)。
为什么不只缓存空值? 如果攻击方生成的是大量随机且不同的 ID,缓存空值会导致 Redis 内存被撑爆。引入布隆过滤器(我们当时用的是 Redis 的 RedisBloom 模块),在请求到达缓存之前先过滤掉肯定不存在的数据。虽然布隆过滤器有误判率(我们设置的是 0.01%),但对于这种场景完全够用。
2023年我接手过一个老项目,所有的配置类缓存都在凌晨 3 点过期,结果每天 3 点整都会有一个小的 DB 访问尖峰。这就是雪崩的雏形。
解决雪崩的核心思路是打散过期时间。在设置缓存时,我绝不会写死 jedis.setex(key, 3600, value),而是:
另外,对于特别核心的数据,我会采用逻辑过期策略,即缓存永不过期,但在 Value 中存入一个过期时间字段,由后台线程或查询时判断是否异步刷新。这能彻底解决雪崩问题,但代码复杂度会上升。
在 Redis 7.2.5 中,内存淘汰策略我通常设置为 volatile-lfu,因为它比 LRU 更能反映数据的真实热度。如果真的因为雪崩导致内存满了,LFU 能保证淘汰掉那些不常用的冷数据,而不是正在被高频访问的热点数据。
---
在做库存扣减时,很多人的第一反应是 DECR 或者先 GET 再 SET。去年我重构一个秒杀系统时,发现上一代代码里充斥着这种逻辑,导致经常出现超卖问题。
我们的场景是:一个热门商品,库存 1000 件,大促时 QPS 峰值预估在 1.5 万。如果使用 Java 代码控制库存,流程是:查 Redis 库存 -> 判断大于 0 -> 减 1 -> 写回 Redis。在高并发下,这 4 步操作不是原子的,会导致多个线程同时读到库存为 1,然后都扣减成功,最终库存变为 -N。
Redis 是单线程执行命令的,Lua 脚本在执行时会被当作一个整体,中间不会插入其他命令。这就是我选择 Lua 来实现库存扣减的根本原因。
我们当时使用的是 Redis 7.2.5,该版本对 Lua 脚本的复制和持久化支持已经非常完善。
这是我在生产环境中使用的库存扣减脚本:
在 Java 端调用这个脚本:
为什么不只用 DECR? 单纯的 DECR 无法做库存校验(比如库存 > 0 才能扣),也无法做幂等性控制。如果不做幂等,用户在弱网环境下点击了两次“提交订单”,可能会导致库存被扣两次。我在这里引入了 bizId(比如订单号或请求唯一ID),利用 Redis 的 Set 结构记录已处理的请求,解决了这个痛点。
前面提到的 SETNX 虽然简单,但在复杂的锁竞争场景下(比如锁自动过期导致的误删),还是需要用 Lua 来封装。我封装了一个标准的加锁和解锁工具类:
实际效果: 我们将这套 Lua 脚本方案上线后,在 12000 QPS 的压测下,库存数据始终保持精准一致,没有任何超卖现象。数据库层面的写入压力也被削峰填谷,从原本的瞬时 5000 写入降到了平稳的 200 写入(通过异步队列)。
目前我们也在关注 Redis 8.0 RC 中引入的查询引擎增强功能,未来或许可以直接在 Redis 侧实现更复杂的库存逻辑,而不必完全依赖 Lua 脚本。但在当下的生产环境(Redis 7.x),Lua 依然是处理这类高并发原子操作的唯一最优解。
去年双十一前,我接了个电商详情页的活。当时业务量涨得快,数据库CPU经常飙到90%,我第一反应就是上Redis扛压力。选了 Redis 7.2.5,因为当时Valkey刚分叉,生态和稳定性还没经过大规模验证,我不敢拿生产环境冒险。
我用了最简单的 Cache Aside 模式,读请求先打缓存,没命中再查库。上线前压测看着挺美,QPS直接翻了三倍。结果上线当天凌晨,运营推了个秒杀活动,流量瞬间打爆。我盯着监控,发现缓存命中率断崖式下跌,数据库还是被拖垮了。
排查了半小时才定位到问题:那是典型的 缓存击穿。运营推的那个爆款商品ID,因为热点数据过期,几万请求同时穿透到数据库。我当时脑子一热,直接给那个Key加了 逻辑过期 的兜底,并在代码里用 Redisson 做了分布式锁,只让一个线程去查库重建缓存,其他的在那等着。
折腾完这一出,我算是明白了:
* 别为了用而用:如果你的日活才几千,别折腾Redis集群,单机的MySQL可能比你瞎搞缓存还稳。
* 热点数据要特殊对待:像这种秒杀场景,热点Key的预热和永不过期策略比啥都重要。
* 一致性是玄学:我后来为了强一致性去搞双写,结果代码复杂度指数级上升,最后还是妥协成了最终一致性。
学Redis别光看命令,多去想想 内存淘汰策略 和 网络抖动 时你的程序会怎么疯。代码跑在本地和跑在生产环境,完全是两码事。别怕出问题,我那次之后,对缓存失效的防御代码写得比谁都溜。