核心业务设计文档:WebSocket 客服 + Redis 防超卖
本文件补充功能设计文档中两个关键业务模块的详细设计:
- 客服聊天(WebSocket 长连接 + 消息补发)
- 限时抢购(Redis 原子操作 + 异步削峰 + 一人一单)
一、客服聊天(WebSocket 实时通信)
1.1 整体架构
┌──────────┐ WebSocket ┌──────────────┐
│ 用户端 │ ◄────────────────► │ Spring │
│ H5/小程序│ /ws/chat?token= │ WebSocket │
└──────────┘ │ Handler │
└──────┬───────┘
│
┌──────┴───────────────┐
│ Message Broker │
│ (In-Memory / Redis │
│ Pub/Sub 集群扩展) │
└──────┬───────────────┘
│
┌──────┴───────┐
│ MySQL 持久化 │
│ chat_session│
│ chat_message│
└──────────────┘
▲
┌──────────┐ WebSocket │
│ 管理端 │ ◄───────────────────────────┘
│ Web │ /ws/chat?token=admin
└──────────┘
1.2 关键概念
| 概念 |
说明 |
| session_id |
会话唯一 ID(DB 主键) |
| session_no |
业务编号(如 CS20260601001) |
| seq |
消息在会话内的单调递增序号(1, 2, 3...),用于排序和断线补发 |
| user_seq |
用户端最大已确认序号(断线时只推 seq > user_seq 的消息) |
| admin_seq |
客服端最大已确认序号(断线时只推 seq > admin_seq 的消息) |
| user_unread / admin_unread |
双方各自未读消息数(在线时实时 +1,已读后清零) |
1.3 WebSocket 握手
客户端 → 服务端:
GET ws://host/ws/chat?token={JWT_TOKEN}
服务端处理:
1. 拦截器中校验 JWT Token
2. 根据 Token 解析出 userId / adminId
3. 建立 STOMP 连接,绑定到会话频道
4. 推送离线消息(seq > last_ack_seq)
1.4 消息发送流程(用户发消息为例)
用户客户端发送 WebSocket 消息:
{ sessionId, type: "text", content: "你好" }
│
▼
服务端 ChatWebSocketHandler.receive()
│
├──► 1. 校验登录态 & 会话归属
├──► 2. 查询 chat_session 中当前最大 seq
│ new_seq = max_seq + 1
├──► 3. 写入 chat_message(seq = new_seq)
├──► 4. 更新 chat_session:
│ - last_message / last_time
│ - admin_unread = admin_unread + 1
├──► 5. 推送给会话中在线的客服(如果有)
│ /topic/chat/{sessionId} → { id, seq, content, ... }
└──► 6. ACK 给用户端
1.5 消息接收 ACK(已读回执)
客户端收到消息后发送 ACK:
{ type: "ack", sessionId, lastSeq: 42 }
│
▼
服务端:
- 更新 chat_session.user_seq = 42(如果用户端)
- 或 chat_session.admin_seq = 42(如果客服端)
- 同时如果对端离线,user_unread / admin_unread 清零
1.6 断线重连 / 离线消息补发
客户端断线后重新连接:
WebSocket /ws/chat?token=xxx&last_ack_seq=42
│
▼
服务端:
1. 校验 Token
2. SELECT * FROM chat_message
WHERE session_id = ? AND seq > 42
ORDER BY seq ASC
3. 推送给客户端
4. 客户端渲染后回传 ACK 更新 user_seq / admin_seq
1.7 关键 Redis 键(可选,用于集群扩展)
| Key |
类型 |
用途 |
chat:online:{userId} |
string (TTL 30s 心跳) |
用户在线状态 |
chat:admin:online:{adminId} |
string |
客服在线状态 |
chat:unread:{sessionId}:{type} |
int |
未读消息计数(与 MySQL 同步) |
ws:user:{userId} |
WebSocket Session |
集群路由用 |
1.8 与"轮询"的区别
| 轮询(旧设计) |
WebSocket(新设计) |
客户端每秒请求一次 /api/chat/messages?lastId= |
一次 WebSocket 长连接,服务端主动推送 |
| 服务器压力随用户数线性增长 |
服务器仅在有消息时推送,连接空闲几乎零开销 |
| 消息延迟 1~N 秒 |
实时(毫秒级) |
| 移动端耗电 |
移动端省电(长连接心跳即可) |
| 无重连机制 |
断线自动重连 + 序号补发 |
二、限时抢购(Redis 防超卖)
2.1 设计目标
- 不能超卖:100 件商品最多卖 100 单
- 一人一单:同一用户在同一活动同一商品只能抢 1 单
- 高性能:10000+ 用户同时抢能扛住
- 不超时不泄洪:流量高峰削峰后端
2.2 整体流程
客户端 服务端
│ │
│ POST /api/seckill/buy │
│ { activityId, skuId, qty } │
│ ──────────────────────────► │
│ │
│ ┌──────┴──────┐
│ │ ① 登录校验 │
│ │ ② 活动状态 │
│ │ ③ Lua 脚本 │ ◄── Redis 原子操作
│ │ 一次性完成│ (防超卖 + 限购)
│ └──────┬──────┘
│ │
│ ┌──────┴──────┐
│ │ ④ 写 MQ │ ◄── 削峰
│ └──────┬──────┘
│ │
│ ◄─── "排队中" 202 ─────────│
│ │
│ ┌──── 轮询或推送 ────┐ │
│ │ /api/seckill/ │ │
│ │ result/{orderNo} │ │
│ └────────────────────┘ │
│ │
│ ┌──────┴──────┐
│ │ ⑤ 消费者线程│
│ │ 写 MySQL │ ◄── 异步落库
│ │ - seckill_order
│ │ - orders / order_item
│ │ - 扣 SKU 库存
│ └──────┬──────┘
│ │
│ ◄─── 推送抢购结果 ─────────│
│ "success: orderNo" │
│ "fail: 已抢完" │
2.3 Redis 数据结构
| Key |
类型 |
初始值 |
用途 |
seckill:stock:{activityId}:{skuId} |
int |
seckill_stock |
总库存(DECR 原子扣减) |
seckill:bought:{activityId}:{skuId}:{userId} |
int 0/1 |
0 |
一人一单标记(SETNX) |
seckill:user_count:{activityId}:{skuId}:{userId} |
int |
0 |
累计购买数量(INBY 用于限购 N 件) |
seckill:result:{orderNo} |
hash |
— |
抢购结果(pending / success / fail) |
seckill:queue:{activityId} |
list |
— |
异步下单消息队列 |
2.4 核心 Lua 脚本(原子完成"校验 + 扣库存 + 限购")
-- KEYS[1] = stock key
-- KEYS[2] = bought flag key
-- KEYS[3] = user count key
-- ARGV[1] = 限购数量
-- 1. 限购校验
local bought = redis.call('GET', KEYS[2])
if bought == '1' then
return {-1, 'ALREADY_BOUGHT'}
end
local userCount = tonumber(redis.call('GET', KEYS[3]) or '0')
if userCount + 1 > tonumber(ARGV[1]) then
return {-2, 'LIMIT_EXCEEDED'}
end
-- 2. 库存校验
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then
return {-3, 'STOCK_EMPTY'}
end
-- 3. 原子扣减
redis.call('DECR', KEYS[1])
redis.call('SET', KEYS[2], '1', 'EX', 86400) -- 24h 过期
redis.call('INCR', KEYS[3])
redis.call('EXPIRE', KEYS[3], 86400)
return {0, 'OK'}
为什么用 Lua? 多个 Redis 命令包成一个原子操作,避免"已读库存但 SETNX 失败"的并发问题。
2.5 异步落库(消费者线程)
@RabbitListener(queues = "seckill.order.queue")
public void handleSeckillOrder(SeckillMessage msg) {
// 1. 写 seckill_order(唯一索引兜底一人一单)
try {
seckillOrderMapper.insert(msg);
} catch (DuplicateKeyException e) {
// 数据库兜底:说明 Redis 已扣但库写失败,需要回补 Redis
redisService.incrStock(msg.getActivityId(), msg.getSkuId());
return;
}
// 2. 生成主订单
Long orderId = orderService.createSeckillOrder(msg);
// 3. 异步扣 MySQL SKU 库存(最终一致)
productSkuMapper.decrStock(msg.getSkuId(), msg.getQuantity());
// 4. 写抢购结果到 Redis
redisService.setSeckillResult(msg.getOrderNo(), "success");
}
2.6 客户端查询结果(轮询或推送)
方式 A:轮询(最简单)
GET /api/seckill/result/{orderNo}?token=xxx
Response:
{ "status": "pending" | "success" | "fail", "orderNo": "...", "message": "..." }
方式 B:WebSocket 推送(推荐)
服务端在消费者线程处理完后,向用户 WebSocket 频道推送:
{ "type": "seckill_result", "orderNo": "SK20260602...", "status": "success" }
2.7 关键时间点
| 时间点 |
操作 |
| 活动开始前 5 分钟 |
预热:把 seckill_product.seckill_stock 同步到 Redis(SETNX) |
| 活动开始 |
客户端开启抢购入口 |
| 活动进行中 |
Lua 原子扣库存 + 异步下单 |
| 活动结束 |
关闭入口;剩余库存回写 MySQL;活动状态置为已结束 |
| 订单超时未支付(30 分钟) |
定时任务扫描 seckill_order,回补 Redis 库存 + 释放"已购"标记 |
2.8 表结构对应关系
| 表 |
作用 |
写入时机 |
seckill_activity |
活动元数据 |
管理员创建活动 |
seckill_product |
活动商品配置 + 兜底库存 |
管理员添加商品;活动开始时预热 Redis |
seckill_order |
抢购成功记录(唯一索引防一人多单) |
消费者线程异步写入 |
orders / order_item |
主订单 |
消费者线程生成 |
product_sku.stock |
SKU 真实库存 |
消费者线程扣减(最终一致) |
2.9 为什么需要 MySQL 唯一索引
- Redis 是单点权威库存,但不能保证事务一致性
- 极端情况下(Redis 主从切换、网络分区),可能出现"Redis 扣减成功但消费者没写入"
seckill_order 的 UNIQUE KEY (user_id, activity_id, sku_id) 是最后一道防线:
- 重复插入时抛
DuplicateKeyException
- 捕获后回补 Redis 库存
- 保证"一人一单"不会因 Redis 故障被绕过
2.10 性能估算
- Redis 单机 QPS:10 万+
- Lua 脚本平均耗时:< 1ms
- 10000 并发抢购 → Redis 处理 ~10000 Lua 调用 → 写 MQ 10000 条
- 消费者线程(可水平扩展)按 200 TPS 落库,10000 条约 50 秒消化完
- 数据库压力:< 200 TPS,MySQL 完全扛得住
三、关键代码位置
| 模块 |
文件路径 |
说明 |
| 客服 WebSocket 配置 |
server-snack/src/main/java/com/snack/server/websocket/WebSocketConfig.java |
注册 STOMP 端点 |
| 客服消息处理 |
server-snack/src/main/java/com/snack/server/websocket/ChatWebSocketHandler.java |
消息接收、ACK、推送 |
| 抢购 Lua 脚本 |
server-snack/src/main/resources/lua/seckill.lua |
原子校验 + 扣库存 |
| 抢购 Controller |
server-snack/src/main/java/com/snack/server/controller/user/SeckillController.java |
/api/seckill/buy |
| 抢购消费者 |
server-snack/src/main/java/com/snack/server/mq/SeckillOrderConsumer.java |
异步落库 |
| 抢购服务 |
server-snack/src/main/java/com/snack/server/service/SeckillService.java |
业务编排 |
| 抢购结果推送 |
server-snack/src/main/java/com/snack/server/websocket/SeckillResultPushService.java |
通过 WebSocket 通知结果 |
四、待办(后续开发)