秒杀系统的数据库架构设计:热点隔离、库存扣减与异步排队的铁三角
一、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原子扣减是防止超卖的关键实现,幂等性检查是防止重复扣减的安全网。最重要的工程经验是:不要试图让数据库承受秒杀级的并发——即使硬件上可行,成本也是惊人的。将数据库定位为"最终的持久化存储"而非"实时的事务处理引擎",用缓存和消息队列在中间做缓冲。