JeeWMS仓库管理系统部署、Docker镜像化与PDA扫码实践 仓库这套系统的坑大多不是写代码写出来的是部署和现场跑出来的。JeeWMS 这个开源仓库管理系统我前后在不同环境里搭过好几套从一台干净的云主机上装 JDK 开始到把它打成 Docker 镜像放进私有仓库再到拿几台 PDA 手持机在现场扫码收货、上架、盘点中间踩过的问题比代码里的注释还多。这篇就把整条链路摊开讲这套系统的定位在哪、源码怎么从零跑起来、镜像怎么做才干净可复用、PDA 端怎么接才不至于在现场掉链子以及那些只有真正跑过一遍才会知道的小坑。适合两类人看一类是要给中小型仓库上一套能落地的仓储管理系统、手头预算和人力都不宽裕的技术负责人另一类是被派去部署和联调、需要在两三天内把环境跑通的开发或运维。不需要你事先精通这套代码但基本的 Java Web 概念要有会敲命令行、能看懂 SQL读起来会顺很多。1. 先把这套系统看清楚再决定怎么装1.1 仓库管理真正难的地方在哪很多团队评估仓储系统喜欢先看功能清单有多长但实际上一线仓库出问题绝大多数不是功能不够而是账实不符。货就在那儿系统里显示还有两百件实际翻遍货架只找到一百六或者货龄管理失控先进先出变成了谁手快谁先拿一批临期货压在角落没人管。这类问题的根子在于信息流和实物流脱节纸单上写的、系统里录的、货架上摆的三份数据各说各话。JeeWMS 这类系统的价值本质上就是把这条链路用库位 条码 流程三件事锁死。库位解决货在哪——从三号仓大概在 B 区精确到B-03-02-01 这个货位找货时间从十几分钟压到一分钟条码解决是谁——每一箱、每一托、每一个批次都有唯一标识靠 PDA 扫一下就完成身份确认比人手抄录快十倍还不容易错流程解决什么时候——收货、质检、上架、移库、拣货、复核、发运每一步都有单据状态和操作人事后追责有据可查。这三件事配齐之后实时库存才是真的实时而不是每天晚上跑个批处理把账面补齐。这套系统适合的场景很明确单仓或几个仓、SKU 数量在几千到几万量级、有批次效期或者多货主管理需求、希望用一套开源方案把基础的出入库盘点和库位管理跑起来的团队。它不适合的是那种超大规模、高度自动化的立体库场景——那种场景下输送线、堆垛机、WCS 的调度逻辑是另一套东西用通用的开源方案硬套反而更费劲。先把边界想清楚再动手装能省掉后面很多返工。1.2 技术栈拆解为什么它装起来比写起来难这套系统的技术选型是比较典型的国内中后台路子后端 Spring SpringMVC MyBatis 的 SSM 组合权限走 Shiro缓存用 Ehcache 或 Redis 二选一数据库 MySQL打包成 war 丢进 Tomcat 跑前端是服务端渲染的页面加一套 jQuery 系的管理后台框架报表导出用 POI 或者类似的库。这个组合的特点是老派但极其稳网上资料多随便找个 Java 出身的人都能接手改。但它也带来一个副作用整套技术栈对版本极度敏感。JDK 8 是硬门槛上到 JDK 11 就会遇到一堆 JAXB 相关类找不到的问题因为那几个包在新版本里被移出标准库了MySQL 5.7 和 8.0 的驱动类名、连接参数、默认认证插件都不一样连接串少写一个参数就是连不上Tomcat 8.5 和 9 的 Servlet 规范版本有差异某些旧依赖在 9 上会报方法签名不匹配。这就是为什么很多人拿到源码之后代码一行没改光是环境就折腾两天。我的建议是别急着 clone 代码先花半小时把版本矩阵定下来写进一张纸上贴在显示器边上。后面所有的奇怪报错里有八成能在这张纸上找到答案。这个习惯在接触任何一套有一定年头的开源系统时都适用因为它们的依赖复杂度往往比新项目高一个量级。1.3 三种部署形态先选好再动手同一套代码落地形态可以差很多。选之前先想清楚这套系统是要跑在自己笔记本上看看功能还是要交付到客户机房的物理服务器上还是要做成一份可以分发给多个现场的标准交付物。目标不同做法完全不同。部署形态适用场景优点代价单机 war 包本地验证、功能演示、小规模单仓上手最快改代码即时生效环境不可复制换机器要重来一遍Docker 单容器中小规模生产、需要快速回滚环境一致交付物是一个镜像需要写 Dockerfile数据库要外置容器 外置数据库 反向代理多现场分发、需要灰度升级升级只换镜像标签数据不受影响运维复杂度上升要有镜像仓库我在第一次做的时候走了弯路直接在服务器上装 JDK、装 Tomcat、手动丢 war 包跑是跑起来了但三个月后要迁到另一台机器发现当初改了哪些配置、装了哪个版本的字体、日志目录权限是怎么设的全忘了。后来重做了一遍镜像化把所有这些隐性知识写进 Dockerfile才算真正把这个系统变成可交付的东西。所以如果你有哪怕一点点这套系统将来要部署到第二台机器的可能性从第一天就按镜像化来做。2. 环境准备把版本咬合关系先捋顺2.1 硬件与操作系统的实话实说很多人问跑这套系统要什么配置我的回答是先问清楚并发量。一个十来个人的仓库日常就是几个 PDA 在扫码、两三个文员在电脑上开单这种负载对服务器的要求低得超乎想象2 核 4G 的云主机跑应用数据库单独一台 2 核 4G磁盘用普通 SSD跑起来毫无压力。真正吃资源的是三个地方——报表导出、大批量数据导入、以及数据库没建索引时的模糊查询。磁盘要单独说一下。WMS 这类系统会存照片收货拍照、异常取证、存附件对账单、质检报告一年下来几十上百 G 很常见。所以数据盘别抠起步 200G并且把应用日志和上传目录分开挂载。日志这东西涨得比你想象的快尤其是开了 DEBUG 级别之后一天几个 G 不夸张一旦和系统盘放一起磁盘写满导致服务假死排查起来非常痛苦。操作系统层面CentOS 系和 Ubuntu 系都行注意两件事一是文件句柄数和进程数限制ulimit -n默认 1024稍微有点并发就不够要在 limits.conf 里调到 65535二是系统时区装完先timedatectl set-timezone Asia/Shanghai不然后面单据时间全对不上跟数据库里的时间差八小时查问题的时候能把人逼疯。2.2 版本矩阵一个都不能错的地方把下面这张表当成施工图逐项对齐。我没有列最新版列的是实际验证过能稳定跑的组合。组件推荐版本关键注意点JDK1.88u202 及以上不要用 JDK 11/17JAXB 等模块被移除会直接报 ClassNotFoundMaven3.6.x3.8 对 http 仓库默认拦截老项目私服是 http 会构建失败MySQL5.7.3x 或 8.0.2x8.0 要用com.mysql.cj.jdbc.Driver并注意认证插件Tomcat8.5.x9.x 部分旧依赖会有 Servlet API 不兼容Redis5.0只在开启 Redis 缓存时需要单机够用Nginx1.18做静态资源代理和 HTTPS 终结关于 MySQL 8.0有两个坑值得单独讲。第一个是认证插件8.0 默认用caching_sha2_password而项目里带的驱动版本如果偏老握手阶段就会抛 Public Key Retrieval is not allowed。解决办法有两个要么在连接串里加allowPublicKeyRetrievaltrueuseSSLfalse要么把账号的认证插件改回mysql_native_password。生产环境我更倾向后者因为改连接串容易在多个配置文件里漏改。第二个是sql_mode。8.0 默认开启ONLY_FULL_GROUP_BY而很多老代码写的GROUP BY语句并不规范测试环境跑得好好的换到 8.0 上直接报 this is incompatible with sql_modeonly_full_group_by。别急着改代码先在会话级别把这个模式放宽验证功能是否正常确认没问题之后再决定是长期放宽还是逐条改 SQL。放宽的做法是在 my.cnf 里配sql_modeSTRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION。2.3 依赖镜像源把构建时间从半小时压到几分钟这里说的镜像有两层意思一层是 Maven 依赖的下载源一层是后面要讲的 Docker 镜像。先讲前者因为它是所有后续工作的前提。默认从中央仓库拉依赖在国内网络环境下第一次构建经常卡到怀疑人生几百个 jar 包能拉半小时中间断一次还得重来。标准做法是在~/.m2/settings.xml里配一个公共镜像站点把所有 central 请求都指向它settings mirrors mirror idpublic-central-mirror/id mirrorOfcentral/mirrorOf namepublic maven mirror/name urlhttps://maven.example-mirror.com/repository/public//url /mirror /mirrors profiles profile idjdk8/id activationactiveByDefaulttrue/activeByDefault/activation properties maven.compiler.source1.8/maven.compiler.source maven.compiler.target1.8/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties /profile /profiles /settings配好之后还有个细节mirrorOf写central只拦中央仓库如果 pom 里显式声明了别的仓库地址那些请求还是会走原地址。想全拦就写*但要注意*会把公司内部的私服也拦掉导致内部包拉不到。折中写法是mirrorOf*,!internal-repo/mirrorOf把内部仓库 id 排除在外。配完镜像之后记得第一次构建完成后把~/.m2/repository目录备份一份。这个目录在 CI 里就是宝贝——把它做成构建镜像的一个层或者挂成缓存卷能让每次构建从十几分钟降到一分钟以内。我在一个项目里做过对比同一个工程冷缓存构建 18 分钟热缓存 47 秒差距就是这么悬殊。3. 源码落地从拿到代码到能登录进去3.1 目录结构导读先认识地图再出门拿到源码之后别急着mvn package先花十分钟把目录结构过一遍后面遇到问题能快速定位。典型的 SSM 结构长这样src/main/java下按controller、service、service/impl、dao也叫 mapper 接口层、entity分包src/main/resources下放spring-*.xml、mybatis-config.xml、jdbc.properties、日志配置src/main/webapp下是 JSP 页面、静态资源js/css/img和WEB-INF/web.xml根目录通常还有一个sql或db文件夹装着初始化脚本。有三处特别值得先看一眼。第一是web.xml里面定义了 Spring 容器的加载路径、字符编码过滤器、Shiro 过滤器链能快速判断这套代码用的是哪种权限模型。第二是pom.xml的packaging如果是war那就必须丢 Tomcat如果是jar且带 spring-boot 插件那就是 Boot 化改造过的版本启动方式完全不同。第三是 SQL 脚本的编码和存储引擎很多导入一半报错的问题就出在这里。我一般还会顺手做一件事在整个工程里搜一下System.out.println和printStackTrace把明显是调试残留的地方记下来。这类打印在生产环境里既污染日志又拖慢性能而且经常出现在高频调用的循环里。不是说一定要改但心里有数出问题时能想到这里可能有干扰。3.2 数据库初始化字符集和 sql_mode 是第一道坎建库这一步看着简单实际是最容易埋雷的地方。正确的顺序是先建库并显式指定字符集再建账号授权最后导入脚本。CREATE DATABASE jeewms DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE USER wms_app% IDENTIFIED BY 换成你自己的强密码; GRANT ALL PRIVILEGES ON jeewms.* TO wms_app%; FLUSH PRIVILEGES;字符集一定要用utf8mb4而不是utf8。原因很实在utf8在 MySQL 里其实是utf8mb3最多存三个字节遇到四字节字符某些生僻字、部分符号直接报错或者截断。仓库里商品名称带特殊符号的情况并不罕见用utf8mb4一步到位省得日后改表。导入脚本时最常见的三个报错这里先给预案。报 Unknown collation: utf8mb4_0900_ai_ci说明脚本是 MySQL 8.0 导出的导进了 5.7 环境把脚本里的排序规则全局替换成utf8mb4_general_ci即可。报 Row size too large说明表里 varchar 太多且用的是老的行格式在 my.cnf 里加innodb_strict_mode0或者把表改成ROW_FORMATDYNAMIC。报 Specified key was too long是老版本 MySQL 对索引长度限制导致的把相关字段长度调小或者改用前缀索引。提示导入前先确认脚本里有没有DROP DATABASE之类的语句。有些开源项目自带的初始化脚本是全量重建风格在已经有数据的库上误执行一次数据就没了。导入前先grep -i drop xxx.sql扫一遍这个动作只要三秒。3.3 配置文件三处必改外加一处常被忘配置文件通常集中在resources下最核心的是数据库连接。需要改的位置一般有三处连接地址、用户名密码、以及连接池参数。连接串的写法有个细节MySQL 8.0 建议带上时区和编码参数jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://127.0.0.1:3306/jeewms?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue jdbc.usernamewms_app jdbc.password换成你自己的强密码serverTimezone这个参数经常被忽略但它是页面上的时间和数据库里的时间差 8 小时这类问题的头号元凶。不加的时候驱动会按服务器默认时区去解释时间一旦容器和宿主机时区不一致数据就开始错乱。连接池参数也值得调一下。默认配置往往最大连接数只有 10几个人同时开报表就占满了表现为页面转圈然后超时。中小规模场景调到 3050 比较合适同时配一个validationQuerySELECT 1和testWhileIdletrue防止连接被数据库侧的超时断开后还留在池子里用。那处常被忘的配置是文件上传目录。默认路径经常写的是开发机上的绝对路径比如D:/upload/挪到 Linux 上直接报目录不存在表现为收货拍照上传失败但日志里只有一句空指针。改成相对路径或者用环境变量注入是更稳妥的做法。我习惯把它设成/data/wms/upload然后在 Docker 里挂卷出来。3.4 编译打包与启动验证环境对齐、配置改完之后就可以构建了。跳过测试是常规操作因为很多老项目的测试用例依赖真实的数据库连接跑了必挂mvn clean package -DskipTests -U-U是强制更新快照依赖第一次构建时加上后续可以去掉。构建产物在target目录下通常是一个几十兆的 war 包。这里有个小坑war 包的文件名决定了访问路径。如果打出来叫jeewms-1.0.war丢进 Tomcat 的 webapps 之后访问路径就是/jeewms-1.0而不是根路径。想直接访问根路径要么把包重命名成ROOT.war要么在 Tomcat 的server.xml里配 Context 的 path。启动之后别急着开浏览器先看日志。日志里出现 Root WebApplicationContext: initialization completed 说明 Spring 容器起来了出现 Server startup in XXX ms 说明 Tomcat 起来了。如果有异常重点看 Caused by 那一行前面几行往往只是包装。第一次登录还需要确认两件事账号密码是初始化脚本里带的还是需要手工在数据库里加以及验证码功能是否开启。不少开源项目默认开着图形验证码但在没有装字体的精简环境里验证码图片生成会直接抛异常表现为登录页一片空白。这种问题在容器化的时候特别容易遇到后面讲镜像的时候会专门处理。4. 镜像化让一套部署变成可复制的东西4.1 Dockerfile 怎么写才不臃肿手写 Dockerfile 最常见的问题是能跑就行结果镜像动辄一两个 G推送到私有仓库要十几分钟回滚的时候拉半天。正确的做法是用多阶段构建构建环境只负责产出 war 包运行环境只带 JRE 和 Tomcat中间产物一个不留。# 第一阶段构建 FROM maven:3.8-openjdk-8 AS build WORKDIR /build COPY pom.xml . RUN mvn -B -q dependency:go-offline COPY src ./src RUN mvn -B -q clean package -DskipTests # 第二阶段运行 FROM tomcat:8.5-jdk8-openjdk-slim ENV TZAsia/Shanghai \ JAVA_OPTS-Xms512m -Xmx1024m -Duser.timezoneAsia/Shanghai -Dfile.encodingUTF-8 RUN rm -rf /usr/local/tomcat/webapps/* COPY --frombuild /build/target/*.war /usr/local/tomcat/webapps/ROOT.war EXPOSE 8080 CMD [catalina.sh, run]这份文件里有几个刻意的设计值得解释。COPY pom.xml单独一行放在源码之前是为了利用镜像层缓存——只要 pom 没变dependency:go-offline这一层就一直命中缓存改业务代码的时候不用重新拉依赖构建时间从十几分钟降到一分钟。rm -rf webapps/*是清掉 Tomcat 自带的示例应用不改的话访问根路径会看到 Tomcat 的欢迎页而不是你的系统白白浪费半小时排查。ROOT.war的命名则是让应用直接挂在根路径省掉一层路径配置。基础镜像的选择也要权衡。tomcat:8.5-jdk8-openjdk完整版大概 460M-slim版本能省掉一百多兆。再往下的 alpine 版本体积更小但 musl libc 和 glibc 的差异会让某些 JNI 依赖出问题而且时区数据、字体都要自己装。我的经验是业务镜像用 slim 就够没必要追极致小体积稳定比省那一百兆重要得多。4.2 配置外置镜像里不该有密码一个常见错误是把数据库密码直接写在镜像里的配置文件中。这样做的后果是镜像一旦流出去密码就跟着流出去了换一个环境就要重新打一个镜像镜像的可复制价值荡然无存。正确做法是把配置文件从镜像里抽出来运行时挂载或者用环境变量覆盖。Spring 的属性注入天然支持这个配置文件里写${DB_HOST:127.0.0.1}这样的占位符运行时通过-e DB_HOSTxxx注入没注入就用默认值。这样同一个镜像可以在测试、预发、生产三套环境里跑只换环境变量。services: wms-app: image: registry.internal:5000/wms/jeewms:v1.0.0-3f9a2c1 environment: - DB_HOSTmysql - DB_PORT3306 - DB_NAMEjeewms - DB_USERwms_app - DB_PASSWORD${WMS_DB_PASSWORD} - TZAsia/Shanghai volumes: - /data/wms/upload:/data/wms/upload - /data/wms/logs:/usr/local/tomcat/logs ports: - 8080:8080挂载日志目录这个动作看着不起眼实际很关键。不挂的话容器一重建日志全没了出问题想查都没得查。挂出来之后还有个好处日志文件落在宿主机上用tail -f直接看比docker logs方便得多尤其是日志量大、需要配合 grep 过滤的时候。4.3 标签规范、私有仓库与回滚镜像标签这件事前期不立规矩后期一定乱。见过最夸张的情况是所有人都在推latest出了问题没人知道线上跑的是哪个版本。我建议的规范是三段式v{业务版本}-{git短哈希}比如v1.2.0-3f9a2c1。业务版本给人看方便和需求单对上短哈希给机器看能精确对应到某一次提交。私有仓库的搭建本身不复杂起一个 registry 容器配好磁盘目录和访问认证就行。要提醒的是存储规划镜像层是增量的但如果你频繁在同一个层里改大文件比如每次构建都apt-get install一遍层会越堆越多几百个版本下来占满磁盘是常事。定期跑一次垃圾回收很有必要把没有被任何标签引用的层清理掉。回滚是镜像化最大的收益点值得单独设计。做法很简单每次上线前把当前运行的标签记在一个文件里或者干脆在编排文件里用变量引用标签。出问题时把变量改回上一个标签重启容器三十秒完成回滚。这比找出上一版 war 包、停 Tomcat、覆盖、重启快一个数量级而且在半夜被叫起来处理故障的时候步骤越少越不容易出错。4.4 时区、字体、健康检查三件小事决定体验这三件事单拎出来都不大但每一件不做都会在某个时刻让你难受。时区问题前面提过容器里的默认时区通常是 UTC而业务要求是东八区。除了TZ环境变量和-Duser.timezone之外slim 镜像里还要确认/usr/share/zoneinfo存在否则TZ设了也不生效。稳妥的做法是在 Dockerfile 里显式装 tzdata 并建立软链RUN apt-get update apt-get install -y --no-install-recommends \ tzdata curl fontconfig fonts-wqy-zenhei \ ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \ echo $TZ /etc/timezone \ rm -rf /var/lib/apt/lists/*字体这行千万别省。只要系统里有导出 PDF 或者生成带中文的图片报表的功能缺字体就会导致中文全部变成方块或者干脆抛异常。fonts-wqy-zenhei这种中文字体包体积不大装上之后所有中文渲染问题一并解决。健康检查则决定了编排系统能不能自动发现进程还在但服务已经挂了的情况。Spring 应用有一种经典的假死状态JVM 活着端口还在监听但线程池全被慢 SQL 占满请求全部超时。这时候光看进程状态是好的健康检查探一下 HTTP 就能暴露出来HEALTHCHECK --interval30s --timeout5s --start-period90s --retries3 \ CMD curl -fs http://127.0.0.1:8080/login || exit 1start-period要留够因为这套系统启动要加载大量 XML 和缓存冷启动一分钟以上很常见给太短会导致容器刚起来就被判定为不健康然后被杀掉陷入重启循环。5. PDA 端对接现场稳定性才是真考验5.1 扫码的三种接入方式与取舍PDA 手持终端接入系统最核心的问题就是怎么把扫到的条码送进程序。主流有三种方式各有各的适用场景。第一种是键盘模拟模式扫码枪把条码内容当成键盘输入一个个字符敲进当前焦点所在的输入框。这种方式实现最简单网页端不用做任何改造一个input就能收。但它的毛病也不少焦点一旦丢到别的控件上条码就敲到别处去了输入法如果是中文状态字符会被吞掉或者变成候选词扫码速度太快的时候部分设备会丢字符表现为条码少了几位。现场最常见的投诉扫码没反应八成就是焦点问题。第二种是广播模式扫码头扫到内容后发一个系统广播应用注册 Receiver 接收。这种方式不依赖焦点也不会被输入法干扰稳定性高一个档次。代价是要在 Android 端写接收逻辑而且不同设备的广播 action 名称不一样得照着设备文档配。public class ScanReceiver extends BroadcastReceiver { public static final String ACTION_SCAN com.vendor.scanner.ACTION_SCAN; Override public void onReceive(Context context, Intent intent) { String code intent.getStringExtra(barcode_string); if (code null || code.trim().isEmpty()) return; // 交给业务层处理注意这里在主线程耗时操作要切子线程 ScanBus.get().post(code.trim()); } }第三种是摄像头识别用设备的摄像头实时识别条码。好处是不依赖专用硬件普通手机也能用坏处是识别率受光线、角度、条码磨损影响大仓库那种昏暗环境加纸箱反光识别率会明显下降。我的建议是固定工位用键盘模拟或广播模式的专用设备临时场景、盘点支援这类用摄像头方案兜底。5.2 接口层的鉴权、幂等与重试PDA 走的是移动网络和办公室里的有线网络完全是两个世界。信号弱、切换基站、进电梯断连这些在仓库里是日常。接口设计如果按办公室思维来做现场就会出各种重复提交、状态错乱。先说鉴权。别用 session仓库里几十台设备session 管理起来很麻烦而且弱网下 session 超时导致的重新登录体验极差。用 token 更合适登录接口返回一个有效期较长的 token后续请求放在 header 里带上服务端校验签名和过期时间。token 里可以带上用户 id 和仓库权限省掉每次查库。再说幂等这是最容易被忽略但后果最严重的一环。想象一下这个场景操作员在收货页面扫了一箱货点了提交网络卡住界面转圈他以为没提交成功又点了一次网络恢复两次请求都到了服务端库存加了两倍。这类问题在现场发生频率极高而且事后很难查因为日志里看起来就是两次正常请求。解决办法是在请求里带一个客户端生成的唯一请求号UUID 就够服务端用一张去重表兜底CREATE TABLE t_request_dedup ( request_id VARCHAR(64) NOT NULL COMMENT 客户端生成的唯一请求号, biz_type VARCHAR(32) NOT NULL COMMENT 业务类型如 RECEIVE/PUTAWAY, created_at DATETIME NOT NULL, PRIMARY KEY (request_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT接口幂等去重表; CREATE UNIQUE INDEX uk_stock_batch ON t_stock_detail (warehouse_id, location_id, sku_id, batch_no);处理逻辑是进来先尝试插入去重表插入成功就执行业务插入报主键冲突就直接返回已处理。这个模式简单粗暴但极其有效配合业务表上的唯一索引形成双保险——万一去重表被清理了数据库层的唯一约束还能拦住重复数据。重试策略也要讲究。不是所有失败都能重试网络超时可以重试业务校验失败比如库位不存在重试一万次也没用。我的做法是给错误码分类只有明确标记为可重试的才进入重试队列重试间隔用指数退避1 秒、2 秒、4 秒最多三次超过就落到本地待处理列表让操作员在界面上看到有 3 条数据待同步而不是无声无息地丢掉。5.3 网络抖动与离线队列仓库里有些角落信号确实差比如冷库、地下室、金属货架密集的区域。这种环境下要求实时提交是不现实的得允许本地暂存。实现思路不复杂PDA 端把所有写操作先落到本地 SQLite标记为待同步然后起一个后台任务按顺序往上推推成功一条就标记一条。界面上给一个明确的同步状态指示——待同步数量、最近同步时间让操作员心里有数。关键点是顺序同一个单据的操作必须按顺序推不能先推上架再推收货否则服务端状态机会乱。做法是给每条记录带一个自增序列号严格按序列号推遇到失败就停在那里等。还有个坑是时间。PDA 的时间是可以手工改的有些设备电池拔了时间就重置。如果业务逻辑依赖客户端时间做批次排序或者先进先出判断那结果一定是灾难。我的处理原则是客户端时间只用于展示和日志所有业务判断一律用服务端时间。服务端收到请求时自己取now()客户端时间最多作为参考字段存一份。5.4 现场调试的几手功夫PDA 端的调试比 Web 端难得多没有控制台没法 F12。几个我常用的手段分享一下。一是日志落盘。在应用里把关键流程的日志写到/sdcard/wms/log/下按天切分。出问题时让操作员把文件导出来比在电话里问你点了什么高效一百倍。日志里至少要有请求号、接口名、请求参数摘要、响应码、耗时。二是保留一个隐藏的诊断页。连续点击版本号五次进入里面放当前服务器地址、token 状态、待同步队列长度、最近十条错误。这个页面在远程支持的时候价值巨大很多问题看一眼就知道方向。三是准备一台陪跑机。现场正式用的设备不要拿来折腾单独留一台同型号的设备做测试装同样的应用但指向测试环境。新版本先在陪跑机上跑一周确认没问题再推给全量设备。这个习惯帮我避免过至少两次全仓库停工的事故。6. 问题排查那些报错背后真正的原因6.1 启动与访问类问题启动报 ClassNotFoundException: javax.xml.bind.DatatypeConverter。这个错误的根源几乎百分之百是 JDK 版本不对。JAXB 在 JDK 8 里是标准库的一部分从 JDK 11 开始被移除。解决办法是换回 JDK 8或者在 JDK 11 上手动补 jaxb-api 依赖。别想着升级一下 JDK 更安全这套代码的依赖树撑不住。页面能打开但样式全丢一片裸 HTML。打开浏览器控制台看静态资源的请求路径如果全是 404八成是上下文路径不匹配。war 包名、Nginx 转发路径、页面里引用的相对路径三者必须一致。最快的确认方式是直接访问http://ip:8080/项目名/js/xxx.js看能不能拿到文件。登录之后立刻跳回登录页。这种一般是 session 或 cookie 的问题。检查三个地方Nginx 转发时有没有把 Cookie 头丢掉应用配置的 domain 和实际访问的域名是否一致如果用了集群session 是否共享。还有一个隐蔽原因是服务器时间不同步导致 token 校验失败date命令看一眼就知道了。定时任务半夜跑两遍。多实例部署时这个问题必现因为每个实例都启动了调度器。解决要么用分布式锁Redis 或数据库行锁都行要么把调度器拆到独立实例上只跑一份。我倾向后者简单明确出问题好定位。6.2 数据与业务类问题库存数量对不上但单据看起来都是对的。这类问题排查有固定套路先看流水表把所有影响该 SKU 在该库位的记录按时间排序加总看是否等于当前库存。如果流水加总等于库存说明是业务理解问题比如调拨在途没算进去如果不等说明有绕过流水直接改库存的代码路径重点查库存调整和初始化导入这两个入口。批次效期排序不对先到期的反而后出。先确认批次号是字符串还是日期类型。字符串排序下 2024-9-1 会排在 2024-10-1 后面因为逐字符比较时 9 大于 1。这种数据录入时不规范月份没补零造成的排序错误在仓库里非常典型。根治办法是在入库时就统一格式比如强制yyyyMMdd八位数字别留弹性空间。导出报表里中文全是问号或者方块。分两种情况如果导出的 CSV 在 Excel 里打开乱码是编码问题加 BOM 头能解决如果是导出的 PDF 里中文是方块是服务器缺中文字体装字体即可。这两个问题的现象很像但根因完全不同看导出文件的后缀就能区分。6.3 常见问题速查表现象最可能的原因快速验证处理方式启动即退出日志无异常内存不足被系统杀掉dmesg | grep -i kill调小堆内存或加内存访问 404war 包名与访问路径不符看 webapps 目录下的实际文件名重命名为 ROOT.war数据库连接超时连接池耗尽或网络不通telnet db_host 3306调大连接池、查防火墙页面时间差 8 小时时区配置缺失对比date和数据库now()补 serverTimezone 和 TZ扫码丢字符键盘模拟模式速度过快换广播模式对比改用广播模式接入重复提交导致库存翻倍缺少幂等控制查流水表同一单号是否两条加请求号去重 唯一索引报表导出 OOM一次查询数据量过大看日志里的堆栈改分页导出、限制时间范围图片上传失败上传目录不存在或无权限ls -l看目录属主建目录并授权给运行用户这张表我建议打印出来贴在工位上遇到问题先对着扫一遍能省下大量翻日志的时间。表里的快速验证那一列是重点它决定你是五分钟定位还是五小时瞎猜。最后分享一个我在多个项目里都验证有效的习惯每次部署完成之后写一份部署现场记录内容包括当天用的各个组件版本号、改了哪几个配置文件、执行了哪些命令、遇到的异常和解决办法。这份记录放在代码仓库的docs目录下跟着版本走。半年后你或者别人再来碰这套系统的时候这份记录的价值远超它占用的那点存储空间——我吃过太多次当时明明记得现在完全想不起来的亏这个习惯就是被那些亏逼出来的。