本文目录导读:

在 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);
}
}
使用建议
推荐方案优先级:
- Lua 脚本 - 完全原子性,支持复杂逻辑 ✅ 最推荐
- DECR/DECRBY - 简单场景,性能最好 ✅ 适合独立操作
- WATCH + MULTI - 乐观锁,适合不需要强一致性的场景
注意事项:
- 初始化库存时使用
setnx防止覆盖 - 扣减失败时考虑是否需要回滚
- 为库存 key 设置合理的过期时间
- 在高并发场景下使用连接池
- 监控 Redis 内存使用情况
选择哪种方式取决于你的业务需求,如果场景简单,直接用 decr 就足够了;如果需要复杂校验或防重复,推荐使用 Lua 脚本。