背景: 朋友公司订单系统,架构古老, 技术栈繁多,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 异步落库
这是大促、秒杀场景常见方案。
思路:
- Redis 先做库存预扣;
- 扣成功后发送 MQ;
- 消费端异步更新 Oracle;
- 订单、支付、取消、回补、对账链路重新设计。
优点:
- 可以承受更高瞬时流量;
- Redis 承担入口洪峰;
- 适合秒杀、大促场景。
缺点:
- 引入 Redis、MQ、对账、补偿、回补等多个组件;
- 一致性从强一致变成最终一致;
- 老系统风险高;
- 如果 Oracle 还是单行库存,消费端依然可能出现行锁等待。
结论: 对存量混合技术栈系统来说,风险较高,不建议作为第一步。
三、推荐主方案:Oracle 库存分桶
核心思路
把一个逻辑 SKU 的库存,拆成多个物理桶。
例如:
表格
| sku_id | bucket_no | stock |
|---|---|---|
| 1001 | 1 | 125 |
| 1001 | 2 | 125 |
| 1001 | 3 | 125 |
| 1001 | 4 | 125 |
| 1001 | 5 | 125 |
| 1001 | 6 | 125 |
| 1001 | 7 | 125 |
| 1001 | 8 | 125 |
总库存仍然是 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
目标: 快速解决热点商品扣减超时问题。
动作:
- 识别最近发生超时、等待明显的 SKU;
- 新建分桶库存表或增加分桶字段;
- 对热点 SKU 初始化 4 桶或 8 桶;
- 把这些 SKU 的扣减逻辑切到分桶模式;
- 观察行锁等待和超时情况。
建议: 不要一开始就全量改造所有商品。
阶段二:收敛库存服务
目标: 避免 C/S、B/S、Java、C#、PB 各自维护一套不一致的扣减逻辑。
动作:
- 抽象统一的库存扣减接口或存储过程;
- 各端通过统一入口操作库存;
- 区分 “扣减”“归还”“查询”“对账” 四类操作;
- 记录操作流水,便于问题追踪。
建议: 这一步比单纯引入 Redis 更重要。 存量系统很多问题不是技术单一问题,而是入口太多、逻辑分散。
阶段三:补充缓存与对账
目标: 降低数据库压力,同时保证库存数据可信。
动作:
- 前台库存展示使用缓存;
- 定期以 Oracle 数据为准进行对账;
- 对异常差额进行人工或自动修复;
- 监控行锁等待、扣减耗时、失败率。
建议: 缓存只承担展示和削峰,不建议一开始就承担强一致库存扣减主逻辑。
十一、是否需要上 Redis 分桶?
如果业务确实有明显的瞬时洪峰,例如大促、秒杀,可以考虑在数据库分桶之上,再增加 Redis 分桶。
推荐做法:
请求 → Redis 分桶预扣 → MQ → Oracle 分桶更新
其中:
- Redis 分桶:挡住入口洪峰;
- Oracle 分桶:避免数据库行锁;
- MQ 消息中携带 Redis 选中的 bucket_no;
- Oracle 直接更新对应桶。
但这一步改造明显更大,适合作为第二阶段优化,而不是存量系统的首选起点。
十二、风险评估
表格
| 方案 | 改造成本 | 一致性 | 风险 | 适合场景 |
|---|---|---|---|---|
| 乐观锁 Version | 低 | 强一致 | 热点商品冲突明显 | 普通商品 |
| 应用队列 | 中高 | 强一致 | 多技术栈适配复杂 | 流量可控系统 |
| Redis + MQ | 高 | 最终一致 | 链路复杂,一致性问题多 | 大促、秒杀 |
| Oracle 分桶 | 中 | 强一致 | 结构调整,灰度得当风险可控 | 存量系统首选 |
十三、落地建议
对于古老、多技术栈混合、主库为 Oracle 的存量系统,我的建议是:
不要一上来就追求最先进架构,先从数据库分桶开始,把热点行锁问题控制住,再考虑是否引入 Redis。
更稳妥的改造顺序是:
- 统一库存入口;
- 热点 SKU 分桶;
- 增加库存流水;
- 补充缓存与对账;
- 确有必要时,再叠加 Redis 分桶与 MQ。
这样既能解决当前超时问题,也不至于把老系统一次性拖入复杂的异步架构。
十四、小结
库存高并发扣减超时,表面上是接口慢,本质上常常是同一个 SKU 热点行被大量事务争抢。
对于存量 Oracle 系统,最值得先尝试的方案不是立刻引入 Redis 和 MQ,而是:
- 先把单行库存拆成多个桶;
- 让并发请求分散到不同行;
- 再通过统一库存服务、缓存和对账逐步优化。
这是我目前认为比较适合这类系统的改造思路,不一定最炫,但通常更稳。