Deprecated: parse_url(): Passing null to parameter #1 ($url) of type string is deprecated in /www/wwwroot/blog_qqvbc_com/usr/plugins/Access/Access_Core.php on line 339

Deprecated: parse_url(): Passing null to parameter #1 ($url) of type string is deprecated in /www/wwwroot/blog_qqvbc_com/usr/plugins/Access/Access_Core.php on line 392

Deprecated: parse_url(): Passing null to parameter #1 ($url) of type string is deprecated in /www/wwwroot/blog_qqvbc_com/usr/plugins/Access/Access_Core.php on line 394
Redis Lua原子操作 + 电商秒杀超卖超卖完整解决方案 - Joyber 的博客

Redis Lua原子操作 + 电商秒杀超卖超卖完整解决方案

说明:全文极简、重实战、无废话,包含 Redis Lua基础用法 + 原子特性原理 + 高并发秒杀防超卖完整落地方案,可直接用于生产环境。

一、Redis Lua 核心知识点(必懂)

1. 为什么Redis需要Lua脚本

作用:将多条Redis命令打包为单个原子指令执行

  • 普通多条命令:存在并发时差、线程安全问题
  • Lua脚本:执行不插针、不中断、原子串行
  • 解决:并发竞争、超卖、库存错乱、分布式锁竞争问题

2. Redis Lua 执行特性(硬核规则)

  • 单个Lua脚本:要么全部执行成功,要么全部失败
  • 脚本执行期间,其他客户端命令全部阻塞等待
  • 不支持事务回滚,但天然具备事务原子性
  • 性能极高:一次网络IO完成多条命令,减少网络开销

3. Redis Lua 标准语法模板

作用:固定传参、取值、返回格式,生产统一规范

-- KEYS[1]、KEYS[2] 传入key
-- ARGV[1]、ARGV[2] 传入参数值
return redis.call("命令名", KEYS[1], ARGV[1])

4. 常用Redis Lua命令

-- 取值
redis.call("GET", KEYS[1])
-- 设值
redis.call("SET", KEYS[1], ARGV[1])
-- 自增/自减
redis.call("INCR", KEYS[1])
redis.call("DECR", KEYS[1])
-- 判断存在
redis.call("EXISTS", KEYS[1])

5. Java/Python 调用Redis Lua通用格式

Python示例

import redis

r = redis.Redis(decode_responses=True)

# 加载Lua脚本
lua_script = """
return redis.call('GET', KEYS[1])
"""

# 执行脚本:keys列表、args列表
res = r.eval(lua_script, 1, "goods_stock")
print(res)

二、秒杀超卖问题根源

场景:库存10件,1000人同时抢购

普通代码bug流程

  1. 线程A查询库存 = 10
  2. 线程B查询库存 = 10(未扣减,并发同时读到充足库存)
  3. 线程A、B同时扣减 → 库存变为8,卖出12件,超卖

本质原因查库存、判库存、扣库存 非原子操作

三、Lua原子防超卖核心思路

一条Lua脚本完成三件原子操作(不可分割)

  1. 查询当前商品库存
  2. 判断库存是否大于0
  3. 满足则扣减库存,不满足直接返回失败

全程单线程执行,杜绝并发同时判定通过

四、完整可上线:秒杀防超卖Lua脚本

1. 脚本功能说明

  • 支持单商品限购1件(防止单人多抢)
  • 原子判定库存 + 原子扣减
  • 记录已抢购用户,防止重复下单
  • 返回状态码,前端/后端可直接判断结果

2. 最终生产级Lua脚本

-- KEYS[1]:商品库存key
-- KEYS[2]:用户抢购记录key
-- ARGV[1]:用户ID
-- ARGV[2]:本次抢购数量(固定1)

-- 1. 判断用户是否已抢购过(防重复下单)
local has_buy = redis.call("sismember", KEYS[2], ARGV[1])
if has_buy == 1 then
    return 0  -- 0:重复抢购,失败
end

-- 2. 获取当前库存
local stock = tonumber(redis.call("get", KEYS[1]) or 0)

-- 3. 库存不足直接返回
if stock < 1 then
    return -1 -- -1:库存不足,秒杀结束
end

-- 4. 原子扣减库存
redis.call("decr", KEYS[1])

-- 5. 记录用户抢购记录
redis.call("sadd", KEYS[2], ARGV[1])

-- 6. 秒杀成功
return 1

3. 返回码统一规范

  • 1:秒杀成功
  • 0:用户重复抢购
  • -1:库存不足、超卖拦截

五、高并发秒杀完整业务流程(无漏洞)

1. 秒杀预热(提前初始化库存)

# 初始化商品库存100件
SET seckill:goods:1001:stock 100
# 用户抢购记录集合
SADD seckill:goods:1001:user

2. 完整执行链路(层层拦截,抗百万并发)

前端拦截:按钮置灰、防抖、倒计时

网关拦截:限流、防刷、IP限制

Redis Lua原子拦截:防超卖、防重复抢(核心)

异步落库:秒杀成功后,MQ异步写入数据库(扛高并发)

六、Python 完整可运行秒杀Demo

功能:模拟100并发抢10库存,绝对无超卖

import redis
import threading

# 连接Redis
r = redis.Redis(decode_responses=True)

# 秒杀Lua脚本
SECKILL_LUA = """
local has_buy = redis.call("sismember", KEYS[2], ARGV[1])
if has_buy == 1 then
    return 0
end

local stock = tonumber(redis.call("get", KEYS[1]) or 0)
if stock < 1 then
    return -1
end

redis.call("decr", KEYS[1])
redis.call("sadd", KEYS[2], ARGV[1])
return 1
"""

# 初始化库存10
r.set("seckill:stock", 10)

def seckill(user_id):
    """单个用户抢购"""
    res = r.eval(SECKILL_LUA, 2, "seckill:stock", "seckill:user", user_id)
    if res == 1:
        print(f"用户{user_id} 秒杀成功")
    elif res == 0:
        print(f"用户{user_id} 重复抢购")
    else:
        print(f"用户{user_id} 秒杀失败,库存不足")

# 模拟100个并发线程
if __name__ == "__main__":
    thread_list = []
    for i in range(1,101):
        t = threading.Thread(target=seckill, args=(f"user{i}",))
        thread_list.append(t)
        t.start()

    for t in thread_list:
        t.join()

    # 最终库存校验
    final_stock = r.get("seckill:stock")
    print("最终剩余库存:", final_stock)
    print("成功抢购人数:", r.scard("seckill:user"))

七、PHP 完整可运行秒杀Demo(生产可用+高并发模拟)

功能:基于PHP Redis扩展 + Lua原子脚本,模拟高并发秒杀,彻底防超卖、防重复抢购,适配PHP框架(ThinkPHP/Laravel)原生使用

环境要求:安装PHP Redis扩展 pecl install redis

<?php
// 环境要求:安装PHP Redis扩展、开启pcntl多进程扩展(模拟高并发)
// 生产适配:兼容Laravel/ThinkPHP/Swoole所有PHP项目

// 1. 连接Redis
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// 解决Redis超时、并发连接问题
$redis->setOption(Redis::OPT_READ_TIMEOUT, -1);

// 2. 生产级秒杀Lua脚本(原子防超卖、防重复抢购)
$luaScript = <<<LUA
local has_buy = redis.call("sismember", KEYS[2], ARGV[1])
if has_buy == 1 then
    return 0
end

local stock = tonumber(redis.call("get", KEYS[1]) or 0)
if stock < 1 then
    return -1
end

redis.call("decr", KEYS[1])
redis.call("sadd", KEYS[2], ARGV[1])
return 1
LUA;

// 3. 初始化秒杀库存(仅首次执行,可后台预热执行)
$redis->del('seckill:stock');
$redis->del('seckill:user');
$redis->set('seckill:stock', 10);

/**
 * 秒杀核心原子方法
 * @param string $userId 唯一用户ID
 * @return int 1=成功 0=重复抢购 -1=库存不足
 */
function seckill(string $userId): int
{
    global $redis, $luaScript;
    // PHP eval固定参数:脚本内容、[KEYS+ARGV数组]、KEYS参数数量
    return $redis->eval($luaScript, [
        'seckill:stock',
        'seckill:user',
        $userId
    ], 2);
}

/**
 * 多进程模拟高并发秒杀(100用户并发抢10库存)
 */
function batchSeckill()
{
    $processNum = 100;
    for ($i = 1; $i <= $processNum; $i++) {
        $pid = pcntl_fork();
        if ($pid == 0) {
            // 子进程执行秒杀
            $userId = 'user_' . $i;
            $res = seckill($userId);
            switch ($res) {
                case 1:
                    echo "【成功】{$userId} 秒杀下单完成\n";
                    break;
                case 0:
                    echo "【拦截】{$userId} 禁止重复抢购\n";
                    break;
                case -1:
                    echo "【售罄】{$userId} 库存不足,秒杀失败\n";
                    break;
            }
            // 必须退出子进程,避免循环派生
            exit(0);
        }
    }

    // 等待所有子进程执行完毕
    while (pcntl_waitpid(0, $status) != -1);
}

// 执行高并发秒杀模拟
batchSeckill();

// 4. 最终数据校验(杜绝超卖核心校验)
global $redis;
$finalStock = $redis->get('seckill:stock');
$successNum = $redis->sCard('seckill:user');

echo "\n========== 秒杀最终统计结果 ==========\n";
echo "剩余库存数量:{$finalStock}\n";
echo "成功下单人数:{$successNum}\n";
echo "校验结果:" . ($finalStock + $successNum == 10 ? "✅ 无超卖、无漏卖,数据正常" : "❌ 数据异常") . "\n";

$redis->close();
?>

一、PHP版本核心特性说明

  • 原子性互通:Lua脚本与Python版本完全一致,底层原子锁库存逻辑通用,跨语言无差异
  • PHP独有规则:Redis eval方法参数顺序固定,必须严格区分KEYS数量,否则脚本执行报错
  • 框架无缝适配:可直接移植 Laravel/ThinkPHP/Swoole 项目,仅需替换框架Redis实例
  • 高并发适配性:基于多进程模拟真实秒杀流量,兼容PHP-FPM、Swoole协程环境,分布式集群完全生效
  • 数据强一致性:最终库存+成功人数恒等于初始库存,彻底杜绝超卖、少卖、漏卖问题

二、PHP生产部署注意事项

  • 线上关闭pcntl多进程代码,仅用业务接口接收请求,多进程仅用于本地压测验证
  • Lua脚本建议使用script load预加载缓存,通过sha1值调用,减少网络传输开销
  • 秒杀Redis Key设置过期时间,自动清理过期秒杀数据,避免内存堆积
  • 接口层增加限流、防刷、IP黑白名单,拦截恶意黄牛请求
  • 脚本预加载优化:生产环境禁止每次请求传递完整Lua脚本,采用script\_load预加载+sha1哈希调用,大幅减少网络IO、提升并发吞吐量

三、PHP Lua脚本缓存(SHA1)生产优化代码

优化作用:首次加载脚本生成唯一SHA1哈希值,后续所有秒杀请求直接通过哈希值调用脚本,无需重复传输大段脚本内容,高并发场景性能提升30%+

<?php
// 连接Redis
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->setOption(Redis::OPT_READ_TIMEOUT, -1);

// 秒杀Lua脚本
$luaScript = <<<LUA
local has_buy = redis.call("sismember", KEYS[2], ARGV[1])
if has_buy == 1 then
    return 0
end
local stock = tonumber(redis.call("get", KEYS[1]) or 0)
if stock < 1 then
    return -1
end
redis.call("decr", KEYS[1])
redis.call("sadd", KEYS[2], ARGV[1])
return 1
LUA;

// 核心:预加载脚本,获取SHA1哈希值(项目初始化执行一次即可)
$sha1Hash = $redis->script('load', $luaScript);
echo "脚本缓存SHA1值:" . $sha1Hash . "\n";

/**
 * 缓存版秒杀方法(生产正式使用)
 * 无需传递完整脚本,仅通过SHA1调用,性能更高
 */
function fastSeckill(string $userId, string $sha1Hash): int
{
    global $redis;
    // evalsha 哈希调用脚本
    return $redis->evalsha($sha1Hash, [
        'seckill:stock',
        'seckill:user',
        $userId
    ], 2);
}

// 初始化库存
$redis->del('seckill:stock', 'seckill:user');
$redis->set('seckill:stock', 10);

// 测试缓存版秒杀
$res = fastSeckill('user_001', $sha1Hash);
var_dump($res);

// 脚本状态校验(判断脚本是否已缓存)
$isExists = $redis->script('exists', $sha1Hash);
echo "脚本是否缓存成功:" . ($isExists[0] ? "是" : "否") . "\n";

$redis->close();
?>

四、脚本缓存生产高可用方案(自动降级容错)

核心解决问题:Redis重启、缓存脚本丢失、evalsha执行失败等线上突发异常,无感知自动降级,保障秒杀服务不宕机、不报错

<?php
// 连接Redis
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->setOption(Redis::OPT_READ_TIMEOUT, -1);

// 原始秒杀Lua脚本
$luaScript = <<<LUA
local has_buy = redis.call("sismember", KEYS[2], ARGV[1])
if has_buy == 1 then
    return 0
end
local stock = tonumber(redis.call("get", KEYS[1]) or 0)
if stock < 1 then
    return -1
end
redis.call("decr", KEYS[1])
redis.call("sadd", KEYS[2], ARGV[1])
return 1
LUA;

// 项目初始化预加载脚本,全局缓存SHA1值
$sha1Hash = $redis->script('load', $luaScript);

/**
 * 高可用秒杀方法(缓存优先 + 自动降级)
 * 优先evalsha高性能调用,失败自动降级eval原生调用
 * @param string $userId 用户唯一ID
 * @return int 1成功 0重复 -1库存不足
 */
function safeSeckill(string $userId): int
{
    global $redis, $sha1Hash, $luaScript;

    $keys = ['seckill:stock', 'seckill:user', $userId];
    $keyNum = 2;

    try {
        // 优先使用缓存脚本(高性能)
        return $redis->evalsha($sha1Hash, $keys, $keyNum);
    } catch (RedisException $e) {
        // 捕获脚本不存在异常,自动降级原生执行
        if (str_contains($e->getMessage(), 'NOSCRIPT')) {
            // 重新加载脚本缓存,修复异常
            $GLOBALS['sha1Hash'] = $redis->script('load', $luaScript);
            // 降级执行完整脚本
            return $redis->eval($luaScript, $keys, $keyNum);
        }
        // 其他异常直接抛出
        throw $e;
    }
}

// 初始化秒杀库存
$redis->del('seckill:stock', 'seckill:user');
$redis->set('seckill:stock', 10);

// 模拟多次秒杀调用,验证容错性
for ($i = 1; $i <= 5; $i++) {
    $res = safeSeckill('user_' . $i);
    echo "用户user_{$i} 结果:" . ($res === 1 ? "秒杀成功" : "失败") . "\n";
}

// 最终数据校验
$finalStock = $redis->get('seckill:stock');
$successNum = $redis->sCard('seckill:user');
echo "\n========== 高可用秒杀结果 ==========\n";
echo "剩余库存:{$finalStock}\n";
echo "成功人数:{$successNum}\n";

$redis->close();
?>

五、高可用方案核心特性

  • 智能优先策略:正常场景使用 evalsha 缓存调用,性能拉满
  • 无感自动降级:捕捉 NOSCRIPT 脚本丢失异常,自动重新加载脚本并降级原生执行
  • 自愈修复能力:异常后自动更新全局SHA1缓存,后续请求恢复高性能模式
  • 业务零影响:整个容错过程无报错、无中断,用户完全感知不到

六、脚本缓存生产使用规范

  • 加载时机:项目启动、框架初始化时执行一次 script load,常驻Redis缓存,无需重复加载
  • 缓存有效期:Redis不主动清理缓存脚本,重启Redis后依赖自动降级逻辑自愈
  • 统一复用:全局统一SHA1常量,所有秒杀接口共用缓存脚本,避免重复加载
  • 异常监控:线上可捕获降级日志,用于监控Redis脚本缓存异常频次,及时排查问题
  • 加载时机:项目启动、框架初始化时执行一次 script load,常驻Redis缓存,无需重复加载
  • 缓存有效期:Redis不主动清理缓存脚本,重启Redis后需重新加载
  • 降级方案:若evalsha报错(脚本丢失),自动降级为eval完整脚本加载,保障服务不中断
  • 统一复用:全局统一SHA1常量,所有秒杀接口共用缓存脚本,避免重复加载

八、彻底解决超卖超买的核心原理总结

1. 为什么普通代码会超卖

查库存、判库存、扣库存 三步非原子,并发间隙导致多人同时抢到资格

2. 为什么Lua能彻底杜绝超卖

Redis单线程执行Lua脚本,整个秒杀逻辑串行原子执行,无间隙、无并发穿插,从底层锁死库存竞争

九、生产环境额外优化(高阶)

  • 脚本缓存:使用 script load + sha1 缓存脚本,不用每次传完整脚本
  • 过期时间:秒杀key设置过期时间,自动释放内存
  • 限流熔断:接口层增加QPS限流,避免打垮Redis
  • 异步落库:Redis只做预扣减,MQ异步更新MySQL,提升吞吐量
  • 防黄牛:绑定用户、IP、设备号,限制单用户限购次数

十、常见误区避坑

  • 误区:Java加锁(synchronized/lock)防超卖

问题:分布式集群部署,单机锁完全失效

  • 误区:Redis多条普通命令判库存

问题:命令之间存在并发空隙,必然超卖

  • 唯一稳妥方案Redis Lua原子脚本(行业通用秒杀最终方案)

标签: none

添加新评论