1. 秒杀系统的本质与整体设计思路
秒杀系统这个词听起来挺唬人,但拆开来看,它要解决的核心问题就一个:在极短时间内,把大量并发请求挡在核心业务逻辑之外,只让极少数有效请求真正落到数据库上。我见过不少团队一上来就堆中间件,Redis、MQ、Nginx 全上,结果压测一跑还是崩,原因往往不是工具不够多,而是没想清楚请求该在哪一层被拦截、哪一层被削峰、哪一层做最终一致性。
先说说秒杀场景的典型特征。一场活动可能只有几百件库存,但瞬间涌入的请求量可能是几十万甚至上百万。这些请求里,90% 以上都是无效的——要么库存已经没了,要么用户根本没资格买,要么就是重复提交。如果每个请求都去查数据库、扣库存,数据库连接池瞬间就被打满,整个服务雪崩。所以秒杀系统的设计哲学不是“让所有请求都成功”,而是“用最低的成本把无效请求快速拒绝掉”。
整体架构我一般分成四层来考虑。第一层是接入层,负责限流、黑名单、防重放;第二层是缓存层,用 Redis 做库存预扣和结果缓存,把大部分请求挡在数据库之前;第三层是异步层,用消息队列把扣减成功的请求异步落库,实现最终一致性;第四层是数据层,数据库只承担最终的持久化职责,不参与高并发争抢。这四层各司其职,任何一层出问题都不会直接把压力传导到最底层。
为什么这么分层?因为秒杀的本质是资源争抢,而资源争抢最怕的就是“所有人同时抢同一把锁”。分层之后,每一层都可以独立做限流和降级。比如接入层发现某个 IP 请求频率异常,直接拒绝;缓存层发现库存为零,直接返回失败;异步层积压过多,可以临时扩容消费者。这种设计让系统具备弹性,而不是一根筋地硬扛。
还有一个容易被忽略的点:秒杀系统的读请求远多于写请求。商品详情页、活动状态、库存余量这些信息,用户会反复刷新查看。如果这些读请求也走数据库,那数据库根本撑不住。所以缓存不仅要存库存,还要存商品信息、活动状态、用户资格等所有高频读取的数据。读走缓存、写走队列,这是秒杀系统的基本功。
2. 核心细节解析与实操要点
2.1 库存预扣与 Redis 原子操作
库存预扣是整个秒杀系统最核心的环节。我见过有人用GET查库存再SET扣减,这种写法在并发场景下必然超卖。正确的做法是用 Redis 的原子操作,比如DECR或者 Lua 脚本。DECR的好处是简单直接,但它有个问题:如果库存扣到负数,你没法区分是正常扣减还是超卖。所以更稳妥的方案是用 Lua 脚本,把“判断库存是否大于零”和“扣减库存”放在一个原子操作里执行。
-- 库存预扣 Lua 脚本 local stock = redis.call('GET', KEYS[1]) if not stock then return -1 -- 库存不存在 end if tonumber(stock) <= 0 then return 0 -- 库存不足 end redis.call('DECR', KEYS[1]) return 1 -- 扣减成功这个脚本在 Redis 里执行时是单线程原子性的,不会出现两个请求同时读到相同库存的情况。返回值的含义要提前和业务层约定好:-1 表示活动未初始化,0 表示已售罄,1 表示扣减成功。业务层根据返回值决定是返回“已抢完”还是继续走下单流程。
注意:Lua 脚本里的
KEYS[1]必须是库存键名,不要在里面拼接用户 ID 之类的动态内容,否则会导致 Redis 集群模式下键槽不一致的问题。
2.2 用户维度去重与限购
库存扣减解决了“不超卖”,但还没解决“同一用户重复抢”。秒杀场景下,用户可能会疯狂点击按钮,或者用脚本刷接口。如果不做用户维度去重,一个人可能抢走多件商品。常见的做法是用 Redis 的SETNX给每个用户加一把分布式锁,键名可以是seckill:lock:活动ID:用户ID,过期时间设短一点,比如 5 秒。
但SETNX有个坑:如果用户第一次请求扣减成功但后续下单失败,锁释放了,用户还能再抢一次。所以更严谨的做法是记录“用户已抢购”的状态,而不是简单的锁。可以用 Redis 的SADD把用户 ID 加到一个集合里,集合的键名是seckill:users:活动ID。每次请求先判断用户是否在集合里,不在才允许扣减库存,扣减成功后再把用户加进去。这样即使用户后续下单失败,也不会重复抢购。
import redis r = redis.Redis() def seckill(activity_id, user_id): user_set_key = f"seckill:users:{activity_id}" stock_key = f"seckill:stock:{activity_id}" # 先判断用户是否已经抢过 if r.sismember(user_set_key, user_id): return "您已经抢购过了" # 执行库存预扣 Lua 脚本 result = r.eval(LUA_SCRIPT, 1, stock_key) if result == 0: return "已售罄" elif result == -1: return "活动未开始" # 扣减成功,记录用户 r.sadd(user_set_key, user_id) return "抢购成功,请尽快下单"这段代码的逻辑是先查用户集合,再扣库存,最后记录用户。看起来没问题,但在极端并发下,两个请求可能同时通过sismember检查,然后都去扣库存。所以更安全的做法是把“判断用户是否已抢”也放进 Lua 脚本里,和库存扣减一起原子执行。这样虽然脚本复杂一点,但能彻底杜绝并发漏洞。
2.3 消息队列削峰与异步落库
库存预扣成功后,真正的订单创建和数据库扣减不能同步做,否则数据库还是会被打爆。这时候消息队列就派上用场了。把扣减成功的请求封装成消息,扔进队列,后端消费者慢慢处理。队列的作用是削峰填谷:瞬间涌入的请求先堆在队列里,消费者按照自己的处理能力匀速消费,数据库压力就平稳了。
消息队列的选型上,Kafka 吞吐量高但运维复杂,RabbitMQ 功能全但性能稍弱,RocketMQ 在电商场景下比较常见。如果团队规模不大,我建议先用 Redis 的 List 或者 Stream 做轻量级队列,等业务量真的上来了再换专业 MQ。Redis List 的LPUSH和BRPOP就能实现一个简单的生产者-消费者模型,虽然可靠性不如专业 MQ,但胜在简单、无需额外运维。
# 生产者:扣减成功后发送消息 def send_order_message(activity_id, user_id): message = { "activity_id": activity_id, "user_id": user_id, "timestamp": time.time() } r.lpush("seckill:order:queue", json.dumps(message)) # 消费者:异步处理订单 def consume_order_message(): while True: _, msg = r.brpop("seckill:order:queue", timeout=5) if msg: data = json.loads(msg) create_order_in_db(data["activity_id"], data["user_id"])消费者处理订单时要注意幂等性。因为网络抖动或者消费者重启,同一条消息可能被处理多次。可以在数据库里给订单表加一个唯一索引,比如(activity_id, user_id)联合唯一,这样重复插入会直接报错,消费者捕获异常后忽略即可。
3. 实操过程与核心环节实现
3.1 环境准备与基础配置
动手之前先把环境搭好。我一般用 Docker Compose 来编排 Redis、MySQL 和消息队列,这样本地开发和测试都方便。Redis 建议用 6.x 以上版本,支持 Lua 脚本的原子执行;MySQL 用 8.0,方便做窗口函数和 CTE 查询;消息队列看团队习惯,用 Redis List 的话连 MQ 都不用装。
version: '3.8' services: redis: image: redis:7-alpine ports: - "6379:6379" command: redis-server --appendonly yes mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: seckill123 MYSQL_DATABASE: seckill_db ports: - "3306:3306" volumes: - ./init.sql:/docker-entrypoint-initdb.d/init.sql数据库表设计上,至少需要三张表:活动表、库存表、订单表。活动表存活动基本信息,库存表存每个活动的库存余量,订单表存用户下单记录。库存表要加一个版本号字段,用于乐观锁更新。
CREATE TABLE `seckill_activity` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(128) NOT NULL, `start_time` DATETIME NOT NULL, `end_time` DATETIME NOT NULL, `status` TINYINT DEFAULT 0 ); CREATE TABLE `seckill_stock` ( `activity_id` BIGINT PRIMARY KEY, `total_stock` INT NOT NULL, `available_stock` INT NOT NULL, `version` INT DEFAULT 0 ); CREATE TABLE `seckill_order` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `activity_id` BIGINT NOT NULL, `user_id` BIGINT NOT NULL, `status` TINYINT DEFAULT 0, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_activity_user` (`activity_id`, `user_id`) );3.2 活动初始化与库存预热
活动开始前,要把库存从数据库加载到 Redis。这一步叫库存预热。预热的时机很关键:太早的话,活动还没开始,库存就被刷没了;太晚的话,活动开始了库存还没加载完,用户请求全部落空。我一般是在活动开始前 5 分钟做预热,同时把活动状态设为“即将开始”,用户请求进来先判断状态,状态不对直接拒绝。
def preload_stock(activity_id): # 从数据库读取库存 stock = db.query("SELECT available_stock FROM seckill_stock WHERE activity_id = %s", activity_id) if not stock: raise Exception("活动不存在") # 写入 Redis stock_key = f"seckill:stock:{activity_id}" r.set(stock_key, stock["available_stock"]) # 初始化用户集合(清空历史数据) user_set_key = f"seckill:users:{activity_id}" r.delete(user_set_key) # 设置活动状态 r.set(f"seckill:status:{activity_id}", "ready")预热的时候要注意:如果活动是重复开启的(比如每天一场),每次开启前都要清空用户集合,否则用户第二天就抢不了了。但清空用户集合和设置库存之间有个时间窗口,如果这时候有请求进来,可能会出问题。所以最好用 Redis 的MULTI事务把这两个操作包起来,保证原子性。
3.3 完整秒杀接口实现
把前面的碎片拼起来,一个完整的秒杀接口大概长这样。接口收到请求后,先做参数校验和活动状态检查,然后执行 Lua 脚本做库存预扣和用户去重,扣减成功后发送消息到队列,最后返回“抢购成功,请尽快下单”。
from flask import Flask, request, jsonify import redis import json import time app = Flask(__name__) r = redis.Redis(host='localhost', port=6379, decode_responses=True) LUA_SCRIPT = """ local stock_key = KEYS[1] local user_set_key = KEYS[2] local user_id = ARGV[1] -- 判断用户是否已抢 if redis.call('SISMEMBER', user_set_key, user_id) == 1 then return -2 -- 重复抢购 end -- 判断库存 local stock = redis.call('GET', stock_key) if not stock then return -1 -- 活动未初始化 end if tonumber(stock) <= 0 then return 0 -- 已售罄 end -- 扣减库存并记录用户 redis.call('DECR', stock_key) redis.call('SADD', user_set_key, user_id) return 1 -- 成功 """ @app.route('/seckill', methods=['POST']) def seckill(): data = request.get_json() activity_id = data.get('activity_id') user_id = data.get('user_id') # 参数校验 if not activity_id or not user_id: return jsonify({"code": 400, "msg": "参数错误"}) # 检查活动状态 status = r.get(f"seckill:status:{activity_id}") if status != "ready": return jsonify({"code": 403, "msg": "活动未开始或已结束"}) # 执行 Lua 脚本 stock_key = f"seckill:stock:{activity_id}" user_set_key = f"seckill:users:{activity_id}" result = r.eval(LUA_SCRIPT, 2, stock_key, user_set_key, user_id) if result == -2: return jsonify({"code": 409, "msg": "您已经抢购过了"}) elif result == -1: return jsonify({"code": 404, "msg": "活动不存在"}) elif result == 0: return jsonify({"code": 410, "msg": "已售罄"}) # 发送消息到队列 message = { "activity_id": activity_id, "user_id": user_id, "timestamp": time.time() } r.lpush("seckill:order:queue", json.dumps(message)) return jsonify({"code": 200, "msg": "抢购成功,请尽快下单"})这个接口在单机 Redis 下能扛住几万 QPS,但如果 Redis 是集群模式,Lua 脚本里的两个键必须在同一个槽位上,否则会报错。解决办法是在键名里加一个固定的哈希标签,比如{seckill}:stock:123和{seckill}:users:123,这样两个键会被分配到同一个槽。
3.4 异步消费者与订单落库
消费者从队列里取消息,创建订单并扣减数据库库存。数据库扣减要用乐观锁,防止并发更新导致数据不一致。
def create_order_in_db(activity_id, user_id): conn = get_db_connection() cursor = conn.cursor() try: # 开启事务 cursor.execute("BEGIN") # 查询当前库存和版本号 cursor.execute( "SELECT available_stock, version FROM seckill_stock WHERE activity_id = %s FOR UPDATE", (activity_id,) ) row = cursor.fetchone() if not row or row["available_stock"] <= 0: cursor.execute("ROLLBACK") return False # 乐观锁更新库存 new_version = row["version"] + 1 cursor.execute( "UPDATE seckill_stock SET available_stock = available_stock - 1, version = %s " "WHERE activity_id = %s AND version = %s", (new_version, activity_id, row["version"]) ) if cursor.rowcount == 0: cursor.execute("ROLLBACK") return False # 创建订单 cursor.execute( "INSERT INTO seckill_order (activity_id, user_id, status) VALUES (%s, %s, 1)", (activity_id, user_id) ) cursor.execute("COMMIT") return True except Exception as e: cursor.execute("ROLLBACK") raise e finally: cursor.close() conn.close()这里用FOR UPDATE加行锁,再配合版本号做乐观锁,双重保险。实际上如果队列消费是单线程的,行锁就够了;但如果是多消费者并发消费,乐观锁能避免版本冲突导致的更新丢失。
4. 常见问题与排查技巧实录
4.1 库存超卖与少卖问题排查
超卖是秒杀系统最致命的问题。我排查超卖一般按这个顺序来:先看 Redis 库存扣减是不是原子操作,再看数据库扣减有没有加锁,最后看消息队列有没有重复消费。如果 Redis 用的是DECR而不是 Lua 脚本,那超卖几乎是必然的,因为DECR不判断库存是否大于零,扣到负数也照扣不误。
少卖的问题更隐蔽。有时候 Redis 库存扣完了,但数据库库存还有剩余,原因是消息队列里的消息丢了,或者消费者处理失败没有重试。解决办法是给消息队列加持久化,消费者处理失败时把消息重新入队,同时记录失败日志,方便后续人工补偿。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 库存扣成负数 | Redis 未做原子判断 | 检查是否用了 Lua 脚本 | 改用 Lua 脚本扣减 |
| 数据库库存与 Redis 不一致 | 消息丢失或消费失败 | 对比两边库存差值 | 消息持久化 + 失败重试 |
| 同一用户抢到多件 | 用户去重逻辑有并发漏洞 | 压测模拟同一用户并发请求 | 用户判断放入 Lua 脚本 |
| 活动开始后库存未加载 | 预热时机太晚或失败 | 检查预热日志和 Redis 键 | 提前预热 + 预热失败告警 |
4.2 Redis 热点 Key 与集群倾斜
秒杀场景下,库存键是典型的热点 Key。所有请求都去读写同一个键,单节点 Redis 很容易被打满。如果用的是 Redis 集群,热点 Key 会导致某个节点负载远高于其他节点,这就是集群倾斜。解决办法有两个:一是把库存拆成多个子库存,比如总库存 1000,拆成 10 个键各 100,请求按用户 ID 哈希到不同键上;二是用本地缓存做一级缓冲,在应用层先挡掉一部分请求。
拆分子库存的方案要注意:每个子库存的扣减还是原子的,但总库存的汇总需要额外处理。可以在 Redis 里维护一个总库存键,子库存扣减成功后异步更新总库存。这样虽然总库存有短暂延迟,但用户看到的是“剩余库存”而不是“精确库存”,延迟几秒完全可以接受。
4.3 接口防刷与限流策略
秒杀接口是最容易被刷的。我见过有人用脚本每秒发几千个请求,直接把服务打挂。防刷要从多个维度入手:IP 维度、用户维度、设备维度。IP 维度可以用 Nginx 的limit_req模块做限流,用户维度可以在 Redis 里记录每个用户的请求频率,超过阈值直接拒绝。
# Nginx 限流配置 limit_req_zone $binary_remote_addr zone=seckill_limit:10m rate=10r/s; server { location /seckill { limit_req zone=seckill_limit burst=20 nodelay; proxy_pass http://backend; } }这段配置的意思是:每个 IP 每秒最多 10 个请求,突发允许 20 个,超过的直接返回 503。nodelay表示突发请求立即处理,不排队。实际压测下来,这个配置能挡掉大部分脚本刷接口的请求。
注意:限流阈值要根据实际业务调整。如果活动太火爆,10r/s 可能还是太高,可以降到 5r/s 甚至 2r/s。但也不能太低,否则正常用户点两下就被限了,体验很差。
4.4 压测与容量评估
上线前一定要压测。我一般用 wrk 或者 JMeter 模拟并发请求,观察 QPS、响应时间、错误率三个指标。压测的目标不是看系统能扛多少,而是找到瓶颈在哪一层。如果 Redis 先扛不住,就加 Redis 节点或者拆热点 Key;如果应用层 CPU 打满,就加机器;如果数据库先挂,就优化 SQL 或者加缓存。
容量评估有个简单的估算方法:假设活动预计有 10 万人参与,每人平均点 5 次,总请求量 50 万。活动持续 1 分钟,平均 QPS 约 8300。但秒杀的特点是请求集中在开始后的前 10 秒,所以峰值 QPS 可能是平均值的 10 倍以上,也就是 8 万左右。按这个峰值来准备资源,再留 50% 的余量,基本就稳了。
| 指标 | 正常范围 | 告警阈值 | 处理措施 |
|---|---|---|---|
| Redis QPS | < 5万 | > 8万 | 拆热点 Key 或加节点 |
| 应用 CPU | < 60% | > 80% | 扩容应用实例 |
| 数据库连接数 | < 50% | > 80% | 优化 SQL 或加缓存 |
| 消息队列积压 | < 1000 | > 10000 | 增加消费者实例 |
5. 个人实操心得与避坑建议
秒杀系统这东西,纸上谈兵和实际跑起来差距很大。我踩过最深的坑是过度设计。一开始就上 Kafka、上分布式锁、上分库分表,结果活动当天流量根本没那么大,白白增加了运维复杂度。后来学乖了,先用最简单的方案跑通,压测发现瓶颈再针对性优化。Redis + Lua + List 队列这套组合,扛个几万 QPS 完全没问题,中小型活动根本用不着上重型中间件。
另一个坑是忽略降级预案。秒杀系统再稳,也可能出意外。Redis 挂了怎么办?消息队列堵了怎么办?我的做法是提前准备好降级开关:Redis 不可用时直接返回“活动太火爆,请稍后再试”,而不是让请求穿透到数据库;消息队列积压超过阈值时,暂停接收新请求,优先消费存量消息。这些开关要能在管理后台一键切换,出问题时不用改代码重新发布。
还有个小技巧:库存扣减成功后不要立刻返回“抢购成功”,而是返回“排队中”。用户看到“排队中”会继续等待,这时候后端慢慢处理订单,处理完了再通过轮询或者推送通知用户。这样做的好处是用户体验更平滑,不会因为瞬间大量“成功”提示导致前端渲染压力过大。而且“排队中”给了系统缓冲时间,即使订单处理稍慢,用户也不会觉得异常。
最后说个关于测试数据的事。压测的时候不要用真实用户 ID,也不要用连续的数字 ID,因为连续 ID 在 Redis 里哈希分布不均匀,会导致热点 Key 问题。用随机生成的 UUID 或者打散的 ID,这样压测结果才接近真实场景。我见过有人用 1 到 10000 的连续 ID 压测,结果 Redis 某个节点 CPU 打满,其他节点闲得发慌,这就是数据分布不均导致的假瓶颈。