331 lines
14 KiB
Markdown
331 lines
14 KiB
Markdown
# 核心业务设计文档:WebSocket 客服 + Redis 防超卖
|
||
|
||
本文件补充功能设计文档中两个关键业务模块的**详细设计**:
|
||
1. 客服聊天(WebSocket 长连接 + 消息补发)
|
||
2. 限时抢购(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 脚本(原子完成"校验 + 扣库存 + 限购")
|
||
|
||
```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 异步落库(消费者线程)
|
||
|
||
```java
|
||
@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:轮询(最简单)
|
||
|
||
```http
|
||
GET /api/seckill/result/{orderNo}?token=xxx
|
||
|
||
Response:
|
||
{ "status": "pending" | "success" | "fail", "orderNo": "...", "message": "..." }
|
||
```
|
||
|
||
#### 方式 B:WebSocket 推送(推荐)
|
||
|
||
服务端在消费者线程处理完后,向用户 WebSocket 频道推送:
|
||
```json
|
||
{ "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 通知结果 |
|
||
|
||
---
|
||
|
||
## 四、待办(后续开发)
|
||
|
||
- [ ] WebSocket 集群方案:使用 Redis Pub/Sub 广播跨节点消息
|
||
- [ ] 抢购接口限流:Sentinel / Resilience4j 按用户 ID 限流
|
||
- [ ] 抢购风控:同一 IP / 设备 5 秒内最多抢 1 次
|
||
- [ ] 客服智能分配:会话进入时按"客服当前未读最少"分配
|
||
- [ ] 客服坐席状态:客服可设置"离开 / 忙碌",自动转接
|