# 核心业务设计文档: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 次 - [ ] 客服智能分配:会话进入时按"客服当前未读最少"分配 - [ ] 客服坐席状态:客服可设置"离开 / 忙碌",自动转接