高并发下的预约抢购系统:像挂号一样精准,排队机制设计全解

🎤 面试官:来,聊聊“高并发下的预约抢购系统”,你怎么设计排队机制?

💡 “面试官好,这个问题我实际做过,核心就八个字:有序排队,精准扣减。我直接按全链路分层来讲,每一层都说说用什么技术、解决什么问题。”

1. 📊 整体架构一图胜千言

在这里插入图片描述

整理了面试真题、每日技术知识点、系统学习路线,√X搜「Rain的Java 大神之路」
每天拆一个知识点,陪你悄悄变强,有空来坐坐。


2. 🚪 接入层:别让洪水冲进系统

🧠 “像挂号一样,你得先在门外把队伍理好。”
手段作用技术落地
前端防重按钮置灰 + 验证码点击后 disable,1 秒内不重复发
网关令牌桶强行限制 QPSSentinel / 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 = 请求到达的纳秒时间戳(或自增序列),保证 FIFO
  • member = 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 选择)
  • 消费者单线程消费该队列,顺序得到保障
库存扣减双保险:
  1. 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  -- 库存不足
  1. 数据库乐观锁:最终落库时再加一层
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 连接,业务服务无状态。

🏁 最终总结

把高并发预约抢购做到像挂号一样精准,技术本质就是:

  1. 有序队列(Redis ZSET)保证公平
  2. Lua 脚本保证原子
  3. RocketMQ 顺序消息异步解耦
  4. DB 乐观锁兜底最终一致
  5. AI 行为分析拦截恶意流量
  6. 多级削峰保证系统不崩

整理了面试真题、每日技术知识点、系统学习路线,√X搜「Rain的Java 大神之路」
每天拆一个知识点,陪你悄悄变强,有空来坐坐。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值