做后端系统的人,基本都会在某个阶段碰到仓储或库存这类需求。我近期梳理了一套仓储服务API,里面最基础、但也最容易做糊的两块接口,就是标题里说的“查询库存”和“创建采购需求”。乍一看这像是两个独立的CRUD:一个查询当前还有多少货,一个提交一张采购申请单。但实际做下来你会发现,它们根本就是一根链条上的两个端点——查询库存回答的是“现在到底有多少可发”,创建采购需求解决的是“发现不够了之后怎么办”。这篇文章围绕仓储服务的仓库管理模块,把这两个接口的需求拆解、字段模型、状态流转、并发处理和排坑记录完整讲一遍,尤其适合正在做供应链、电商后台、进销存系统,或者准备从零搭建仓储服务的同学参考。
1. 项目背景与整体设计思路
1.1 标题里三个关键词,其实是同一条业务链
先拆一下这个标题:“仓储服务”是系统定位,“API”是交付形式,“仓库管理”是业务域,而“查询库存”和“创建采购需求”是这条业务链上的两个动作。很多人容易把这两件事割裂开,库存查询就简单查一个总数,采购需求就做一张普通的申请单,两张表互不相干,结果运营每天在页面两边来回切换,数据还对不上。
实际上,这两块是一前一后的因果链路:任何库存补充动作,都要先搞清楚现在有多少库存、可用量是多少、在途量是多少,才能决定该不该补、补多少。而创建采购需求这个动作,本质上就是“库存查询结果低于安全线之后触发的一个补货动作”。所以我的落地思路是:把这两件事放在同一个服务内,共用同一套库存口径,把库存查询结果作为创建采购需求时的推荐补货依据。
这套仓储服务API需要同时支撑几类场景:仓库作业端的PDA回传数据、运营端的库存查询页面、上游订单系统的锁库与扣减,以及采购侧的补货入口。正因为使用方多,接口设计从一开始就有几个硬约束:高频调用不能拖垮数据库;库存数据必须可追溯;创建采购需求不能因为并发操作造成重复补货。所有的表结构设计和事务边界,后来都是围绕这几点展开的。
1.2 从业务场景反推API边界
在真正动手写接口之前,我习惯先把边界画清楚。“查询库存”属于读操作,看起来简单,但查询的维度往往非常复杂——按仓库查、按SKU查、按货主查、按批次查,还得支持分页和聚合。“创建采购需求”属于写操作,表面上是插入一条申请记录,但实际要校验SKU是否存在、数量是否超限、是否已经有未完成的同款需求,甚至还牵扯到后面采购单的生成。
为什么要把这两个接口放在同一个“仓储服务-API-仓库管理”模块下,而不是拆到独立的库存服务和采购服务里?核心原因是数据一致性。库存是仓储服务的核心主数据,采购需求是库存低于安全线后的补货入口,两个东西使用完全相同的“可用量”“在途量”“安全库存”口径。如果拆成两个微服务,库存和采购需求各自维护一套字段,一旦某个服务更新不即时,运营看到的补货建议就等于拍脑袋。
API层面我选择RESTful加JSON的方式,资源路径以/warehouses为根,具体操作作为子资源暴露。接口版本通过路径前缀/api/v1管理,方便后续扩展字段而不会把老版本接口搞挂。统一响应结构里边带上状态码、提示信息和数据体,调用方不需要自己猜结果,这种约定在前后端联调阶段能省掉大量沟通成本。
1.3 统一响应约定与通用字段
这里多说一句统一响应结构,因为后面所有接口示例都会用到。我们约定所有接口返回的JSON都长这样:
{ "code": "0", "message": "success", "data": { } }code为0表示成功,其他非零值表示业务异常,message给可读的描述。所有分页接口的data里统一包含list、total、pageNum、pageSize四个字段。这样老前端也好、新前端也好,接接口的时候不需要针对每个接口单独写一套解析逻辑。
2. 查询库存接口:从字段设计到多维度检索
2.1 为什么“账面库存”不能直接拿来用
库存查询接口最坑的地方,不是SQL写不出来,而是“库存数量”这个口径本身有歧义。如果一张库存表里只有一个总数字段,那这系统基本离上线还差十万八千里。真实业务里的库存至少分成这么几个口径:
- 物理库存:仓库里实际存放的数量,比如仓库地面或货架上实实在在有的货。
- 锁定量:已经创建了出库单但还没完成拣货发货的数量,这些货已经被订单“预占”了。
- 可用库存:物理库存减去锁定量,也就是现在还能继续下新单、能承诺给客户的量。
- 在途量:已经下单采购但还没到货入库的数量,这是补货计算里特别重要的一项。
- 安全库存:为了防止断货而设置的兜底库存水位,它不是实际库存,是一个配置值。
- 待采购量:已经创建了采购需求但还没有转成采购单的数量,这个字段很多人会漏掉。
打个生活化的比方,这就跟电影院锁座一样。放映厅里总共有200个座位是“物理库存”,已经被别人选走锁定的座位是“锁定量”,你现在还能在App上选的座位才是“可用库存”。而明天还有一批票会放出来,那就是“在途量”。查询库存的时候如果只告诉用户一个200,用户根本没法判断能不能下单、要不要补货。
2.2 查询接口的URL、入参与出参设计
查询接口采用GET方式,核心路径设计为:
GET /api/v1/warehouses/{warehouseId}/inventories入参支持按SKU编码批量过滤、按货主过滤、按批次过滤,同时带有分页参数:
warehouseId: 仓库ID(路径参数) skuIds: SKU编码列表,多个用逗号分隔 ownerCode: 货主编码(可选) batchNo: 批次号(可选) pageNum: 页码,默认1 pageSize: 每页数量,默认20,最大50出参需要把这些字段都给全:skuId、skuName、unit、physicalQty、lockedQty、availableQty、inboundQty、pendingPurchaseQty、safeStockQty、batchNo、status。
实际响应示例:
{ "code": "0", "message": "success", "data": { "list": [ { "skuId": "SKU-0001", "skuName": "无线蓝牙耳机", "unit": "个", "physicalQty": 1200.500, "lockedQty": 150.000, "availableQty": 1050.500, "inboundQty": 300.000, "pendingPurchaseQty": 0.000, "safeStockQty": 200.000, "batchNo": "B20250101", "status": "ACTIVE" } ], "total": 1, "pageNum": 1, "pageSize": 20 } }你仔细看这个出参,会发现它不是只给一个总数就完了。之所以要把availableQty、inboundQty、pendingPurchaseQty、safeStockQty全部暴露出去,是因为运营和采购在看库存的时候,需要的不是“仓库里有多少货”,而是“我现在能卖多少、还有多少货在来的路上、已经申请补了多少、缺货红线在哪里”。这些字段在后面创建采购需求接口里,直接参与建议补货量的计算。
2.3 多维度检索和聚合合计的处理
大多数情况下,查询库存不只是按SKU列表去查。实际业务里会遇到几个典型场景:一个仓库下要看所有SKU的库存汇总;只看某个货主的货;只看特定批次的临期商品;甚至按库位分组看每个货位的实物分布。
我建议在设计表结构时就提前把这些维度考虑进去,而不是查询时临时加条件。库存主表的核心维度是warehouse_id + sku_id + batch_no + owner_code,查询索引按这些字段组合建立。如果是页面上的库存列表,通常默认查询维度是“仓库+SKU”,批次在未开启批次管理的情况下不参与展示;一旦开启批次管理,就需要增加批次条件,并且每个批次都有独立可用量。
页面展示还有一个容易忽略的点——聚合合计。运营经常需要在库存列表底部直接看到“当前筛选条件下,可用库存总量是多少、低于安全库存的SKU有多少个”。这些聚合结果如果通过SQL的SUM和GROUP BY实现,在数据量大的时候会拖慢查询。我在做的时候是把聚合逻辑放进查询接口的响应里,额外返回一个summary节点,里面包含总物理量、总可用量、低库存SKU数量等统计值,前端直接用这个节点渲染合计行,不需要再单独调一个统计接口。
2.4 查询性能和分页细节
库存表在真实场景里往往数据量不小。一个中等规模的仓,SKU量几千种,再加上批次、货主维度,几百万行库存记录是很正常的事。查询接口有几个性能细节必须留意:
- 避免深分页:
pageNum翻到几十页之后,LIMIT offset, size的查询会越来越慢。我在查询库存列表时用“游标分页”替代传统分页:入参传上一页最后一条记录的id,查询条件带上id < 上次的id ORDER BY id DESC LIMIT size。这样固定扫描数据量,页面仍然可以按排序字段展示。 - 批量IN条件控制长度:
skuIds一次传几十个或者上百个,生成的SQL会很长,数据库解析也慢。我这边把单个接口的SKU批量上限压到50个,超过的提示前端分批查询。如果真的需要按大量SKU查询,就提供一个异步导出任务,而不是让同步接口硬扛。 - 缓存不要乱用:库存是强一致数据,特别是可用库存、锁定量这些字段,任何缓存造成的数据延迟都可能引发超卖。所以我在这个模块里几乎不用Redis缓存库存数,宁可让查询请求打到数据库,配合好索引和分页。真到扛不住的时候,再做读写分离或汇总表,而不是简单加缓存。
3. 创建采购需求接口:状态机与补货计算
3.1 采购需求从哪里来
创建采购需求这个动作,在仓库管理和供应链系统里通常有以下几种业务来源:
- 低库存预警:系统定时任务发现某个SKU的可用库存低于安全库存水位,自动给运营或采购生成一条待处理建议。
- 人工创建:运营在库存查询页面看到某个SKU库存不足,手动发起采购申请。
- 销售预测:商品运营根据大促或季节性销量,提前创建大批量补货需求。
- 盘点差异:仓库盘点后发现账面库存与实际库存存在差异且实际偏少,需要通过采购需求补足。
在接口设计里,我用一个sourceType字段区分这些来源,这样后续做数据统计和流程追溯的时候,可以清楚知道每个需求是谁发起、为什么发起。人工创建是普通后台操作,低库存预警则是定时任务批量调同一个接口,所以这个接口从一开始就必须考虑批量提交和幂等控制。
3.2 “建议补货量”是怎么算出来的
这个是我认为整个功能里最值钱的部分。采购需求明细里如果没有一个合理的“建议补货量”,纯靠运营拍脑袋填数字,那系统跟内部聊天工具里发Excel没区别。我采用的建议补货量计算逻辑如下:
建议补货量 = 安全库存 + 平均日销量 × 备货周期 - 可用库存 - 在途量 - 待采购量逐个参数说明:
- 安全库存:商品维度配置的一个固定值,表示再低就会影响正常销售。
- 平均日销量:从库存流水表汇总近30天实际出库数量,除以30天得到。采用实际出库流水,而不是订单量,因为订单可能取消或长时间不发货。
- 备货周期:从发起补货到商品入库需要的天数,也就是采购提前期。比如供应商发货加物流通常需要7天,那备货周期就是7。
- 可用库存:来自查询库存接口的
availableQty。 - 在途量:已经创建采购单但尚未入库的数量,来自采购单状态同步,不能漏算,否则会导致重复采购。
- 待采购量:本系统内已经创建但还没转成采购单的采购需求数量,这个字段尤其关键。如果没有这一个因子,两个运营同时看到的低库存建议就都会生成采购需求,最后超补一大堆。
举个例子,某SKU安全库存是20,近30天日均销量是8,备货周期7天,可用库存12,在途量5,待采购量0,那么建议补货量就是20 + 8 × 7 - 12 - 5 - 0 = 59。这个计算结果会展示在采购需求明细的“推荐采购量”里,人工创建时可以在这个数字基础上调整,但最终保存之前要填写“实际采购量”,两列同时保留,方便后续复盘是哪个环节算偏了。
3.3 接口入参与状态机设计
创建采购需求的主接口设计如下:
POST /api/v1/warehouses/{warehouseId}/purchase-demands请求体:
{ "clientRequestId": "REQ-20250302-001", "sourceType": "LOW_STOCK_ALERT", "expectArrivalDate": "2025-03-12", "remark": "蓝牙耳机低于安全库存,触发自动补货", "items": [ { "skuId": "SKU-0001", "recommendedQty": 59.000, "actualQty": 59.000, "reason": "低于安全库存" } ] }clientRequestId是幂等键,用于防止请求重放或重复提交。recommendedQty是系统算出来的推荐采购量,actualQty是最终确认要采购的量。创建之后需求单状态为“草稿”,提交后进入“待审核”,审核通过变成“已确认”,后续被采购模块转成采购单后变为“已转采购单”,整个流程是:
草稿 → 待审核 → 已确认 → 已转采购单 → 已完成 ↘ 已驳回 / 已取消状态变化不放在一个接口里硬编码,而是通过状态字段和操作事件驱动。比如“提交”只能从“草稿”到“待审核”,“驳回”只能从“待审核”到“已驳回”。这样业务流程即使后续加节点,也不会影响已有逻辑。
3.4 创建需求时的校验、幂等与事务边界
创建采购需求接口有一个特别要注意的地方:它不是简单的INSERT。在插入前必须完成几项校验,否则后续采购流程会乱掉:
- 校验SKU是否存在且处于可采购状态。
- 校验仓库是否存在且启用。
- 校验明细数量大于0,且不能超过采购上限额度。
- 校验同一SKU是否已经有未完成的采购需求,如果有就不再重复创建,而是返回已有需求单号。
幂等的实现方式,我选的是数据库唯一索引加clientRequestId字段。每次调用接口先根据clientRequestId查一次,如果已经存在就返回已有记录;没有就直接插入,唯一索引在极端并发下也能挡住重复。这里不能用“先查再插”的普通逻辑,因为两个请求同时查的时候都不存在,然后都插入,唯一索引才是最后一道防线。
事务边界也要控制好。创建采购需求这个动作,事务内做的是:插入需求主表、插入需求明细表、更新相关SKU的待采购量。事务里不要调用外部接口,比如不要直接调采购服务生成采购单,也不要发送通知消息。否则事务会长时间持有数据库连接,并发一高就很危险。我的做法是事务内只做本服务的数据变更,事务提交后通过事件异步通知下游采购模块。
4. 数据库表结构与并发控制
4.1 库存主表和库存流水表怎么设计
库存相关的表,我最核心的经验是:库存主表永远不直接维护“可用库存”这个字段,而是通过物理库存和锁定量计算出来。表结构示例:
CREATE TABLE inventory ( id BIGINT AUTO_INCREMENT PRIMARY KEY, warehouse_id BIGINT NOT NULL, sku_id VARCHAR(50) NOT NULL, batch_no VARCHAR(50) DEFAULT '', owner_code VARCHAR(50) DEFAULT '', physical_qty DECIMAL(18, 3) NOT NULL DEFAULT 0, locked_qty DECIMAL(18, 3) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, created_time DATETIME NOT NULL, updated_time DATETIME NOT NULL, UNIQUE KEY uk_wh_sku_batch_owner (warehouse_id, sku_id, batch_no, owner_code) ) ENGINE=InnoDB;available_qty不落库,查询时用physical_qty - locked_qty算出。这样做的原因很简单:一旦可用库存也作为一个字段单独存储,任何一次锁定和解锁操作都需要保证两个字段同时变化,一旦某个请求只更新了其中一个字段,数据就开始漂移,而且很难定位是哪一步出了错。
库存流水的表结构是审计和计算的源头,任何库存变动都必须写流水:
CREATE TABLE inventory_ledger ( id BIGINT AUTO_INCREMENT PRIMARY KEY, warehouse_id BIGINT NOT NULL, sku_id VARCHAR(50) NOT NULL, batch_no VARCHAR(50) DEFAULT '', owner_code VARCHAR(50) DEFAULT '', change_type VARCHAR(30) NOT NULL, change_qty DECIMAL(18, 3) NOT NULL, before_qty DECIMAL(18, 3) NOT NULL, after_qty DECIMAL(18, 3) NOT NULL, biz_no VARCHAR(64) NOT NULL, created_time DATETIME NOT NULL, INDEX idx_skl_wh_sku_time (warehouse_id, sku_id, created_time) ) ENGINE=InnoDB;所谓“可用库存”,本质上是流水表数据的汇总结果,库存主表里的physical_qty和locked_qty是用来减少查询压力的冗余字段。日均销量这类统计指标,也是从这个流水表里按时间窗口汇总出来的。
4.2 采购需求两张表怎么设计
采购需求分成主表和明细表两张,主表存一次请求的整体信息,明细表存具体SKU和数量:
CREATE TABLE purchase_demand ( id BIGINT AUTO_INCREMENT PRIMARY KEY, demand_no VARCHAR(30) NOT NULL, warehouse_id BIGINT NOT NULL, source_type VARCHAR(30) NOT NULL, status VARCHAR(30) NOT NULL, client_request_id VARCHAR(64) NOT NULL, expect_arrival_date DATE, remark VARCHAR(200), creator VARCHAR(50), created_time DATETIME NOT NULL, updated_time DATETIME NOT NULL, UNIQUE KEY uk_demand_no (demand_no), UNIQUE KEY uk_client_req_id (client_request_id) ) ENGINE=InnoDB; CREATE TABLE purchase_demand_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, demand_no VARCHAR(30) NOT NULL, sku_id VARCHAR(50) NOT NULL, recommended_qty DECIMAL(18, 3) NOT NULL, actual_qty DECIMAL(18, 3) NOT NULL, confirmed_qty DECIMAL(18, 3) DEFAULT 0, status VARCHAR(30) NOT NULL, reason VARCHAR(200), created_time DATETIME NOT NULL, updated_time DATETIME NOT NULL, INDEX idx_demand_no (demand_no) ) ENGINE=InnoDB;client_request_id的唯一索引就是防重的那道保险。demand_no是给业务和外部看的单号,格式可以为PD + 日期 + 序列号。这里要注意一个细节:业务单号demand_no和数据库自增id是两个东西。业务单号用于跨系统引用,自增id只用于内部关联,别把数据库主键直接暴露给前端或者外部接口。
4.3 并发场景下的锁与防超补
创建采购需求的时候,最典型的并发问题就是“两个运营同时点了创建”。解决方案除了上面的唯一索引,更关键的是在事务里对库存行加锁。创建需求前需要读取库存数据来计算建议补货量,同时更新该SKU的待采购量。这个操作如果不在锁的保护下做,就可能出现两个人读到的都是同一个“建议补货量”,然后重复创建。
我用的方式是:事务内先对inventory表对应的行执行SELECT ... FOR UPDATE,把某个warehouse_id + sku_id的行锁住,再执行后续计算和插入。示例:
SELECT id, physical_qty, locked_qty, version FROM inventory WHERE warehouse_id = 1001 AND sku_id = 'SKU-0001' AND batch_no = '' AND owner_code = '' FOR UPDATE;行锁的粒度已经具体到某个仓库、某个SKU、某个批次。这样并发下第二个事务会处于等待状态,等第一个事务提交后再计算,就不会重复补货。之所以选择数据库行锁而不是分布式锁,是因为这里是单库单服务内的强一致操作,数据库行锁天然支持悲观锁定,代码更简单,也不容易出现分布式锁超时或误删锁的情况。
如果同一个需求明细里要锁多个SKU,注意加锁顺序必须固定,比如按sku_id排序后再逐个加锁。不然两个并发请求各自锁了A之后想锁B,另一个锁了B之后想锁A,就产生死锁。
4.4 大事务边界与接口性能
创建采购需求的接口事务边界我控制在非常小的范围:事务里只做插入主表、插入明细表、锁库存行并更新待采购量,大概两三张表的操作。所有外部消息、审批通知都放到事务提交之后异步处理。这样接口在正常情况下的事务耗时能控制在几十毫秒以内。
库存查询接口的性能主要靠组合索引。inventory表上不同查询场景的索引匹配要提前跑EXPLAIN确认,比如按仓库分页查SKU列表,走的是warehouse_id加sku_id的联合索引;按货主过滤则要确认owner_code有没有挂在索引开头。如果某次查询发现执行计划走了全表扫描,不要犹豫,直接看索引是否建全。几百万行数据下,一次全表扫描就能把接口耗时从几十毫秒拖到几秒。
5. 联调实战与常见问题排查
5.1 一次完整的“查询→创建→再查询”演练
我把整套流程串起来走一遍,方便你理解两个接口是怎么协作的。
第一步,运营打开库存查询页面,查询SKU-0001的库存情况:
GET /api/v1/warehouses/1001/inventories?skuIds=SKU-0001返回结果里显示availableQty=12,safeStockQty=20,inboundQty=5,pendingPurchaseQty=0。系统同时计算出建议补货量:
20 + 8 × 7 - 12 - 5 - 0 = 59第二步,运营点击“创建采购需求”,系统自动带出推荐数量59,运营确认后提交:
POST /api/v1/warehouses/1001/purchase-demands接口返回demandNo=PD202503021001,状态为“待审核”。同时该SKU的待采购量更新为59。
第三步,再执行一次查询库存接口,会发现pendingPurchaseQty已经变成59。此时即便运营再点创建采购需求,接口也会因为检测到已有未完成的采购需求而返回同一张单号,而不是重新生成一遍。
5.2 实际踩过的几个坑
第一个坑:刚开始做的时候,我把available_qty直接作为库存表的一个字段维护。结果上线第一天,一个出库操作和一个锁库操作并发执行,available_qty先被扣减又被加回,出现负数。排查了很久才发现是字段维护顺序不一致。后来我改成不落库,统一用physical_qty - locked_qty计算,才根治这个问题。
第二个坑:创建采购需求时出现死锁。原因是一个事务里先锁了SKU-A再锁SKU-B,另一个事务则先锁SKU-B再锁SKU-A。两边相互等待,数据库直接报死锁。后来我把所有涉及多个SKU的锁操作统一按SKU编码排序,先排序后加锁,死锁就没再出现。
第三个坑:库存数量字段一开始用了浮点类型,结果某天出现0.1 + 0.2 = 0.30000000000000004这种问题。这个其实很好解决,所有库存数量字段统一用DECIMAL(18, 3),不要给任何数量的字段用float或double。
第四个坑:查询接口的skuIds参数在联调阶段被前端一次性塞了500个SKU编码,生成的SQL巨长,数据库直接报错。后来我在接口层做了批量上限校验,超过50个就提示分批,同时给运营留了异步导出功能,一次导几万条都没问题。
5.3 常见问题速查表
| 问题现象 | 常见原因 | 推荐排查方法 |
|---|---|---|
| 查询库存与实际库存对不上 | 部分操作没有写库存流水,或流水字段缺失 | 查inventory_ledger表,按业务单号逐笔核对 |
| 多了相同商品的采购需求 | 前端按钮连点、接口无幂等控制 | 检查purchase_demand表的client_request_id唯一索引 |
| 建议补货量为负 | 在途量或待采购量统计遗漏 | 检查inbound_qty与pending_purchase_qty的数据同步链路 |
| 创建需求接口偶发死锁 | 多SKU加锁顺序不一致 | Service层按sku_id排序后再逐行加锁 |
| 查询库存接口很慢 | 组合索引缺失或深分页 | 执行EXPLAIN查看执行计划,改为游标分页 |
| 库存数量出现精度错误 | 字段用了浮点数类型 | 数量字段统一换成DECIMAL(18, 3) |
6. 后续扩展与个人体会
6.1 可以继续深入做下去的几个方向
这套基础版本跑顺之后,后续可以扩展的方向其实蛮多的。低库存预警定时任务是最直接的下一步:每天凌晨扫描一遍库存主表,找出可用库存低于安全库存、且没有未完成采购需求的SKU,自动生成待办或直接调用创建采购需求接口。再往下可以做动态安全库存,不再用固定的safeStockQty,而是根据近几周的销量波动、供应商到货周期变化,每周自动调整安全库存水位。还有一个非常实用的方向是多仓调拨联动:A仓缺货但B仓有货时,优先生成调拨单而不是采购单;只有全网库存都不够时,才真正触发对外采购需求。这几个扩展全都基于同样的库存口径体系,前面的表结构基本不用大改。
6.2 最后分享一点个人体会
做这两个接口前,我以为核心难点是SQL和接口参数,做完了才发现真正难的是“口径对齐”。运营说的库存、采购说的库存、财务说的库存,往往不是同一个数字。开发同学如果在前期多花半天跟仓库和采购同事对清楚“可用库存、在途量、待采购量、安全库存”这几个词的定义,后面联调能少吵十次架。另外提一个小建议:创建采购需求后,企业中会有大量长尾SKU数量单位是“千克”“箱”而不是“个”,所以所有数量字段提前用DECIMAL(18, 3)而不是整数类型,否则上线后会为了单位换算改表结构改到怀疑人生。这套接口做完之后,我最直观的感受就是:查询库存和创建采购需求虽然是两个接口,但它们共享的数据模型和计算口径,才是仓储服务真正值钱的部分。