秒杀系统的数据库架构设计:热点隔离、库存扣减与异步排队的铁三角
一、100万人抢1000台手机,数据库连接池瞬间打满
秒杀是对数据库最极端的压力测试。当100万用户在同一秒钟点击"抢购"按钮时,1000台手机的库存要在这100万请求中原子扣减且不能超卖。传统做法是直接在MySQL中UPDATE stock SET count=count-1 WHERE id=12345 AND count>0——在1000QPS下工作良好,但在10万QPS下连接池迅速耗尽,大量请求排队等待锁释放,最终超时告终。
秒杀场景的独特挑战在于"热点集中"——100%的请求都打在同一个SKU的同一行库存记录上。在InnoDB中,这行数据被频繁加锁和修改,行锁争抢导致大量线程在等待,CPU花在锁调度上而非实际处理业务。当等待队列超过innodb_thread_concurrency限制时,新请求被拒绝——这就是秒杀期间大量用户看到"系统繁忙"的根因。
二、秒杀数据库的三层解耦:热点缓存、事务排队与最终一致
第一层:网关限流。前端的100万并发本质上不可能也不需要在数据库层处理。网关层使用令牌桶算法将流入量控制在10万QPS以内——即使Redis处理能力远超这个值,也不能让更多的请求进入内层,因为后端MySQL的消费能力是固定的(约1000 QPS)。
第二层:Redis原子库存扣减。使用Lua脚本实现检查库存+扣减的原子操作,单分片Redis轻松支持10万QPS。扣减成功后生成一个唯一Token返回给用户,同时将扣减事件推入Redis List作为排队队列。用户拿到Token后进入"排队中"状态。
第三层:异步消费落库。后端消费者以固定速率(如1000/s)从Redis队列中消费扣减事件,逐个写入MySQL并创建订单。这个消费速率由MySQL的写入能力决定,不能超过它。消费者单线程处理消除了MySQL行锁争抢。
三、基于Redis Lua的原子库存扣减实现
— lua/stock_deduct.lua
— Redis Lua脚本:原子库存扣减+生成排队Token
— KEYS[1]: stock_key (库存Key)
— KEYS[2]: queue_key (排队队列Key)
— ARGV[1]: user_id
— ARGV[2]: request_id (幂等Key)
— ARGV[3]: max_per_user (每用户限购数量)
local stock_key = KEYS[1]
local queue_key = KEYS[2]
local user_id = ARGV[1]
local request_id = ARGV[2]
local max_per_user = tonumber(ARGV[3]) or 1
— 幂等性检查
local idempotent_key = 'seckill:idempotent:' .. request_id
if redis.call('EXISTS', idempotent_key) == 1 then
return {0, 'duplicate_request', request_id}
end
— 用户限购检查
local user_bought_key = 'seckill:user_bought:' .. user_id
local user_bought = tonumber(redis.call('GET', user_bought_key)) or 0
if user_bought >= max_per_user then
return {0, 'user_limit_exceeded', user_id}
end
— 库存检查与扣减
local stock = tonumber(redis.call('GET', stock_key)) or 0
if stock <= 0 then
return {0, 'sold_out', 0}
end
— 扣减库存
local remaining = redis.call('DECR', stock_key)
if remaining < 0 then
— 超卖回滚
redis.call('INCR', stock_key)
return {0, 'sold_out', 0}
end
— 标记幂等(60秒过期,防止重复请求)
redis.call('SETEX', idempotent_key, 60, '1')
— 更新用户购买计数
redis.call('INCR', user_bought_key)
redis.call('EXPIRE', user_bought_key, 86400) — 24h过期
— 生成Token并入队
local token = user_id .. '_' .. request_id .. '_' .. redis.call('TIME')[1]
redis.call('LPUSH', queue_key, token)
return {1, 'queued', token}
# consumer/mysql_writer.py
import redis
import pymysql
import logging
import time
logger = logging.getLogger(__name__)
class SeckillConsumer:
"""秒杀异步消费者:从Redis队列消费到MySQL"""
def __init__(self, redis_client, mysql_conn, batch_size: int = 10):
self.redis = redis_client
self.mysql = mysql_conn
self.batch_size = batch_size
def consume(self, queue_key: str, stock_table: str):
"""消费秒杀队列"""
while True:
tokens = []
# 批量获取Token
for _ in range(self.batch_size):
token = self.redis.rpop(queue_key)
if token:
tokens.append(token.decode())
if not tokens:
logger.debug("Queue empty, sleeping…")
time.sleep(0.1)
continue
# 批量写入MySQL
try:
cursor = self.mysql.cursor()
for token in tokens:
user_id, request_id, _ = token.split('_')
# 实际扣减库存+创建订单
cursor.execute(f"""
UPDATE {stock_table}
SET stock = stock – 1
WHERE sku_id = 12345 AND stock > 0
""")
if cursor.rowcount == 0:
logger.error(f"MySQL stock deduction failed for {token}")
# 补偿:回滚Redis库存
self.redis.incr('seckill:stock:12345')
continue
self.mysql.commit()
logger.info(f"Batch processed: {len(tokens)} orders")
except Exception as e:
self.mysql.rollback()
logger.error(f"Consumer batch failed: {e}")
# 重新入队或写入死信队列
for token in tokens:
self.redis.lpush(queue_key + ':dlq', token)
四、超卖0容忍 vs 少卖可接受:不同业务的库存一致性策略
不同业务对库存一致性的要求是天差地别的。手机秒杀要求0超卖——多卖一台就要赔一台(赔偿用户或重新生产),但少卖几台完全可以接受(库存可以下次活动继续卖)。机票超售则可以容忍一定比例的超卖,因为总有用户退票改签——这个比例通过历史数据模型来优化。金融理财产品则要求精确一致——卖出的份额必须严格等于实际库存,少卖和多卖都是合规问题。
一致性策略的选择直接决定了架构复杂度。0超卖场景使用Redis原子扣减+串行消费到MySQL能够满足要求。容忍少卖的场景甚至不需要Redis——直接MySQL行锁扣减简单可靠。精确一致的金融场景则需要TCC两阶段提交+日终对账的完整保障。
五、总结
秒杀数据库架构的核心思路是"在数据库之前截流",通过网关限流→Redis原子库存→异步持久化三层解耦,将数据库压力从10万QPS降低到1000QPS。Redis Lua原子扣减是防止超卖的关键实现,幂等性检查是防止重复扣减的安全网。最重要的工程经验是:不要试图让数据库承受秒杀级的并发——即使硬件上可行,成本也是惊人的。将数据库定位为"最终的持久化存储"而非"实时的事务处理引擎",用缓存和消息队列在中间做缓冲。

