仓储服务API实战:库存查询与采购需求的接口设计与并发控制
做后端系统的人基本都会在某个阶段碰到仓储或库存这类需求。我近期梳理了一套仓储服务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) ) ENGINEInnoDB;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) ) ENGINEInnoDB;所谓“可用库存”本质上是流水表数据的汇总结果库存主表里的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) ) ENGINEInnoDB; 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) ) ENGINEInnoDB;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?skuIdsSKU-0001返回结果里显示availableQty12safeStockQty20inboundQty5pendingPurchaseQty0。系统同时计算出建议补货量20 8 × 7 - 12 - 5 - 0 59第二步运营点击“创建采购需求”系统自动带出推荐数量59运营确认后提交POST /api/v1/warehouses/1001/purchase-demands接口返回demandNoPD202503021001状态为“待审核”。同时该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)而不是整数类型否则上线后会为了单位换算改表结构改到怀疑人生。这套接口做完之后我最直观的感受就是查询库存和创建采购需求虽然是两个接口但它们共享的数据模型和计算口径才是仓储服务真正值钱的部分。