我这边有一套比较典型的架构:
- 后端用 Java 微服务
- 数据源是 ERP(类似用友 U8)
- 客户那边用的是 CRM(类似纷享销客)
领导的需求也很朴素: "ERP 里的客户、订单啥的,定时同步到 CRM,保证那边数据是最新的。"
刚听的时候我也觉得这不就是个"搬运工"活吗?
结果一上手才发现:这里面特别容易搞成"又慢又占资源"的那种烂活。
所以我最后加了一层 Redis,中间做了一步"对比判断",直接把同步过程提速到"几秒级"。下面我就用第一人称好好讲一下整个过程。
一、最开始的方案:暴力全量,跑一次半天
最开始我写的就是大家最容易想到的那种:
- 定时任务触发
- 从 ERP 全量查表,比如查客户表 N 条记录
- 对每一条数据,去 CRM 那边查一下是不是存在、数据是否一致
- 不一致就调用接口更新,一致就算了
很快问题就来了:
- ERP 那边数据量上来了,全表查询本身就有点吃力
- CRM 的接口一条要几十毫秒到几百毫秒
- N 一大,整体同步时间就是N × 接口耗时
- 更要命的是:大部分数据其实根本没变,只是我每次都"重新认识它们一遍"
每天看着日志里一条条接口调用,我心里只有一个感觉:太浪费了。
二、我想明白的那个瞬间:我只需要知道"谁变了"
有一天我在看日志的时候,突然意识到一个很简单的事实:
业务上每天真正变动的只有很少一部分数据,但技术上我却在全量折腾所有数据。
那我真正想要的,其实只有一个东西:
"在这么多 ERP 数据里,哪些是这次新变出来的?"
如果我能很快知道"谁变了",同步逻辑就可以变成:
- 只对"变化的数据"调用 CRM 接口
- 其他完全不动
所以核心问题其实不是"怎么同步",而是:
我要有一种本地的方式,记住"上一次这些数据长什么样",然后跟这一次做个对比。
想到这里,其实答案就出来了:用 Redis 记一份"指纹"。
三、加一层 Redis:给每条记录做一个"指纹"
我最后采用的是一套很简单的设计:
每条 ERP 记录生成一个摘要(hash),把这个摘要存到 Redis 里。
下一次同步的时候,再算一遍摘要,对比一下:
- 如果 Redis 里没有这条记录 → 说明是新增
- 如果有,但摘要不一样 → 说明被修改过
- 如果摘要一样 → 当作没变化,直接跳过
1. 摘要怎么生成?
我这边是 Java,大概长这样:
javascript
String buildDigest(ErpCustomer c) {
String raw = c.getId()
+ "|" + c.getName()
+ "|" + c.getCategory()
+ "|" + c.getPhone()
+ "|" + c.getAddress()
+ "|" + c.getUpdateTime(); // 或者最后修改时间
return DigestUtils.md5Hex(raw);
}
也就是把对业务有影响的字段串起来,算一个 MD5。
只要这些字段里有一个变了,hash 一定不一样。
2. Redis 里怎么存?
我用的是 hash 结构:
makefile
key: 客户ID
Value: 这一条客户数据的 MD5 摘要
这样:
- 根据客户 ID 可以 O(1) 拿到上次的摘要
- Redis 本身就是内存级访问,毫秒级的
四、完整同步流程:ERP → Redis → CRM
我现在的同步流程大概是这样:
Step 1:从 ERP 全量查询
ini
List<ErpCustomer> erpList = erpDao.queryAll();
全量查这一步是没法省的,但只是一条 SQL,速度还能接受。
Step 2:遍历数据,计算摘要
scss
for (ErpCustomer c : erpList) {
String newDigest = buildDigest(c);
String oldDigest = redis.hget("erp:customer:hash", c.getId());
if (oldDigest == null) {
// 新增
createToCrm(c);
redis.hset("erp:customer:hash", c.getId(), newDigest);
} else if (!newDigest.equals(oldDigest)) {
// 有变更
updateToCrm(c);
redis.hset("erp:customer:hash", c.getId(), newDigest);
} else {
// 没变,直接跳过
}
}
Step 3:处理 ERP 已删除的数据(可选)
如果你希望 ERP 删掉的,CRM 那边也删掉(或标记失效),还可以做一步:
- 把这次 ERP 查询出来的所有 ID 放一个 Set 里
- 再从 Redis 里把所有 Field(ID)拿出来
- 不在 ERP 集合里的那些 ID,就当作"已经在 ERP 被删除了"
伪代码:
scss
Set<String> erpIds = erpList.stream()
.map(ErpCustomer::getId)
.collect(Collectors.toSet());
Set<String> redisIds = redis.hkeys("erp:customer:hash");
for (String id : redisIds) {
if (!erpIds.contains(id)) {
// ERP 没了,但 Redis 里还有,说明被删除
deleteInCrm(id);
redis.hdel("erp:customer:hash", id);
}
}
整体结构就变成:
arduino
ERP(SQL Server)
↓ 全量查询
同步服务(Java)
↓ 生成摘要,对比 Redis
Redis(只存摘要)
↓ 只把新增/修改/删除的差异
CRM(纷享销客等)
五、实际效果:从"全量折磨"到"几秒钟搞定"
举一个比较接地气的数字(不是理论,是我这边真实体感):
- ERP 客户表:5 万条
- 每次变动:可能就几十条
- CRM 接口耗时:假设 100ms 一次
原来的暴力方案:
- 5 万条每条都要跟 CRM 打一次交道
- 理论时间:5 万 × 100ms = 5,000,000ms ≈ 5,000 秒(一个多小时)
- 实际上还有各种网络延迟、限流、重试,更难受
加了 Redis 之后:
- 全量查 ERP:几十万行,几十到几百毫秒级(取决于表情况)
- 遍历 5 万条生成 hash + 对比 Redis:都在内存里跑,非常快
- 真正需要调 CRM 接口的:只有几十条
就算是 100 条更新 × 100ms,也就十来秒,加上外围逻辑,整体就是几秒到十几秒级别。
最直观的变化是日志:
以前一大片接口调用刷屏,现在只剩下一小截"真正有必要发出去的请求",看着心情都好很多。
六、这套方案里我踩过的几个点
为了真实一点,这里说几个我自己遇到的问题:
1. hash 字段一定要选对
一开始我只用了部分字段,比如 name、phone 之类,后来发现有些"业务上很关键"的字段没被算进去(比如所属业务员、客户分类),导致这些字段改变了但 hash 没变,CRM 那边不会更新。
教训:
在设计摘要的时候,最好把所有会同步到 CRM 的字段都算进去,或者直接用"最后修改时间 + ID"的组合,前提是 ERP 能保证这个时间是可靠的。
2. CRM 调用失败时不要急着刷 Redis
有一点很关键:
只有当 CRM 调用成功了,才把新的 hash 写回 Redis。
否则会出现这种情况:
- 我把摘要写进 Redis 了
- 结果那次 CRM 调用其实失败了
- 下次再同步时,程序会以为"一样,就跳过了",实际上 CRM 那边压根没更新成功
所以我的顺序是:
scss
if (createOrUpdateCrmSuccess) {
redis.hset(...); // 成功之后再写
} else {
// 进重试队列/写错误日志
}
3. Redis 崩了一次怎么办?
Redis 本质上只是一个"加速对比"的缓存,不是唯一的真相来源。
如果哪天 Redis 崩了、丢数据了,我的策略也很简单粗暴:
重建一下 Key,让这一次同步当作"第一次同步",全量刷一遍 CRM。
这种情况对 CRM 影响就是那一次会比较慢,但数据不会错,只是开销稍微大点。
七、我现在是怎么用这套方案的?
目前我会把这套方案当成一个通用模板,不止是客户,可以用在很多地方:
- ERP → CRM 的客户、商品、价格、库存
- MES → 报表系统的数据
- 甚至是两个内部系统之间的基础数据同步
基本套路都是:
- 确定主键
- 定义"会影响业务"的字段
- 生成摘要(fingerprint)
- 用 Redis 记录"上一次的 fingerprint"
- 每次同步时只处理 fingerprint 变化的数据
这套东西,说白了不是什么高大上的技术,就是一个很朴素但非常管用的"小心思" 。
最后
我一直觉得,像这种 ERP→CRM 的同步,看着是重复劳动,但里面其实有很多可以打磨的空间:
- 你可以什么都不想,暴力查 + 暴力调接口,跑得慢就加机器
- 也可以像这样,动一点点脑子,把"差异检测"前置到 Redis 这一层
我更喜欢第二种。
如果你现在手上也有类似"系统 A 同步数据到系统 B"的需求,而且同步特别慢、接口特别多,不妨试试这一套:
"全量扫描 + Redis 摘要对比 + 增量调用目标系统接口"。
实现不复杂,但效果往往会超出预期。