文章目录
- 高并发场景
-
- 高并发秒杀
-
- 下单
-
- 第一阶段:抗住流量(漏斗模型)
- [第二阶段:核心扣减(Redis + Lua)](#第二阶段:核心扣减(Redis + Lua))
-
- [1. 为什么不用 Java 代码直接调 Redis?](#1. 为什么不用 Java 代码直接调 Redis?)
- [2. Lua 脚本登场](#2. Lua 脚本登场)
- [3. Java 怎么调?](#3. Java 怎么调?)
- [第三阶段:异步下单(MQ 削峰)](#第三阶段:异步下单(MQ 削峰))
- 进阶:大厂还会做什么?(防坑指南)
- 支付是怎么做的?(和第三方交互)
- 超时订单取消是怎么实现的?(关单)
-
- [方案 1:定时任务轮询(Quartz / XXL-JOB)------ 最土但最简单](#方案 1:定时任务轮询(Quartz / XXL-JOB)—— 最土但最简单)
- [方案 2:Redis 过期监听 ------ 看起来很美,实际是个坑](#方案 2:Redis 过期监听 —— 看起来很美,实际是个坑)
- [方案 3:延迟队列(Delay Queue)------ 大厂标准](#方案 3:延迟队列(Delay Queue)—— 大厂标准)
-
- [**RabbitMQ 实现:**](#RabbitMQ 实现:)
- [**RocketMQ 实现:**](#RocketMQ 实现:)
- 关单时的"库存回滚"
相信大部人初级开发跟我一样,平时接触不到什么高并发场景,虽然也能花心思做,但总是受困于杀鸡用牛刀或工资配不上努力或没时间等。故本人整理一份分场景的高并发解决方案,也会包括高可用场景。
高并发场景
高并发秒杀
下单
第一阶段:抗住流量(漏斗模型)
秒杀的本质是"大流量漏斗"。
- 用户请求:1000 万
- 网关层拦截:剩下 100 万
- Redis 扣减:剩下 1 万
- MySQL 下单:剩下 1000(真实的库存数)
如果没有 Redis 在中间挡着,1000 万请求直接打到 MySQL,数据库瞬间爆炸。
第二阶段:核心扣减(Redis + Lua)
这是你问的重点。为什么一定要用 Lua?
1. 为什么不用 Java 代码直接调 Redis?
如果你在 Java 里写:
java
// ❌ 错误示范:会有并发安全问题
int stock = redis.get("stock:1001");
if (stock > 0) {
redis.set("stock:1001", stock - 1);
return true;
}
问题一(原子性):get 和 set 是两步操作。高并发下,线程 A 读了 1,线程 B 也读了 1,结果两个人都卖出去了,超卖!
问题二(性能):一来一回(RTT)网络请求两次,慢。
2. Lua 脚本登场
Redis 执行 Lua 脚本是原子性的(Atomic)。Redis 把它当做一个命令执行,中间不会被其他命令插入。
标准的秒杀 Lua 脚本:
java
-- KEYS[1]: 商品库存 Key (stock:1001)
-- KEYS[2]: 用户去重 Key (users:1001) - 这是一个 Set
-- ARGV[1]: 用户 ID
-- ARGV[2]: 购买数量 (通常是 1)
local stockKey = KEYS[1]
local userSetKey = KEYS[2]
local userId = ARGV[1]
local num = tonumber(ARGV[2])
-- 1. 校验:用户是否重复购买
-- sismember 返回 1 表示存在
if redis.call('sismember', userSetKey, userId) == 1 then
return -1 -- 重复购买
end
-- 2. 校验:库存是否足够
-- get 获取当前库存
local currentStock = tonumber(redis.call('get', stockKey))
if currentStock == nil or currentStock < num then
return -2 -- 库存不足
end
-- 3. 执行:扣库存 & 记录用户
redis.call('decrby', stockKey, num)
redis.call('sadd', userSetKey, userId)
return 1 -- 抢购成功
3. Java 怎么调?
在 Spring Boot 中使用 Redisson 或 StringRedisTemplate 调用这个脚本。
第三阶段:异步下单(MQ 削峰)
Redis 扣完库存,返回 1(成功)给 Java 后端。
这时候,千万不要去直接插 MySQL!
原因:MySQL 的写入性能(TPS)只有几千,Redis 却是几万甚至十万。Redis 放行了 1 万个请求,MySQL 还是接不住。
做法 :发消息队列(RabbitMQ / RocketMQ / Kafka)。
流程:
- Redis (Lua):扣减成功。
- Java:发送一条消息到 MQ:{userId: 888, goodsId: 1001, time: ...}。
- Java:立马返回给前端:"排队中,请稍后查询结果"。
- MQ 消费者:
- 慢慢地从 MQ 里拉消息。
- 一条一条地写入 MySQL (INSERT INTO order ...)。
- 这个过程叫削峰填谷。
进阶:大厂还会做什么?(防坑指南)
-
缓存预热
秒杀开始前 5 分钟,通过定时任务把 MySQL 里的库存同步到 Redis 里。否则秒杀一开始,Redis 里没数据,请求穿透到 DB,DB 卒。
-
内存标记(本地缓存)
如果 Redis 也扛不住(比如 1000w QPS),大厂会在 Java 本地内存(JVM)里再设一道防线。
- 用 AtomicBoolean hasStock = true。
- 如果 Redis 返回库存空了,就把 JVM 里的这个标记设为 false。
- 后续请求连 Redis 都不用查,直接在 Java 层返回"没货了"。
- 库存回滚
如果用户抢到了(Redis 扣了),但发 MQ 失败了,或者 MySQL 写入失败了,或者用户 15 分钟没支付。
必须有一个补偿机制(延时队列),把 Redis 里的库存加回去 (INCR),并把用户从去重 Set 里踢出去。
一个标准的轻量级秒杀架构:
Nginx(限流) -> Java(内存标记) -> Redis(Lua原子扣减) -> MQ(异步) -> MySQL(最终落库)
支付是怎么做的?(和第三方交互)
我们刚才讲了 秒杀下单(Redis扣减 -> MQ异步入库),现在订单已经在 MySQL 数据库里了,状态是 "待支付" (UNPAID)。
支付环节的核心是"回调(Callback)"。你的后端不需要去"拉"支付状态,而是等支付宝/微信来"推"给你。
流程图解:
- 用户动作:在前端页面看到"抢购成功,请支付",点击【立即支付】按钮。
- 请求支付参数 :前端请求你的后端接口 getPayParams(orderId)。
- 你的后端去调用支付宝 SDK,生成一串加密的支付串(或者 URL)。
- 关键点:你在调用 SDK 时,必须填一个参数叫 notify_url(回调地址),告诉支付宝:"钱收到了以后,发个请求通知我的 https://api.mydomain.com/pay/callback 这个接口"。
- 唤起收银台:前端拿到串,唤起支付宝 App。用户输入密码,付钱。
- 支付宝回调(核心) :
- 用户付钱成功瞬间,支付宝服务器会异步发送一个 POST 请求给你的 notify_url。
- 你的接口收到请求:
- 验签:确认是支付宝发的,不是黑客伪造的。
- 查状态:查数据库,如果订单已经是 PAID,直接返回 success。
- 改状态:把数据库订单状态从 UNPAID 改为 PAID。
- 记流水:记录支付流水号。
- 返回结果:告诉支付宝 "我收到了"(返回 success 字符串),否则支付宝会过一会再重发通知(重试机制)。
超时订单取消是怎么实现的?(关单)
如果用户抢到了(库存扣了),但是占着茅坑不拉屎,15 分钟还不付钱,我们必须把订单关掉,并且把库存还回去,让别人能买。
方案 1:定时任务轮询(Quartz / XXL-JOB)------ 最土但最简单
- 做法 :写一个定时任务,每分钟跑一次。
- SQL:SELECT * FROM order WHERE status = 'UNPAID' AND create_time < (NOW() - 15min)
- 遍历结果,执行关单逻辑。
- 缺点 :
- 时效性差:可能误差一分钟。
- 性能差:如果订单表有几千万数据,每分钟全表扫一次,数据库会挂。
- 适用:小项目,数据量少。
方案 2:Redis 过期监听 ------ 看起来很美,实际是个坑
- 做法:下单时,往 Redis 存一个 key order:timeout:1001,设置 TTL 为 15 分钟。开启 Redis 的 notify-keyspace-events,监听 key 过期事件。
- 缺点:不可靠! Redis 的过期事件不仅可能会延迟,甚至可能会丢失(Redis 不保证消息必达)。生产环境严禁使用此方案做核心业务。
方案 3:延迟队列(Delay Queue)------ 大厂标准
这是最稳健的方案。利用消息队列(MQ)的特性。
RabbitMQ 实现:
- 死信队列(DLX) :
- 建一个普通队列 A,设置消息过期时间(TTL)为 15 分钟。这个队列不设消费者。
- 建一个死信队列 B,绑定到队列 A 上。
- 发送:下单成功后,发一条消息 {orderId: 1001} 到队列 A。
- 流转:消息在队列 A 里躺了 15 分钟,没人领,过期了。RabbitMQ 会自动把它踢到死信队列 B。
- 消费:你的消费者监听队列 B。收到的消息一定是 15 分钟前的。
- 逻辑 :
- 收到 {orderId: 1001}。
- 查数据库:这单现在是 PAID 吗?
- 如果是 PAID:说明用户付钱了,忽略这条消息。
- 如果是 UNPAID:说明超时了,执行关单。
RocketMQ 实现:
RocketMQ 自带延迟级别 messageDelayLevel。
发送消息时设为 level=18 (对应 30分钟,具体看配置),30 分钟后消费者才能收到。
关单时的"库存回滚"
这一点非常重要,也是秒杀闭环的最后一步。
当延迟队列的消费者发现订单超时,执行关单时,必须做两件事(事务一致性):
- MySQL:UPDATE order SET status = 'CANCELED' ...
- Redis :把库存加回去!
- INCR stock:1001 (库存 +1)
- SREM users:1001 user_888 (把用户从黑名单移除,让他能再抢)
注意: 如果你用了本地内存(AtomicBoolean)做前置拦截,记得也要把本地内存的标记改回 true(表示有货了)。
总结
- 支付:靠第三方的回调通知来改状态。
- 超时:靠 MQ 延迟队列 来触发检查。
- 核心动作:超时未付 -> 改状态 -> Redis 库存回滚。