我把一个“U8 库存全量同步”从“能跑”改成“能长期稳定跑”:并发 + 全局限流 + 幂等复盘

今天下午我干了一件小事:把一个库存同步任务从"能跑"改成"能长期稳定跑"。

听起来像废话,但你真做过"全量同步到第三方系统"就知道:最难的从来不是字段映射,而是快、稳、别翻车。

这篇不讲 Redis 八股,也不聊架构玄学,就讲我今天下午踩的坑、做的改造,以及为什么这些改动能救命。


0)全流程先讲清楚:这件事到底在同步什么?

我的目标是:把 U8 CurrentStock 的库存全量同步到 CRM 的自定义对象 "现存库存量查询表"

为什么用全量?

  • U8 的数据会刷、会回写;很多场景下你依赖的"更新时间戳"不可靠。
  • 做增量最怕的不是慢,而是悄悄漏数据:日志看着没问题,最终数据却有缺口。

所以我选了更笨但更稳的方式:全量扫 + 幂等同步

全流程(高层版)

每次任务跑起来,大概是下面这条流水线:

  1. 分页读取 U8 CurrentStock(每页 100,直到查不到)。

  2. 每页先做一件事:把这一页所有 AutoIDCRM 记录 id 映射补齐:

    • 先从 Redis 批量取(AutoID → crmId)
    • Redis 没命中的再去 CRM 批量查(IN AutoID)
    • 查到的回写 Redis,减少后续外部请求
  3. 进入"同步单条"逻辑(并发执行):

    • SETNX processing:{autoId} 抢占位:避免同一 AutoID 并发重复处理

    • 判断 CRM 是否存在:

      • 不存在 → 走 create(受窗口配额限流)

      • 存在 → 计算签名 sig,与 Redis 里的旧 sig 比较:

        • sig 不变 → skip
        • sig 变化 → update(受 QPS 限流)
  4. 本页结束后,把新增的 crmId / sig 批量写回 Redis(HMSET)

  5. fixedDelay 触发下一次,避免 cron 到点重叠。


1)初版实现长什么样?为什么必须改?

初版逻辑非常朴素:

  • 串行处理
  • 每条 create / update
  • 每条后面 sleep(100ms) 作为"节流"

问题也很直接:

  • :库存上万时耗时离谱。
  • 还会限流:哪怕你 sleep 了,企业配额是全局共享的,别的系统不 sleep 一样把配额用光。
  • 任务可能重叠:cron 到点触发,上次还没结束就又起一轮,问题雪上加霜。

我今天下午真正想解决的不是"跑得快",而是三件事:

  • 吞吐可提升(不再靠 sleep)
  • 配额可控(不会被 429/30004 一巴掌拍死)
  • 结果可靠(不重复、不漏处理)

2)改造总纲:并发解决吞吐,令牌桶解决配额,其余保障幂等

我把改造拆成 6 个关键动作:

  1. 页级并发:固定线程池,页内并发处理 create/update
  2. 全局限流:Redis 令牌桶(集群可见),把 sleep 变成硬规则
  3. create/update 限流分离:窗口配额 vs QPS
  4. 并发去重:SETNX processing:{autoId} 抢占位
  5. 页尾批量写 Redis:把"每条写一次"变成"一页写一次"
  6. 调度改 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 升级到"全局限流"。

你会发现:系统最大的提升不是快了多少,而是终于变得可控了

相关推荐
幌才_loong2 小时前
.NET 8 中 EF Core 的 DbContext 配置全解析
后端·.net
刘一说2 小时前
Spring Boot中IoC(控制反转)深度解析:从实现机制到项目实战
java·spring boot·后端
悟空码字2 小时前
SpringBoot参数配置:一场“我说了算”的奇幻之旅
java·spring boot·后端
没逻辑2 小时前
Gopher 带你学 Go 并发模式
后端
自由生长20242 小时前
理解 Java Stream API:从实际代码中学习
后端
回家路上绕了弯3 小时前
深度解析分布式事务3PC:解决2PC痛点的进阶方案
分布式·后端
狗头大军之江苏分军3 小时前
快手12·22事故原因的合理猜测
前端·后端
仲夏月二十八3 小时前
关于golang中何时使用值对象和指针对象的描述
开发语言·后端·golang
计算机毕设VX:Fegn08953 小时前
计算机毕业设计|基于springboot + vue医院挂号管理系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计