Python 秒杀系统实战:库存预扣 + 防超卖 极致优化实现

小张是个独立开发者,自己搭了个二手球鞋交易平台。每次限量款发售,他都得提前喝两杯咖啡盯着后台。因为一秒内涌入上千人抢十双鞋,数据库瞬间卡死,卖出了15双------库存成了负数。他下定决心要重写秒杀系统。

场景还原:库存为什么变成负数

先看小张原来的代码:

kotlin 复制代码
def create_order(user_id, product_id):
    product = Product.query.get(product_id)
    if product.stock > 0:
        product.stock -= 1
        db.session.commit()
        create_order_record(user_id, product_id)
        return "成功"
    return "已售罄"

看起来没问题,但高并发下藏着巨大的坑。当两个请求同时读到product.stock = 1,都判断stock > 0成立,然后各自减1提交,库存就从1变成了-1。

问题的根源在于"读-判断-写"不是原子操作。多个请求交错执行,互相看不见对方正要做的修改。

方案一:数据库行锁(最直接的防线)

用数据库的行锁机制,让更新操作变成串行执行。MySQL的InnoDB引擎在更新时会自动锁住这一行。

python 复制代码
def create_order_with_lock(user_id, product_id):
    from sqlalchemy import text
    
    # 用原生SQL加FOR UPDATE,锁住这一行
    sql = text("SELECT stock FROM products WHERE id = :pid FOR UPDATE")
    product = db.session.execute(sql, {"pid": product_id}).fetchone()
    
    if product.stock > 0:
        update_sql = text("UPDATE products SET stock = stock - 1 WHERE id = :pid")
        db.session.execute(update_sql, {"pid": product_id})
        db.session.commit()
        create_order_record(user_id, product_id)
        return "成功"
    db.session.rollback()
    return "已售罄"

FOR UPDATE让第一个拿到锁的事务执行完之前,其他所有请求都在排队等待。这样库存肯定减不超,但性能直线下降------每个请求都得等前一个提交才能继续。

适合并发量不大(每秒几十到一百)的场景。小张的平台高峰时上千人抢,这么搞数据库连接池会瞬间爆满。

方案二:Redis预扣库存(性能飞跃)

把库存从数据库搬到内存里。Redis单线程处理命令,天然就没有并发问题。

python 复制代码
import redis
import json

r = redis.Redis(host='localhost', port=6379, db=0)

def init_seckill(product_id, stock):
    """秒杀活动开始前,把库存存入Redis"""
    r.set(f"stock:{product_id}", stock)
    # 记录已下单的用户,防止重复抢
    r.delete(f"users:{product_id}")

def seckill(user_id, product_id):
    # 用Lua脚本保证原子性
    lua_script = """
    local stock_key = KEYS[1]
    local users_key = KEYS[2]
    local user_id = ARGV[1]
    
    -- 检查是否已经抢过
    if redis.call('sismember', users_key, user_id) == 1 then
        return -1
    end
    
    -- 扣库存
    local stock = redis.call('decr', stock_key)
    if stock >= 0 then
        redis.call('sadd', users_key, user_id)
        return stock
    else
        -- 库存不足,回滚(实际上decr负数后没法回滚,所以先检查)
        return -2
    end
    """
    
    # 改进版:先检查再扣减
    lua_fixed = """
    local stock_key = KEYS[1]
    local users_key = KEYS[2]
    local user_id = ARGV[1]
    
    if redis.call('sismember', users_key, user_id) == 1 then
        return -1
    end
    
    local stock = redis.call('get', stock_key)
    if not stock or tonumber(stock) <= 0 then
        return -2
    end
    
    redis.call('decr', stock_key)
    redis.call('sadd', users_key, user_id)
    return tonumber(stock) - 1
    """
    
    stock_key = f"stock:{product_id}"
    users_key = f"users:{product_id}"
    
    result = r.eval(lua_fixed, 2, stock_key, users_key, user_id)
    
    if result == -1:
        return "您已经抢过了"
    elif result == -2:
        return "已售罄"
    else:
        # 异步写入数据库
        async_save_order(user_id, product_id)
        return f"抢到了,剩余{result}件"

Lua脚本在Redis里是原子执行的,整个过程不会被其他命令打断。decr之前先get检查库存,彻底杜绝超卖。

方案三:消息队列削峰填谷

Redis扛住了抢购请求,但每个成功用户都要写数据库创建订单。上万请求同时写数据库,照样会崩。

用消息队列把写操作变成异步的。用户点击抢购后立刻返回"排队中",后台慢慢处理。

python 复制代码
import pika
import threading
from queue import Queue

# 简单的内存队列(适合单机演示)
order_queue = Queue(maxsize=10000)

def async_save_order(user_id, product_id):
    """生产者:把订单放入队列"""
    order_queue.put({
        "user_id": user_id,
        "product_id": product_id,
        "timestamp": time.time()
    })

def order_worker():
    """消费者:后台线程慢慢写数据库"""
    while True:
        order_data = order_queue.get()
        try:
            # 这里写数据库
            create_order_record(order_data["user_id"], order_data["product_id"])
            print(f"订单已保存: {order_data}")
        except Exception as e:
            print(f"保存失败: {e}")
            # 失败重试逻辑
            time.sleep(1)
            order_queue.put(order_data)
        finally:
            order_queue.task_done()

# 启动4个后台线程并发消费
for _ in range(4):
    t = threading.Thread(target=order_worker, daemon=True)
    t.start()

实际生产环境会用RabbitMQ或Kafka。消费者按数据库能承受的速度慢慢处理,秒杀瞬间的流量洪峰就被削平了。

极致优化:令牌桶限流

即便Redis性能再好,也不可能无限扩展。加上限流,保护系统不被恶意刷单击垮。

python 复制代码
import time

class TokenBucket:
    def __init__(self, rate, capacity):
        self.rate = rate  # 每秒补充的令牌数
        self.capacity = capacity  # 桶的容量
        self.tokens = capacity
        self.last_refill = time.time()
    
    def acquire(self):
        now = time.time()
        # 补充令牌
        elapsed = now - self.last_refill
        self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
        self.last_refill = now
        
        if self.tokens >= 1:
            self.tokens -= 1
            return True
        return False

# 每个商品独立限流器,每秒只放行100个请求
limiter = TokenBucket(rate=100, capacity=100)

def seckill_with_limit(user_id, product_id):
    if not limiter.acquire():
        return "系统繁忙,请稍后再试"
    return seckill(user_id, product_id)

令牌桶比计数器算法更平滑。漏桶强行让请求匀速通过,令牌桶允许短时间突发流量------比如前0.5秒用掉100个令牌,后0.5秒就只能等令牌慢慢补充。

完整实战:从开始到结束

把上面所有组件拼起来,形成一个完整的秒杀流程:

python 复制代码
from flask import Flask, request, jsonify
import redis
import threading
import time

app = Flask(__name__)
r = redis.Redis(decode_responses=True)

# 初始化秒杀活动
def init_seckill(product_id, stock, total_limit=1000):
    r.set(f"stock:{product_id}", stock)
    r.set(f"total_limit:{product_id}", total_limit)
    r.delete(f"users:{product_id}")

# 优化的Lua脚本(检查+扣减+去重)
SECILL_LUA = """
local stock = redis.call('get', KEYS[1])
if not stock or tonumber(stock) <= 0 then
    return 0
end
local user_exists = redis.call('sismember', KEYS[2], ARGV[1])
if user_exists == 1 then
    return -1
end
redis.call('decr', KEYS[1])
redis.call('sadd', KEYS[2], ARGV[1])
return 1
"""

@app.route('/seckill/<int:product_id>')
def seckill_api(product_id):
    user_id = request.args.get('user_id')
    if not user_id:
        return jsonify({"code": 400, "msg": "缺少user_id"})
    
    stock_key = f"stock:{product_id}"
    users_key = f"users:{product_id}"
    
    result = r.eval(SECILL_LUA, 2, stock_key, users_key, user_id)
    
    if result == 0:
        return jsonify({"code": 200, "msg": "已售罄"})
    elif result == -1:
        return jsonify({"code": 200, "msg": "每人限购一件"})
    else:
        # 异步落库
        order_queue.put({"user_id": user_id, "product_id": product_id})
        return jsonify({"code": 200, "msg": "抢购成功,正在处理"})

if __name__ == '__main__':
    init_seckill(1, 10)  # 商品1号,库存10件
    app.run(debug=False, threaded=True)

用wrk或ab压测一下:

ini 复制代码
# 模拟200个并发,总共1000个请求
wrk -t4 -c200 -d10s --timeout=2s "http://localhost:5000/seckill/1?user_id=123"

Redis轻松扛住几千并发,库存始终没超卖,数据库订单表也因为有队列保护而稳如泰山。

踩坑经验分享

Redis挂了怎么办? 秒杀开始前做一次库存全量备份到数据库。Redis宕机时快速降级到数据库行锁方案,虽然慢但不会丢失订单。

用户重复点击怎么办? 前端按钮置灰只做一半工作。真正防重靠Lua脚本里的sismember检查。更彻底的办法是用SETNX给每个用户加一个短时锁:

python 复制代码
def prevent_double_click(user_id, product_id):
    lock_key = f"click_lock:{user_id}:{product_id}"
    if r.setnx(lock_key, 1):
        r.expire(lock_key, 2)  # 2秒过期
        return True
    return False

库存热key问题:几千人抢同一个商品,Redis单节点网卡可能被打满。可以用本地缓存分摊压力:

python 复制代码
from cachetools import TTLCache

local_cache = TTLCache(maxsize=100, ttl=1)  # 1秒过期

def get_stock_with_cache(product_id):
    if product_id in local_cache:
        return local_cache[product_id]
    stock = r.get(f"stock:{product_id}")
    local_cache[product_id] = stock
    return stock

每个服务器节点缓存1秒,把Redis的查询压力降低几十倍。

最终架构图

用户请求 → Nginx限流 → Flask应用 → 令牌桶限流 → Redis预扣库存 → 消息队列 → 数据库落库

每一层都是漏斗结构,流量从外到内逐渐收敛。最外层的Nginx挡住恶意刷量,Redis只处理真正的库存操作,数据库最终只写入成功订单。

小张按照这套架构重写了秒杀系统。下一款限量球鞋发售时,后台监控面板一片绿色,10双鞋在0.3秒内被抢光,库存精准归零。他终于可以不用喝咖啡盯后台了。

相关推荐
Dshuishui2 小时前
Locust 压测网站小工具
python·pip
笨鸟先飞的橘猫2 小时前
数据结构学习——跳表
数据结构·python·学习
21439652 小时前
Less如何构建CSS样式库_通过继承机制优化组件化开发
jvm·数据库·python
qq_413847402 小时前
如何通过 reflect.Value 获取切片的底层值
jvm·数据库·python
zhangchaoxies2 小时前
JavaScript中单线程事件循环EventLoop的卡顿预警
jvm·数据库·python
InfinteJustice2 小时前
Laravel Blade 中高效筛选并限制关联分类数据的实践指南
jvm·数据库·python
2301_815279522 小时前
SQL分组求和结果显示为零的技巧_利用IFNULL或CASE语句
jvm·数据库·python
zhangchaoxies2 小时前
Python Web应用负载均衡方案_结合Nginx权重设置实现高可用
jvm·数据库·python
qq_334563552 小时前
C#怎么操作SQLite加密数据库 C#如何创建和使用加密的SQLite数据库文件保护数据【数据库】
jvm·数据库·python