案发场景:
晚上 8 点,电商平台准点开启“茅台秒杀”。
你的代码写得很规范:使用 Lua 脚本包裹了GET和DECR命令,保证了不超卖。
物理规则的无情碾压:
秒杀开启的第 0.01 秒,网关放进了 50 万个并发请求。
这些请求全部打向了 Redis 集群中存放stock:maotai的那台 Master 节点。
Redis 主线程疯狂地执行 Lua 脚本,但 CPU 毕竟是有物理极限的。它的处理速度上限也就是每秒 10 万次。
结果:剩下的 40 万个请求在 TCP 队列里死等,随后大面积触发Read timed out。更可怕的是,这台 Master 节点因为 CPU 100% 假死,引发了哨兵的误判,导致了集群主从切换,秒杀活动彻底流产。
极客的破局之道:
既然单线程的极限是 10 万,那我们就强行把它变成多线程!
怎么变?借用 Java 里ConcurrentHashMap的核心哲学:分段锁(分片)。
我们把 1 个含有 10 万库存的超级 Hot Key,物理切割成 10 个含有 1 万库存的小 Key,分散到集群的 10 台不同机器上!
1. 核心原理解剖:化整为零的空间折叠魔法
面对单点写瓶颈,唯一的解法就是横向扩展(Scale Out)。
第一步:库存切割(预热阶段)
在秒杀开始前,我们不写SET stock:maotai 100000。
我们将这 10 万库存,均匀分成 10 份。
在 Redis 里生成 10 个不同的 Key:
stock:maotai:bucket:1= 10000stock:maotai:bucket:2= 10000- …
stock:maotai:bucket:10= 10000
由于这 10 个 Key 的名字不同,按照 Redis Cluster 的CRC16算法,它们会被完美地散列到集群中的 10 台不同的 Master 节点上。
第二步:流量路由(秒杀进行时)
当 50 万个用户的请求打进网关时,Java 应用服务器如何决定去扣哪个桶的库存?
绝招:随机路由 / 用户 ID 路由。
我们可以在 Java 代码里,生成一个1 ~ 10的随机数,或者用User_ID % 10。
把这 50 万流量,平均分发给这 10 个 Bucket!
奇迹发生:
原本 1 台机器扛 50 万并发(必死)。
现在变成了 10 台机器,每台机器只扛 5 万并发。Redis 单节点压力骤降 90%,如履平地般顺滑地完成了全部扣减操作!这就是并发写的降维打击。
2. 致命的副作用:“库存碎片化”与“假性售罄”
架构没有银弹。库存分片虽然解决了并发性能问题,却引入了一个极其烧脑的业务逻辑漏洞:假性售罄。
灾难推演:
秒杀到了尾声,库存快没了。
- Bucket 1 还剩 0 个。
- Bucket 2 还剩 5 个。
此时,用户 A 发起抢购。他的User_ID刚好路由到了Bucket 1。
Java 应用去 Bucket 1 一查,发现库存为 0,于是直接向用户 A 返回:“商品已售罄!”
结果用户 A 跑到微博上大骂:“我都看到页面上明明显示还有 5 件库存,你却提示我售罄?你们是在搞黑箱操作耍猴吗?!”
只要总库存 > 0,就绝对不能告诉用户售罄。这就是秒杀系统的绝对底线。
3. 终极防御:轮询借库与 Lua 合体技
为了解决假性售罄,我们必须实现**“借库(Fallback)”**机制。
当路由到的 Bucket 没有库存时,不能立刻失败,而是要去其他 Bucket 里“碰碰运气”。
高阶工作流:
- 随机定位到一个起始桶,比如
Bucket 3。 - 尝试用 Lua 脚本在
Bucket 3扣减。 - 如果
Bucket 3库存不足,立刻尝试Bucket 4,然后是Bucket 5…直到循环遍历完所有的 10 个桶。 - 如果 10 个桶全都扣减失败,才能真正宣告“售罄”。
灾难再临(网络风暴):
如果你在 Java 里面用个for循环,挨个去调 Redis 的 Lua 脚本。
遇到极端情况(前 9 个桶都空了),你扣减一次库存,竟然要在客户端和 Redis 之间发起9 次网络 RTT 通信!这会让应用服务器的网络连接池瞬间爆满。
大厂的破局解法:合并路由与单节点合并
- 不要分太多桶:桶的数量通常等于 Redis Master 节点的数量(比如 3 个或 5 个)。
- 在 Redis 服务端做合并:对于要求极其严苛的场景,可以开发 Redis C 语言底层模块(Module),在服务端直接进行跨 Slot 的库存协调。但绝大多数情况下,Java 端的少量
for循环重试已经足够,因为“售罄”通常发生在最后几秒,此时整体并发流量已经大降。
4. 代码落地:Spring Boot 库存分片扣减实战
下面是一套支持“失败轮询”的库存分片扣减逻辑。
importorg.springframework.data.redis.core.StringRedisTemplate;importorg.springframework.data.redis.core.script.DefaultRedisScript;importorg.springframework.stereotype.Service;importjava.util.Collections;importjava.util.concurrent.ThreadLocalRandom;@ServicepublicclassShardedInventoryService{privatefinalStringRedisTemplateredisTemplate;privatefinalDefaultRedisScript<Long>deductScript;privatestaticfinalintBUCKET_COUNT=10;// 总桶数publicShardedInventoryService(StringRedisTemplateredisTemplate){this.redisTemplate=redisTemplate;this.deductScript=newDefaultRedisScript<>("if tonumber(redis.call('get', KEYS[1]) or '0') > 0 then "+" return redis.call('decr', KEYS[1]) "+"else "+" return -1 "+"end",Long.class);}/** * 核心逻辑:分片扣减库存,支持失败借库 */publicbooleandeductInventory(StringgoodsId){// 1. 随机生成一个起始的 Bucket 编号intstartBucket=ThreadLocalRandom.current().nextInt(BUCKET_COUNT)+1;// 2. 遍历所有 Bucket,尝试扣减for(inti=0;i<BUCKET_COUNT;i++){// 计算当前尝试的桶编号 (环形遍历: 3, 4, 5...10, 1, 2)intcurrentBucket=((startBucket+i-1)%BUCKET_COUNT)+1;StringbucketKey="stock:"+goodsId+":bucket:"+currentBucket;// 3. 执行 Lua 脚本扣减LongremainStock=redisTemplate.execute(deductScript,Collections.singletonList(bucketKey));if(remainStock!=null&&remainStock>=0){// 扣减成功,立刻返回!System.out.println("✅ 扣减成功!命中的桶是: "+currentBucket);returntrue;}// 返回 -1 说明当前桶空了,继续下一次循环尝试其他桶}// 3. 所有桶都遍历完了,依然失败,才是真正的售罄!System.err.println("❌ 绝对售罄!所有桶均无库存。");returnfalse;}}5. 架构师的延伸:与 JavaLongAdder的跨时空共鸣
如果你熟悉 Java 并发包(JUC),你会发现 Redis 的这种“库存分片”思想,和 JDK 1.8 中引入的极其牛逼的并发工具类LongAdder的底层源码思想如出一辙!
在以前的AtomicLong中,多个线程在高并发下使用 CAS(比较并交换)去修改同一个数字,会导致疯狂的死循环重试,榨干 CPU(这就等同于全部打向 Redis 的一个 Key)。
而LongAdder底层维护了一个叫Cell[]的数组。当并发量高时,它把不同的线程通过 Hash 散列到不同的Cell里去独立累加(等同于 Redis 的 Bucket)。最后求总数时,再把所有Cell的值加起来。
优秀的架构思想,无论是在单机 JVM 内存里,还是在跨机房的分布式集群中,永远都是相通的。时间不够,空间来凑;单点不行,分而治之。
总结
当流量小的时候,系统拼的是代码规范和算法复杂度;
当流量大到一定程度,系统拼的就成了物理学常识。
Redis 的“单线程高性能”神话,在百万级并发写面前依然会被打破。
通过“库存分片”,我们将系统对 CPU 单核的时钟周期压榨,转化为了对多台机器网卡和多核 CPU 的整体调度。配合精妙的 Failover 轮询机制,我们在性能极限与数据严谨性之间,找到了最美的平衡。