前两年帮一家做家居百货的客户收拾仓库的烂摊子,他们当时管库存的工具是四个 Excel 表加一个微信群。仓库管理系统这个词,在很多人脑子里约等于"买套软件装上就完事",可真到现场你会发现,同样叫仓库管理系统,管 200 个 SKU 和管 2 万个 SKU 完全是两码事,单仓和全国五个仓又是两码事。那家客户最后卡在的不是软件功能,而是"到底谁负责在什么时间点录哪一笔账"这件事没人说得清。这套系统我前后迭代了三版,从最早的 Spring Boot 单体一路改到拆出库存服务,踩过的坑基本能写一本小册子。
下面这些内容,是想给三类人看的:一是中小电商或者制造业的 IT 负责人,正纠结要不要自研仓储这块;二是刚接手 WMS 项目、被"库存对不上"折磨到失眠的开发同学;三是想把仓库从人治改成系统管的运营负责人。我会把需求边界怎么划、数据模型怎么设计、入库出库盘点怎么串、超卖和幂等怎么防、上线后怎么排查问题,全部摊开讲一遍,能直接抄的部分我会给出建表语句和核心逻辑,不能直接抄的我会说清楚为什么。
1. 先搞清楚要管什么:仓库管理系统的需求边界怎么划
1.1 Excel 管不动的那一刻,才是上系统的正确时机
我见过太多团队在只有 30 个 SKU、一天发 20 单的时候就上 WMS,结果系统比业务还重,仓管员每天要在系统里点四十几次鼠标,最后大家集体绕开系统走线下。判断要不要上,我自己总结了一条粗糙但好用的线:当"找货时间"开始超过"拣货时间",或者"月底对账"需要花掉两个以上工作日,这才是真正的临界点。
具体可以拿几个信号来对:
- 同一个 SKU 在不同表格里出现了不同的库存数字,且没人能说清哪个是对的;
- 出现"系统里有货、货架上找不到"的情况,一个月超过三次;
- 客户退货后,退货商品进不了库存账,只能堆在角落当"待处理";
- 有保质期或者批次要求的商品,出了问题查不到是哪一批进的、发给谁了。
只要命中两条以上,就说明问题已经不在人的层面,而是缺一套强约束的账。WMS 的本质不是"记录",而是"约束"——它强制要求任何一次实物移动都必须有一张单据对应,任何一次数量变化都必须留下流水。这一点想明白了,需求就好写了。
1.2 买成品、买成品加二次开发、纯自研,三条路的取舍
这是项目启动前最纠结的一步,我给个我自己用的判断框架。核心看的不是预算,而是"你的仓储作业有没有独特性"。
| 方案 | 适合场景 | 单仓年成本区间 | 主要风险 |
|---|---|---|---|
| 直接买 SaaS 成品 | 标准电商发货,SKU 少于 5000,无批次效期 | 几千到几万 | 数据在别人手里,流程改不动 |
| 成品 + 二次开发 | 有 ERP 集成需求,流程八成标准 | 十万级 | 二次开发的接口费和维护费是坑 |
| 纯自研 | 有特殊作业流程,比如批次追溯、委外加工、多货主 | 人力成本为主 | 团队流动后没人接得住 |
我做过一个复盘:客户买成品的钱,通常只占三年总投入的三成,剩下七成是实施费、接口费、定制费和每年的维护费。反过来说,自研也不是省钱,而是把钱从"买"换成了"养人"。所以真正的判断标准是——你的业务是不是稳定。如果未来一年流程还要大改,那自研的灵活性值这个钱;如果流程三年不动,买成品更划算。
我的建议是走中间路线:标准的部分买,核心的库存账自己做。因为库存账一旦被外部系统锁死,后面想加个字段都得排期,那是真的难受。
1.3 把业务抽象成四个核心对象:货、位、单、账
不管多大的仓库,抽象到最后就是四个东西,我管它叫"货位单账"。
货,就是 SKU,但要加上批次、效期、序列号这些维度。一个 SKU 在不同批次下,物理上是不同的东西,管理上也必须分开。
位,就是库位。库位不是简单的货架编号,它要带属性:是拣货位还是存储位,能不能混放,承重多少,是否靠近出货口。这些属性决定了后续上架策略怎么写。
单,就是单据。入库单、出库单、调拨单、盘点单、退货单,全部是单据。单据有状态机,状态不能乱跳,这是保证流程不走形的关键。
账,就是库存流水。库存表存的是"当前快照",流水表存的是"每一次变化"。两张表必须同时写,且要能对得上。我后来加了一条硬性校验:任何时刻,库存表的数量都应该等于流水表的累加值,如果不等,说明有代码绕过正规流程改了库存。这条校验在测试环境跑了一整年,抓出过三次隐蔽的 bug。
注意:很多团队只建库存表不建流水表,觉得冗余。等到客户投诉"上个月少了 200 件货"的时候,你连查的入口都没有。流水表是 WMS 的审计日志,不是可选项。
2. 技术选型与数据模型:把地基打牢
2.1 技术栈怎么选才不容易翻车
WMS 这类系统的技术特征很明显:读写比大概在 7:3 到 5:5 之间,单条数据量不大,但并发集中在几个时间点(比如早上八点集中开单、下午四点集中发货),而且一旦卡住,整个仓库的人都会站着等你。所以选型的核心是"稳"和"团队能维护",不是"新"和"炫"。
我自己落地的组合是:后端 Spring Boot + MySQL 8.0 + Redis,前端 Vue 3 + Element Plus,App 端用 UniApp 打包成 Android PDA 应用。这个组合没有一处是最先进的,但每一处都有一堆现成的坑和解决方案,出问题时搜索能找到答案,这在关键系统里比技术先进性重要得多。
选 MySQL 而不是 PostgreSQL,主要原因是团队里懂 MySQL 的人多,且我们那个量级(单表库存记录 300 万行以内)完全不需要更复杂的特性。如果真的到了单表上亿行,我的做法是先按仓库维度分库,而不是换数据库。
Redis 在这里只干三件事:缓存热点 SKU 信息、做库存预扣、做分布式锁。绝对不要用 Redis 当主存储。我见过一个团队为了追求下单速度,把库存只放 Redis,结果一次机房网络抖动,Redis 主从切换丢了几千条库存变更,最后只能人工对账三天。这个教训太贵了。
2.2 库存表是整个系统的命门,索引和约束一步都不能省
库存表设计上,我吃过最大的亏是"没有唯一索引"。早期版本我允许同一个 SKU 在同一个库位有多条记录,想着按批次分开就行,结果代码里到处都是先查后插的逻辑,并发一上来就插出重复行,库存直接翻倍。
正确的做法是用数据库的唯一约束来保证"一个维度组合只有一行":
CREATE TABLE wms_inventory ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID', location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID', sku_id BIGINT UNSIGNED NOT NULL COMMENT '商品ID', batch_no VARCHAR(64) NOT NULL DEFAULT '' COMMENT '批次号', qty_on_hand INT NOT NULL DEFAULT 0 COMMENT '实物库存', qty_allocated INT NOT NULL DEFAULT 0 COMMENT '已分配(占用)', qty_available INT NOT NULL DEFAULT 0 COMMENT '可用库存', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_inv ( warehouse_id, location_id, sku_id, batch_no ), KEY idx_sku (sku_id), KEY idx_location (location_id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '库存快照表';这里有几个细节值得说。第一,batch_no用空字符串默认值而不是 NULL,是因为 MySQL 的唯一索引里 NULL 不参与去重,如果允许 NULL,同一个组合会插出无数行,这是个非常隐蔽的坑。第二,qty_available是冗余字段,理论上等于qty_on_hand - qty_allocated,但我还是落库了,原因是查询太频繁,每次现算会让索引失效,而且能通过一个约束检查发现数据异常。第三,version字段是为了乐观锁准备的,后面讲并发扣减会用到。
库存流水表设计要更简单,但字段要全:
CREATE TABLE wms_inventory_transaction ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, biz_no VARCHAR(64) NOT NULL COMMENT '业务单号', biz_type VARCHAR(32) NOT NULL COMMENT 'INBOUND/OUTBOUND/MOVE/STOCKTAKE', warehouse_id BIGINT UNSIGNED NOT NULL, location_id BIGINT UNSIGNED NOT NULL, sku_id BIGINT UNSIGNED NOT NULL, batch_no VARCHAR(64) NOT NULL DEFAULT '', change_qty INT NOT NULL COMMENT '正数入库负数出库', before_qty INT NOT NULL COMMENT '变更前数量', after_qty INT NOT NULL COMMENT '变更后数量', operator_id BIGINT UNSIGNED NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_biz (biz_type, biz_no), KEY idx_sku_time (sku_id, create_time) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '库存流水表';before_qty和after_qty这两个字段是我极力推荐加的。很多人只存change_qty,结果对账时只能累加,一旦有一次错误就全盘皆错。存了前后值,你就能快速定位"到底是哪一笔变更把账搞乱了"。
2.3 库位编码设计:别等到有 5000 个库位才后悔
库位编码看着是小问题,实际上是后期最容易返工的地方。我见过一个仓库用"1-2-3"这种纯数字编号,结果三个月后加了新货架,编码体系直接乱套,只能全部重新贴标签,贴了两天。
我推荐用分段语义编码,格式是"区-巷-架-层-位",例如A-03-012-04-02表示 A 区 3 号巷道第 12 个货架第 4 层第 2 个位置。这个编码的好处有三个:人一眼能看出物理位置,拣货员不用查表;打印出来就能按字符串排序,天然符合拣货路径;扩容时只需新增区号,不影响已有编码。
对应到表结构上,我建议把编码拆成维度字段存,同时存一个完整编码方便显示:
CREATE TABLE wms_location ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, warehouse_id BIGINT UNSIGNED NOT NULL, loc_code VARCHAR(32) NOT NULL COMMENT '完整编码 A-03-012-04-02', zone_code VARCHAR(8) NOT NULL COMMENT '区', aisle_code VARCHAR(8) NOT NULL COMMENT '巷道', rack_code VARCHAR(8) NOT NULL COMMENT '货架', level_code VARCHAR(8) NOT NULL COMMENT '层', slot_code VARCHAR(8) NOT NULL COMMENT '位', loc_type TINYINT NOT NULL DEFAULT 1 COMMENT '1存储位 2拣货位 3暂存位', allow_mix TINYINT NOT NULL DEFAULT 0 COMMENT '是否允许混放SKU', max_weight DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '承重kg', status TINYINT NOT NULL DEFAULT 1 COMMENT '1可用 0停用', PRIMARY KEY (id), UNIQUE KEY uk_loc (warehouse_id, loc_code), KEY idx_path (warehouse_id, aisle_code, rack_code, level_code) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '库位表';allow_mix这个字段看着不起眼,实际作用极大。高频小件允许混放能省空间,但大件和贵重品必须一库位一 SKU,否则盘点时会崩溃。我在项目里把它做成上架时的硬校验:如果目标库位不允许混放,且已经有其他 SKU,系统直接拦掉,不给操作员"我确认一下"的选项。因为只要给了这个选项,就一定会有人点。
实操心得:库位标签一定要用耐磨材质,且编码同时印条码和二维码。条码枪扫条码快,但二维码在标签磨损后识别率更高,两种都印,成本增加不到两毛钱,能省掉无数次"扫不出来手输"的麻烦。
3. 核心流程落地:入库、出库、盘点怎么串起来
3.1 入库四步走:收货、质检、上架、结单
入库流程我见过最乱的做法是"货到了直接录库存",一步到位。这种做法在数量对得上时看着挺快,但一旦出现短装、破损、错发,就没有任何缓冲地带,账上多了货,实际没货,只能靠盘点抹平。
规范的流程应该切成四步,每步都有明确的状态:
- 收货:货到月台,按 ASN(到货通知单)或采购单核对总件数,记录实收。这一步只记录"来了多少箱",不涉及具体上架库位。
- 质检:抽检或者全检,标记合格、不合格、待定。不合格品走退货或者暂存流程,不能进正常库存。
- 上架:按上架策略分配到具体库位,操作员用 PDA 扫码确认,此时库存才真正增加。
- 结单:所有明细上架完成后关闭单据,差异部分生成差异记录,由采购或供应商跟进。
这个流程的价值在于"责任分段"。收货员只对件数负责,质检员只对质量负责,上架员只对库位准确负责。出了问题能定位到人,也能定位到环节。
上架策略我用的是规则引擎加优先级:
| 优先级 | 规则 | 适用场景 |
|---|---|---|
| 1 | 指定库位上架 | 大件、贵重品、客户指定 |
| 2 | 同 SKU 已有库位优先 | 减少分散,方便盘点 |
| 3 | 按 ABC 分类就近上架 | A 类高频品靠近出货口 |
| 4 | 按空闲库位顺序分配 | 兜底规则 |
ABC 分类的计算方式很简单,按近 90 天的出库频次排序,累计占比前 70% 的算 A 类,70% 到 90% 的算 B 类,剩下的算 C 类。这个分类建议每周跑一次批处理更新,不要实时算,否则每次上架都要扫全表。
上架入库的核心事务代码大概长这样:
@Transactional(rollbackFor = Exception.class) public void putaway(PutawayCmd cmd) { // 1. 校验单据状态,防止重复上架 InboundOrder order = orderMapper.selectForUpdate(cmd.getOrderId()); if (order.getStatus() != InboundStatus.QC_PASSED) { throw new BizException("单据状态不允许上架"); } // 2. 校验库位可用性和混放限制 Location loc = locationMapper.selectById(cmd.getLocationId()); checkMixRule(loc, cmd.getSkuId()); // 3. 加库存(不存在则插入,存在则累加) inventoryMapper.upsertQty( cmd.getWarehouseId(), cmd.getLocationId(), cmd.getSkuId(), cmd.getBatchNo(), cmd.getQty()); // 4. 写流水,beforeQty 从 upsert 返回或再查一次 Inventory inv = inventoryMapper.selectUnique( cmd.getWarehouseId(), cmd.getLocationId(), cmd.getSkuId(), cmd.getBatchNo()); transactionMapper.insert(buildTxn(inv, cmd.getQty(), cmd.getBizNo())); // 5. 更新单据明细已上架数量 orderItemMapper.addPutawayQty(cmd.getItemId(), cmd.getQty()); }这里的upsertQty用的是INSERT ... ON DUPLICATE KEY UPDATE,靠唯一索引兜底,避免并发下插出两行。注意这个语句在 MySQL 里返回的影响行数有讲究:插入返回 1,更新返回值变化时返回 2,值没变时返回 0。如果你的代码靠返回值判断成功失败,一定要按这个规则处理,否则会出现"明明更新了却判定失败"的诡异现象,我自己被这个坑了整整一个下午。
3.2 出库:从订单到发货的六道关卡
出库比入库复杂得多,因为涉及占用、拣货、复核,每一步都可能出错。我把它拆成六步:订单接收、库存占用、生成波次、拣货、复核、发货过账。
库存占用是最关键的一步。订单进来后,先判断可用库存是否足够,够就增加qty_allocated,这一步不动qty_on_hand,因为货还在架上。这里必须用带条件的更新语句,把判断和修改放在一条 SQL 里:
UPDATE wms_inventory SET qty_allocated = qty_allocated + #{qty}, qty_available = qty_available - #{qty}, version = version + 1 WHERE warehouse_id = #{warehouseId} AND sku_id = #{skuId} AND qty_available >= #{qty} AND id = #{id};判断影响行数是否为 1,为 0 就说明可用库存不足,直接抛异常回滚。这条语句的精妙之处在于,qty_available >= qty这个条件是在数据库层面判断的,不存在"先查后改"的时间窗口,天然防超卖。
拣货环节的核心是路径。我做过测算,一个 8000 平方米的仓库,不用路径优化,拣货员一天走的距离大概是 18 到 22 公里;用了 S 形路径(也叫蛇形路径,按巷道来回穿插)之后,降到 10 公里左右,效率提升接近一半。
复核这一步很多小团队会砍掉,觉得浪费时间。我强烈建议保留,而且要用扫码复核,不是点数。复核的核心作用是截住拣货错误,一旦货发出去,退回来的成本是复核成本的十倍以上。复核时系统会提示"应该是什么",操作员扫"实际是什么",不一致就报警,这一道关卡能拦掉九成以上的错发。
发货过账是真正扣减库存的时刻,同时扣掉qty_on_hand和qty_allocated,并写一条负数流水。这里要注意,发货过账必须和物流单号绑定,不然以后客户说没收到货,你连发没发都说不清。
3.3 拣货路径与波次:把走路的距离砍掉一半
波次是把多个订单合并成一批一起拣。合波的核心逻辑是"同一区域的订单放一起",而不是简单的按时间顺序。我用的合波策略有这么几条优先级:
- 优先合并同一巷道内的订单;
- 单个波次的 SKU 总数控制在 30 到 50 之间,太多反而容易出错;
- 急单单独成波,不参与合并;
- 大件单独成波,因为要用不同的拣货车。
合波算法本身不复杂,难的是参数调优。我最初的参数是"单波 20 个订单",实测下来拣货员抱怨"来回跑太多";调到 50 个订单后,又出现"拣货车装不下"的问题。最后定在 30 个订单、SKU 数不超过 40,配合两辆拣货车,这个组合在他们仓库是最顺的。
这里必须说一个反直觉的点:路径优化的收益,跟仓库的物理布局关系极大,跟算法的复杂度关系不大。我见过有人花两周实现了一套遗传算法求解 TSP,收益还不如把出货口的位置从角落挪到中间。所以在动算法之前,先画一张仓库平面图,看看动线是不是本身就有问题。
3.4 盘点:明盘、盲盘和循环盘点的选择
盘点分三种,选错了会很痛苦。
明盘是操作员能看到系统账面数量。好处是效率高,坏处是容易"照着账面填",实际差异被掩盖。适合账实一致度比较高的仓库。
盲盘是操作员看不到账面数量,只报实盘数,由系统比对。这个能真实反映差异,但效率低,且操作员会有心理压力。适合第一次盘点或者差异较大的仓库。
循环盘点是不停线,每天抽一部分库位盘,比如按 ABC 分类,A 类每月盘一次,C 类每季度盘一次。这是我最推荐的方式,因为全仓停线盘点的损失太大,而且一次盘完,第二天又乱了。
盘点的差异处理是整个流程里最需要制度的环节。我的做法是:盘点产生的差异不直接调库存,而是生成一张"盘盈盘亏单",需要仓管主管审批后才调整。这样做虽然多了一步,但能防止操作员为了省事随手改库存。
-- 盘点差异调整 UPDATE wms_inventory SET qty_on_hand = #{actualQty}, qty_available = #{actualQty} - qty_allocated, version = version + 1 WHERE id = #{invId} AND version = #{version}; -- 差异必须留痕,且带上审批人 INSERT INTO wms_inventory_transaction (biz_no, biz_type, warehouse_id, location_id, sku_id, batch_no, change_qty, before_qty, after_qty, operator_id) VALUES (#{bizNo}, 'STOCKTAKE', #{whId}, #{locId}, #{skuId}, #{batchNo}, #{diffQty}, #{beforeQty}, #{actualQty}, #{operatorId});注意:盘点的
biz_no一定要能关联到具体的盘点任务和盘点人,不要用"系统调整"这种模糊的单号。我遇到过对账时发现有笔调整查不到来源,最后翻了两天日志才定位到是某个离职同事用测试脚本改的。
4. 三个最容易翻车的地方:超卖、重复提交、批次追溯
4.1 库存扣减的三种方案与真实压测数据
库存扣减是 WMS 和电商系统共同的难点。我实测过三种方案,把它们放在同一台 4 核 8G 的机器上,用 200 并发压 3000 库存,结果差异很明显。
| 方案 | 实现方式 | 3000 库存 200 并发结果 | 优点 | 缺点 |
|---|---|---|---|---|
| 悲观锁 | SELECT ... FOR UPDATE | 无超卖,QPS 约 380 | 逻辑简单不易错 | 锁等待严重,热点 SKU 排队 |
| 乐观锁 | version 字段 CAS | 无超卖,QPS 约 900,冲突重试率 12% | 吞吐高 | 高并发下重试放大 |
| Redis 预扣 | Lua 脚本原子扣减 | 无超卖,QPS 约 4200 | 极快 | 需处理缓存与库的最终一致 |
最后我在生产用的是"Redis 预扣 + 数据库兜底"的混合方案:日常走 Redis,每笔 Redis 扣减成功后就发一条消息异步落库;同时有个定时任务每 5 分钟对一次账,发现 Redis 和数据库的差额超过阈值就告警。
Redis 的 Lua 脚本大概是这样:
-- KEYS[1] = inv:stock:{skuId}:{warehouseId} -- ARGV[1] = 扣减数量 local key = KEYS[1] local qty = tonumber(ARGV[1]) local stock = redis.call('GET', key) if not stock then return -2 -- 缓存不存在,回源加载 end if tonumber(stock) < qty then return -1 -- 库存不足 end return redis.call('DECRBY', key, qty)返回 -2 的时候要回源查数据库并重建缓存,这个过程必须加分布式锁,否则 200 个并发同时回源会把数据库打穿。锁的粒度是 SKU 维度,不是全局,压测下来效果差很多。
踩过的坑:Redis 预扣之后如果业务失败(比如订单取消),必须把库存加回去,而且要保证"加回"这个操作幂等。我最初忘了这一步,导致少量订单在取消后库存没回滚,最后账少了。解决方案是给回滚操作也带上业务单号,用同样的幂等表拦住重复回滚。
4.2 幂等:接口重试和消息重投的必修课
WMS 里几乎所有写接口都需要幂等。原因很实际:网络抖动导致前端重试、消息队列重投、用户手快点两次、PDA 在信号差的地方重复提交,这四种情况我全都遇到过。
幂等的实现方式我用的是"唯一业务键 + 唯一索引",最土但最可靠:
CREATE TABLE wms_idempotent_record ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, biz_type VARCHAR(32) NOT NULL COMMENT '业务类型', biz_key VARCHAR(128) NOT NULL COMMENT '业务唯一键', result_json TEXT COMMENT '首次执行结果快照', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_biz (biz_type, biz_key) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '幂等记录表';业务逻辑里先尝试插入,插入成功说明是第一次,继续执行;插入失败(唯一键冲突)说明已经处理过,直接返回首次的结果快照。这里有个细节:插入和业务操作必须在同一个事务里,否则会出现"幂等记录写了但业务没执行"的情况,那样重试反而会被挡住。
业务键怎么设计很关键。入库上架我用orderId + itemId + locationId,出库发货我用orderId + logisticsNo,库存调整我用bizNo + operatorId + timestamp。原则是:同一个业务动作无论重试多少次,键必须一样;不同的业务动作,键必须不一样。
4.3 批次与效期追溯,出问题时能查到哪一步
批次追溯在食品、医药、化妆品行业是硬性要求,在普通电商里也越来越多被提到。要实现追溯,核心是在库存和流水里都带上batch_no,并且批次信息要能反查到入库单。
批次追溯要能回答三个问题:这批货是什么时候进的、进的哪一批、发给了谁。前两个靠入库单和批次表,第三个靠出库流水的批次关联。
-- 查某批次的所有去向 SELECT t.biz_no, t.change_qty, t.create_time, o.customer_name, o.logistics_no FROM wms_inventory_transaction t LEFT JOIN wms_outbound_order o ON o.order_no = t.biz_no WHERE t.sku_id = #{skuId} AND t.batch_no = #{batchNo} AND t.biz_type = 'OUTBOUND' ORDER BY t.create_time;效期管理上,我建议加一个"临期预警"的定时任务:按 SKU 配置预警天数(比如保质期 12 个月的提前 60 天预警),每天凌晨跑一次,把临期批次推给运营。同时出库时优先分配效期早的批次,这就是 FEFO(先到期先出)策略。实现上就是在选批次的时候按expire_date升序排。
实操心得:批次号一定要在收货时就确定,不要等到上架再生成。因为收货和上架可能是两个人在不同时间做的,如果上架时生成批次号,收货环节的质检结果就没法关联到批次上。我早期就是在上架时生成,结果出现过"质检不合格的批次被当成合格品上架"的问题。
5. 常见问题排查与上线经验
5.1 问题速查表
系统上线后,问题会集中爆发在头两个月。我把遇到过的问题整理成了速查表,基本覆盖了八九成的情况。
| 现象 | 可能原因 | 排查方式 | 处理建议 |
|---|---|---|---|
| 库存表有记录但可用库存为负 | 并发扣减绕过校验 | 查流水表看变更序列 | 加约束qty_available >= 0,代码层用条件更新 |
| 同一库位同一 SKU 出现两行 | 缺唯一索引或索引含 NULL | SELECT ... GROUP BY ... HAVING COUNT(*) > 1 | 补唯一索引,batch_no默认空串 |
| 单据状态卡在中间不动 | 事务超时或异常未回滚 | 查应用日志和数据库锁等待 | 加状态机校验和补偿任务 |
| PDA 提交后无反应 | 网络超时,前端未做重试 | 查网关访问日志 | 接口做幂等,前端加自动重试 |
| 拣货任务重复分配 | 波次生成任务并发 | 查波次表生成时间 | 波次生成加分布式锁 |
| 库存对得上但实物找不到 | 库位上架错误 | 按 SKU 查库位分布 | 加盘点任务,上架强制扫码 |
这里我想特别说"库存表有记录但可用库存为负"这条。这个问题的根因通常是代码里用了UPDATE ... SET qty = qty - 1 WHERE id = ?这种没有条件的更新。修复方式很简单,加上AND qty_available >= #{qty},并且在数据库层面加一个 CHECK 约束或者用触发器兜底。MySQL 8.0.16 之后是支持 CHECK 约束的,可以放心用:
ALTER TABLE wms_inventory ADD CONSTRAINT chk_qty_non_negative CHECK (qty_on_hand >= 0 AND qty_allocated >= 0 AND qty_available >= 0);加了这条约束之后,任何试图把库存减成负数的代码都会直接抛异常,问题会在测试阶段就暴露,而不是等到生产环境对不上账。
5.2 上线前两周必须做的三件事
第一件是历史数据迁移加双向核对。把 Excel 里的库存导入系统时,一定要做两遍:第一遍导入,第二遍导出,然后和原始 Excel 做差异比对。差异清单要人工确认每一项,不能"大概差不多就行"。我做过一次迁移,1.2 万行数据里有 37 行差异,其中 5 行是因为 Excel 里有隐藏行,另外 32 行是 SKU 编码里有全角空格。如果没核对,这 37 行会在上线后变成 37 个投诉。
第二件是并行运行一周。系统上线后不要马上停掉线下记录,让仓库同时用系统和新表格记录一周,每天比对。并行期间会很累,但能发现大量流程问题,比如"某个环节忘了在系统里点确认"。
第三件是培训要分角色,不要开大会。收货员只关心收货界面,拣货员只关心 PDA 上的拣货任务,你把他们放一起讲两小时,谁都记不住。我的做法是每个角色单独培训 30 分钟,讲完立刻在测试环境实操 10 遍,当场考核。这个投入是值得的,我算过,一个操作员如果第一天就学会,后面能省下至少两天的手把手教。
5.3 性能与监控:慢查询和库存对不上怎么抓
慢查询监控上,我把long_query_time设成 0.5 秒,并且每周看一眼慢查询日志。WMS 里最常见的慢查询有三个:一是库存表按 SKU 查所有库位但没用上索引;二是流水表按时间范围查但没有时间索引;三是报表类查询直接扫全表。
针对报表,我的建议是彻底分开:主库只做事务,报表走单独的只读从库,或者每天凌晨把数据同步到一张宽表里。我最开始图省事让报表直接查主库,结果月底出报表的时候,整个仓库的操作都变慢了,因为报表一个查询跑二十秒,把连接池占满了。
库存对账我做了三层监控:
- 实时层:每次库存变更后,校验
qty_available = qty_on_hand - qty_allocated,不等就直接告警。这个检查放在业务代码里,性能开销可忽略。 - 小时层:每小时跑一次任务,对比库存表按 SKU 汇总和流水表末尾的
after_qty是否一致。 - 日层:每天凌晨全量重算,把库存表按流水重新推导一遍,和实际库存表比对,输出差异报告。
这三层监控跑起来后,我最大的感受是:库存对不上这件事,从来不是某一刻突然发生的,而是一点点积累的。实时层能抓住 90% 的问题,剩下的靠日层兜底。如果只做日层,问题发生到发现可能隔了 24 小时,那时候已经很难定位是哪一笔操作引起的了。
6. PDA 扫码作业与硬件配合的实战细节
6.1 PDA 选型与离线作业
PDA 是仓库里最容易被忽视但最影响体验的设备。我前后换过四款 PDA,最后定下来的标准是:屏幕 4 寸以上、支持物理扫描键、电池能撑一个班次(8 小时连续扫描)、重量 300 克以内。品牌上不用太纠结,但一定要选工业级的,消费级手机改装的 PDA 在冬天低温环境下手套操作基本没法用。
离线作业是我强烈建议加的能力。仓库里总有一些角落信号差,如果每次扫描都要等服务器返回,操作员会急死。我的做法是本地队列加断点续传:PDA 本地用 SQLite 存一份待提交的任务,扫描后先写本地,后台线程负责上传,上传成功才删除本地记录。这样即使在信号盲区,操作员也能连续作业,走到有信号的地方自动同步。
这里有个必须注意的点:离线队列里的操作如果和服务器状态冲突了(比如别人已经把货拣走了),同步回来的时候必须有冲突处理策略。我的策略是"先来后到 + 人工介入":同步时如果校验失败,把这条记录标记为"待处理",推给主管,不自动丢弃也不自动覆盖。
6.2 标签与条码规范
条码这一块踩过的坑特别多。最早我用的是 Code128,优点是通用,但密度低,标签要做得比较大。后来改成 QR 码,同样面积能放更多信息,且磨损后识别率更高。现在我的做法是双码并存:商品标签主码用 Code128(因为很多扫码枪对一维码识别更快),库位标签用 QR 码。
标签内容上,商品码我用SKU编码 + 批次号,库位码直接用库位编码。注意不要用自增 ID 做条码内容,因为 ID 在系统间迁移时可能变化,出问题没法人工识别。用业务编码的好处是,即使系统挂了,工作人员看标签也能知道这是什么。
打印的时候还要注意两个参数:一是条码高度不要小于 8 毫米,太小了扫描识别率会明显下降;二是条码左右要留白,至少 2 毫米,很多打印模板把条码贴边打,导致扫码枪识别困难。这两个细节我是被操作员投诉了好几次才改过来的。
实操心得:标签打印机的碳带和标签纸一定要配套,混用会导致条码发灰或者一擦就掉。我在一个项目上因为换了便宜的标签纸,结果三个月后整个仓库的标签都褪色到扫不出来,只能重新贴一遍,那两天的加班费比省下的纸钱多得多。
7. 这套系统后续可以怎么扩展
系统跑稳之后,可以往上接的东西其实很多。最直接的是对接 ERP,把采购单、销售单的数据流打通,省掉人工重复录入。这一块的关键是接口口径要统一,我建议约定一个中间表,双方都往里写,而不是点对点调接口,因为点对点接口一旦一方改字段,另一方就得跟着上线。
再往上是和设备层对接,比如电子标签拣货、称重设备、自动分拣线。这些设备的接入方式各不相同,但共同点是都需要一套"设备指令队列",把系统指令和硬件动作解耦。我的做法是抽一层 DeviceGateway,所有设备的指令都先入队,由网关按设备类型分发,这样换设备的时候只改网关,不动业务代码。
还有一个方向是数据看板。仓库主管每天最关心的其实就几个数:今天的出库单量、准时发货率、拣货差错率、库存周转天数。这几个指标我做了个简单的看板挂在仓库办公室,每小时刷新一次,比任何报表都好用,因为大家路过就能看到。
最后说个我自己踩的坑。有段时间我花了很多精力去做"智能推荐上架库位"的算法,结果上线后仓库主管直接关掉了这个功能,因为他更信任老员工的判断。后来我改成"系统推荐 + 允许人工指定 + 记录人工覆盖原因",用了两个月后,系统推荐的采纳率才慢慢涨到七成。这件事让我明白,仓库里的很多决策是经验驱动的,系统要做的不是替代人,而是让人做决策的时候有更多依据。后来我把每一次人工覆盖的原因都存了下来,定期分析,反而优化出了更贴合他们实际的策略。