第八节:基于Lua+Redis实现 库存增加/扣减 和 流水表对账 方案实操
一. 前言
1. 什么时候扣减库存?有何弊端?
方案1:下单时扣减库存
优势:
(1) 能防止超卖 (有效避免多人同时下单导致超卖问题)
(2) 实时性强:下单就扣库存,确保库存及时更新
弊端:
(1).取消订单问题:如果用户取消订单或超时未支付,需要额外处理库存回滚的逻辑。
(2).恶意占用库存:如果用户不支付或支付失败,需要额外处理库存的恢复,会导致少卖。
(3).并发量高:下单并发量高,这个环节处理库存扣减会存在显著的热点问题。
方案2:支付成功在回调接口扣减库存
优势:
(1) 避免少卖和订单浪费:确保了库存的准确性,因为只有支付成功才会扣减库存。
(2) 避免取消订单问题:用户取消订单或超时未支付,无需额外处理库存的回滚。
(3) 并发量没那么高:支付成功回调的并发没有那么高,而且可以异步重试。
弊端:
(1) 超卖风险:比如用户支付成功了,但是没库存了,给用户的体验不好。
【解决方案:退款 或者 沟通延迟发货】
2. 本节目标
本节实操高并发下,如何基于redis扣减库存、增加库存,引入对账表的作用。
至于redis扣减完库存的后续操作,使用MQ也好,或者其他方案?不在本节探讨,后续的秒杀方案章节,会有详细介绍。
二. 方案实操
1. 初始化库存
模拟100个库存即可
/// <summary>
/// 01-初始化库存
/// </summary>
/// <returns></returns>
[HttpPost]
public IActionResult InitStock()
{
RedisHelper.Set("stock_001", 100);
return Json(new { status = "ok", msg = "初始化成功" });
}
2. 扣减库存
先判断流水表中是否有记录→对库存进行相关校验→扣减库存→插入流水
/// <summary>
/// 03-扣减库存
/// </summary>
/// <param name="stockKey">库存的key</param>
/// <param name="streamHashId">流水表对应的HashId </param>
/// <param name="needStock">本次要扣减的库存数</param>
/// <param name="operationKey">本次要增加流水中的唯一编号,前端传递,可以用来做幂等</param>
/// <returns></returns>
[HttpPost]
public IActionResult DecreaseStock(string stockKey = "stock_001", string streamHashId = "stock:stream_001",
int needStock = 1, string operationKey = "id_123456")
{
//KEYS[1]=库存的key 【stock_001】
//ARGV[1]=本次要扣减的库存数 【1】
//ARGV[2]=流水对应hash的hashId 【stock:stream_001】
//ARGV[3]=本次要扣减的唯一编号 【id_123456 前端传递】
//string operationKey = $"id_{Guid.NewGuid():N}"; //改为前端传递
try
{
var luaScript = """
-- 检查流水表中是否已经执行过
if redis.call('hexists',ARGV[2], ARGV[3]) == 1 then
return redis.error_reply('The operation has already existed')
end
-- 判断库存的key是否存在
local current = redis.call('get', KEYS[1])
if current == false then
return redis.error_reply('key not found')
end
-- 判断库存是否为数字
if tonumber(current) == nil then
return redis.error_reply('current value is not a number')
end
-- 判断库存是否为0
if tonumber(current) == 0 then
return redis.error_reply('inventory is empty')
end
-- 判断库存数量是否小于扣减数量
if tonumber(current) < tonumber(ARGV[1]) then
return redis.error_reply('inventory is not enough')
end
-- 计算库存
local new_stock = tonumber(current) - tonumber(ARGV[1])
redis.call('set', KEYS[1], tostring(new_stock))
-- 获取Redis服务器的当前时间(秒和微秒)
local time = redis.call("time")
-- 转换为毫秒级时间戳
local currentTimeMillis = (time[1] * 1000) + math.floor(time[2] / 1000)
-- 使用哈希结构存储日志
redis.call('hset',ARGV[2], ARGV[3], cjson.encode({
action = "decrease",
from = current,
to = new_stock,
change = ARGV[1],
by = ARGV[3],
timestamp = currentTimeMillis
}))
return new_stock
""";
var result = RedisHelper.Eval(luaScript, stockKey, [needStock, streamHashId, operationKey]);
return Json(new { status = "ok", msg = "扣减成功", data = result });
}
catch (Exception ex)
{
return Json(new { status = "error", msg = "扣减失败", data = ex.InnerException.Message });
}
}
3. 新增库存
先判断流水表中是否有记录→对库存进行相关校验→扣减库存→插入流水
/// <summary>
/// 02-增加库存
/// </summary>
/// <param name="stockKey">库存的key</param>
/// <param name="streamHashId">流水表对应的HashId </param>
/// <param name="needStock">本次要增加的库存数</param>
/// <param name="operationKey">本次要增加流水中的唯一编号,前端传递,可以用来做幂等</param>
/// <returns></returns>
[HttpPost]
public IActionResult AddStock(string stockKey = "stock_001", string streamHashId = "stock:stream_001",
int needStock = 1,string operationKey= "id_123456")
{
//KEYS[1]=库存的key 【stock_001】
//ARGV[1]=本次要增加的库存数 【1】
//ARGV[2]=流水对应hash的hashId 【stock:stream_001】
//ARGV[3]=本次要增加流水中的唯一编号 【eg: id_123456,前端传递】
//string operationKey = $"id_{Guid.NewGuid():N}"; //改为前端传递
try
{
var luaScript = """
-- 检查流水表中是否已经执行过
if redis.call('hexists', ARGV[2], ARGV[3]) == 1 then
return redis.error_reply('The operation has already existed')
end
-- 判断库存的key是否存在
local current = redis.call('get', KEYS[1])
if current == false then
return redis.error_reply('key not found')
end
-- 判断库存是否为数字
if tonumber(current) == nil then
return redis.error_reply('current value is not a number')
end
-- 计算库存
local new_stock = (current == nil and 0 or tonumber(current)) + tonumber(ARGV[1])
redis.call('set', KEYS[1], tostring(new_stock))
-- 获取Redis服务器的当前时间(秒和微秒)
local time = redis.call("time")
-- 转换为毫秒级时间戳
local currentTimeMillis = (time[1] * 1000) + math.floor(time[2] / 1000)
-- 使用哈希结构存储日志
redis.call('hset', ARGV[2], ARGV[3], cjson.encode({
action = "increase",
from = current,
to = new_stock,
change = ARGV[1],
by = ARGV[3],
timestamp = currentTimeMillis
}))
return new_stock
""";
var result = RedisHelper.Eval(luaScript, stockKey, [needStock, streamHashId, operationKey]);
return Json(new { status = "ok", msg = "增加成功", data = result });
}
catch (Exception ex)
{
return Json(new { status = "error", msg = "增加失败", data = ex.InnerException.Message });
}
}
三. 流水表原理
1 原理
采用hash结构,hashId-key-value,其中
hashId可以基于商品Id进行生成,
key可以用买家 id,下单的 token 以及扣减数量拼接到一起的。
value 中明确的记录了 本次扣减的变化的库存数、变化前的库存数 以及 变化后的库存数,还有 变化的操作ID 以及 变化的时间戳
2 流水表作用
(1) 幂等:当我们在lua脚本执行时,会先去查询下是否存在对应的流水,如果查询到了,则说明本次是一个重复请求,直接幂等掉
(2) 对账:Redis的库存扣减之后,数据库还是要扣减的,那么如何保证双方都一定成功呢,如何发现不一致的情况呢,那就需要这个流水了。
每一次扣减都有一条流水记录,这样就可以用Redis中的流水和数据库中的流水做核对,如果一致的话则没问题,但是不一致的话,
可能是丢消息了,或者系统执行异常了。这时候就需要人工介入来解决这个问题了
3 流水记录何时删除?
(1) 主动删除:流水对完帐后,主动删除
(2) 被动删除:商品下架后,对流水设置24小时到期,到期后基于redis的过期策略进行删除
!
- 作 者 : Yaopengfei(姚鹏飞)
- 博客地址 : http://www.cnblogs.com/yaopengfei/
- 声 明1 : 如有错误,欢迎讨论,请勿谩骂^_^。
- 声 明2 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。

浙公网安备 33010602011771号