今天下午我干了一件小事:把一个库存同步任务从"能跑"改成"能长期稳定跑"。
听起来像废话,但你真做过"全量同步到第三方系统"就知道:最难的从来不是字段映射,而是快、稳、别翻车。
这篇不讲 Redis 八股,也不聊架构玄学,就讲我今天下午踩的坑、做的改造,以及为什么这些改动能救命。
0)全流程先讲清楚:这件事到底在同步什么?
我的目标是:把 U8 CurrentStock 的库存全量同步到 CRM 的自定义对象 "现存库存量查询表" 。
为什么用全量?
- U8 的数据会刷、会回写;很多场景下你依赖的"更新时间戳"不可靠。
- 做增量最怕的不是慢,而是悄悄漏数据:日志看着没问题,最终数据却有缺口。
所以我选了更笨但更稳的方式:全量扫 + 幂等同步。
全流程(高层版)
每次任务跑起来,大概是下面这条流水线:
-
分页读取 U8 CurrentStock(每页 100,直到查不到)。
-
每页先做一件事:把这一页所有
AutoID的 CRM 记录 id 映射补齐:- 先从 Redis 批量取(AutoID → crmId)
- Redis 没命中的再去 CRM 批量查(IN AutoID)
- 查到的回写 Redis,减少后续外部请求
-
进入"同步单条"逻辑(并发执行):
-
SETNX processing:{autoId}抢占位:避免同一 AutoID 并发重复处理 -
判断 CRM 是否存在:
-
不存在 → 走 create(受窗口配额限流)
-
存在 → 计算签名 sig,与 Redis 里的旧 sig 比较:
- sig 不变 → skip
- sig 变化 → update(受 QPS 限流)
-
-
-
本页结束后,把新增的
crmId/sig批量写回 Redis(HMSET) 。 -
用
fixedDelay触发下一次,避免 cron 到点重叠。
1)初版实现长什么样?为什么必须改?
初版逻辑非常朴素:
- 串行处理
- 每条 create / update
- 每条后面
sleep(100ms)作为"节流"
问题也很直接:
- 慢:库存上万时耗时离谱。
- 还会限流:哪怕你 sleep 了,企业配额是全局共享的,别的系统不 sleep 一样把配额用光。
- 任务可能重叠:cron 到点触发,上次还没结束就又起一轮,问题雪上加霜。
我今天下午真正想解决的不是"跑得快",而是三件事:
- 吞吐可提升(不再靠 sleep)
- 配额可控(不会被 429/30004 一巴掌拍死)
- 结果可靠(不重复、不漏处理)
2)改造总纲:并发解决吞吐,令牌桶解决配额,其余保障幂等
我把改造拆成 6 个关键动作:
- 页级并发:固定线程池,页内并发处理 create/update
- 全局限流:Redis 令牌桶(集群可见),把 sleep 变成硬规则
- create/update 限流分离:窗口配额 vs QPS
- 并发去重:
SETNX processing:{autoId}抢占位 - 页尾批量写 Redis:把"每条写一次"变成"一页写一次"
- 调度改
fixedDelay:避免任务重叠
下面开始按"工程复盘"的顺序讲,每个点都带上能抄的代码片段。
3)优化一:一页先补齐 crmIdMap,别每条都问 CRM
全量同步最怕的结构是:每条一查 CRM。
我做的事情是:每页开始先把 AutoID 的 crmId 映射补齐:
- Redis HMGET 一把取出来
- 缺失的再批量去 CRM 做 IN 查询
- 查到的回填 Redis(避免下次再查)
代码片段:构建本页 crmIdMap(Redis 优先、CRM 补齐)
scss
private Map<Integer, String> buildCrmIdMapForPage(List<CurrentStockDetailVO> page) {
Set<String> autoIdStrSet = page.stream()
.map(CurrentStockDetailVO::getAutoId)
.filter(Objects::nonNull)
.map(String::valueOf)
.collect(Collectors.toSet());
// 1) Redis 批量取:autoId -> crmId
Map<String, String> redisCrmIds =
RedisUtils.getMultiCacheMapValue(REDIS_KEY_INVENTORY_ID, autoIdStrSet);
Map<Integer, String> crmIdMap = new HashMap<>();
for (Map.Entry<String, String> e : redisCrmIds.entrySet()) {
try { crmIdMap.put(Integer.parseInt(e.getKey()), e.getValue()); }
catch (NumberFormatException ignored) {}
}
// 2) Redis 没命中的,再去 CRM 批量查(IN)
List<CurrentStockDetailVO> missing = page.stream()
.filter(s -> s.getAutoId() != null && !crmIdMap.containsKey(s.getAutoId()))
.toList();
if (!missing.isEmpty()) {
Map<Integer, String> queried = batchQueryCrmInventoryByAutoIds(missing);
queried.forEach((autoId, crmId) -> {
crmIdMap.put(autoId, crmId);
RedisUtils.setCacheMapValue(REDIS_KEY_INVENTORY_ID, autoId.toString(), crmId);
});
}
return crmIdMap;
}
这一段做完,结构就从"每条查 CRM"变成"每页最多查一次 CRM"。
4)优化二:页内并发(线程池),把串行吞吐拉起来
我不追求花活,就用固定大小线程池。
- 一页 100 条:每条一个任务
- 等这一页全部结束,再进入下一页
好处:
- 吞吐上来得非常明显
- 页级边界更清晰,方便后面做"页尾批量写 Redis"和统计
代码片段:页内并发提交 + 等待全部完成
ini
ExecutorService executor = Executors.newFixedThreadPool(concurrent);
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (CurrentStockDetailVO stock : page) {
futures.add(CompletableFuture.runAsync(() -> {
// 单条处理逻辑(create/update/skip)
handleOne(stock, crmIdMap, idBatch, sigBatch, counters);
}, executor));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
注意:并发上来后,不做全局限流必炸(下一节)。
5)优化三:把 sleep 干掉,用 Redis 令牌桶做"全局限流"
之前的 sleep(100ms) 最大的问题:它只约束一个线程,不约束整个集群,也不理解"企业配额"。
我把节流升级为"全局可见的硬规则":
- 用 Redis 实现令牌桶
- 多实例共享同一个限流键
关键点:create/update 限流必须拆开
- create 通常是窗口配额(20 秒最多 N 次)
- update 更像QPS(每秒 N 次)
混一个桶会互相拖死,所以我拆成两个桶:
rate:crm:inventory:create:20 秒窗口、最多 50 次(我主动留余量给其他系统)rate:crm:inventory:update:秒级 QPS,比如 150/s(可配置调低)
代码片段:调用前先 acquire(抢不到就短等待)
typescript
private void acquireCreateRate() {
while (true) {
boolean ok = RedisUtils.tryAcquireTokenBucket(
"rate:crm:inventory:create",
createRate, // 例如 50
createIntervalSeconds // 例如 20
);
if (ok) return;
try { Thread.sleep(50); } catch (InterruptedException ignored) {}
}
}
private void acquireUpdateRate() {
while (true) {
boolean ok = RedisUtils.tryAcquireQps(
"rate:crm:inventory:update",
updateQps // 例如 150/s
);
if (ok) return;
try { Thread.sleep(10); } catch (InterruptedException ignored) {}
}
}
这一步之后,系统的行为变得"可控":
- 不再靠拍脑袋 sleep
- 也不再靠运气避开高峰
- 触发限流时会自动"软刹车",而不是直接报错翻车
6)优化四:并发去重(processing 占位键)
并发之后,另一个隐蔽坑是:同一 AutoID 可能被重复处理。
原因包括:
- 多实例同时跑
- 任务重叠
- 页内并发、异常重试
我的做法很朴素:处理前 SETNX 抢占位,抢到才干活,并且带 TTL 防死锁。
代码片段:SETNX 占位 + finally 释放
ini
String processingKey = "crm:inv:7cu9T:processing:" + autoId;
boolean locked = RedisUtils.setCacheObjectIfAbsent(processingKey, "1", 300); // 5min TTL
if (!locked) {
// 说明别的线程/实例正在处理同一条
skip.increment();
return;
}
try {
// 这里做 create / update / skip
} finally {
RedisUtils.deleteObject(processingKey);
}
这段逻辑不复杂,但它能把"并发引入的重复处理风险"压到很低。
7)优化五:签名幂等(无变化直接 skip,减少 update 压力)
我不会无脑 update。
每条库存我会把关键字段拼成一个签名(JSON),放 Redis:
- quantity / num / rate
- 仓库、批号、自由项(宽幅、透气、后处理、客户、业务员...)
更新前对比:
oldSig == newSig→ skip- 不同才 update
代码片段:buildSignature + 对比
scss
String oldSig = RedisUtils.getCacheMapValue(REDIS_KEY_INVENTORY_SIG, autoIdStr);
String newSig = buildSignature(stock);
if (oldSig != null && oldSig.equals(newSig)) {
skip.increment();
return;
}
// 需要 update
acquireUpdateRate();
updateCrmInventoryQuantity(...);
// 成功后把 newSig 写回(这里我会放到页尾批量写)
这一招的价值:很多库存记录一天内字段根本不变,全量扫不等于全量更新。
8)优化六:页尾批量写 Redis(HMSET),让系统更平滑
并发后如果每条 create/update 都立即写 Redis,会带来很多碎请求。
所以我改成:
- 页内任务只收集"要回写的 id/sig"
- 页尾一次 HMSET
代码片段:页内收集、页尾一次写
ini
Map<String, String> idBatch = new ConcurrentHashMap<>();
Map<String, String> sigBatch = new ConcurrentHashMap<>();
// 任务内:只 put,不立刻写 Redis
idBatch.put(autoIdStr, crmId);
sigBatch.put(autoIdStr, newSig);
// 页尾:一次写回
if (!idBatch.isEmpty()) {
RedisUtils.setCacheMap(REDIS_KEY_INVENTORY_ID, idBatch);
}
if (!sigBatch.isEmpty()) {
RedisUtils.setCacheMap(REDIS_KEY_INVENTORY_SIG, sigBatch);
}
这一步不一定让你"快一倍",但会让整体负载更平滑、Redis 往返更少。
9)优化七:调度从 Cron 改成 fixedDelay,避免任务重叠
cron 的问题是:到点就触发,不管上一轮是否结束。
全量同步耗时不稳定,一旦重叠:
- 并发翻倍
- 限流更难控
- processing 占位冲突更多
所以我改成 fixedDelay:上一轮结束后再延迟一段时间启动下一轮。
代码片段:fixedDelay 调度
kotlin
@Scheduled(
fixedDelayString = "${inventory.sync.fixed-delay-ms:300000}",
initialDelayString = "${inventory.sync.initial-delay-ms:0}"
)
public void syncInventoryChanges() {
// ...
}
10)整合一下:单条处理的"最终样子"(简化版骨架)
这段是把上面所有逻辑串起来的"单条处理骨架",方便读者形成整体印象。
ini
private void handleOne(CurrentStockDetailVO stock, Map<Integer, String> crmIdMap,
Map<String, String> idBatch, Map<String, String> sigBatch) {
Integer autoId = stock.getAutoId();
if (autoId == null) return;
// 1) 并发占位
String processingKey = "crm:inv:7cu9T:processing:" + autoId;
if (!RedisUtils.setCacheObjectIfAbsent(processingKey, "1", 300)) return;
try {
String autoIdStr = autoId.toString();
String crmId = crmIdMap.get(autoId);
String newSig = buildSignature(stock);
// 2) 不存在 -> create(窗口限流)
if (crmId == null || crmId.isEmpty()) {
acquireCreateRate();
String newCrmId = createCrmInventoryFromStock(stock);
if (newCrmId != null) {
idBatch.put(autoIdStr, newCrmId);
sigBatch.put(autoIdStr, newSig);
crmIdMap.put(autoId, newCrmId);
}
return;
}
// 3) 存在 -> 签名不变 skip;变了才 update(QPS 限流)
String oldSig = RedisUtils.getCacheMapValue(REDIS_KEY_INVENTORY_SIG, autoIdStr);
if (oldSig != null && oldSig.equals(newSig)) return;
acquireUpdateRate();
updateCrmInventoryQuantity(crmId, /*...*/);
sigBatch.put(autoIdStr, newSig);
} finally {
RedisUtils.deleteObject(processingKey);
}
}
11)今天下午的结论:真正救命的是"可控"
很多文章会把这种改造写成"用了线程池所以快了"。
但我今天下午的真实体感是:
- 线程池解决的是"跑得快"
- 令牌桶解决的是"跑得稳"
- 占位键 + 签名解决的是"跑得对"
- fixedDelay 解决的是"不会自残式重叠"
把这四件事凑齐,全量同步这种看似枯燥的活儿,才能真正变成"可以长期在线上运行的任务"。
如果你也在做"全量同步到外部系统"的活儿,强烈建议把节流从 sleep 升级到"全局限流"。
你会发现:系统最大的提升不是快了多少,而是终于变得可控了。