架构和服务拆分定了骨架,但真正见真章的是关键业务场景的技术方案。这篇聚焦趣玩搭最具挑战的三个场景——抢票秒杀、门票候补、钱包对账——逐层拆解设计思路。
一、抢票秒杀方案
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 -- 已售罄
endRedis 单线程模型下,这段 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 数据结构设计
选择 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 账务模型
两个关键设计:
流水表记录
before_balance和after_balance:每笔操作前后余额都留痕,方便逐笔追溯和校验钱包主表使用乐观锁(
version字段):避免并发扣款导致的余额异常
3.3 三层对账体系
第一层:内部流水自平衡(实时)
每笔钱包操作采用「先记流水再变余额」模式:
插入流水记录(
before_bal= 当前余额,after_bal= 当前余额 + amount)更新钱包余额(带乐观锁)
写入时校验
after_balance = before_balance + amount,不一致立即告警并阻断
这一层是实时防线,在问题发生的第一时间就拦截。
第二层:内部账账核对(T+1 凌晨,XXL-JOB 调度)
每天凌晨 02:00 启动四项核对任务:
第三层:银行/微信渠道对账(T+1 下午,XXL-JOB 调度)
这是最外层的对账圈,对接微信支付官方对账单:
下载对账单:通过微信商户平台 API 获取前日对账文件
逐笔比对:以微信流水号为主键,与
t_payment_order逐笔匹配分类差异:
平台有微信无(掉单):自动调用微信查单接口确认状态后补推
微信有平台无(漏单):挂起待人工补录
金额不一致:挂起待人工处理
自动修复:掉单场景可自动修复,其余差异统一挂起
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 的分阶段演进规划,以及六大核心风险的预案。