snack-mall/db/BUSINESS_DESIGN.md

331 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 核心业务设计文档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** | 会话唯一 IDDB 主键) |
| **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_messageseq = 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": "..." }
```
#### 方式 BWebSocket 推送(推荐)
服务端在消费者线程处理完后,向用户 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 单机 QPS10 万+
- Lua 脚本平均耗时:< 1ms
- 10000 并发抢购 Redis 处理 ~10000 Lua 调用 MQ 10000
- 消费者线程可水平扩展 200 TPS 落库10000 条约 50 秒消化完
- 数据库压力< 200 TPSMySQL 完全扛得住
---
## 三、关键代码位置
| 模块 | 文件路径 | 说明 |
|------|----------|------|
| 客服 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
- [ ] 客服智能分配会话进入时按"客服当前未读最少"分配
- [ ] 客服坐席状态客服可设置"离开 / 忙碌"自动转接