以前我们公司业务部每天最怕的事情,就是月底对账那几天。电商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"); List<PurchaseInDTO> 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 { ResponseEntity<String> 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-8,HTTP传输时没有指定字符集。解决方式是在Feign配置里统一加上encode: UTF-8,同时设置Content-Type: application/json;charset=UTF-8。这类问题排查起来费时费力,建议在接口设计阶段就约定好编码格式。
4.2 接口超时和数据库连接池耗尽问题
上线后跑了一周,第二个大坑出现了:每到业务高峰时段,同步任务和对端系统的正常业务接口互相争抢数据库连接池,导致两边都变慢。目标系统给的反馈是“你们一跑同步,我们的查询接口就超时”。我看了一眼对端系统的数据库连接池配置,最大连接数只有10,而我这边同步任务最长时占用8个连接,直接把连接池打满了。
解决方式做了三件事。第一,同步任务的SQL尽量走索引,减少执行时间。第二,把同步任务的并发线程数从10降到了3,降低对目标系统的压力。第三,也是最重要的,错峰执行,把大量数据同步的任务安排在深夜低峰期跑,高峰时段只保留必要的核心小任务。同步不是越快越好,在自身可控的范围内给目标系统留出余量,才是长期稳定运行的关键。
4.3 排查工具与日志记录心得
数据协同类问题排查起来最痛苦的是“不知道是哪一段出了问题”。所以我从一开始就坚持给每一次同步打全链路日志。日志里包含:数据源系统标识、目标系统标识、业务单据号、幂等键、同步状态、耗时、错误码、错误详情。每个同步请求生成一个唯一的traceId,贯穿数据拉取、转换、推送、落库全流程。
排查问题时,我的习惯是先按traceId查全链路日志,看数据在哪一段丢失;再查同步任务表状态,判断是没执行还是执行失败;最后查目标系统接口日志,看看请求到底有没有到达对端。最多半小时就能定位到问题源头。
监控告警我选了两条路。一条是同步失败率告警:如果连续10次同步任务失败,立刻推送告警到钉钉群。另一条是同步积压数量告警:如果待推送表里的积压数据超过100条,说明同步能力跟不上业务速度,需要及时扩容或优化。告警不能太多,告警越多越没人看,我现在只保留这两条核心告警,反而执行率更高。
5. 做完这个项目后,我的一些实战体会
整个协同方案从梳理数据边界到上线稳定运行,前后用了一个多月。回头再看,最关键的不是技术,而是把“数据边界”和“数据口径”先定义清楚。技术方案本身都是成熟的东西,定时任务、接口调用、幂等设计、状态机,网上资料一大堆,但要让它适配自己的业务场景,必须深入理解每个系统到底在管什么数据、哪些数据需要协同、协同后怎么验证一致性。
我踩过的最大一个坑,就是对账环节做得太晚。如果一开始就把对账任务设计进去,很多数据不一致问题能在测试阶段就暴露出来,而不是等到业务部门反馈后才匆匆补救。强烈建议任何做系统数据协同的朋友,在方案设计阶段就把对账机制放进规划里,不要等同步流程跑通了再补。
另外,我强烈建议保留一个人工干预的入口。数据协同涉及两个系统,任何一方做版本升级、配置调整、数据订正,都可能影响正在同步的数据。自动化做得再完善,总有边缘业务场景是规则覆盖不了的。让有权限的业务人员在干预台上手动确认一两条异常数据,比在代码里写各种复杂规则要高效得多。
最后分享一个小技巧:接口联调时,不要只测正常路径。把网络超时、返回数据缺字段、目标系统宕机这些异常情况全测一遍,甚至人为造一些脏数据,看看同步服务能不能优雅处理。我在测试阶段特意模拟过办公用品系统数据库连接池满的情况,发现重试机制在特定场景下会把超时重试的请求堆积起来。提前发现这些隐患,比上线后半夜爬起来修复要舒服得多。
这套方案后续还可以扩展的方向有两个。一是把同步任务改造为基于事件驱动的架构,用提前落库的事件表配合独立的派发线程,替代现在的定时轮询,降低业务高峰期的主流程压力。二是把数据同步抽象成通用组件,通过配置化方式接入新的业务系统,这样以后再有第三套系统加入协同,不需要再造一个同步服务。不过这些都属于锦上添花,先把现有的协同链路稳定跑起来,才是当下最值得做的事情。