多商户商城的库存,为什么不能挂在订单上
商城的第一版,库存通常长在商品表上:product.stock 一个字段,下单减一,取消加一。二十个商户、几百个 SKU,这套写法能撑过验收。第一次大促就会露馅:两个商户同时卖最后一件,订单都创建成功,库存减成了负数;财务那边更麻烦,订单金额改过一次,结算单上的数字和订单对不上,谁也不知道该以哪个为准。
多数人第一反应是并发没控制好,去加锁、去改 Redis。锁加上以后,超卖少了,账还是对不上。
问题不在锁,在表。商品、库存、订单、履约、结算被压进了同一两张表,每个字段都有人写,每个状态都有人改,最后没有人能说清楚一行数据该由谁负责。

商品、库存、订单、结算,各回答一个问题
商品回答“卖什么”,库存回答“还有多少、被谁占着”,订单回答“买卖双方约定了什么”,履约回答“发了多少、退了多少”,结算回答“谁该给谁多少钱”。五个问题各有自己的时间尺度:商品可以今天改、明天改;库存每秒都在变;订单一旦提交就冻结;结算要等到履约完成才发生。把它们放进一张表,等于要求这五个时间尺度同步,做不到。
| 域 | 回答的问题 | 谁写 | 放进来就会出事的字段 |
|---|---|---|---|
| 商品 | 卖什么、标价多少 | 商户、平台运营 | 库存数量、订单状态 |
| 库存 | 还有多少、锁了多少 | 库存域(订单经它的事件触发预留) | 商品标题、销售价 |
| 订单 | 买了什么、按什么价 | 买家下单时一次写定 | 实时库存、结算金额 |
| 履约 | 发了多少、退了多少 | 仓储、售后 | 商品描述 |
| 结算 | 该付多少、扣了什么 | 结算任务 | 订单支付状态 |
这张表其实是权限表:谁写哪一列,比表怎么拆更重要。下面按域说。
商品在平台和商户后台,是两份数据
平台维护的是标准商品:品牌、类目、规格、条码。商户维护的是自己的上架记录:卖哪个标准商品、卖多少钱、包不包邮、参不参加活动。两者是多对多,中间要有一张 mall_listing。
CREATE TABLE mall_listing (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
merchant_id BIGINT NOT NULL,
spu_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
sale_price_cent INT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
version INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_merchant_spu (tenant_id, merchant_id, spu_id),
KEY idx_tenant_status (tenant_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
同一个条码,两个商户报不同的价,这在建材城、批发市场里是常态。平台不能把价格收回到标准商品上,否则改一次价要改所有商户;商户也不能改标准商品的类目,否则平台的筛选和报表全乱。上架记录要留版本。商户改价、平台改类目、运营下架,可能同时发生,用 version 做乐观锁,冲突时让其中一方重读,比后写覆盖前写好查。
类目属性归平台,销售属性归商户。商户想加一个“产地”,不该去改平台表结构,放在扩展属性里。
商品域还会被问一个看似简单的问题:订单里的商品名,是查商品表还是存一份?存一份。商品可以改名、改图、下架,订单上显示的必须是下单当时的那一版;落点在订单快照里,不是上架记录的 title。这不算冗余,算凭证——商品后来改名改图,都动不了订单上的这一份。
库存要记成一本账
库存的核心问题在于这些数是怎么变成现在这样的,光知道“有多少”没用。只存一个 stock 字段,任何一次异常减少都查不出来;记成账面数、锁定数、流水,出了问题能一路倒推。
CREATE TABLE inv_stock (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
on_hand INT NOT NULL DEFAULT 0,
locked INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
UNIQUE KEY uk_wh_sku (tenant_id, warehouse_id, sku_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE inv_reserve (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
biz_no VARCHAR(64) NOT NULL, -- 平台订单号,子单号另存映射
warehouse_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
qty INT NOT NULL,
deducted_qty INT NOT NULL DEFAULT 0, -- 已扣多少,部分发货靠它走完状态机
status VARCHAR(16) NOT NULL DEFAULT 'LOCKED', -- LOCKED / PAID / DEDUCTED / RELEASED
expire_at DATETIME NOT NULL,
UNIQUE KEY uk_biz_sku (tenant_id, biz_no, sku_id, warehouse_id),
KEY idx_release (status, expire_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
可售数等于 on_hand - locked,不是 on_hand。下单只锁库存,不扣账面数;发货才把 locked 减掉、on_hand 减掉;取消和超时释放锁。订单的每一步都要能在 inv_reserve 里找到对应的一行,状态从 LOCKED 走到 DEDUCTED 或 RELEASED,deducted_qty 记发掉多少——发两件退一件的部分发货,靠它把同一行走到部分扣减再进终态,qty 单值表示不了这个;唯一键带上 warehouse_id,同单同 SKU 跨两仓预占才不撞键。非订单的库存动作不走这张表,走 inv_flow。
扣减必须是一条带条件的更新,不能先查再改:
UPDATE inv_stock
SET locked = locked + #{qty},
version = version + 1
WHERE tenant_id = #{tenantId}
AND warehouse_id = #{warehouseId}
AND sku_id = #{skuId}
AND on_hand - locked >= #{qty};
影响行数为 0 就是库存不够,直接拒绝下单,不要重试到成功——库存不足不重试,死锁和锁超时要重试,两种失败分开处理。幂等的顺序写死:同一事务里先 INSERT inv_reserve,撞 uk_biz_sku 即整体回滚,成功后再执行条件 UPDATE;反过来先 UPDATE 后 INSERT,插入冲突时锁已经加上,重试路径就加两次。inv_stock 的 version 只是变更计数器,并发控制靠条件 UPDATE 本身。同一个订单重复提交,第二次插入冲突,锁只加一次。
一百件库存,一百二十个订单同时进来。带条件的更新会让后二十个失败,失败就返回“已售罄”;先查再改的写法,一百二十个都能查到一百件,然后一起减。
发货扣减和取消释放这两条路径,比下单锁库更危险——支付回调、释放任务、重投的发货事件会同时到,它们同样要走状态条件,并且和 inv_stock 的数值变更放在同一个本地事务里:
-- 发货扣减:状态 CAS,影响行数为 0 说明已释放或已扣,直接终止
UPDATE inv_reserve SET status = 'DEDUCTED', deducted_qty = deducted_qty + #{qty}
WHERE tenant_id = #{tenantId} AND biz_no = #{bizNo}
AND sku_id = #{skuId} AND warehouse_id = #{warehouseId} AND status = 'LOCKED';
-- 同一事务里再动数值行,locked 够扣才动
UPDATE inv_stock
SET locked = locked - #{qty}, on_hand = on_hand - #{qty}, version = version + 1
WHERE tenant_id = #{tenantId} AND warehouse_id = #{warehouseId}
AND sku_id = #{skuId} AND locked >= #{qty};
-- 取消释放:把第一条的判断换成 status = 'LOCKED',第二条改成 locked - #{qty}
重复投递由状态 CAS 吸收,不需要分布式锁;两条 UPDATE 只成功一条就整体回滚。
多仓库是另一个坑。总库存看着够,单个仓库不够,订单照样发不出去。下单时要先定发货仓,再锁那个仓的库存;跨仓调拨是另一条流程,不能让下单逻辑顺手扣别的仓。
锁的过期时间要按业务定,下限取支付渠道到账的 P99,常规是三十分钟量级;大促回调只会更慢,把锁缩短是在制造自己警告的事故。定得太长,用户放弃支付后库存一直占着;定得太短,支付回调还没回来锁就过期了——所以回调要先做一次状态 CAS:UPDATE inv_reserve SET status='PAID' WHERE biz_no=? AND status='LOCKED',0 行就走补锁,补不到按缺货退款,这条分支必须有。expire_at 落在 inv_reserve 上,释放任务按 status='LOCKED' AND expire_at < NOW() 走 idx_release 扫,不按订单状态猜,也别让已释放的行永远躺在扫描集里。
库存记的是实物,实物会盘亏、会破损、会退货。盘点单、报损单、退货入库单都要走流水,不能直接 UPDATE on_hand = 100。流水是另一张表:
CREATE TABLE inv_flow (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
biz_type VARCHAR(16) NOT NULL, -- STOCKTAKE 盘点 / DAMAGE 报损 / RETURN_IN 退货入库 / ALLOT 调拨
biz_no VARCHAR(64) NOT NULL,
delta_locked INT NOT NULL DEFAULT 0,
delta_on_hand INT NOT NULL,
before_on_hand INT NOT NULL,
after_on_hand INT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_wh_sku (tenant_id, warehouse_id, sku_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
直接改数的后果,是月底盘点差了三件,没人知道是哪一天少的。盘点、报损、退货入库走 inv_flow,下单锁库走 inv_reserve,两条流水各有各的 biz_type,月底差三件,倒推三行就能定位到单。

订单只负责约定
一个购物车跨三个商户,提交后生成一个平台订单和三个商户子单。平台订单管支付和总金额,子单管各自的商品、运费和履约状态。只建一张订单表,三个商户的发货状态只能挤在一个字段里,谁先发谁后发都说不清。
CREATE TABLE order_sub (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
order_no VARCHAR(32) NOT NULL,
sub_no VARCHAR(32) NOT NULL,
merchant_id BIGINT NOT NULL,
status VARCHAR(24) NOT NULL, -- CREATED / PAID / SHIPPED / CONFIRMED / SETTLING / SETTLED / CANCELLED
confirm_received_at DATETIME NULL, -- 确认收货时间,结算扫描用
after_sale_end_at DATETIME NULL, -- 售后期截止,结算扫描用
goods_amount_cent INT NOT NULL,
discount_cent INT NOT NULL DEFAULT 0,
freight_cent INT NOT NULL DEFAULT 0,
payable_cent INT NOT NULL,
price_snapshot JSON NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_sub_no (tenant_id, sub_no),
KEY idx_order (tenant_id, order_no),
KEY idx_settle_scan (tenant_id, status, after_sale_end_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
快照按商品行存:商品名、单价、规格、促销标签,加三列分摊金额(平台券、商户券、运费各摊到行多少)。后面商品改价、改标题,子单不动。退款时按快照里的分摊算,不按当前售价重算——重算就和“按快照算”矛盾。
订单状态和履约状态要分开。订单可以是“已支付”,同时三个子单分别是“待发货”“已发货”“已签收”。把发货状态塞进订单主状态,退款和售后就会卡住:用户要退其中一个子单,订单主状态该回退到哪一步?
拆单、合单、部分发货,都会让“一个订单一个物流单号”的假设失效。物流单要单独一张表,和子单是一对多。这个设计多写一张表,少写一堆 if。
售后也要绑在子单和商品行上,不能只绑订单。退一件商品,运费退不退、优惠怎么摊、佣金怎么冲,都要在退款单上写清楚;只记一个退款总额,结算时只能按比例猜,猜出来的数财务不会认。
“按商品行结算”的前提是商品行存在。子单级的金额拆不出行粒度,order_item 和退款单至少要有:
CREATE TABLE order_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
sub_no VARCHAR(32) NOT NULL,
sku_id BIGINT NOT NULL,
qty INT NOT NULL,
unit_price_cent INT NOT NULL,
alloc_platform_discount_cent INT NOT NULL DEFAULT 0, -- 优惠按行分摊
alloc_merchant_discount_cent INT NOT NULL DEFAULT 0,
alloc_freight_cent INT NOT NULL DEFAULT 0,
snapshot JSON NOT NULL,
UNIQUE KEY uk_sub_sku (tenant_id, sub_no, sku_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE refund_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
refund_no VARCHAR(32) NOT NULL,
sub_no VARCHAR(32) NOT NULL,
sku_id BIGINT NOT NULL,
qty INT NOT NULL,
refund_goods_cent INT NOT NULL,
refund_freight_cent INT NOT NULL DEFAULT 0,
commission_offset_cent INT NOT NULL DEFAULT 0, -- 佣金冲减,结算行靠它对平
status VARCHAR(16) NOT NULL,
UNIQUE KEY uk_refund_no (tenant_id, refund_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
settle_item 的 gmv、优惠、退款,全部从这两张表来;settle_item 上还要有 merchant_id,商户查自己的结算行靠它过滤。
结算的输入是履约结果
结算最容易犯的错,是拿订单的 payable_cent 当结算金额。订单金额是买家应付,结算金额是商户应收,中间隔着平台券、商户券、运费、佣金、退款,每一项的承担方都不一样。
一笔订单,货 128 元、运费 10 元,商户券 10 元、平台券 18 元。买家实付 110 元(138 减两张券),商户的计佣基数是 118 元——货 128 减商户券 10,平台券由平台承担,不进商户的账;商户应收是 118 加运费 10 再减 5% 佣金,122.10 元。钱的恒等式是:买家实付 110 加平台补贴 18,等于商户应收 122.10 加平台佣金 5.90,两边都是 128;这四个数没有一个等于订单总额 138,拼不平的那一笔要在表格里有一行。
| 数字 | 金额 | 归谁 |
|---|---|---|
| 买家实付 | 110.00 | 订单域 |
| 平台承担的平台券 | 18.00 | 平台补贴 |
| 商户计佣基数 | 118.00 | 结算域 |
| 平台佣金 5% | 5.90 | 结算域 |
| 商户应收 | 122.10 | 结算域 |
计佣基数含不含运费、商户券在计佣前还是计佣后扣,每个平台选的不一样,定下来写进 rule_version,别留在代码第三行。
结算单要按子单和商品行生成,不能按订单生成。一个订单退了一件商品,结算行要能单独冲掉那一行;按订单结算,退一件和退全部在账上长得一样。
CREATE TABLE settle_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
bill_id BIGINT NOT NULL,
sub_no VARCHAR(32) NOT NULL,
sku_id BIGINT NOT NULL,
qty INT NOT NULL,
gmv_cent INT NOT NULL,
platform_discount_cent INT NOT NULL DEFAULT 0,
merchant_discount_cent INT NOT NULL DEFAULT 0,
refund_cent INT NOT NULL DEFAULT 0,
commission_cent INT NOT NULL DEFAULT 0,
net_cent INT NOT NULL,
merchant_id BIGINT NOT NULL, -- 商户查自己的结算行靠它过滤
rule_version VARCHAR(16) NOT NULL,
source_type VARCHAR(16) NOT NULL, -- SHIPMENT / REFUND,幂等来源
source_no VARCHAR(32) NOT NULL, -- 发货单号 / 退款单号
UNIQUE KEY uk_source (tenant_id, source_type, source_no, sku_id),
KEY idx_bill (bill_id),
KEY idx_sub (tenant_id, sub_no, merchant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
rule_version 必须存。佣金比例、平台券分摊规则、账期都会变;结算单生成以后,规则再改,已经生成的行不能跟着变。财务对账时问“这笔为什么扣了 5.9”,答案要能从这一行和对应的规则版本里查出来,而不是去翻现在的配置。
settle_item 有两个写入方:消费 ShipmentConfirmed 的消费者,和“发了货没生成结算行”时补的对账任务。幂等靠 uk_source,两个写入方都按它插,撞键即跳过——没有这个键,事件重投一次就重复入账一次,重复给商户打款就是这么来的。
结算的触发点是履约,不是支付。货没发,钱在平台上,商户应收还没成立;确认收货、售后期结束,才轮到结算。这个时间差就是账期,也是平台垫资压力的来源。二清和资金池的问题,07 那篇写过,这里只强调一句:结算单是账,不是转账指令,两者要分开存。
账期不是越长越好。T+7 对小商户是压款;对平台,沉淀资金是 07 那篇讲过的二清红线,真正的垫资出现在平台承担平台券这类补贴单上——账期一拉长,结算日就是集中支付压力最大的一天。结算单生成后要和支付流水、退款流水对一遍,差一分都要在打款前找出来,打出去再追回,成本比事前拦截高一个量级。
结算不是一张单,是一批。每天凌晨把到期、已确认收货、售后期结束的子单捞出来——按 idx_settle_scan 扫 status 和 after_sale_end_at,别全表捞——按商户汇总成结算单,再按打款渠道拆成批次。批次要有状态:待打款、打款中、部分成功、已完成。银行回单回来逐笔核销,失败的挂起,不自动重试到成功——卡号错了重试一百次还是错。回单没解析,系统里显示“已打款”,商户账上没钱,第二天电话就打过来了。发票开错比打款打错更难改。结算金额、服务费、商品金额要分开开,退货要红冲;发票状态要能回到对应的结算行,不能只在财务系统里留一个 Excel。
边界就是谁也不许改对方的行
五个域拆开以后,最常见的退化是“为了省一次查询”,订单服务直接 UPDATE inv_stock,结算任务直接读 order_sub.status 再改。写 inv_stock 的 SQL 只能出现在库存模块内部,订单服务调接口或发事件,自己不落这条 SQL——表分开了,边界还是假的。
判断边界真假,只看一条:同一个业务动作,会不会有两个模块去写同一行。会,就还没分开。
跨域的动作走事件:订单提交后发 OrderSubmitted,库存域消费、锁库;发货后发 ShipmentConfirmed,结算域消费、生成结算行。事件要落 outbox 表,和业务数据在同一个事务里写,再由投递任务发出去。直接 publish 再写库,进程一挂,事件和业务数据各丢一半。消费者要幂等。biz_no 唯一键、事件 id 去重表,选一个。超时和补偿也要有:锁了库存没支付,定时任务释放;发了货没生成结算行,对账任务补。事件驱动不是免掉一致性,是把一致性从“一次事务”挪到“可重试的流程”。
代码结构跟着域走:商品、库存、订单、履约、结算各自一个模块,跨模块只调接口、不查对方的表。物理分库可以晚点做,这条先守住;哪天真要拆库,改的是连接池配置,不是几千行业务代码。
权限也沿着域走。商户只能看自己的子单、自己的结算行;仓库只能看自己仓的库存和发货单;平台运营能看全量,但不能改商户的售价。这些条件写在各自域的查询里,不要指望网关统一加一个 merchant_id 就完事——导出、报表、消息通知,每个入口都要各自带条件。
超卖那天,缺的不是一把锁
回到开头那次大促。超卖不是因为没有分布式锁,是因为可售数被算成了 on_hand,锁只加在了下单接口上,取消和超时没人释放;账对不上,是因为订单金额被改过,而结算读的是订单当前值,不是下单时的快照。这两件事,加锁都治不了。
判断一套多商户商城能不能撑过第二年,不用看它用了多少中间件。看它的一张订单能不能同时回答三个问题:货从哪个仓发、钱按哪一版规则分、退一件之后账怎么变。三个都答得出来,边界就是真的。