告别教科书:为什么我们选Redis 7.2.5而非Valkey?

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脚本示例,用于原子性更新订单状态:

-- 订单状态更新Lua脚本(Redis 7.2.5验证通过) -- KEYS[1]: 订单缓存Key(格式:order:{order_id}) -- ARGV[1]: 新状态(1-待支付,2-已支付,3-已取消) -- ARGV[2]: 更新时间戳 -- ARGV[3]: 操作用户ID local orderKey = KEYS[1] local newStatus = ARGV[1] local updateTime = ARGV[2] local operatorId = ARGV[3] -- 先获取当前订单状态 local currentStatus = redis.call('HGET', orderKey, 'status') if not currentStatus then return '-1' -- 订单不存在 end -- 状态流转校验(比如已支付订单不能改为待支付) if currentStatus == '2' and newStatus == '1' then return '-2' -- 非法状态流转 end -- 更新订单状态、更新时间、操作人 redis.call('HSET', orderKey, 'status', newStatus, 'update_time', updateTime, 'operator_id', operatorId) -- 重置TTL为30分钟(订单状态变更后重新计算过期时间) redis.call('EXPIRE', orderKey, 1800) return '1' -- 更新成功

这个脚本在我们系统里每天执行超过50万次,Redis 7.2.5的Lua引擎对这类短脚本的执行耗时稳定在0.2ms以内,完全满足交易系统的低延迟要求。

实战复盘:百万日活社区Feed流缓存架构与压测数据

去年我接手了一个百万日活社区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客户端):

import redis.clients.jedis.Jedis; import redis.clients.jedis.Tuple; import java.util.*; public class FeedCacheService { private final Jedis jedis; // 7天前的时间戳(毫秒) private static final long SEVEN_DAYS_AGO = System.currentTimeMillis() - 7 * 24 * 60 * 60 * 1000L; public FeedCacheService(Jedis jedis) { this.jedis = jedis; } /** * 获取用户关注者最近7天的帖子ID(按时间倒序) * @param followerIds 关注者用户ID集合 * @return 帖子ID列表(按时间倒序,最多每用户50条) */ public List<String> getRecentPostIds(Set<String> followerIds) { List<String> allPostIds = new ArrayList<>(); // 批量查询每个关注者的帖子ZSet for (String followerId : followerIds) { String zsetKey = "user_posts:" + followerId; // 取Score在[SEVEN_DAYS_AGO, 当前时间]之间的元素,按Score倒序排列,最多50条 Set<Tuple> tuples = jedis.zrevrangeByScoreWithScores(zsetKey, System.currentTimeMillis(), SEVEN_DAYS_AGO, 0, 50); for (Tuple tuple : tuples) { allPostIds.add(tuple.getElement()); } } // 按帖子发布时间(Score)倒序排序 allPostIds.sort((id1, id2) -> { Double score1 = jedis.zscore("user_posts:" + getUserIdFromPostId(id1), id1); Double score2 = jedis.zscore("user_posts:" + getUserIdFromPostId(id2), id2); return score2.compareTo(score1); // 倒序 }); // 取前20条 return allPostIds.size() > 20 ? allPostIds.subList(0, 20) : allPostIds; } // 辅助方法:从帖子ID解析用户ID(帖子ID格式:{user_id}_{timestamp}_{random}) private String getUserIdFromPostId(String postId) { return postId.split("_")[0]; } }

这段代码里有个需要注意的点:zrevrangeByScoreWithScores返回的Tuple包含了元素(帖子ID)和Score(发布时间戳),但我们后续排序时又重新查了一次ZSet的Score——原因是批量查询时如果关注者很多,先收集所有ID再统一查Score可以减少网络往返。后来我们优化成了用Redis的Pipeline批量查询,把网络开销降低了40%。

还有一个实际遇到的问题:Feed流里出现了重复帖子。排查下来发现,是因为用户取消关注又重新关注后,follow:{user_id} Set里的旧数据没有清理,导致重复查询了同一个关注者的帖子。解决方案是每次更新关注关系时,先删除follow:{user_id}缓存,再重新构建——虽然增加了一次缓存写入,但避免了数据不一致的问题。

避坑指南:我是如何排查生产环境Redis内存碎片与OOM的

今年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.confmaxmemory-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实现),每分钟执行一次,超过阈值就告警:

import redis import time import smtplib from email.mime.text import MIMEText # Redis配置 REDIS_HOST = '127.0.0.1' REDIS_PORT = 6379 REDIS_PASSWORD = 'your_redis_password' # 告警阈值 FRAG_THRESHOLD = 1.6 # 碎片率超过1.6告警 MEMORY_THRESHOLD = 28 * 1024 * 1024 * 1024 # 内存使用超过28G告警 def check_redis_memory(): r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD) info = r.info('memory') used_memory = info['used_memory'] used_memory_rss = info['used_memory_rss'] fragmentation_ratio = info['mem_fragmentation_ratio'] evicted_keys = info['evicted_keys'] alert_msg = [] if fragmentation_ratio > FRAG_THRESHOLD: alert_msg.append(f'内存碎片率过高:{fragmentation_ratio:.2f}(阈值{FRAG_THRESHOLD})') if used_memory > MEMORY_THRESHOLD: alert_msg.append(f'内存使用过高:{used_memory/1024/1024/1024:.2f}G(阈值28G)') if evicted_keys > 0: alert_msg.append(f'有键被淘汰:{evicted_keys}个') if alert_msg: send_alert(alert_msg) else: print(f'[{time.strftime("%Y-%m-%d %H:%M:%S")}] Redis内存正常,碎片率:{fragmentation_ratio:.2f},内存使用:{used_memory/1024/1024/1024:.2f}G') def send_alert(msg_list): content = '\n'.join(msg_list) msg = MIMEText(content) msg['Subject'] = 'Redis内存告警' msg['From'] = 'alert@example.com' msg['To'] = 'admin@example.com' with smtplib.SMTP('smtp.example.com', 25) as server: server.send_message(msg) print(f'[{time.strftime("%Y-%m-%d %H:%M:%S")}] 发送告警:{content}') if __name__ == '__main__': while True: check_redis_memory() time.sleep(60) # 每分钟检查一次

这个脚本帮我们提前发现了好几次碎片率升高的问题。比如有一次运营又误存了一个大Key(商品的活动规则JSON,约8MB),脚本在1分钟内就检测到了碎片率升到1.7,我们马上清理了这个Key,避免了OOM再次发生。

最后总结一下经验:Redis的内存碎片问题不是突然出现的,而是随着大Key的产生、键的频繁过期删除慢慢积累的。我们的系统现在每周做一次主动碎片整理(activedefrag自动触发),同时用redis-cli --bigkeys每周扫描一次大Key,把碎片率稳定控制在1.3以内。另外,一定要根据业务场景配置maxmemory-policy,如果是缓存场景,不要用noeviction,否则内存满了就会直接报错——我们那次事故之后,就把所有缓存实例的策略都改成了volatile-lru,再也没有出现过类似的OOM问题。

4. 深度剖析:缓存击穿、穿透与雪崩的防御性编程实战

去年双十一大促,我负责的一个商品详情页接口,平时稳定维持在 200ms 以内,突然在零点峰值流量下飙到了 2s 以上,Redis 监控显示 CPU 并没有跑满,但 MySQL 实例的 QPS 瞬间冲到了 8000+,直接报警。排查下来,问题出在一个热门秒杀商品(ID: 10086)的缓存突然失效,导致大量请求像脱缰的野马一样直接击穿缓存去查库。这次事故让我彻底重构了我们项目的缓存防御体系。

我们当时用的是 Redis 7.2.5(2024年5月发布版本),这个版本在内存管理和过期策略上已经比较成熟,但光靠默认配置是不够的。

缓存击穿:互斥锁重建

针对上面那个 10086 商品的问题,核心在于单个热点 Key 过期瞬间的高并发回源。我现在的做法是使用 SETNX 加一个分布式锁,只有拿到锁的线程才能去查数据库并重建缓存。

public String getProductInfo(String productId) { String cacheKey = "product:info:" + productId; String result = jedis.get(cacheKey); if (result != null) { return result; } // 缓存不存在,尝试获取分布式锁 String lockKey = "lock:" + cacheKey; String lockValue = UUID.randomUUID().toString(); // 尝试加锁,设置 30 秒超时,防止死锁 boolean locked = "OK".equals(jedis.set(lockKey, lockValue, "NX", "EX", 30)); if (locked) { try { // 二次检查缓存,防止在等待锁期间其他线程已经重建了缓存 result = jedis.get(cacheKey); if (result != null) return result; // 模拟查库,耗时约 150ms result = queryFromDb(productId); // 写回 Redis,设置 5 分钟过期,加一个随机 1-60 秒的偏移,防止集体雪崩 int expireTime = 300 + new Random().nextInt(60); jedis.setex(cacheKey, expireTime, result); return result; } finally { // 释放锁脚本,保证原子性,防止误删别人的锁 String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; jedis.eval(luaScript, Collections.singletonList(lockKey), Collections.singletonList(lockValue)); } } else { // 未获取到锁,休眠 50ms 后重试 try { Thread.sleep(50); } catch (InterruptedException e) {} return getProductInfo(productId); // 递归重试 } }

为什么这么做? 如果不加锁,在缓存失效的瞬间,假设进来 5000 个请求,5000 个请求都会去查库。我们的 MySQL 实例当时最大连接数设置的是 2000,直接被打挂。加了锁之后,只有 1 个请求去查库,其余 4999 个在等待或重试,数据库压力瞬间降为 1。

缓存穿透:空值缓存与布隆过滤器

有一次线上监控发现,有个接口的调用量突然暴增,但都是 404。排查发现有人在用脚本疯狂请求不存在的 ID(比如负数或超大的随机数)。这就是典型的缓存穿透。

我的解决方案是缓存空值 + 布隆过滤器(Bloom Filter)

public String getUserProfile(long userId) { String key = "user:profile:" + userId; String data = jedis.get(key); if (data != null) { // 特殊标记处理空值,防止缓存穿透 if ("NULL_VALUE".equals(data)) { return null; } return data; } // 这里可以先用布隆过滤器判断(伪代码,实际需整合 RedisBloom 模块) // if (!bloomFilter.mightContain(userId)) return null; // 查库 String dbData = queryUserFromDb(userId); if (dbData == null) { // 缓存空值,设置较短的过期时间(例如 5 分钟),防止恶意攻击 jedis.setex(key, 300, "NULL_VALUE"); return null; } jedis.setex(key, 3600, dbData); return dbData; }

为什么不只缓存空值? 如果攻击方生成的是大量随机且不同的 ID,缓存空值会导致 Redis 内存被撑爆。引入布隆过滤器(我们当时用的是 Redis 的 RedisBloom 模块),在请求到达缓存之前先过滤掉肯定不存在的数据。虽然布隆过滤器有误判率(我们设置的是 0.01%),但对于这种场景完全够用。

缓存雪崩:过期时间随机化

2023年我接手过一个老项目,所有的配置类缓存都在凌晨 3 点过期,结果每天 3 点整都会有一个小的 DB 访问尖峰。这就是雪崩的雏形。

解决雪崩的核心思路是打散过期时间。在设置缓存时,我绝不会写死 jedis.setex(key, 3600, value),而是:

// 基础过期时间 1 小时,加上 0-600 秒的随机偏移 int baseExpire = 3600; int randomExpire = baseExpire + new Random().nextInt(600); jedis.setex(key, randomExpire, value);

另外,对于特别核心的数据,我会采用逻辑过期策略,即缓存永不过期,但在 Value 中存入一个过期时间字段,由后台线程或查询时判断是否异步刷新。这能彻底解决雪崩问题,但代码复杂度会上升。

Redis 7.2.5 中,内存淘汰策略我通常设置为 volatile-lfu,因为它比 LRU 更能反映数据的真实热度。如果真的因为雪崩导致内存满了,LFU 能保证淘汰掉那些不常用的冷数据,而不是正在被高频访问的热点数据。

---

5. 进阶落地:基于Lua脚本实现高并发下的库存扣减与分布式锁

在做库存扣减时,很多人的第一反应是 DECR 或者先 GETSET。去年我重构一个秒杀系统时,发现上一代代码里充斥着这种逻辑,导致经常出现超卖问题。

我们的场景是:一个热门商品,库存 1000 件,大促时 QPS 峰值预估在 1.5 万。如果使用 Java 代码控制库存,流程是:查 Redis 库存 -> 判断大于 0 -> 减 1 -> 写回 Redis。在高并发下,这 4 步操作不是原子的,会导致多个线程同时读到库存为 1,然后都扣减成功,最终库存变为 -N。

为什么必须用 Lua 脚本

Redis 是单线程执行命令的,Lua 脚本在执行时会被当作一个整体,中间不会插入其他命令。这就是我选择 Lua 来实现库存扣减的根本原因。

我们当时使用的是 Redis 7.2.5,该版本对 Lua 脚本的复制和持久化支持已经非常完善。

这是我在生产环境中使用的库存扣减脚本:

-- 库存扣减 Lua 脚本 -- KEYS[1]: 库存 Key -- ARGV[1]: 扣减数量 -- ARGV[2]: 订单流水号(用于幂等性控制,防止重复扣减) local stock_key = KEYS[1] local decr_count = tonumber(ARGV[1]) local biz_id = ARGV[2] -- 1. 检查是否已经处理过这个业务ID(幂等性,防止网络重试导致重复扣减) local processed = redis.call('SISMEMBER', 'decr:processed:' .. stock_key, biz_id) if processed == 1 then return 1 -- 已经处理过,直接返回成功 end -- 2. 获取当前库存 local current_stock = tonumber(redis.call('GET', stock_key)) -- 3. 库存不足判断 if not current_stock or current_stock < decr_count then return -1 -- 库存不足 end -- 4. 执行扣减 redis.call('DECRBY', stock_key, decr_count) -- 5. 记录处理过的业务ID,设置 1 小时过期,防止集合无限增长 redis.call('SADD', 'decr:processed:' .. stock_key, biz_id) redis.call('EXPIRE', 'decr:processed:' .. stock_key, 3600) return 0 -- 扣减成功

在 Java 端调用这个脚本:

public boolean deductStock(String stockKey, int count, String bizId) { String luaScript = "-- 上面的 Lua 脚本内容"; // 使用 Redis 7.2.5 的 eval 方法 Object result = jedis.eval( luaScript, Collections.singletonList(stockKey), Arrays.asList(String.valueOf(count), bizId) ); Long res = (Long) result; if (res == 0) { // 扣减成功,异步写数据库 log.info("库存扣减成功,Key: {}, 数量: {}", stockKey, count); return true; } else if (res == -1) { log.warn("库存不足,Key: {}", stockKey); } else if (res == 1) { log.info("重复请求,已忽略,业务ID: {}", bizId); } return false; }

为什么不只用 DECR? 单纯的 DECR 无法做库存校验(比如库存 > 0 才能扣),也无法做幂等性控制。如果不做幂等,用户在弱网环境下点击了两次“提交订单”,可能会导致库存被扣两次。我在这里引入了 bizId(比如订单号或请求唯一ID),利用 Redis 的 Set 结构记录已处理的请求,解决了这个痛点。

分布式锁的 Lua 实现

前面提到的 SETNX 虽然简单,但在复杂的锁竞争场景下(比如锁自动过期导致的误删),还是需要用 Lua 来封装。我封装了一个标准的加锁和解锁工具类:

// 加锁 public boolean tryLock(String lockKey, String requestId, int expireSeconds) { // NX: 不存在才设置, EX: 过期时间 String result = jedis.set(lockKey, requestId, "NX", "EX", expireSeconds); return "OK".equals(result); } // 解锁(Lua脚本保证原子性) public void unlock(String lockKey, String requestId) { String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " + " return redis.call('del', KEYS[1]) " + "else " + " return 0 " + "end"; jedis.eval(luaScript, Collections.singletonList(lockKey), Collections.singletonList(requestId)); }

实际效果: 我们将这套 Lua 脚本方案上线后,在 12000 QPS 的压测下,库存数据始终保持精准一致,没有任何超卖现象。数据库层面的写入压力也被削峰填谷,从原本的瞬时 5000 写入降到了平稳的 200 写入(通过异步队列)。

目前我们也在关注 Redis 8.0 RC 中引入的查询引擎增强功能,未来或许可以直接在 Redis 侧实现更复杂的库存逻辑,而不必完全依赖 Lua 脚本。但在当下的生产环境(Redis 7.x),Lua 依然是处理这类高并发原子操作的唯一最优解。

站长实战手记

一次差点让我背P0故障的缓存改造

去年双十一前,我接了个电商详情页的活。当时业务量涨得快,数据库CPU经常飙到90%,我第一反应就是上Redis扛压力。选了 Redis 7.2.5,因为当时Valkey刚分叉,生态和稳定性还没经过大规模验证,我不敢拿生产环境冒险。

我用了最简单的 Cache Aside 模式,读请求先打缓存,没命中再查库。上线前压测看着挺美,QPS直接翻了三倍。结果上线当天凌晨,运营推了个秒杀活动,流量瞬间打爆。我盯着监控,发现缓存命中率断崖式下跌,数据库还是被拖垮了。

排查了半小时才定位到问题:那是典型的 缓存击穿。运营推的那个爆款商品ID,因为热点数据过期,几万请求同时穿透到数据库。我当时脑子一热,直接给那个Key加了 逻辑过期 的兜底,并在代码里用 Redisson 做了分布式锁,只让一个线程去查库重建缓存,其他的在那等着。

折腾完这一出,我算是明白了:

* 别为了用而用:如果你的日活才几千,别折腾Redis集群,单机的MySQL可能比你瞎搞缓存还稳。

* 热点数据要特殊对待:像这种秒杀场景,热点Key的预热和永不过期策略比啥都重要。

* 一致性是玄学:我后来为了强一致性去搞双写,结果代码复杂度指数级上升,最后还是妥协成了最终一致性。

学Redis别光看命令,多去想想 内存淘汰策略网络抖动 时你的程序会怎么疯。代码跑在本地和跑在生产环境,完全是两码事。别怕出问题,我那次之后,对缓存失效的防御代码写得比谁都溜。