snack-mall/db/BUSINESS_DESIGN.md

14 KiB
Raw Permalink Blame History

核心业务设计文档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 脚本(原子完成"校验 + 扣库存 + 限购"

-- 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": "..." }

方式 BWebSocket 推送(推荐)

服务端在消费者线程处理完后,向用户 WebSocket 频道推送:

{ "type": "seckill_result", "orderNo": "SK20260602...", "status": "success" }

2.7 关键时间点

时间点 操作
活动开始前 5 分钟 预热:把 seckill_product.seckill_stock 同步到 RedisSETNX
活动开始 客户端开启抢购入口
活动进行中 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_orderUNIQUE 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 次
  • 客服智能分配:会话进入时按"客服当前未读最少"分配
  • 客服坐席状态:客服可设置"离开 / 忙碌",自动转接