SpringBoot+SSM物联网仓储管理系统:从设备接入到库存一致性实战

发布时间:2026/10/14 4:02:07
SpringBoot+SSM物联网仓储管理系统:从设备接入到库存一致性实战
接手这套Java SpringBoot SSM技术栈的物联网仓储管理系统时仓库里已经躺着一批RFID读写器、扫码枪和温湿度传感器。客户想把这批设备用起来又不想再让操作员每天对着表格文件手工改库存。我第一次去现场那天正赶上月底盘点两个人推着手推车拿着纸质盘点表挨个货架抄数量旁边的RFID门禁被当成摆设。那一刻我就知道这个系统要解决的不只是技术问题还有现场操作习惯的问题。最终交付出来的系统并不复杂核心有设备接入网关、库存中心、出入库任务、预警看板四大块。整体落地之后仓库的库存流水不再靠人录入而是靠设备事件驱动生成。这篇记录从需求梳理到上线切换的完整过程重点讲设备接入、库存模型、并发扣减和现场调试这些容易出问题的地方。适合准备做仓储物联网项目的后端开发者也适合在传统WMS里想接入设备的团队参考。1. 立项之前我先摸清了仓库里的哑设备到底有多少每次做这类项目我都不急着写代码先在现场泡半天把仓库里实际存在的设备、网络、操作流程全部摸一遍。很多人觉得物联网仓储管理系统不就是把扫码枪的数据接进来嘛但真正动手之后才发现接数据只是最表层的工作仓库现场的流程瓶颈、设备协议差异、操作员习惯才是决定项目成败的东西。1.1 传统WMS与物联网仓储管理系统的分界线在很多人眼里只要给仓库配上扫码枪把表格文件换成数据库就算上了仓储系统。但传统WMS和物联网仓储管理系统的分界线不是有没有扫码枪而是数据到底是人主动录进去的还是设备自己送上来的。传统WMS的数据入口是单据操作员扫描、录入、提交库存按单据更新而物联网仓储管理系统多了一个常驻设备接入层RFID、扫码枪、温湿度传感器、PLC全都变成了数据生产者系统需要实时接收、解析、校验和响应。我判断一个项目需不需要走物联网方案就一句话设备会不会在没有人工提交单据的情况下产生库存数据。如果会就必须有完整的接入层如果只是替代人工录入那按传统WMS做就行别把架构搞复杂。这个判断标准帮我避免了很多过度设计。这个项目现场摆着三种设备RFID读写器、手持PDA、温湿度传感器。RFID读写器装在库房出入口理论上托盘经过就能自动识别PDA给拣货员扫描库位温湿度传感器挂在冷藏区。但它们之间没有任何关联设备数据分别存在三套小工具里。客户的诉求很明确把设备数据统一收到一个平台里库存、入库、出库、盘点都用同一套数据。维度传统WMS物联网仓储管理系统数据来源人工扫描、键盘录入设备订阅、定时上报、网关采集库存更新单据提交后批量扣减事件驱动实时扣减/冻结设备联动基本没有RFID/传感器/PLC联动异常处理单据改单心跳中断、设备离线预警、差异自动冻结1.2 我为什么没有把RFID、扫码枪直接写进业务Service刚开始我也走过弯路当时想得很简单Netty监听端口收到RFID数据后直接调用库存Service把数量加一。这样做Demo很快但后面会很痛苦。仓库里不止一种设备RFID产生识别事件PDA产生扫码事件温湿度传感器产生环境事件如果每一种都在业务Service里硬编码协议一旦调整业务代码要跟着改设备升级还会打扰核心流程项目越做越难维护。所以最终我坚持把设备接入单独拆了一层大致是四层结构接入层、协议解析层、消息处理层、业务服务层。接入层只负责维持连接和接收字节流协议解析层把不同设备的原始数据统一转成DeviceMessage对象消息处理层负责幂等、校验和分发给对应的业务事件业务服务层只处理业务逻辑不需要关心自己面对的是RFID还是扫码枪。提示设备接入层不要直接调用库存Service。哪怕初期图省事后面加设备时一定会后悔。有人会问项目标题里同时写SpringBoot和SSM是不是重复其实不重复。这套系统的老底子是Spring MVC MyBatis也就是SSMSpringBoot把配置收拢后Controller还是Spring MVC的数据访问还是MyBatis的事务还是Spring的。SpringBoot负责自动装配、内嵌容器、健康检查、监控接入但底层技术栈没有推倒重来。做这种项目没必要为了技术新而强行换掉正在运行的既有模块关键是明确职责边界。2. 网关接入层实战从TCP帧到统一设备消息设备接入层是整个物联网仓储管理系统最容易踩坑的部分。许多后端开发者的第一反应是写一个HTTP接口让设备把数据POST过来。但真正仓库里的设备五花八门有的走TCP私有协议有的走MQTT有的通过网关上报还有的老设备只有串口转换器。把所有设备收口到统一消息模型比单纯写几个接口要扎实得多。2.1 设备接入模块的分层所谓分层不是说建几个包就完事而是要让数据流像流水线一样单向流动。我习惯在工程里把IOT相关的代码放在独立的包下里面再拆Netty、MQTT、HTTP三个子模块。Netty模块负责TCP服务、协议解析、帧解码MQTT模块负责订阅传感器网关上报的主题HTTP模块用来接收PDA、第三方系统推送。这些子模块之间不允许互相调用只允许向消息处理模块生产消息。配置文件里最常见的就是端口、线程数、心跳超时和设备连接参数。下面这段是当时的一个简化配置实际项目里会多出很多固定参数但核心就这几个iot: netty: port: 9002 boss-threads: 2 worker-threads: 8 max-frame-length: 1024 heartbeat: timeout-seconds: 90 mqtt: url: tcp://127.0.0.1:1883 topics: sensor/data/#iot.netty.port只需要一个因为所有TCP设备都连同一台网关靠deviceId区分。max-frame-length要设大一点防止某些厂商的帧头字段带长附加数据。heartbeat.timeout-seconds我一般设90秒低于这个值网络抖动容易误判高于120秒设备假死状态发现太慢。具体值要看现场网络质量办公网可以短仓库内网可以长。2.2 一个RFID读到EPC之后系统里发生了什么很多第一次接触物联网的开发者会以为设备数据到了消息队列就完事了。其实从网口字节到业务事件中间还有不少细节。我拿RFID为例走一遍完整的链路RFID读写器通过网口连上Netty服务发送一帧数据。帧解码器按帧头加长度字段把字节流拆成完整报文。协议解析器解析出天线ID、EPC编码、CRC校验结果。解析结果转成DeviceMessage投递到内部消息队列。消费者根据EPC编码查RFID绑定关系找到对应的SKU和库位。生成一个库存识别事件此时不直接改库存先进入待确认池。操作员在PDA上确认后才真正生成库存流水。关键的坑在第6步。仓库里存在设备误读、重复读的情况如果每次读到EPC都直接加库存很快就会出现一大批幽灵库存。先进入待确认池相当于给设备数据加了一道人工闸门。确认动作可以由PDA扫描完成也可以根据规则自动匹配高可信度数据。统一消息体的字段最好从一开始就固定不要为每种设备设计不同JSON。我常用的结构是deviceType、deviceId、eventType、payload、occurTimepayload携带各设备的差异化数据。这样消息处理层写一套通用逻辑就能吃下所有设备。字段说明deviceType设备类型如RFID、PDA、SENSORdeviceId设备编号eventType事件类型如READ、TEMP、HEARTBEATpayload原始载荷JSONoccurTime事件时间以服务器接收时间为准2.3 调试现场最常出现的三个问题先说粘包。TCP是字节流没有消息边界。RFID设备一条完整帧可能几十个字节Netty接收缓冲区里可能同时混着半条和下一条。解决方法是给每帧设计长度字段或者用固定的帧头分隔符。我看过有些设备固件的长度字段不规范最终只能在自定义Decoder里做兼容把异常帧记录到单独日志避免刷屏。第二个常见问题是心跳假死。设备端看起来连接还在实际上已经不再发送任何数据了。服务端要有一个空闲检查机制超过超时时间没有收到任何帧就把连接关闭同时通知告警。这里要特别注意有些设备的心跳帧不是独立发送而是跟随数据帧一起空闲检查要按读事件算不能只认心跳消息。第三个是设备时间不准。RFID事件里带的时间戳有的设备用的是本地RTC电池时钟断电后可能慢几分钟。库存系统一旦按这个时间排序流水顺序就乱了。我后来统一规定业务上所有时间以服务器接收时间为准设备上报时间只保留在payload里做参考。这点在对接文档里要提前写明否则对方硬件人员会反复来找你改。3. 库存模型设计核心不强后面全是窟窿设备接入是躯干库存模型才是大脑。库存模型设计得不好实时数据越多脏数据也越多。我把核心库存拆成四个表库位表、批次表、库存表、流水表。这张表结构看起来简单但每张表的职责、字段之间的关系、状态位的含义都直接影响后续所有业务逻辑。3.1 库位、批次、序列号三层关系表关键字段作用storage_locationlocation_id, zone_id, location_code, status物理仓位inventory_batchbatch_id, sku_id, batch_no, produce_date, expire_date批次属性inventory_stockstock_id, location_id, batch_id, sku_id, quantity, frozen_qty, version实时库存inventory_flowflow_id, source_type, source_no, location_id, batch_id, change_qty, before_qty, after_qty, create_time流水凭证看到这张表有人会问为什么库存还要单独分一张库存表直接按库位加SKU加一个数量字段不就行了不行因为一个库位上可能同时放多个批次而同一SKU不同批次的效期、供应商、批次号不同。做库存查询、先进先出出库时逻辑会特别绕。分表之后库位表管位置批次表管属性库存表管数量职责非常清晰。批次管理在仓储项目里几乎避不开。尤其是食品、医药类仓库效期字段直接决定能否发货。出库时如果只按数量扣减不考虑批次很容易把临期货物压在库里。我们后来在出库任务里增加了批次策略先进先出、先到期先出、指定批次出库三种模式全部通过配置切换。流水表是整个系统的账本。所有库存变更都要先写流水再改库存而不是只改一个数量。后续对账、追溯、审计全靠它。我处理过的库存不一致80%都能靠流水回放定位。如果嫌流水表太大可以按月分表但字段和索引要保持一致不能随意简化。3.2 出入库任务状态机库存不是一次性加加减减而是跟着任务流动。这个项目的出入库主流程我抽象成一条状态机入库流程是入库单创建、到货登记、上架确认、完成出库流程是出库单创建、拣货分配、拣货确认、复核、完成。这两个流程的节点完全由业务现场决定不能照搬其他系统。在出库流程里最有价值的是拣货分配和拣货确认之间的库存锁定。分配时先冻结库存确认时再真正扣减取消时释放冻结。如果不做冻结两个拣货员可能同时看到同一批可拣库存都抢同一个批次最后导致一张出库单没货可发。冻结字段frozen_qty就是为了解决这个问题库存表的quantity减去frozen_qty才是当前可分配量。状态流转上用列表表示就是出库单状态DRAFT - ASSIGNED - PICKING - CONFIRMED - DONE取消分支DRAFT、ASSIGNED、PICKING这三个状态都可以取消取消时释放冻结库存CONFIRMED之后不能直接取消只能走红冲拣货过程中发现货物破损走EXCEPTION分支异常单可以暂停、重开或转报废这个设计看起来简单实际联调阶段经常会漏掉一个分支。比如拣货过程中发现货物破损怎么办我在状态机里加了一个EXCEPTION分支异常单可以暂停、重开或转报废不会影响其他正常任务。状态机不是越复杂越好而是要覆盖现场真实发生的每一种情况。3.3 超卖和重复入库怎么避免超卖的本质是多个并发请求同时扣减同一批库存。传统方案是在Service里先查询库存再比对数量再扣减这种三步操作在并发场景下必然出问题。我用两个手段Redis分布式锁加数据库条件更新。Transactional public void deductStock(DeductRequest req) { // 锁粒度库位批次SKU而不是全局SKU String lockKey stock:lock: req.getLocationId() : req.getBatchId(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(30)); if (!Boolean.TRUE.equals(locked)) { throw new BizException(当前库位正在处理请稍后重试); } try { int rows inventoryStockMapper.deductWithCheck( req.getLocationId(), req.getBatchId(), req.getSkuId(), req.getQty()); if (rows 0) { throw new StockNotEnoughException(库存不足); } inventoryFlowMapper.insert(buildFlow(req)); } finally { redisTemplate.delete(lockKey); } }这里有个细节锁的粒度和事务边界要匹配。上面的写法在finally里立刻释放锁但如果事务还没提交另一个线程拿到锁后去读数据库看到的仍是旧库存等于锁白加。更严谨的做法是把锁放在事务外层或者等事务提交回调之后再释放。实际项目中我会用事务模板把库存扣减和流水插入放在一个事务里锁包在外面。重复入库的坑也常见。一台RFID门禁可能连续读到同一个EPC两次或者操作员扫了两次码。我的做法是在消息处理层做幂等用业务键入库单号加EPC编码在Redis里做SETNX时间为30秒。重复消息会被直接丢弃同时记录一条去重日志。像这种幂等逻辑不要散落在各个Service统一收敛在消息处理层最省心。4. 调整并发和数据一致性别让系统在仓库忙碌时崩掉仓库作业有明显的潮汐特征早上收货、下午拣货、月底盘点时PDA的请求会短时间集中。如果每一个扫码事件都同步写数据库数据库连接池很容易被打满。这一节说说我实际调整并发和数据一致性的思路。4.1 用Redis做分布式锁锁粒度要比你想象的小很多团队用Redis锁都会犯一个错误锁一个SKU的整个库存。比如SKU A分布在A01和A02两个库位一个出库任务在A01拣货另一个在A02拣货二者根本不冲突。如果用SKU级锁A02的任务也要排队吞吐量白白少一半。锁粒度应该细到库位加批次加SKU这一行库存记录。这就像火车站窗口分开排队而不是所有人排一队。极端情况下同一个库位同一个批次被多个任务争抢分布式锁才会介入大多数正常请求无需等待。锁的过期时间也很重要仓库任务一般几秒内完成我习惯设置30秒超过这个时间任务本身就应该报警了。4.2 数据库版的乐观锁防止库存被扣成负数Redis锁解决了跨实例互斥但不能替代数据库约束。我习惯在inventory_stock表上加version字段扣减SQL写成update inventory_stock set quantity quantity - #{qty}, version version 1 where stock_id #{stockId} and quantity #{qty}这条SQL同时完成了数量判断和版本更新返回0就说明库存不足或已经变化。有些团队喜欢先select再判断再update我建议改成单条update数据库的行锁天然帮我们避免了并发覆盖。配合前面的Redis锁一个是前置排队一个是最终兜底两个一起用才算把超卖这扇门关上了。库存流水为什么重要因为哪怕加了锁和约束运行时间长了也可能出现程序bug、人工误操作、设备重传库存表和实际数对不上。靠流水表做日终对账可以把差异缩小到具体的单号和操作人。对账逻辑不复杂定期把当天流水按库存维度汇总再和库存表比对有差异就走告警告警之后再看具体情况是重新计算还是人工调整。4.3 为什么消息队列能扛住PDA集中扫码的峰值不同业务场景适合不同的处理方式我把它们列成了一张表场景处理方式原因温湿度上报异步落库不需要操作员等待RFID识别事件异步进待确认池设备可能重复读PDA上架/出库确认同步返回操作员需要明确结果盘点数据批量上报批处理写入避免逐条插入频繁提交异步化有一个代价消息丢失。我在消费者里加了本地日志补偿每次消费先记录消费日志处理成功后更新状态。万一消息队列出了故障可以通过补偿任务重新投递。库存相关消息我还会多一步落库前的唯一键检查真正把重复消费和数据不一致的概率压到最低。5. 没有设备也能开工模拟器和联调环境搭建硬件设备没到场不代表开发就要停摆。设备接入层最怕的就是等硬件一等就是半个月开发进度全卡住。我当时的做法是用软件模拟设备把整个接入、解析、业务链路先跑通等真机到了只验证几个边界点就行。5.1 自己写一个Netty模拟客户端RFID部分我自己写了一个Netty Mock客户端定时向服务端发送符合协议的模拟帧。做法很简单用Bootstrap连上iot.netty.port然后把协议定义的帧组装成ByteBuf写过去。public class MockRfidClient { public static void main(String[] args) throws Exception { Bootstrap bootstrap new Bootstrap(); bootstrap.group(new NioEventLoopGroup(1)) .channel(NioSocketChannel.class) .handler(new SimpleChannelInboundHandlerByteBuf() { Override protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) { System.out.println(serv ack: ByteBufUtil.hexDump(msg)); } }); Channel channel bootstrap.connect(127.0.0.1, 9002).sync().channel(); while (true) { channel.writeAndFlush(buildRfidFrame(EPC-001)); Thread.sleep(5000); } } }模拟客户端最有用的是可以主动制造异常。比如连上后一分钟不发任何数据验证心跳超时逻辑发一个CRC错误的帧验证解析器是否把异常数据丢掉发一个长度字段畸形的帧验证自定义Decoder不会把整个连接搞挂。这些在真机上很难测在模拟器里却是家常便饭。5.2 协议文档和实际固件不一致这个项目里我吃过一次亏。设备文档里写的TCP帧长度字段位置和厂商实际发货的固件不一样导致解析出来的EPC全是乱码。后来把真实报文用十六进制抓出来一对比才发现文档落后了一版。从那以后我要求设备联调必须先抓包以实际报文为准文档只做参考。PDA上报接口的字段也需要早期约定。不同设备厂商PDA对被扫描条码的字段命名不同有的叫code有的叫barcode有的叫epc如果后端不做兼容每次换设备就要改一遍接口。我在HTTP接入模块里加了一个字段映射器把所有扫码类设备统一转成barcode字段上报格式先做一次标准化。5.3 日志和链路追踪多设备、多队列、多消费者混合在一起没有TraceId基本没法排查问题。我在入口处统一生成一个UUID放到MDC里后续所有日志都带上它。排查某个RFID数据为什么没有生成库存流水直接拿设备日志里的时间、设备ID、EPC去全链路搜索很快就能定位。日志里要带的关键信息就是设备ID、库位、任务号。如果只有一条堆栈在仓库现场基本等于没有日志。设备接入日志和业务日志我建议分文件输出设备接入日志可能量很大混在一起会让业务日志很难翻。6. 上线切换和现场调试里的真实教训系统开发完不代表能直接上线最麻烦的是把老账本搬到新系统同时不能耽误仓库正常发货。项目切换那几周我几乎天天泡在现场遇到的问题比开发阶段还多。6.1 库存盘点和期初数据导入我当时定的步骤是确定盘点时点冻结出入库停半小时做全面盘点盘点差异复核逐项确认原因期初数据通过正式接口导入不直接insert导入后跑一遍流水和库存差异对账第二点很容易被忽略。如果直接往数据库里插期初库存没有对应的盘点流水上线后出了问题根本查不出是谁导入的。所有期初数据都走系统接口生成一批特殊的期初调整单审计才清晰。这是上线前后的分水岭绝对不能为了省事跳过。6.2 无线网络覆盖比想象中重要设备接入层是PC服务器扛得住真正容易出问题的是仓库里的无线网络。金属货架、钢梁都会衰减信号PDA在通道深处经常断网重连。第一版上线时操作员走到货架中间扫码明明是连续扫描系统却收到三条重复请求这是网络超时后PDA自动重发导致的。我在实施前会建议客户做一次无线覆盖测试AP不能只在办公室走廊要布在货架通道上。信号强度低于某个阈值的区域需要增加AP或调整位置。很多现场问题看起来是软件bug根子其实是网络。6.3 操作员界面要配得上仓库现场PDA界面和后台管理端完全是两种设计逻辑。后台可以多筛选条件、复杂报表但PDA必须大按钮、大字号、少选项。操作员戴着手套屏幕经常沾灰不可能去操作一个需要精确点击的密集列表。还有一点不要强迫操作员在所有场景下都手动选择库位。他们在高强度作业时很容易选错。实际项目中我们让扫描任务码自动带出库位同时允许手工修正这样既减少错误又不僵化。上线初期会有操作员觉得多一步校验是找麻烦但运行两周后盘点差异率明显下降他们也接受了这套流程。项目收尾后我自己沉淀下来的习惯有两个。第一所有设备接入的调试记录按日期归档包括抓包报文、修改记录、现场网络状况。每次要加一种新设备翻旧记录比找厂商售后更快。第二不要把物联网仓储想得太玄乎设备实时接入只是让库存数据从人工录入变成事件驱动如果核心的库存模型、状态机、幂等机制没有设计好实时数据只会制造更多脏数据。现在我接手类似项目第一件事还是去仓库现场站半个小时不看系统只看操作员怎么干活设备装在哪网络能不能到。把这些搞清楚后面的代码其实都是水到渠成的事。