Spring Boot实现ERP与办公系统数据同步的实战方案
以前我们公司业务部每天最怕的事情就是月底对账那几天。电商ERP里的订单、库存、采购单和企业内部的办公用品管理系统、财务审批流各跑各的。ERP说库存有货办公系统里报销单已经走到财务了两边数据却对不上。业务员只能导表格、复制粘贴、人工核对一搞就是两三天。后来我把这两个系统的数据协同重新做了一遍用Spring Boot搭了一套可落地的同步方案总算是把这条链路理顺了。这篇就聊聊我当时怎么拆解需求、设计接口、实现同步、排查问题给同样被多系统数据折腾的朋友一个参考。这里说的电商ERP和企业管理系统其实就是两类典型的内部系统。电商ERP负责订单、商品、库存、采购、财务结算这些核心供应链数据企业管理系统则可能是OA、办公用品管理、资产盘点、行政报销这类内部流程系统。它们各自的数据模型不一样业务口径不一样数据库也不一样但业务上又必须咬合ERP里的采购入库单要触发办公用品管理系统的资产入库办公系统的领用申请要回写ERP的库存占用行政采购的报销审批结果要反哺ERP的应付账款。这种“各管一段但必须串起来”的场景就是数据协同要解决的核心问题。1. 项目背景与协同方案设计1.1 先搞清楚到底要协同什么数据在动手写代码之前我花了整整两天做了一件事梳理数据协同的边界。不是所有数据都需要在两个系统间来回同步如果把不该同步的数据也同步过去接口越做越多维护成本翻倍出错的概率也成倍增长。我把自己当时梳理的协同数据拆成了三大类。第一类是主数据主要是商品档案、供应商档案、部门组织架构。这类数据的特点是变化频率低但两个系统都需要引用一旦不一致就会引发后续所有单据的对不上。第二类是业务单据包括采购订单、采购入库单、办公用品领用单、盘点单。这类数据是协同的核心几乎每个动作都涉及跨系统的状态流转。第三类是财务结果数据比如采购结算金额、部门费用归属。这类数据往往在月底结账时特别敏感差一分钱都麻烦。我当时用一张表格把这些数据按“来源系统、目标系统、同步方向、实时性要求、容错要求”列了一遍。比如办公用品的领用申请必须从办公管理系统实时写入ERP锁定库存实时性要求高容错要求中等而ERP的采购入库单同步到办公管理系统可以容忍延迟5分钟但要求失败后自动重试不能丢数据。这个表格做完之后接口要写多少个、哪些走实时、哪些走批量基本就清楚了。1.2 同步方案选型为什么不直接双写数据库从头再来一次的话我依然不会选“两个系统直接连数据库”这条路。双写数据库看起来简单粗暴实际上后患无穷。不同系统的表结构是私有的直接暴露数据库表相当于把内部实现细节送给对方后续任何一方改表结构都会导致另一方崩溃。更麻烦的是事务边界跨数据库根本做不到强一致A库写入成功、B库写入失败的情况没办法用本地事务解决最后反而要写一堆补偿代码。我最后选择了接口化同步的方案把数据协同抽象成一层独立的同步服务。每个系统对外暴露的是业务接口而不是数据库表。同步服务通过调用双方的API完成数据交换。这样做的好处有三个第一数据边界清楚谁的数据谁负责维护谁对外提供接口谁保证接口质量第二接口可以独立做权限校验、参数校验、日志记录万一出了问题有据可查第三系统内部怎么改造都不影响对端只要接口契约不变就行。整个协同链路我分了四层数据源系统、数据同步服务、目标系统、监控告警。数据源系统负责产生业务数据并对外暴露接口同步服务负责定时拉取或者实时推送做数据转换和状态管理目标系统负责接收同步数据并落库监控告警负责记录每一次同步的日志和结果失败时触发告警。同步服务是中间层也是我这次改造的核心。1.3 定时任务与消息推送的取舍数据实时性的要求决定了传输方式。当时我面临一个选择实时推送用消息队列还是定时拉取用定时任务。电商ERP的订单数据理论上实时性越高越好下单之后恨不得库存立刻锁定。但企管系统里的办公用品领用延迟几分钟其实影响不大。最终我采用了组合方案核心链路走定时轮询加增量感知非核心链路用定时批量同步。原因是消息队列虽然实时性好但两个系统的技术栈和部署环境差异大引入MQ会增加运维复杂度而双方都有关系型数据库轮询判断增量数据的技术门槛低出了问题也容易排查。当然如果对端系统已经成熟地使用了MQ走MQ不是不行但当时我评估下来把消息队列的引入本身变成项目风险不如先用定时任务跑起来后续需要再升级。这套方案还有一个隐性的好处可控性高。定时任务的同步频率可以由我统一调节业务高峰期低峰期都可以调而消息推送一旦上游抖动消息积压的排查压力会转嫁到同步服务上。在实际业务中很多协同场景并不需要毫秒级响应与其追求实时性不如把成功率提上去。2. 数据模型与接口设计2.1 统一数据字典两边吵了半天最后靠约定解决两个系统各自维护各自的数据字典比如办公用品的分类ERP里叫“办公耗材”企管系统里叫“行政物料”听起来是一个东西代码值却对不上。如果不做统一转换同步过去的数据全是脏数据。这个问题最开始没引起重视直到第一次联调测试发现ERP推过来的“GOODS-001”到了办公用品系统变成了一个不存在分类我才意识到数据字典的映射是协同方案的地基。解决方式是在同步服务里维护一张映射表把双方系统中的枚举值、编码规则、单位换算统一对应起来。比如计量单位ERP用的是“箱”企管系统用的是“件”一箱等于十二件这个换算逻辑必须在同步服务里做掉而不是丢给业务人员手工处理。商品分类、供应商编号、部门代码也都逐一做了映射。字段级别的映射我用了配置文件加数据库表双层设计。稳定的映射关系写在数据库表里方便随时调整复杂的转换逻辑用Java代码实现比如日期格式从ERP的“yyyyMMddHHmmss”转成企管系统的“yyyy-MM-dd HH:mm:ss”金额从分转成元状态码从数字转成枚举。这套映射做完之后两边业务部门终于不用在会议上为“库存单位到底按箱还是按件”来回拉扯了口径在系统层面就被固定下来。2.2 接口安全与幂等设计防止重复同步和脏数据接口协同最怕的是重复调用。定时任务跑批时网络超时重试一次结果目标系统里生成了一模一样的两条单据库存扣两次账目错乱。解决这个问题必须靠幂等设计而不能指望上游不重试。我当时设计了一个全局唯一幂等键规则是“来源系统编码业务类型业务单据号”。例如ERP的采购入库单同步到企管系统幂等键就是“ERP-PURCHASE_IN-20250315001”。目标系统接收数据时先查幂等表如果存在就直接返回上次结果不存在才执行插入逻辑。这个机制让重复请求不再产生重复数据。接口签名也做了改造。每个系统调用同步服务时Header里带上appId、timestamp和sign。sign用appSecret加上请求参数按字典序拼接后做HMAC-SHA256摘要。timestamp超过5分钟视为过期请求直接拒绝。这样既能防止接口被恶意调用又能在一定程度上避免因为网络原因导致的重复报文。虽然协同系统之间是内网调用但这种安全机制还是建议加上内网不等于绝对可信多一层校验成本不高但能挡住很多低级问题。2.3 同步任务表与状态机设计同步服务的核心表有三张同步任务表、同步日志表、幂等表。同步任务表记录每个业务批次比如某次定时同步一共要处理哪些单据同步日志表记录每一条数据的同步结果幂等表单独存放同步过程中用过的幂等键。同步任务的状态我设计成一个状态机待同步、同步中、成功、失败、已补偿。这个状态机的价值在于出现问题的时候能立刻知道这个任务卡在哪一步以及还有多少存量任务处于中间状态。每个同步任务启动时先更新状态为“同步中”全部处理完后更新为“成功”出现异常时记录失败原因保留现场数据等待补偿机制介入。设计这个状态机的时候我特意没让失败状态自动转成功而是要求人工介入或补偿确认后才置为成功。原因很简单数据协同中出现的数据差异往往不是技术问题而是业务问题比如ERP那边单据被作废了企管系统这边不知道该不该继续接收自动处理搞不定这种场景留一个人工审核入口反而更稳妥。3. 基于Spring Boot的核心同步流程实现3.1 从电商ERP拉取增量数据的定时任务定时任务是数据协同的主力。我用Spring Boot的Scheduled注解实现了定时拉取逻辑配置了一个独立的线程池避免同步任务挤占业务接口的线程资源。同步频率上采购入库单是5分钟一次领用单是1分钟一次主数据是每天凌晨2点全量刷新一次。增量拉取的核心是游标设计。我不建议直接按时间戳同步因为两个系统的服务器时间可能不同步时间戳比较的误差会导致数据漏拉。我用的是自增ID和高水位线方式每次拉取时记录当前已处理的最大ID下次从大于这个ID的数据开始拉。当然这张表必须有可靠的自增主键而且业务数据一旦生成ID不会被篡改。实施前要确认数据库表的情况如果没有自增ID的表可以先做一次改造。拉取数据的伪代码如下Component public class PurchaseInSyncTask { private final SyncCursorService cursorService; private final ErpClient erpClient; private final SyncMapper syncMapper; Scheduled(fixedDelay 300000, initialDelay 10000) public void syncPurchaseIn() { long lastId cursorService.getLastSyncId(ERP_PURCHASE_IN); ListPurchaseInDTO list erpClient.listPurchaseInByCursor(lastId, 200); for (PurchaseInDTO item : list) { try { String idempotentKey ERP-PURCHASE_IN- item.getBillNo(); syncMapper.saveWithIdempotent(idempotentKey, item); cursorService.updateCursor(ERP_PURCHASE_IN, item.getId()); } catch (DuplicateKeyException e) { // 幂等键冲突说明已同步过更新游标即可 cursorService.updateCursor(ERP_PURCHASE_IN, item.getId()); } } } }fixedDelay的意思是上一次执行完毕后再隔5分钟执行下一次这个比fixedRate更适合同步任务因为处理时间波动不会导致任务堆积。initialDelay给应用启动留了缓冲避免刚启动还没有完全初始化就开始拉数据。拉取时做了批次控制每200条一批防止一次拉太多数据导致内存溢出或接口超时。游标和数据是分开更新的如果游标更新失败数据不会重复同步因为幂等键会拦下来如果游标更新成功但数据保存失败幂等表里没有记录下次还能重新拉取。这样的设计保证数据“不丢不重”。3.2 向办公用品管理系统推送数据的双写策略拉取只是把数据从ERP搬到同步服务接下来还需要推送到目标系统。当时我接的是基于Spring Boot开发的办公用品管理系统推送接口是标准的REST接口。推送过程我采用了“先写本地再推远端”的双写策略避免网络抖动导致的数据丢包。具体流程是从ERP拉取的数据先落库到同步服务的待推送表中标记为“待推送”然后异步推送。推送成功的标记为“已推送”推送失败的保留现场数据稍后由补偿任务重试。这里的核心原则是不能用内存里还没落库的临时数据直接调用对端接口否则服务重启或宕机那些数据就无影无踪了。推送时的重试机制我也做了限制单次推送超时时间是3秒最多重试3次重试间隔按1秒、2秒、4秒递增。如果3次都失败不会继续死磕而是把数据标记为“推送失败”交给补偿任务处理。重试太多会导致对端系统压力剧增甚至把对端拖垮得不偿失。推送代码我做了统一的封装只暴露同步服务内部使用public SyncResult pushToOfficeSystem(String endpoint, Object payload, String idempotentKey) { int retryTimes 0; while (retryTimes 3) { try { ResponseEntityString resp restTemplate.postForEntity(endpoint, payload, String.class); if (resp.getStatusCode().is2xxSuccessful()) { return SyncResult.success(); } } catch (ResourceAccessException ex) { log.warn(推送超时第{}次重试, retryTimes 1); } retryTimes; Thread.sleep(1000L * retryTimes); } return SyncResult.fail(); }这条链路的关键是超时时间必须比对端接口的实际处理时间预留充足。我曾经踩过坑对端系统的大批量入库操作耗时超过10秒而我这边的HTTP连接超时设置是5秒导致大批量同步时从来没有成功过。后来我跟对端系统的开发者一起排查才定位到问题把接口逻辑改成批量处理并把超时调整到20秒才正常。3.3 失败补偿我为什么不建议直接原地重试同步任务失败后最直接的思路是把失败的记录再调用一次。但实际业务里原地重试往往解决不了问题。比如办公用品系统里对应的供应商档案还没创建推送采购入库单时就会因为外键约束失败你重试一百次也一样失败除非先把供应商档案同步过去。所以我做了一个失败分级补偿机制。第一类是临时性失败比如网络超时、目标系统CPU占用过高、数据库连接池满这类问题等待一段时间后大概率能恢复我用延迟重试解决。第二类是业务性失败比如数据本身不合法、对端系统缺少前置基础数据这类问题需要调整数据或者先处理前置数据我把它推送到人工处理队列。第三类是持久性失败比如接口路径配置错误、请求格式不兼容这类问题重试没有意义直接告警给开发人员排查。补偿任务单独跑一个定时任务每10分钟扫描一次待补偿表。待补偿表里记录着原始数据、失败原因、重试次数、下一次重试时间。每次重试后如果还是失败就把重试次数加1重试间隔翻倍。超过5次之后不再自动重试改为发告警通知运维人员介入。这个机制帮我避免了很多“无用功”式的重试。有一次办公用品系统版本升级后接口入参变动我这边所有推送全部失败。靠补偿任务记录下来的失败原因我一眼就定位到是字段名从“skuNo”变成了“stockCode”修改映射配置后存量失败数据在下一次补偿时自动恢复业务数据一条没丢。3.4 数据对账与人工干预台数据协同做到最后光靠接口同步成功并不代表万事大吉。有一次我发现ERP里的库存和办公用品系统里的库存账实完全对不上同步日志全显示成功但两边对账就是有差异。后来查下来是因为办公用品系统里有几条数据被其他模块直接修改回滚了而同步服务根本感知不到。所以我增加了一个对账任务。每天凌晨跑一次把ERP那边的关键数据汇总数和办公用品系统那边的汇总数做比对比如商品总数、采购入库单数、金额合计。对不上时就生成对账差异单推送给相关业务人员由他们在人工干预台确认是补推数据还是手动修正。人工干预台就是一张待办表加一个简易的管理页面展示差异数据、同步状态、失败原因操作人员点“重推”或“忽略”即可。对账SQL的核心其实就是按维度聚合后做差集SELECT a.bill_no, a.amount, b.bill_no AS target_bill_no FROM erp_purchase_in a LEFT JOIN office_purchase_in b ON a.bill_no b.bill_no WHERE b.bill_no IS NULL这种SQL跑出来的数据就是ERP里有但办公系统没有的单据是日常对账中最常见的问题。反过来再跑一遍看看办公系统里有没有多余的单据就完成了双向校验。虽然对账SQL很简单但它把“系统间是否真正一致”这个抽象的问题变成了具体可查的列表价值非常高。4. 上线后踩过的坑与排查思路4.1 数据不一致问题编码、时区、状态码的连环坑第一次联调测试时我发现ERP推送过来的“在途库存”在办公系统里显示成了“可用库存”两边业务人员都确认这两个字段是同一个含义但就是数值对不上。排查发现ERP里的“在途库存”包含已经发货但未入库的部分而办公用品系统里的“可用库存”只统计已入库部分。这根本不是数据同步的问题是业务口径问题。后来在同步服务里加了一个数据来源标记让办公系统可以区分展示来源类型才解决了这个争议。时间字段也是高频问题。ERP数据库用的是中国标准时间而办公用品系统所在服务器的系统时区被设置成了UTC时间。Java 8的LocalDateTime不会自动进行时区转换直接存进去就差了8小时。解决方案是统一采用ISO 8601字符串格式传递时间并明确标注时区偏移量接收方解析时统一转成目标系统的本地时间。编码问题在谷歌浏览器上测试时根本不出现一到实际生产就冒出来。有一次同步过来的商品名称在办公系统里显示为乱码原因是ERP用的是GBK编码办公系统数据库用的是UTF-8HTTP传输时没有指定字符集。解决方式是在Feign配置里统一加上encode: UTF-8同时设置Content-Type: application/json;charsetUTF-8。这类问题排查起来费时费力建议在接口设计阶段就约定好编码格式。4.2 接口超时和数据库连接池耗尽问题上线后跑了一周第二个大坑出现了每到业务高峰时段同步任务和对端系统的正常业务接口互相争抢数据库连接池导致两边都变慢。目标系统给的反馈是“你们一跑同步我们的查询接口就超时”。我看了一眼对端系统的数据库连接池配置最大连接数只有10而我这边同步任务最长时占用8个连接直接把连接池打满了。解决方式做了三件事。第一同步任务的SQL尽量走索引减少执行时间。第二把同步任务的并发线程数从10降到了3降低对目标系统的压力。第三也是最重要的错峰执行把大量数据同步的任务安排在深夜低峰期跑高峰时段只保留必要的核心小任务。同步不是越快越好在自身可控的范围内给目标系统留出余量才是长期稳定运行的关键。4.3 排查工具与日志记录心得数据协同类问题排查起来最痛苦的是“不知道是哪一段出了问题”。所以我从一开始就坚持给每一次同步打全链路日志。日志里包含数据源系统标识、目标系统标识、业务单据号、幂等键、同步状态、耗时、错误码、错误详情。每个同步请求生成一个唯一的traceId贯穿数据拉取、转换、推送、落库全流程。排查问题时我的习惯是先按traceId查全链路日志看数据在哪一段丢失再查同步任务表状态判断是没执行还是执行失败最后查目标系统接口日志看看请求到底有没有到达对端。最多半小时就能定位到问题源头。监控告警我选了两条路。一条是同步失败率告警如果连续10次同步任务失败立刻推送告警到钉钉群。另一条是同步积压数量告警如果待推送表里的积压数据超过100条说明同步能力跟不上业务速度需要及时扩容或优化。告警不能太多告警越多越没人看我现在只保留这两条核心告警反而执行率更高。5. 做完这个项目后我的一些实战体会整个协同方案从梳理数据边界到上线稳定运行前后用了一个多月。回头再看最关键的不是技术而是把“数据边界”和“数据口径”先定义清楚。技术方案本身都是成熟的东西定时任务、接口调用、幂等设计、状态机网上资料一大堆但要让它适配自己的业务场景必须深入理解每个系统到底在管什么数据、哪些数据需要协同、协同后怎么验证一致性。我踩过的最大一个坑就是对账环节做得太晚。如果一开始就把对账任务设计进去很多数据不一致问题能在测试阶段就暴露出来而不是等到业务部门反馈后才匆匆补救。强烈建议任何做系统数据协同的朋友在方案设计阶段就把对账机制放进规划里不要等同步流程跑通了再补。另外我强烈建议保留一个人工干预的入口。数据协同涉及两个系统任何一方做版本升级、配置调整、数据订正都可能影响正在同步的数据。自动化做得再完善总有边缘业务场景是规则覆盖不了的。让有权限的业务人员在干预台上手动确认一两条异常数据比在代码里写各种复杂规则要高效得多。最后分享一个小技巧接口联调时不要只测正常路径。把网络超时、返回数据缺字段、目标系统宕机这些异常情况全测一遍甚至人为造一些脏数据看看同步服务能不能优雅处理。我在测试阶段特意模拟过办公用品系统数据库连接池满的情况发现重试机制在特定场景下会把超时重试的请求堆积起来。提前发现这些隐患比上线后半夜爬起来修复要舒服得多。这套方案后续还可以扩展的方向有两个。一是把同步任务改造为基于事件驱动的架构用提前落库的事件表配合独立的派发线程替代现在的定时轮询降低业务高峰期的主流程压力。二是把数据同步抽象成通用组件通过配置化方式接入新的业务系统这样以后再有第三套系统加入协同不需要再造一个同步服务。不过这些都属于锦上添花先把现有的协同链路稳定跑起来才是当下最值得做的事情。