PHP Redis 原子扣减

wen PHP项目 3

本文目录导读:

PHP Redis 原子扣减

  1. 最简方式:decr/decrBy(推荐用于简单场景)
  2. Lua 脚本方式(推荐用于需要校验的场景)
  3. 使用事务(MULTI/EXEC)替代方案
  4. 完整实践:秒杀/抢购场景
  5. 使用建议

在 PHP 中使用 Redis 实现原子扣减,有多种方法,最常用的是 decr 和 Lua 脚本,以下是完整的实践方案:

最简方式:decr/decrBy(推荐用于简单场景)

<?php
// 连接 Redis
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// 初始化库存(仅第一次)
$redis->set('stock:1001', 100);
// 原子扣减 1 个
$remaining = $redis->decr('stock:1001'); // 返回扣减后的剩余值
// 原子扣减 N 个
$remaining = $redis->decrBy('stock:1001', 5);
// 判断是否扣减成功(库存充足)
if ($remaining >= 0) {
    echo "扣减成功,剩余库存: {$remaining}";
} else {
    // 扣减失败,需要恢复库存
    $redis->incrBy('stock:1001', 5); // 回滚
    echo "库存不足,扣减失败";
}

⚠️ 注意:decr 不会返回操作前的值,所以无法确认是否扣减了负数,需要额外判断。

Lua 脚本方式(推荐用于需要校验的场景)

1 基础库存扣减

<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// Lua 脚本:原子扣减库存
$lua = <<<LUA
-- KEYS[1] = 库存 key
-- ARGV[1] = 扣减数量
local stock = tonumber(redis.call('get', KEYS[1]) or '0')
local deduct = tonumber(ARGV[1])
if stock >= deduct then
    redis.call('decrby', KEYS[1], deduct)
    return stock - deduct  -- 返回剩余库存
else
    return -1  -- 库存不足
end
LUA;
$result = $redis->eval($lua, ['stock:1001', 10], 1);
if ($result >= 0) {
    echo "扣减成功,剩余库存: {$result}";
} else {
    echo "库存不足";
}

2 带过期时间的库存扣减

<?php
// 防止库存永久存在,设置过期时间
$redis->set('stock:1001', 100, ['EX' => 86400]); // 24小时过期
// Lua 脚本:带过期检查
$lua = <<<LUA
local stock_key = KEYS[1]
local deduct = tonumber(ARGV[1])
local ttl = redis.call('ttl', stock_key)
-- 检查 key 是否存在且还有剩余时间
if ttl < 0 then
    return -2;  -- key 不存在或已过期
end
local stock = tonumber(redis.call('get', stock_key) or '0')
if stock >= deduct then
    redis.call('decrby', stock_key, deduct)
    -- 重置过期时间(如果需要)
    redis.call('expire', stock_key, 86400)
    return stock - deduct
else
    return -1
end
LUA;
$result = $redis->eval($lua, ['stock:1001', 5], 1);

3 带用户防重的库存扣减

<?php
// 场景:同一用户不能重复扣减
$lua = <<<LUA
local stock_key = KEYS[1]
local user_key = KEYS[2]  -- 记录已扣减用户的 key
local user_id = ARGV[1]
local deduct = tonumber(ARGV[2])
-- 检查用户是否已扣减过
if redis.call('sismember', user_key, user_id) == 1 then
    return -2;  -- 重复操作
end
local stock = tonumber(redis.call('get', stock_key) or '0')
if stock >= deduct then
    redis.call('decrby', stock_key, deduct)
    redis.call('sadd', user_key, user_id)
    -- 设置用户记录过期时间
    redis.call('expire', user_key, 3600)
    return stock - deduct
else
    return -1
end
LUA;
$result = $redis->eval($lua, 
    ['stock:1001', 'order_users:1001'],
    ['user_123', 2], 
    2
);

使用事务(MULTI/EXEC)替代方案

<?php
// 虽然事务不是原子的,但可以结合 WATCH 实现乐观锁
$redis->watch('stock:1001');
$stock = $redis->get('stock:1001');
if ($stock < 10) {
    $redis->unwatch();
    echo "库存不足";
    exit;
}
// 开始事务
$redis->multi();
$redis->decrBy('stock:1001', 10);
$result = $redis->exec();
if ($result) {
    echo "扣减成功";
} else {
    echo "操作失败,请重试";
}

完整实践:秒杀/抢购场景

<?php
class StockService {
    private $redis;
    public function __construct($redis) {
        $this->redis = $redis;
    }
    /**
     * 原子扣减库存
     * @param string $productId 商品ID
     * @param int $quantity 扣减数量
     * @param int $userId 用户ID
     * @return array [code, message, data]
     */
    public function deductStock($productId, $quantity = 1, $userId = null) {
        $stockKey = "stock:{$productId}";
        $userKey = "deduct_users:{$productId}";
        $lua = <<<LUA
-- KEYS[1]: 库存key
-- KEYS[2]: 用户记录key  
-- ARGV[1]: 用户ID
-- ARGV[2]: 扣减数量
-- ARGV[3]: 用户记录过期时间
local user_id = ARGV[1]
local deduct = tonumber(ARGV[2])
local expire_time = tonumber(ARGV[3])
-- 1. 检查用户是否已参与过
if user_id and user_id ~= '' then
    if redis.call('sismember', KEYS[2], user_id) == 1 then
        return {0, 'duplicate', 0}
    end
end
-- 2. 检查是否存在
local stock = redis.call('get', KEYS[1])
if not stock then
    return {0, 'not_exists', 0}
end
-- 3. 转换并检查
stock = tonumber(stock)
if stock < deduct then
    return {0, 'out_of_stock', 0}
end
-- 4. 执行扣减
redis.call('decrby', KEYS[1], deduct)
-- 5. 记录用户(防止重复)
if user_id and user_id ~= '' then
    redis.call('sadd', KEYS[2], user_id)
    if expire_time > 0 then
        redis.call('expire', KEYS[2], expire_time)
    end
end
-- 6. 返回成功
return {1, 'success', stock - deduct}
LUA;
        try {
            $results = $this->redis->eval($lua, 
                [$stockKey, $userKey],
                [$userId ?? '', $quantity, 86400],
                2
            );
            return [
                'success' => $results[0] == 1,
                'message' => $results[1],
                'remaining' => (int)$results[2]
            ];
        } catch (Exception $e) {
            return [
                'success' => false,
                'message' => '系统错误: ' . $e->getMessage(),
                'remaining' => 0
            ];
        }
    }
    /**
     * 恢复库存(订单取消时)
     */
    public function rollbackStock($productId, $quantity) {
        $key = "stock:{$productId}";
        return $this->redis->incrby($key, $quantity);
    }
}

使用建议

推荐方案优先级:

  1. Lua 脚本 - 完全原子性,支持复杂逻辑 ✅ 最推荐
  2. DECR/DECRBY - 简单场景,性能最好 ✅ 适合独立操作
  3. WATCH + MULTI - 乐观锁,适合不需要强一致性的场景

注意事项:

  • 初始化库存时使用 setnx 防止覆盖
  • 扣减失败时考虑是否需要回滚
  • 为库存 key 设置合理的过期时间
  • 在高并发场景下使用连接池
  • 监控 Redis 内存使用情况

选择哪种方式取决于你的业务需求,如果场景简单,直接用 decr 就足够了;如果需要复杂校验或防重复,推荐使用 Lua 脚本。

抱歉,评论功能暂时关闭!