type
status
date
slug
summary
tags
category
password
1、架构设计原则
设计秒杀系统的核心思想是:分层过滤、逐步递减。不要让巨大的流量直接冲击后端数据库。就像漏斗一样,每一层都过滤掉一部分无效请求,最终只有极少量的请求到达数据库。
一个典型的秒杀系统架构可以分为以下几层:
- 前端层:面向用户,展示商品详情,提供秒杀入口。
- 网关层:负责限流、鉴权和请求分发。
- 业务服务层:负责核心业务逻辑(如库存判断、订单生成),可以划分为秒杀服务、订单服务、库存服务等。
- 缓存层:承担绝大部分的读请求和库存扣减操作。
- 数据库层:最终的数据持久化。
下图清晰地展示了一个高效秒杀系统的请求漏斗模型:
项目实战参考 imseckill
2、分层详解与核心技术点
2.1 前端层优化
目的是过滤掉大部分低阶脚本和手动刷子,减少无效请求。
- 静态资源分离:将秒杀页面的HTML、CSS、JS、图片等静态资源部署到CDN和对象存储上,减少后端服务器压力。
- 页面静态化:将商品详情页(描述、价格等)提前生成静态页面,缓存起来。只有“秒杀”按钮用动态接口。
- 按钮控制:
- 置灰与倒计时:在秒杀开始前,按钮置灰,防止用户提前请求。
- 点击后禁用:用户点击后立即禁用按钮,防止用户疯狂点击重复提交。
- 验证码:在提交前弹出验证码(如滑动拼图),可以有效防止机器人刷单和稀释请求峰值(因为用户输入需要时间,使得请求在时间上趋于平缓)。
2.2 网关层优化
目标是拦截非法请求和防止流量过载。
- 限流:使用网关(如Nginx、Spring Cloud Gateway)的限流功能,对同一IP、同一用户ID在短时间内的大量请求进行限制(例如,一个用户ID每秒只能请求1次)。
- 鉴权:校验用户登录状态,拦截未登录的非法请求。
- 熔断降级:如果后端服务压力过大,可以进行熔断,返回友好的降级页面(如“活动太火爆,请稍后再试”)。
2.3 业务服务层(秒杀核心)
这是实现秒杀逻辑的核心,需要保证原子性和高性能。
- 独立服务:将秒杀功能设计成一个独立的微服务,与主站业务隔离,避免秒杀流量拖垮整个网站。
- 缓存为王:
- 查询库存:所有库存查询操作直接读Redis,绝不读数据库。
- 扣减库存:核心中的核心! 库存扣减必须在Redis中完成。
- 方案:使用Redis的原子操作(如
DECR
或Lua
脚本)来预减库存。DECR
操作后,如果结果大于等于0,才表示秒杀成功,否则表示库存不足。 - 优点:Redis单线程内存操作,性能极高,且能保证原子性,避免超卖。
- 异步化:秒杀成功并不等于下单成功。秒杀服务只负责快速判断库存,然后立即返回。
- 秒杀成功后,生成一个唯一的“秒杀令牌”或“资格”,并异步通知用户。
- 将创建订单的请求放入消息队列(如Kafka、RabbitMQ)中,由后端的订单服务异步消费,慢慢写入数据库。
- 优点:前端请求可以立即返回,避免了因同步写数据库(慢操作)而阻塞,系统吞吐量极大提升。
2.4 缓存层
- 选用Redis:高性能,支持原子操作,是秒杀系统的标配。
- 预热数据:在秒杀开始前,将商品和库存数量提前加载到Redis中。
- 数据结构设计:缓存数据结构的设计是重点,应该遵循以下原则:
- 高性能:所有操作必须是O(1)或接近O(1)的复杂度,大量使用内存数据结构。
- 高并发:利用Redis的单线程原子操作避免竞态条件,应对瞬时海量请求。
- 可靠性:防止超卖(卖出的商品超过库存),这是秒杀系统最基本的要求。
- 最小化网络IO:减少客户端与Redis的交互次数,使用更高效的数据结构和命令(如Lua脚本)。
一个典型的秒杀活动涉及
活动(SeckillActivity)
和商品(SKU)
。我们通常为每个秒杀商品(SKU)设计以下数据结构:数据类型 | Key格式范例 | 用途 | 核心命令 |
String | seckill:stock:{skuId} | 核心库存计数 | DECR , SET ... NX |
Set | seckill:users:{activityId} | 已购用户名单(防重) | SADD |
String/Hash | seckill:item:{skuId} | 商品详情缓存 | GET , HGETALL |
Hash | seckill:order:{act}:{uid} | 临时订单存储 | HSET |
2.4.1 商品库存缓存 - String
(最关键)
这是最核心的缓存,用于预扣库存,防止超卖。
- Key:
seckill:stock:{skuId}
- 例如:
seckill:stock:123456
- Value: 初始库存数量(整数)。
- 操作:
- 初始化:
SET seckill:stock:123456 1000 NX
(设置1000件库存,如果不存在) - 扣减库存:
DECR seckill:stock:123456
- 返回值
>= 0
:扣减成功,用户有购买资格。 - 返回值
< 0
:库存不足,扣减失败。
- 优点:
DECR
是原子操作,无需担心并发问题。- 性能极高。
注意:实际库存应略大于或等于Redis中缓存的数量,最终一致性通过后续流程(如数据库最终扣减)保证。
2.4.2 已购用户名单 - Set
/ BitMap
防止同一用户重复购买(“一人一单”)。
- Key:
seckill:users:{activityId}
或seckill:users:{skuId}
- 例如:
seckill:users:888
(活动ID为888)
- Value: 用户ID的集合。
- 操作:
- 判断并记录:使用
SADD seckill:users:888 userId
- 返回值
1
:该用户本次秒杀首次购买,成功加入集合。 - 返回值
0
:该用户已经购买过,重复请求。
- 优点:
SADD
也是原子操作,判断和添加一步完成。Set
的查询和插入效率都很高(O(1))。
对于海量用户(亿级)的秒杀,可以使用
BitMap
来极致地节省内存:- Key:
seckill:users:bitmap:{activityId}
- 操作: 将用户ID的哈希值或取模后的值作为偏移量(offset)。
SETBIT seckill:users:bitmap:888 10000001
:将ID为10000001的用户标记为已购买。GETBIT seckill:users:bitmap:888 10000001
:查询该用户是否已购买。
- 优点:极其节省内存(512MB可存储40亿以上的用户标记)。
- 缺点:可能存在哈希冲突(但秒杀场景下,冲突即意味着重复,可以接受),需要将用户ID映射到一个足够大的范围内。
2.4.3 商品详情缓存 - Hash
/ String
(JSON)
用于秒杀开始前和过程中展示商品信息(名称、图片、价格等)。
- Key:
seckill:item:{skuId}
- Value:
- 使用
String
类型存储序列化后的JSON数据(简单方便)。 - 或使用
Hash
类型存储字段(更节省网络流量,可以获取部分字段)。
- 操作:
- 预热缓存:在活动开始前,将商品信息从数据库加载到Redis。
- 查询:
HGETALL seckill:item:123456
或GET seckill:item:123456
。
2.4.4 临时订单缓存 - Hash
(可选)
用户扣减库存成功后,会生成一个临时订单。这个订单可以放入Redis,作为用户“秒杀成功”的凭证,后续异步持久化到数据库。
- Key:
seckill:order:{activityId}:{userId}
- Value: 一个Hash,存储订单的核心信息(订单ID、商品ID、状态、创建时间等)。
- 操作:
HSET seckill:order:888:10000001 field1 value1 field2 value2 ...
2.4.5 数据结构的工作过程
一个完整的秒杀请求处理流程如下,展示了上述数据结构如何协同工作:
- 请求入口:用户请求秒杀接口,携带
activityId
,skuId
,userId
。
- 校验层 (Redis):
- 风控/频率限制:检查用户请求频率是否过高(可使用额外
String
+过期时间实现)。 - 重复购买检查:
SADD seckill:users:888 10000001
,如果返回0,直接返回“已购买”。
- 库存预扣减 (Redis):
DECR seckill:stock:123456
- 如果返回值
< 0
,直接返回“已售罄”。
- 生成订单 (Redis/本地内存):
- 库存扣减成功后,快速在Redis中写入一条临时订单记录 (
HSET
),或仅在应用服务器内存中生成一个订单ID。然后立即向用户返回“秒杀成功”的响应。
- 异步落库 (MQ):
- 将订单信息发送到消息队列(如RocketMQ/Kafka)。
- 由消息消费者异步、可靠地将订单写入数据库,并完成最终的库存扣减(例如:
UPDATE stock SET stock = stock - 1 WHERE sku_id = ? AND stock > 0
)。
2.5 数据库层
- 最终一致性:消息队列的消费者从队列中取出创建订单的请求,最终完成数据库的库存扣减(
UPDATE stock SET stock = stock - 1 WHERE product_id = xx AND stock > 0
)和订单创建。
- 数据库优化:对库存字段做乐观锁校验(
WHERE stock > 0
),作为最后的防线。也可以使用分库分表。
3、常见问题
3.1 防作弊
方案一:前端防护
增加攻击成本,过滤掉大部分低阶脚本和手动刷子。
- 按钮防重复点击:
- 点击后立即禁用按钮,变为“已提交”或倒计时状态,防止用户连续点击。
- 关键:后端仍需做最终校验,前端只是体验优化,不可信任。
- 请求随机数/签名(Token):
- 页面加载时,后端生成一个随机的 Token 并埋藏在页面或返回给客户端。
- 客户端发起秒杀请求时,必须携带此 Token。
- 后端校验 Token 的有效性(是否存在、是否使用过、是否过期)。每个 Token 仅能使用一次,有效防止重放攻击。
- 图形验证码:
- 策略性出现:不在初始阶段就弹出,而是在检测到可疑行为(如短时间内多次请求)后触发。例如:
- 同一 IP 短时间内请求次数过多。
- 用户滑动速度异常(非人类行为)。
- 复杂度选择:简单滑动、点选文字或算术题,平衡安全与体验。在活动开始前或高峰期可以适当提升复杂度。
- 活动开始时间由后端同步:切勿使用前端本地时间作为秒杀开始判断,容易被篡改。所有时间校验必须以服务端时间为准。
方案二:网关层防护
- IP 限流与黑名单:
- 对单个 IP 在单位时间(如 1 秒、5 秒)内的请求次数进行限制。超过阈值则直接拒绝,返回错误码或加入短期黑名单。
- 动态黑名单:识别到异常 IP(如大量无效请求、代理池 IP)后,自动加入黑名单一段时间。
- 用户ID限流:对于已登录的用户,在网关层对其 UserID 进行限流(请求每秒1个),防止单个用户绕过 IP 限制(如切换代理)进行刷单。
业务方案三:服务层
防止重复购买
- 活动资格标记:在 Redis 中为
用户ID:活动ID
设置一个有过期时间的标记,成功下单后设置,防止重复购买
3.2 超卖问题
问题:卖出的数量大于设定的最大库存数量,导致库存变成负数。
根本原因:多个并发请求在同一时刻都读到了相同的库存余量(比如还剩1件),都判断为可购买,然后都去执行扣减操作(扣减1后更新为0)。
解决方案:通过Redis原子操作 + 数据库乐观锁 双重保障,彻底解决。
方案一:Redis原子操作
Redis 将库存的判断和扣减写成一个 Lua 原子脚本。
方案二:数据库乐观锁
数据库在库存扣减时使用乐观锁,乐观锁基于版本号实现,在更新时带上第一次读取的版本号,只有版本号匹配才更新成功。
3.3 库存扣减问题
问题:库存扣减是下单减库存还是支付减库存?如果保证库存不被超卖(即卖出的商品不超过库存总数)的前提下,同时保证系统能处理极高的TPS。
解决方案:
方案一:下单扣库存(最常用)
流程:
- 用户发起下单请求。
- 系统校验资格后,在订单库中创建一条状态为“未支付”的订单。
- 同步调用库存服务,进行库存扣减(预占库存)。
- 库存扣减成功,返回成功给用户,进入支付流程。
技术实现关键点(如何解决高并发和超卖):
- Redis 预减库存 + 异步落库:
- 预热:活动开始前,将商品库存从数据库加载到 Redis 中(例如使用
hash
结构,key: sku_id, field: stock, value: 数量
)。 - 内存扣减:秒杀请求到达时,先在 Redis 中执行
HINCRBY key stock -1
操作。这个操作是原子的,可以避免超卖。 - 判断库存:扣减后判断结果值,如果
>=0
,表示扣减成功;如果<0
,表示库存不足,需要回滚刚才的扣减(HINCRBY key stock 1
)。 - 异步落库:Redis 扣减成功后,发送一个 MQ 消息,让库存消费者异步地将库存变化持久化到数据库中。这极大地降低了数据库的写压力。
- 数据库扣库存(最终保证):
- 即使用了 Redis,数据库仍然是最终数据的权威来源。异步落库就是最终写入数据库。
- 直接操作数据库的方案:
UPDATE stock SET quantity = quantity - 1 WHERE sku_id = #{skuId} AND quantity > 0;
。利用数据库的行锁和quantity > 0
的条件保证不会超卖。但在极高并发下,数据库压力巨大,容易成为瓶颈。
优点:
- 用户体验好,下单时就知道能否买到。
- 逻辑清晰,预占库存可以防止超卖。
缺点:
- 会产生大量未支付的“幽灵订单”,需要配套强大的回滚机制。
方案二:支付扣库存
流程:
- 用户下单时,只校验库存是否大于0(例如在Redis中查),但不实际扣减,直接创建订单。
- 用户支付成功后,再由支付回调通知系统,此时才真正执行库存扣减。
优点:
- 避免了库存被未支付的订单长期占用,库存周转率更高。
缺点:
- 用户体验极差:用户可能成功下单,但在支付时提示库存已售罄,导致支付失败,体验不丝滑。
- 技术实现更复杂:需要处理“支付了但库存没了”的极端情况,并进行退款等补偿操作。
适用场景:对库存周转率要求极高,且能接受较差支付体验的场景(如一些B端采购)。
方案三:缓存校验 + 异步扣减
这更像是方案一的优化版,是大型秒杀系统的标准实践。
流程:
- 请求准入:网关层或前置服务对请求进行合法性校验(如验签、限流)、恶意请求过滤。
- Redis 校验库存:读取 Redis 中的库存信息,如果已售罄,直接返回失败。
- 生成临时订单:库存有余量时,快速生成一个“待扣减”状态的订单ID,并写入缓存(Redis),同时将订单数据发送到 MQ。
- 返回用户“排队中”:立即返回用户“秒杀排队中,请等待支付”等结果,避免用户长时间等待。
- 异步消费者处理:后台的 MQ 消费者按顺序从队列中取出订单消息,完成真正的数据库库存扣减(
UPDATE ... WHERE quantity > 0
)和订单状态更新(“待支付”)。
- 支付通知:数据库扣减成功后,再通知用户完成支付。
优点:
- 将最耗时的数据库操作全部异步化,前端响应极快,吞吐量巨大。
- 数据库压力平滑,避免了瞬时高峰。
缺点:
- 系统复杂性最高,依赖消息队列的可靠性。
- 用户感知有延迟。
3.4 超时回滚问题
问题:无论采用哪种扣库存方案,只要有“预占”概念,就必须有回滚机制。如果部分用户扣减库存后未支付,需要回滚库存。
解决方案:延时消息 + 定时任务扫描
- 状态标记:订单创建时,记录订单的创建时间
create_time
和状态status
(如0-未支付
)。
- 延时消息触发:
- 订单创建成功后,向消息队列(如 RocketMQ)发送一条延时消息,延迟时间为支付超时时间(如15分钟)。
- 消息体包含订单ID。
- 消息消费与检查:
- 15分钟后,消费者收到这条延时消息。
- 消费者根据订单ID查询订单当前状态。
- 如果订单状态仍是“未支付”:执行回滚操作。
- 释放Redis中预占的库存(
HINCRBY key stock 1
),并从已购买用户Set中SREM
删除用户记录。 - 将订单状态更新为“已超时关闭”。
- 发送库存释放MQ消息,让数据库库存最终也
+1
(如果采用异步落库方案)。 - 如果订单状态已是“已支付”:忽略此消息,什么都不做。
技术要点:
- 幂等性:回滚操作必须是幂等的,因为网络原因可能导致消息重复投递。确保即使多次执行回滚逻辑,库存也只加一次,订单状态也只更新一次。
- 数据库操作:回滚库存的SQL:
UPDATE stock SET quantity = quantity + 1 WHERE sku_id = #{skuId};
- 补偿机制:除了延时消息,最好还有一个定时扫描的补偿任务(例如每小时扫描一次超时未支付的订单),防止某些消息意外丢失导致库存无法回收。
3.5 缓存失效问题
问题:当缓存中的热点数据(如商品库存)过期或不存在时,大量请求会直接穿透到数据库,导致数据库压力激增,即缓存击穿问题。
解决方案:在秒杀开始前,通过后台任务预先将商品库存信息加载到Redis中,并采用“逻辑过期”策略,确保Key在秒杀期间永远不会物理失效。
- 永不过期 + 逻辑过期:
- 物理永不过期:Redis 中的库存Key不设置过期时间。
- 逻辑过期:在缓存Value中嵌入一个过期时间戳(如
{“value": 10, "expire_time": 1737811020}
)。 - 工作流程:
- 应用读取缓存数据,判断逻辑时间戳是否过期。
- 如果未过期,直接返回数据。
- 如果已过期,应用程序会尝试获取一个分布式锁(如
Redis SETNX
)。 - 拿到锁的线程负责从数据库加载最新数据,更新缓存和逻辑过期时间。
- 其他没拿到锁的线程不等待,而是直接返回旧的、已过期的数据,直到缓存被更新。
- 互斥锁 (Mutex Lock):
- 当缓存失效时,不是所有线程都去查数据库,而是先尝试获取一个分布式锁。
- 只有拿到锁的线程有权查询数据库并重建缓存。
- 其他未获取到锁的线程等待一小段时间后,重试从缓存获取。
3.6 事务和消息一致性
在秒杀系统中,通常采用“业务核心流程异步化”的策略来应对高并发。即:
- 用户点击“秒杀”后,系统先进行前置校验(如是否已售罄、用户是否重复购买等)。
- 校验通过后,立即返回“排队中”或“秒杀成功,等待支付”的状态,而不是同步地去执行扣减库存、生成订单等耗时数据库操作。
- 将这些耗时的、核心的数据库操作异步化,通常通过消息队列(如 RocketMQ, Kafka)来解耦和削峰。
这个过程就引入了“事务”和“消息”:
- 事务:指扣减库存、生成订单这两个数据库操作,它们必须是一个原子性的整体,要么都成功,要么都失败。
- 消息:指前置校验通过后,发送的那个触发后续异步操作的通知。
一致性问题的本质是:如何保证发送消息这个动作,与前置校验通过这个状态(通常写入了Redis等缓存)是绝对一致的?
如果消息发送失败,但系统记录了“用户已秒杀成功”,用户无法再次抢购,但后续流程又没触发,这就产生了数据不一致(少卖)。
如果消息发送成功,但发送后服务宕机,没能记录“用户已秒杀成功”状态,可能导致用户重复抢购(超卖)。
解决此问题的核心思想是:将消息发送纳入到本地事务中,保证本地数据库操作和消息发送这两个分散系统的操作具有原子性。
方案一:本地事务表(最大努力通知)
这是最常用、可靠性很高的方案。
核心思想:在业务数据库中创建一张“消息表”,将消息先作为一条记录和业务数据在同一个本地事务中写入数据库。然后有一个后台任务定时轮询这张表,将消息发送到MQ,发送成功后删除或更新状态。
步骤:
- 事务开启:开启数据库事务。
- 执行业务逻辑:完成前置校验,并在Redis等缓存中标记“用户已参与秒杀”(防止重复提交)。
- 写入本地消息表:将需要发送的消息内容(如 userId, skuId)作为一条记录插入到本地数据库的
message_table
中,状态为“待发送”。
- 提交事务:提交事务。至此,业务状态和消息状态在本地数据库达成了强一致性。
- 定时任务扫描:有一个独立的“消息发送者”服务,定时扫描
message_table
中状态为“待发送”的消息。
- 发送消息到MQ:将消息发送到消息队列。
- 更新消息状态:发送成功后,将本地消息表的状态更新为“已发送”或直接删除该记录。
- 消费消息:下游的库存和订单服务消费MQ消息,在一个事务中完成扣库存和生成订单的操作。
- 幂等性处理:消费者端必须实现幂等性(根据消息中的唯一ID,如业务主键,判断是否已处理过),以防消息重复消费。
优点:
- 实现简单,可靠性高,依赖于成熟的数据库事务。
- 对MQ无特殊要求,任何MQ都适用。
缺点:
- 业务数据库需要额外维护一张消息表。
- 消息的实时性取决于定时任务的扫描间隔(虽然可以很短,如100ms)。
方案二:使用支持事务消息的MQ(如RocketMQ)
如果MQ本身支持“事务消息”机制,可以更优雅地解决这个问题。RocketMQ是此方案的典型代表。
核心思想:MQ提供一种“半消息(Half Message)”机制,先发送一个对消费者不可见的消息,待本地事务提交成功后再确认该消息,使其对消费者可见。
步骤:
- 发送半消息:秒杀服务先向RocketMQ发送一条“半消息”(也称为“预备消息”)。此时消费者看不到此消息。
- 执行本地事务:秒杀服务执行本地事务(前置校验,在Redis中标记用户状态等)。
- 根据事务结果提交或回滚:
- 如果本地事务成功:向MQ发送一个
Commit
指令,半消息正式投递,下游服务可以消费。 - 如果本地事务失败:向MQ发送一个
Rollback
指令,MQ丢弃该半消息。
- 事务回查机制(关键):如果步骤3之后,MQ长时间(如超时)没有收到秒杀服务的
Commit
或Rollback
指令(比如秒杀服务在提交后宕机了),MQ会主动回调秒杀服务的一个特定接口,询问这个消息的最终状态。秒杀服务需要根据本地事务记录(例如查库或查Redis)返回Commit
或Rollback
。
优点:
- 消息的实时性非常高,无需本地表扫描。
- 实现了本地事务和消息发送的强一致性。
缺点:
- 依赖于MQ的特殊功能,技术选型受限(主要就是RocketMQ)。
- 需要实现“事务回查”接口,增加了些许复杂度。
4、一个典型的秒杀流程
- 用户:点击“秒杀”按钮。
- 网关:限流(对用户ID进行限流 1s/次)、防刷(识别机器人)、合法性校验(校验用户登录状态)。
- 秒杀服务:
- 接收请求,携带用户ID和商品ID。
- 调用Redis,执行
Lua脚本
或DECR
命令预减库存。 - 如果返回结果 < 0,直接返回“库存已售罄”。
- 如果返回结果 >= 0,生成秒杀唯一令牌,并将消息(用户ID, 商品ID, 令牌)放入消息队列。立即返回“抢购中,请等待订单创建”。
- 订单服务:
- 从消息队列消费消息。
- 校验令牌合法性。
- 执行数据库事务,最终扣减库存并创建订单。
- 如果数据库扣减失败(极少数情况,如库存不足),则回滚Redis库存,并更新订单状态为“无效”。
- 如果数据库扣减成功,更新Redis中的订单状态为“待支付”,通知用户下单成功。
- 超时回滚:
- 订单创建后,发送一条延迟15分钟的MQ消息。
- 延迟消息消费者检查订单状态,若未支付,则执行:回滚Redis库存 -> 更新数据库库存(通过MQ异步)-> 关闭订单。
- 支付回调:用户支付成功,回调服务将订单状态更新为“已支付”。
- Author:mcbilla
- URL:http://mcbilla.com/article/25e85c7d-7c1d-80ce-9ba8-c2bb03e51ff2
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!