架构和服务拆分定了骨架,但真正见真章的是关键业务场景的技术方案。这篇聚焦趣玩搭最具挑战的三个场景——抢票秒杀、门票候补、钱包对账——逐层拆解设计思路。


一、抢票秒杀方案

1.1 业务挑战

热门活动上架瞬间(跨年露营、音乐节等)可能产生数千并发抢票请求。核心矛盾是:高并发下防止超卖、保证库存一致性、用户体验流畅不卡顿。

这三个目标天然存在冲突——强一致性意味着串行化,串行化意味着慢。我们的策略是用五层防线逐层削峰,把真正打到核心库存操作上的请求量降低两个数量级。

1.2 五层防线

第一层:前端削峰

在前端就拦截掉大量无效请求:

  • 按钮点击后 3 秒内置灰,防止用户连续点击

  • 答题验证(滑块/拼图)拉长请求时间窗口,天然错峰进入后端

  • 请求携带幂等令牌(UUID),网关层对同一令牌去重

仅这一层,就能将瞬时请求量降低 60%–70%。

第二层:网关限流

Sentinel 在 Gateway 层做两级限流:

  • 接口级:秒杀接口配置 QPS 上限(如 2000/s),超出直接返回「排队中」

  • 用户级:同一用户 ID 限制 1 次/s,通过 Redis 滑动窗口计数器实现

被限流的请求不是简单返回错误,而是返回一个「排队中」的友好状态,前端轮询查询结果。用户体感是"在排队"而不是"报错了"。

第三层:Redis 预扣库存(核心)

这是整个方案的关键。活动上架前,将门票库存预热到 Redis:

Key:    ticket:stock:{ticketId}
Field:  total = 100    # 总库存
Field:  sold  = 0      # 已售数量

扣减库存使用 Lua 脚本保证原子性:

-- 伪代码
local sold  = tonumber(redis.call('HGET', KEYS[1], 'sold'))
local total = tonumber(redis.call('HGET', KEYS[1], 'total'))
if sold < total then
    redis.call('HINCRBY', KEYS[1], 'sold', 1)
    return 1  -- 扣减成功
else
    return 0  -- 已售罄
end

Redis 单线程模型下,这段 Lua 脚本天然串行化执行,无需分布式锁。单个 Redis 实例即可支撑 10 万+ QPS 的库存扣减操作。

第四层:异步落库

Redis 扣减成功后,不同步写数据库,而是发送 MQ 消息:

Topic: TICKET_SOLD
Body:  { ticketId, userId, timestamp }

下游 Order 服务消费消息并创建订单,订单创建后冻结 15 分钟支付窗口。超时未支付则触发回滚:

  • Redis:HINCRBY sold -1,释放库存

  • 订单:状态更新为已关闭

  • 候补队列:触发下一位候补用户(见下节)

回滚操作通过 RocketMQ 延迟消息实现,无需定时任务轮询。

第五层:兜底校验

即使以上四层都到位,极端情况下仍可能出现 Redis 与 DB 不一致(如 Redis 宕机恢复)。因此我们增加了两道兜底:

  • 数据库层 CHECK 约束:库存字段 stock >= 0,即使极端情况下也不会真正超卖

  • 定时校准:XXL-JOB 每分钟对比 Redis 与 DB 库存,不一致时以 DB 为准修正 Redis

1.3 数据流总览

用户点击抢票
    → 前端幂等令牌
    → Gateway Sentinel 限流
    → Ticket 服务 Lua 扣减 Redis 库存
    → MQ 异步
    → Order 服务创建订单
    → 15min 支付窗口
    → Payment 服务完成支付
    → Notify 服务出票通知

二、门票候补方案

2.1 业务场景

门票售罄后,用户可以加入候补队列。一旦有人退票或主理人加票,系统自动按排队顺序为候补用户补票,并发送支付通知。

这个场景的难点在于:候补补票必须严格按序不能重复分配超时未支付要自动流转给下一人

2.2 数据结构设计

存储

Key / 表

结构

用途

Redis

waitlist:{ticketId}

Sorted Set(score = 时间戳)

候补排队,天然有序

Redis

waitlist:lock:{ticketId}

String + TTL

补票分布式锁

MySQL

t_ticket_waitlist

id / user_id / ticket_id / status / created_at

持久化候补记录

选择 Redis Sorted Set 而不是 List,是因为 Sorted Set 支持 ZSCORE 快速判断某用户是否已在队列中,同时 ZPOPMIN 可以原子性地弹出最早加入的用户。

2.3 候补入队

用户点击候补
    → 校验:该用户未重复加入(ZSCORE 判断)
    → 校验:候补队列未满(ZCARD < 200)
    → Redis ZADD waitlist:{ticketId} {timestamp} {userId}
    → 异步写入 MySQL 候补表(状态:WAITING)

2.4 退票触发补票(核心流程)

这是最复杂的部分,涉及六个步骤的协调:

步骤一:退票成功后,Order 服务发送 MQ 消息 TICKET_REFUNDED(携带 ticketId 和 count=1)。

步骤二:Ticket 服务消费消息,获取分布式锁 waitlist:lock:{ticketId}(Redis SET NX EX 10s),防止并发退票时重复分配。

步骤三:执行 ZPOPMIN waitlist:{ticketId},弹出排名最前的候补用户。如果队列为空,释放锁并结束。

步骤四:为该用户执行 Redis Lua 扣减库存(复用秒杀逻辑)。扣减成功后,创建待支付订单,通过微信模板消息通知用户在 30 分钟内完成支付。

步骤五:如果用户 30 分钟未支付,延迟消息触发关闭订单、释放库存,并递归执行步骤三——将票分配给下一位候补用户。

步骤六:更新 MySQL 候补表状态(ALLOCATED / EXPIRED),释放分布式锁。

整个流程的关键设计点:

  • 递归流转:用户未支付不是简单地把票放回库存,而是继续分配给下一个候补者,直到队列为空或有人成功支付

  • 分布式锁:确保同一时刻只有一个线程在操作某个票种的候补队列,避免一张票分配给多人

  • 幂等:每次 MQ 消费携带 msgId,Consumer 端做幂等表去重

2.5 防重设计

  • 同一用户同一票种仅允许一条候补记录:Redis ZSCORE 判断存在性 + MySQL 唯一索引(user_id + ticket_id)

  • 补票操作通过分布式锁保证串行执行

  • 所有状态变更记录完整操作日志,便于事后审计


三、钱包对账方案

3.1 业务背景

趣玩搭的钱包涉及四大资金场景:余额充值、活动消费、退款回退、提现。资金链路复杂且敏感,必须做到每日自动对账,差异 T+1 内发现并处理

3.2 账务模型

核心字段

说明

t_wallet_account

user_id / balance / frozen / version

钱包主表,乐观锁控制并发

t_wallet_transaction

tx_id / user_id / type / amount / before_bal / after_bal / biz_id

流水明细,双向追溯

t_payment_order

pay_id / channel / amount / status / wx_trade_no

支付单,含三方流水号

t_reconcile_result

date / type / status / diff_amount / detail

对账结果表

两个关键设计:

  1. 流水表记录 before_balanceafter_balance:每笔操作前后余额都留痕,方便逐笔追溯和校验

  2. 钱包主表使用乐观锁(version 字段):避免并发扣款导致的余额异常

3.3 三层对账体系

第一层:内部流水自平衡(实时)

每笔钱包操作采用「先记流水再变余额」模式:

  1. 插入流水记录(before_bal = 当前余额,after_bal = 当前余额 + amount)

  2. 更新钱包余额(带乐观锁)

  3. 写入时校验 after_balance = before_balance + amount,不一致立即告警并阻断

这一层是实时防线,在问题发生的第一时间就拦截。

第二层:内部账账核对(T+1 凌晨,XXL-JOB 调度)

每天凌晨 02:00 启动四项核对任务:

核对项

逻辑

差异处理

钱包余额核对

SUM(所有流水 amount) = 当前 balance,按 user_id 逐条校验

差异记入 reconcile_result

订单流水核对

PAYMENT_SUCCESS 订单金额 vs. 钱包消费流水金额,按 biz_id 关联

差异记入 reconcile_result

退款流水核对

REFUND_SUCCESS 退款金额 vs. 钱包退款流水金额

差异记入 reconcile_result

差异汇总

所有差异写入 t_reconcile_result,状态 PENDING

通知财务人员人工复核

第三层:银行/微信渠道对账(T+1 下午,XXL-JOB 调度)

这是最外层的对账圈,对接微信支付官方对账单:

  1. 下载对账单:通过微信商户平台 API 获取前日对账文件

  2. 逐笔比对:以微信流水号为主键,与 t_payment_order 逐笔匹配

  3. 分类差异

    • 平台有微信无(掉单):自动调用微信查单接口确认状态后补推

    • 微信有平台无(漏单):挂起待人工补录

    • 金额不一致:挂起待人工处理

  4. 自动修复:掉单场景可自动修复,其余差异统一挂起

3.4 对账时序

T+1 02:00    内部账账核对启动
T+1 02:30    生成内部差异报告
T+1 14:00    下载微信支付对账单
T+1 14:30    渠道比对执行
T+1 15:00    生成综合对账报告
T+1 15:30    差异自动修复 + 人工复核
T+1 16:00    报告推送至财务企业微信群

为什么渠道对账放在下午?因为微信支付的对账单通常在 T+1 上午 10:00 之后才能下载完整。

3.5 资金安全兜底

除了三层对账,还有一组运行时安全机制:

  • 余额变更使用乐观锁(version 字段),避免并发扣款

  • 提现操作先冻结再打款,打款失败自动解冻,避免资金流失

  • 所有钱包写操作封装为事务模板,杜绝裸 SQL 写入

  • Grafana 仪表盘实时展示钱包总余额曲线,异常波动秒级触发 AlertManager 告警

对账不是为了找到问题之后修复——而是为了在问题还没变大之前就发现它。三层体系从实时、日级、渠道级三个维度构建了完整的资金安全网。


下一篇趣玩搭百万 DAU 演进路线:四阶段升级与风险应对——从当前 1.2K DAU 到百万 DAU 的分阶段演进规划,以及六大核心风险的预案。


本站由 困困鱼 使用 Stellar 创建。