🎤 面试官:来,聊聊“高并发下的预约抢购系统”,你怎么设计排队机制?
💡 “面试官好,这个问题我实际做过,核心就八个字:有序排队,精准扣减。我直接按全链路分层来讲,每一层都说说用什么技术、解决什么问题。”
1. 📊 整体架构一图胜千言
整理了面试真题、每日技术知识点、系统学习路线,√X搜「Rain的Java 大神之路」
每天拆一个知识点,陪你悄悄变强,有空来坐坐。
2. 🚪 接入层:别让洪水冲进系统
🧠 “像挂号一样,你得先在门外把队伍理好。”
| 手段 | 作用 | 技术落地 |
|---|---|---|
| 前端防重 | 按钮置灰 + 验证码 | 点击后 disable,1 秒内不重复发 |
| 网关令牌桶 | 强行限制 QPS | Sentinel / Nginx limit_req |
| IP/用户风控 | 识别黄牛 🎭 | AI 模型分析点击频率、异常 UA,实时打分 |
🔧 Java 示例:Sentinel 流控
// 抢购入口加上注解
@SentinelResource(value = "seckill", blockHandler = "blockHandler")
public R seckill(Long skuId, Long userId) {
// 进入排队逻辑
}
搭配 Dashboard 可动态调整阈值。
3. 🕒 排队核心:Redis 的 ZSET 就是一张“叫号单”
这是整个系统的灵魂,要做到像挂号一样精准,必须满足三点:
- 顺序绝对公平:先到先得,不能插队
- 原子入队出队:多线程安全
- 快速返回排队号:不能让用户干等
✅ 选型:Redis 的 Sorted Set (ZSET)
score= 请求到达的纳秒时间戳(或自增序列),保证 FIFOmember=userId:skuId唯一标识
入队操作(Lua 脚本保证原子性):
-- 入队并返回排队序号
local queueKey = KEYS[1] -- seckill:queue:sku123
local member = ARGV[1] -- userId:123
local score = ARGV[2] -- 时间戳
redis.call('ZADD', queueKey, score, member)
return redis.call('ZRANK', queueKey, member) + 1 -- 排队号从1开始
🟢 用户立刻看到:“您的排队号为 326,前方还有 325 人”。
出队消费(定时任务或事件驱动):
-- 原子弹出队首元素
local queueKey = KEYS[1]
local stockKey = KEYS[2] -- 库存key
local members = redis.call('ZPOPMIN', queueKey, 1)
if #members == 0 then return nil end
-- 检查库存(可在此扣减,或交给MQ侧)
return members[1] -- 返回 userId:skuId
出队后扔进 RocketMQ 顺序消息,保证同一个商品的处理有序。
4. ⚡ 异步处理与防超卖:两道保险
消息队列选择支持顺序的 RocketMQ:
- 同一个
skuId发到同一个 MessageQueue(按 key 选择) - 消费者单线程消费该队列,顺序得到保障
库存扣减双保险:
- Redis 库存预热:活动开始前把库存加载到 Redis,用 DECR + Lua 原子校验
local stock = redis.call('GET', KEYS[1])
if stock and tonumber(stock) > 0 then
redis.call('DECR', KEYS[1])
return 1 -- 扣减成功
end
return 0 -- 库存不足
- 数据库乐观锁:最终落库时再加一层
UPDATE product SET stock = stock - 1
WHERE id = #{skuId} AND stock >= 1
如果 affected rows = 0,触发回补 Redis 库存并退款。
5. 🎯 如何“像挂号一样精准”?——排队状态可见与超时取消
用户端通过 WebSocket/SSE 实时推送排队进度:
{"queueNumber":326, "ahead":125, "status":"WAITING", "estWait": "约5分钟"}
⏳ 超时放弃机制:
- Redis 中存储一个
pending状态,设置 TTL(如 15 分钟) - 如果用户未在规定时间内确认,自动出队释放名额,通知下一位
- 这是挂号系统的精髓:把名额给真正需要的人
6. 🤖 AI 方向加持:智能风控与动态排队
作为 Java 研发 AI 方向,我们还可以把 AI 用在:
- 黄牛检测:基于用户行为序列(点击间隔、设备指纹)用 LSTM 模型实时打分,高可疑直接拒绝入队
- 流量预测:根据历史秒杀曲线,动态调整令牌桶阈值,避免误拦真实用户
- 智能排队:急诊式插队(黑卡用户),但严格限制比例,在 ZSET 里增加一个优先级维度(Redis 无法直接双维度,可开多个队列逻辑合并)
🔧 模型部署:AI 模型被打包成 jar 内嵌,或者通过 gRPC 调用 Python 推理服务,Java 侧只做低延迟决策。
7. ⚠️ 面试官挖坑:你说 ZSET 有什么缺点?
🧐 “ZSET 使用内存,如果排队用户百万级,内存会爆。如何解决?”
- 答案:分段队列 + 虚拟队列。只有获得“预排队令牌”的才能进入真正 ZSET,前端排队号落到缓存,大部分用户看到的只是一个虚拟号码,真正资源池里的人可控,例如只有前 5000 名进 Redis。
8. 📦 总结黄金公式
高并发预约 = 前端消峰 + 网关限流 + 有序队列(Redis ZSET) + MQ异步削峰 + 库存双保险 + 实时进度通知 + AI风控
✨ 整个回答既接地气又有深度,面试官会觉得你不仅懂技术,还能把业务场景落得明明白白。
💎 核心代码 & 技术亮点
下面的代码都是生产项目的简化版,重点看注释里的设计思路。
⚡ 亮点1:原子入队并返回排队号(Redis + Lua)
// 抢购服务:用户点击抢购,进入排队
public QueueResult enterQueue(Long skuId, Long userId) {
String queueKey = "seckill:queue:" + skuId;
String member = userId.toString();
// 用微秒时间戳做 score,保证严格 FIFO
double score = System.nanoTime() / 1000.0;
String luaScript =
"redis.call('ZADD', KEYS[1], ARGV[2], ARGV[1]) " +
"local rank = redis.call('ZRANK', KEYS[1], ARGV[1]) " +
"return rank";
// executeLua 返回的是用户在队列中的索引(0-based)
Long rank = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(queueKey), member, String.valueOf(score));
if (rank == null) {
throw new BizException("排队异常");
}
int queueNumber = rank.intValue() + 1; // 排队号从 1 开始
// 返回给前端,前端开始轮询或建立 WebSocket
return new QueueResult(queueNumber, rank.intValue());
}
🧠 技术亮点:
- ZADD + ZRANK 在一个 Lua 脚本里原子执行,避免并发导致序号错乱。
- 使用 纳秒时间戳 比毫秒更精细,高并发下 score 冲突概率极低。
🎯 亮点2:消息队列的精准出队 + 库存扣减
出队可以用一个调度任务去拉取 Redis 队首,然后投递到 RocketMQ。这里直接把消费端和库存扣减写成原子逻辑,做到不超卖不超发。
// RocketMQ 消费者(同sku串行消费)
@RocketMQMessageListener(topic = "seckill-order", consumerGroup = "seckill-order-group")
public class SeckillOrderConsumer implements RocketMQListener<String> {
@Override
public void onMessage(String userIdStr) {
Long userId = Long.parseLong(userIdStr);
String stockKey = "seckill:stock:" + skuId;
// 原子扣库存 Lua 脚本
String deductLua =
"local stock = redis.call('GET', KEYS[1]) " +
"if stock and tonumber(stock) > 0 then " +
" redis.call('DECR', KEYS[1]) " +
" return 1 " +
"end " +
"return 0";
Long success = redisTemplate.execute(
new DefaultRedisScript<>(deductLua, Long.class),
Collections.singletonList(stockKey));
if (success == null || success == 0) {
// 库存不足,返回“已抢光”,并通知前端
notifyUser(userId, "SOLD_OUT");
return;
}
try {
// 真正创建订单(数据库乐观锁防超卖兜底)
orderService.createOrder(skuId, userId);
notifyUser(userId, "SUCCESS");
} catch (DuplicateOrderException e) {
// 幂等处理:重复消费时直接返回
// 如果创建失败,回补 Redis 库存
redisTemplate.opsForValue().increment(stockKey);
notifyUser(userId, "FAILED");
}
}
}
💡 亮点:
- 库存扣减与出队解耦:出队由调度中心统一从 ZSET POP,保证顺序;扣库存由消费者自己拿 Redis 原子操作,不依赖外部事务。
- 数据库乐观锁兜底:
update ... where stock>=1,避免 Redis 和 DB 不一致时的超卖。 - 消费端幂等:通过用户唯一业务号 + 分布式锁或唯一索引,保证重复消息不会被处理两次。
🚀 亮点3:WebSocket 实时推送排队进度
前端不用轮询,降低服务器压力,体验也更好。
// 定时任务:每秒拉取队列前部信息并推送
@Scheduled(fixedDelay = 1000)
public void pushQueueProgress() {
String queueKey = "seckill:queue:" + skuId;
// 获取队首用户 ID 和它目前的排名
Set<ZSetOperations.TypedTuple<String>> range =
redisTemplate.opsForZSet().rangeWithScores(queueKey, 0, 0);
if (range == null || range.isEmpty()) return;
String firstUserId = range.iterator().next().getValue();
// 简单估算等待时间(需结合出队速率计算,这里略)
long aheadCount = redisTemplate.opsForZSet().rank(queueKey, firstUserId);
// 通过 WebSocket 向前端广播
webSocketServer.sendToUser(firstUserId, new QueueProgress(aheadCount, "排队中,预计等待3分钟"));
}
✨ 亮点:
- 不是每个用户都去查一次 Redis,而是服务端批量计算后精准推送,读压力大幅降低。
- 可结合 Guava Cache 本地缓存部分用户的排队状态,进一步保护 Redis。
⚠️ 此场景的技术难点 & 解决方案
面试官听到这里,一定会追问:“实现过程里最难的地方在哪?怎么解决的?”
🔥 难点 1:绝对顺序与高可用如何兼得
问题:
主从 Redis 下,ZSET 操作在主节点,如果主宕机,切换到从会导致队列丢失,或者因哨兵切换期间的写入延迟导致顺序错乱。
解决方案:
- Redis 集群使用 Raft 协议(如 Redis Cluster 或者持久化开启 AOF always),但性能损耗大。
- 更务实的方案:容忍极少量排队号断层,业务上只保证相对顺序,同时把队列的持久化通过另一个异步线程写入 DB 备份,故障恢复时从 DB 重建。
- 如果必须强顺序:用 RocketMQ 顺序消息 做队列存储,Redis 仅做缓存排序,但复杂度剧增。
🔥 难点 2:热点 SKU 导致 Redis 单分片压力过大
问题:
当 100 万人同时抢一个 SKU,所有 ZADD 请求都打到 Redis 的同一个 key,对应一个分片,单机 CPU 飙升。
解决方案:
- 多级队列:客户端先获取“虚拟排队令牌”(简单的自增 ID),只有令牌编号小于某个阈值(如 5000)的用户,才会进入 Redis ZSET。其余用户直接在本地或 Nginx 层提示“人数已满”。
- 前端分流:在 CDN 或 Nginx 层直接返回一个静态的“已满”页面,90% 的流量根本打不到 Java 服务。
- Redis 本地缓存:用 Nginx+Lua 直接判断队列长度,把压力阻挡在最外层。
🔥 难点 3:库存“被超卖”的分布式事务问题
问题:
Redis 扣减成功,但数据库写入订单失败,需要回补库存,此时又并发卖出,容易产生超卖或数据不一致。
解决方案:
- 先扣 DB,再扣 Redis(反过来)。采用乐观锁扣 DB,只有 DB 扣成功才算真正占有。Redis 库存仅用于快速拦截无效流量,最终一致性以 DB 为准。
- 定时对账:每分钟把 DB 实际库存同步到 Redis,修正差异。
- 使用 SEATA 等分布式事务框架?抢购场景绝对不行,性能太差。只要保证最终一致即可。
🔥 难点 4:恶意用户伪造排队号、跳过排队
问题:
黄牛通过抓包拿到接口后,直接调创建订单接口,绕过排队机制。
解决方案:
- 排队号签名:入队时服务端生成一个带有 HMAC 签名的 token,创建订单时必须携带此 token,服务端验签并比对 token 里的用户 ID 与当前用户是否一致。
- 下单接口强依赖排队 token,且 token 一次性消费后立即在 Redis 中标记失效。
- 结合 AI 风控,对无排队 token 的请求直接拉黑 IP/账号。
🔥 难点 5:大量 WebSocket 长连接的管理
问题:
几十万用户同时保持 WebSocket 连接等待通知,服务器连接数和内存撑不住。
解决方案:
- Netty 优化:单机可达几十万连接,但需要调优系统参数(文件句柄数、内核网络缓冲区)。
- 分层通知:核心排队用户(前 5000)用 WebSocket;其他用户降级到 客户端短轮询 或 Server-Sent Events,甚至发短信/推送。
- 连接共享:若用网关(如 Spring Cloud Gateway),可让网关层统一维护 WS 连接,业务服务无状态。
🏁 最终总结
把高并发预约抢购做到像挂号一样精准,技术本质就是:
- 有序队列(Redis ZSET)保证公平
- Lua 脚本保证原子
- RocketMQ 顺序消息异步解耦
- DB 乐观锁兜底最终一致
- AI 行为分析拦截恶意流量
- 多级削峰保证系统不崩
整理了面试真题、每日技术知识点、系统学习路线,√X搜「Rain的Java 大神之路」
每天拆一个知识点,陪你悄悄变强,有空来坐坐。


355

被折叠的 条评论
为什么被折叠?



