高并发秒杀与抽奖服务设计:资格控制、奖品发放与性能优化

本文内容聚焦于秒杀与抽奖服务的后台设计,不涉及接入层,主要讨论业务流程、资格控制、奖品发放、事务取舍以及数据层性能优化。

秒杀和抽奖的业务形态虽然不同,但在后台系统中面临的问题高度相似:大量用户在短时间内竞争有限资源,系统既要防止资格被重复使用和奖品超发,也要在高并发下保持稳定。

一、业务特征与整体流程

对后台服务而言,秒杀和抽奖都属于“参与人数远大于可用物品数量”的高并发场景。如果业务对参与者有额外要求,还需要在进入核心逻辑前完成资格校验。

秒杀请求通常会在活动约定的时间点集中爆发;抽奖活动如果开放时间固定,同样可能出现瞬时流量高峰。两类活动最终都只有部分用户能够成功,因此需要重点处理资格、概率、库存和并发一致性问题。

1. 基本业务流程

  1. 进入抽奖或秒杀逻辑。
  2. 检查请求参数并校验用户资格。
  3. 以原子方式扣除资格。
  4. 执行抽奖或发放物品。

2. 核心模块

  • 资格模块:负责查询、增加和扣除用户资格。
  • 抽奖(发货)模块:负责概率计算、奖品发放和发放记录。

3. 主要并发问题

  • 同一用户的并发:同一用户在同一时刻发起多次抽奖操作,可能使用一份资格获得多个奖品。
  • 同一物品的并发:大量用户同时竞争同一物品,处理不当可能造成库存超发。

存储层可以选择 MySQL 或 NoSQL。MySQL 便于查询和配置奖品,并且具备较强的事务支持,但在高并发写入场景下容易成为性能瓶颈。Redis 的核心命令执行具备良好的原子性和并发性能,单机处理能力通常可以超过 1 万次/秒,更适合承载高频计数和队列操作。

二、资格模块:解决同一用户的并发

扣除资格前,需要先判断用户是否具备参与资格;如果没有资格,则直接返回。资格扣除必须采用原子操作,否则并发请求可能同时通过校验,进而重复消耗同一份资格。MySQL 和成熟的 NoSQL 数据库都可以实现这一要求,常见方案有以下两种。

1. Insert 方式

MySQL 和 NoSQL(已知 DCache)都支持该方式。可以将账号设为主键,将日期或次数设为联合主键。当主键与联合主键已经存在时,数据库会报错或返回“数据已存在”,业务层据此即可判断用户已经参与过。

  • 日期作为联合主键:适合按天控制用户资格。
  • 次数作为联合主键:适合控制用户的累计参与次数。
  • 日期和次数共同组成联合主键:适合控制用户每天的参与次数。Insert 主要用于防止并发重复写入,总次数仍需与业务配置进行比较。

2. Incr 方式

Incr 方案与 Insert 方案的思路相似。可以将日期组合到 Key 中,通过原子加减后的结果判断用户是否仍有资格。使用 Redis 或 DCache 时,可以直接执行 Incr(DCache 提供 AtomUpdate 接口)并获得变更后的结果值。如果使用公司的 CKV 数据库或其他不返回操作结果的接口,则需要通过 CAS 保证一致性。

需要特别注意:许多主备模型的存储在主机故障并切换到备机时,可能存在短暂的数据不一致。如果业务没有额外保护,这类不一致可能导致资格被重复使用或奖品多发。

三、抽奖或发放模块:解决奖品并发

该模块主要负责保存和发放物品。存储可以选择 MySQL 或 Redis;对于结构化能力较弱、又缺少原子操作支持的 NoSQL,则需要借助 CAS 保证数据一致性。

1. MySQL 方式

可以通过带条件的 Update 原子扣减奖品库存:

UPDATE t_prize_pool
SET value = value - 1
WHERE value > 0
LIMIT 1;

如果每个物品对应一条记录,也可以在发放时同时记录领取人:

UPDATE t_prize_pool
SET status = 1,
    lucky_uin = 'uin'
WHERE status = 0
LIMIT 1;

2. Redis 方式

可选的数据结构包括 Hash、List 和 Set。Hash 可以通过计数命令控制发放数量,List 和 Set 则可以通过 Pop 操作取出奖品。综合实现复杂度和使用方式,推荐采用 List 保存奖品队列。

之前在活动开发中设计过一个通用接口,奖品存放在队列中:

int doLotteryPrize(
    int iBusinessId,
    int iPoolId,
    int inverseOfProbability,
    out bool bLucky,
    out string strPrize,
    out int iPrizeRecordCount
);

输入参数:业务 ID、业务下的奖池 ID(可以按天划分奖池,也可以为不同物品设置不同奖池),以及概率的倒数(1 表示必中,0 表示必不中)。

输出参数:是否中奖、奖品名称,以及该奖品在当前业务下的累计发放数量。

3. 接口处理流程

抽奖接口处理流程

流程中的任何一步出错,都应立即返回。为了防止奖品超发,这里设置了两层保护:

  1. List 原子操作:只要没有人为向 List 中错误地添加超出配置数量的物品,发放时就不会 Pop 出超过设定数量的奖品。
  2. 发放次数校验:通过 iPrizeRecordCount 返回该奖品的累计发放次数。即使误添加了多余物品,调用方仍可以将该值与配置总量比较,并决定是否继续发放。

四、回滚与事务性取舍

资格扣除后,如果奖品发放失败,是否需要回滚应由具体业务决定。按照我的理解,多数活动业务可以选择不回滚。原因在于,回滚机制会显著增加业务逻辑复杂度,也会引入新的出错点,甚至可能产生可被利用的漏洞。

相比构建复杂的回滚链路,更合理的做法通常是保持服务简单、提高可用性并尽量降低错误率。对于极少量异常,可以通过完整日志进行事后核对和单独补偿。

Redis 和 MySQL 都支持事务性操作,但两者的语义并不相同。Redis 不像 MySQL 那样提供事务回滚能力,它主要保证一组命令按顺序执行,且执行过程不会被其他命令打断。如果业务确实需要回滚,只能自行设计补偿逻辑。

五、数据层性能优化

MySQL 的并发能力相对较弱,NoSQL 更适合高并发场景。但无论使用哪种存储,其 IOPS 或 TPS 都有上限,最终都可能成为系统瓶颈。逻辑层通常可以通过水平扩容提升处理能力,数据层则可以从以下几个方向进行优化。

1. 分而治之

假设系统每秒需要处理 3 万个请求,而单台数据层接口的处理能力是 1 万次/秒,可以将物品分布到 3 台不同的数据库机器上,再由逻辑层将请求尽量均匀地分配到这些机器。这样相当于对数据层进行了水平扩展。

数据层分片扩展示意图

2. 排队处理

如果只剩一台 iPhone,排队也是一种可行的处理方式。

请求排队处理示意图

采用队列后,如果请求没有被及时消费,用户就无法立即获得最终结果。不过,在部分业务场景中,这种异步反馈是可以接受的。例如 12306 订票网站会先接收订票请求,再等待后台处理完成后返回最终结果。

3. 按需放行

在设计方案之前,还需要判断业务是否真的需要复杂的分库或排队机制。将物品分散到多台数据库,或者设计一套可靠队列,都会增加系统复杂度。更常见的情况是,几十万用户同时竞争几台 iPhone。

只要在逻辑层控制进入数据层获取物品的请求数量,数据层的奖品竞争问题就可以得到有效缓解:

  • 如果只有 5 台 iPhone,每秒只允许 5 个请求进入数据层,MySQL 就足以完成处理。
  • 如果有 2 万台 iPhone,每秒放行 1 万个请求,NoSQL 可以在约 2 秒内完成发放。
  • 如果物品数量更多,可以适当延长处理时间;如果实时性要求较高,则应采用前面的分而治之方案。

这里讨论的只是数据层中的物品获取。每个用户的获取状态可以从 Cache 中读取,用户状态之间通常不存在资源竞争,因此一般不会成为主要瓶颈。

按需放行请求示意图

六、总结

秒杀与抽奖服务的核心,不只是提高接口吞吐量,更重要的是控制有限资源在并发场景下被正确消费。设计时可以围绕以下三点展开:

  • 通过原子操作解决资格重复使用和奖品超发问题。
  • 根据业务容错要求,在强事务、简单实现和事后补偿之间做出合理取舍。
  • 根据奖品数量和实时性要求选择分片、排队或按需放行,避免为了高并发而过度设计。

在多数场景中,保持链路简单、明确容量边界,并为少量异常保留可追踪和可补偿能力,往往比引入复杂机制更可靠。

posted @ 2026-09-23 18:15  白春雨  阅读(7)  评论(0)    收藏  举报