老系统库存扣减超时,不一定需要马上引入 Redis

背景: 朋友公司订单系统,架构古老, 技术栈繁多,C/S、B/S混合,主库Oracle,经常遇到部分商品售卖过程中,出现卡顿的情况。经常观察和定位后,发现是库存扣减出现的偶发性超时。人力有限也不想大改系统架构,就一起讨论了一下后续可落地的一些小方案和建议。

一、问题本质:不是锁机制有问题,而是热点行被反复争抢

很多老系统的库存扣减逻辑大致是这样的:

update t_stock
set stock = stock - #{num}
where sku_id = #{skuId}
  and stock >= #{num};

这条 SQL 本身是原子的,Oracle 行锁也没有问题。 问题在于:同一个 SKU 的所有请求都在更新同一行记录。

当某个商品在短时间内出现多单同时扣减时:

  • 第一个请求拿到行锁;
  • 后续请求必须等待前一个事务提交或回滚;
  • 等待队列越来越长;
  • 接口超时、事务堆积、部分订单失败。

这就是典型的热点行锁竞争。


二、为什么常见方案不一定适合存量系统

1. 乐观锁 Version

update t_stock
set stock = stock - #{num}, version = version + 1
where sku_id = #{skuId}
  and version = #{oldVersion}
  and stock >= #{num};

优点:

  • 改造成本低;
  • 不需要改表结构太多;
  • 非热点商品可用。

缺点:

  • 热点 SKU 会出现大量版本冲突;
  • 大量请求反复重试;
  • CPU 压力上升;
  • 不一定能明显改善超时问题。

结论: 适合并发不高的普通商品,不建议作为热点库存问题的主方案。


2. 应用层队列串行化

把同一个 SKU 的扣减请求排队,串行进入数据库。

优点:

  • 数据库侧逻辑改动较小;
  • 可以避免并发行锁。

缺点:

  • 多技术栈系统改造复杂;
  • C/S、B/S、Java、C#、PB 都可能要适配;
  • 响应时间变长;
  • 队列成为新瓶颈;
  • 多实例部署还需要 SKU 路由。

结论: 适合作为辅助削峰手段,不太适合作为存量系统第一改造方案。


3. Redis 预扣 + MQ 异步落库

这是大促、秒杀场景常见方案。

思路:

  1. Redis 先做库存预扣;
  2. 扣成功后发送 MQ;
  3. 消费端异步更新 Oracle;
  4. 订单、支付、取消、回补、对账链路重新设计。

优点:

  • 可以承受更高瞬时流量;
  • Redis 承担入口洪峰;
  • 适合秒杀、大促场景。

缺点:

  • 引入 Redis、MQ、对账、补偿、回补等多个组件;
  • 一致性从强一致变成最终一致;
  • 老系统风险高;
  • 如果 Oracle 还是单行库存,消费端依然可能出现行锁等待。

结论: 对存量混合技术栈系统来说,风险较高,不建议作为第一步。


三、推荐主方案:Oracle 库存分桶

核心思路

把一个逻辑 SKU 的库存,拆成多个物理桶。

例如:

表格

sku_idbucket_nostock
10011125
10012125
10013125
10014125
10015125
10016125
10017125
10018125

总库存仍然是 1000,但同一个 SKU 不再只有一行。

扣减时,数据库随机选择一个有足够库存的桶进行更新。

这样,并发请求会分散到不同行,而不是全部挤在同一行。


四、分桶方案为什么适合存量系统

1. 风险相对可控

主要改造集中在库存表结构和库存扣减 / 归还逻辑,不一定要一开始就引入 Redis 和 MQ。

2. 仍然保留数据库事务边界

订单和库存可以尽量保持原有事务模型,不像 Redis + MQ 那样需要彻底重构链路。

3. 可以按 SKU 灰度

不是所有商品都必须分桶。

  • 热点 SKU:分 4 桶、8 桶或 16 桶;
  • 普通 SKU:分 1 桶,等价于原来的单行模式。

4. 对多技术栈系统更友好

库存分桶本质上是数据库侧的结构调整,只要各端最终调用同一套库存服务或存储过程,就可以逐步收敛。


五、表结构建议

create table t_sku_stock (
  id number primary key,
  sku_id number not null,
  bucket_no number not null,
  stock number not null default 0,
  create_time date default sysdate,
  update_time date default sysdate,
  constraint uk_sku_bucket unique (sku_id, bucket_no)
);

说明:

  • sku_id + bucket_no 是逻辑唯一键;
  • 每个 SKU 预先创建 1~N 个桶;
  • 冷 SKU 可以只保留 1 个桶。

六、桶是怎么分配的?

1. 桶不是哈希出来的

不要根据订单号、用户号做哈希取模来固定分配桶。

这种方式容易导致:

  • 某个桶先被扣光;
  • 其他桶还有库存;
  • 业务提前返回库存不足。

2. 桶是预先生成的

每个 SKU 在上架或初始化时,预先创建固定编号的桶:

  • 1 桶
  • 4 桶
  • 8 桶
  • 16 桶

具体数量取决于商品热点程度。

3. 扣减时动态选桶

update t_sku_stock
set stock = stock - #{num},
    update_time = sysdate
where sku_id = #{skuId}
  and bucket_no = (
        select bucket_no
        from t_sku_stock
        where sku_id = #{skuId}
          and stock >= #{num}
        order by dbms_random.value
        fetch first 1 row only
      )
  and stock >= #{num};

说明:

  • 子查询随机找一个有足够库存的桶;
  • 外层 update 只锁这一行;
  • 返回行数大于 0,表示扣减成功;
  • 返回 0,业务层可以有限重试。

七、归还库存怎么处理?

订单取消、退货时,不需要严格归还到原来扣减的那个桶。

可以随机选择一个桶归还:

update t_sku_stock
set stock = stock + #{num},
    update_time = sysdate
where sku_id = #{skuId}
  and bucket_no = (
        select bucket_no
        from t_sku_stock
        where sku_id = #{skuId}
        order by dbms_random.value
        fetch first 1 row only
      );

注意: 归还逻辑必须保证最终总库存正确,避免漏还、重复还。


八、总库存怎么查询?

真实总库存可以通过汇总得到:

select sum(stock)
from t_sku_stock
where sku_id = #{skuId};

但不建议把这个 SQL 放在下单主链路中高频执行。

更合适的方式是:

  • 下单链路只关心扣减是否成功;
  • 前台展示库存读取缓存;
  • 后台对账、管理页面再使用 sum 查询。

九、存储过程初始化示例

create or replace procedure proc_init_sku_bucket(
  p_sku_id      in  number,
  p_total_stock in  number,
  p_bucket_cnt  in  number,
  p_ret_code    out number,
  p_ret_msg     out varchar2
)
is
  v_avg number;
  v_mod number;
  v_stock number;
  v_exists number;
begin
  p_ret_code := 0;
  p_ret_msg  := 'success';

  if p_bucket_cnt < 1 then
    p_ret_code := -1;
    p_ret_msg  := 'bucket count must >= 1';
    return;
  end if;

  if p_total_stock < 0 then
    p_ret_code := -1;
    p_ret_msg  := 'stock can not < 0';
    return;
  end if;

  select count(1)
  into v_exists
  from t_sku_stock
  where sku_id = p_sku_id;

  if v_exists > 0 then
    p_ret_code := -1;
    p_ret_msg  := 'sku bucket already exists';
    return;
  end if;

  v_avg := trunc(p_total_stock / p_bucket_cnt);
  v_mod := mod(p_total_stock, p_bucket_cnt);

  for i in 1 .. p_bucket_cnt loop
    if i <= v_mod then
      v_stock := v_avg + 1;
    else
      v_stock := v_avg;
    end if;

    insert into t_sku_stock(sku_id, bucket_no, stock, create_time, update_time)
    values(p_sku_id, i, v_stock, sysdate, sysdate);
  end loop;

  commit;

exception
  when others then
    rollback;
    p_ret_code := -2;
    p_ret_msg  := 'error: ' || sqlerrm;
end proc_init_sku_bucket;
/

调用方式:

declare
  v_code number;
  v_msg  varchar2(200);
begin
  proc_init_sku_bucket(
    p_sku_id      => 1001,
    p_total_stock => 1000,
    p_bucket_cnt  => 8,
    p_ret_code    => v_code,
    p_ret_msg     => v_msg
  );
  dbms_output.put_line('code:' || v_code || ', msg:' || v_msg);
end;
/

十、存量系统可执行改造步骤

阶段一:止血,先改热点 SKU

目标: 快速解决热点商品扣减超时问题。

动作:

  1. 识别最近发生超时、等待明显的 SKU;
  2. 新建分桶库存表或增加分桶字段;
  3. 对热点 SKU 初始化 4 桶或 8 桶;
  4. 把这些 SKU 的扣减逻辑切到分桶模式;
  5. 观察行锁等待和超时情况。

建议: 不要一开始就全量改造所有商品。


阶段二:收敛库存服务

目标: 避免 C/S、B/S、Java、C#、PB 各自维护一套不一致的扣减逻辑。

动作:

  1. 抽象统一的库存扣减接口或存储过程;
  2. 各端通过统一入口操作库存;
  3. 区分 “扣减”“归还”“查询”“对账” 四类操作;
  4. 记录操作流水,便于问题追踪。

建议: 这一步比单纯引入 Redis 更重要。 存量系统很多问题不是技术单一问题,而是入口太多、逻辑分散。


阶段三:补充缓存与对账

目标: 降低数据库压力,同时保证库存数据可信。

动作:

  1. 前台库存展示使用缓存;
  2. 定期以 Oracle 数据为准进行对账;
  3. 对异常差额进行人工或自动修复;
  4. 监控行锁等待、扣减耗时、失败率。

建议: 缓存只承担展示和削峰,不建议一开始就承担强一致库存扣减主逻辑。


十一、是否需要上 Redis 分桶?

如果业务确实有明显的瞬时洪峰,例如大促、秒杀,可以考虑在数据库分桶之上,再增加 Redis 分桶。

推荐做法:

请求 → Redis 分桶预扣 → MQ → Oracle 分桶更新

其中:

  • Redis 分桶:挡住入口洪峰;
  • Oracle 分桶:避免数据库行锁;
  • MQ 消息中携带 Redis 选中的 bucket_no;
  • Oracle 直接更新对应桶。

但这一步改造明显更大,适合作为第二阶段优化,而不是存量系统的首选起点。


十二、风险评估

表格

方案改造成本一致性风险适合场景
乐观锁 Version低强一致热点商品冲突明显普通商品
应用队列中高强一致多技术栈适配复杂流量可控系统
Redis + MQ高最终一致链路复杂,一致性问题多大促、秒杀
Oracle 分桶中强一致结构调整,灰度得当风险可控存量系统首选

十三、落地建议

对于古老、多技术栈混合、主库为 Oracle 的存量系统,我的建议是:

不要一上来就追求最先进架构,先从数据库分桶开始,把热点行锁问题控制住,再考虑是否引入 Redis。

更稳妥的改造顺序是:

  1. 统一库存入口;
  2. 热点 SKU 分桶;
  3. 增加库存流水;
  4. 补充缓存与对账;
  5. 确有必要时,再叠加 Redis 分桶与 MQ。

这样既能解决当前超时问题,也不至于把老系统一次性拖入复杂的异步架构。


十四、小结

库存高并发扣减超时,表面上是接口慢,本质上常常是同一个 SKU 热点行被大量事务争抢。

对于存量 Oracle 系统,最值得先尝试的方案不是立刻引入 Redis 和 MQ,而是:

  • 先把单行库存拆成多个桶;
  • 让并发请求分散到不同行;
  • 再通过统一库存服务、缓存和对账逐步优化。

这是我目前认为比较适合这类系统的改造思路,不一定最炫,但通常更稳。

评论

为保障内容质量,评论需站长审核通过后才会展示;提交后没立刻看到属正常,请勿重复提交。

还没有评论,来抢沙发~