前阵子有个业务方找到我,说要做微信群发消息,一次性给好几千个客户推送通知。需求听起来很简单:写个接口,把消息发给所有人。可真把代码写起来才发现,就这么一个“for循环调接口”的活儿,坑比想象中深得多。直接循环调用,发到一半被平台限流,接口报错也不知道哪批失败了,客户收到重复消息还跑来投诉。最后我老老实实把方案重构成了分片发送 + 失败重试机制,才把这个问题彻底收住。
这篇文章我就把完整的优化过程、代码思路和踩坑记录分享出来。如果你的项目里也涉及Java对接微信群发、短信群发或者其他类消息API接口的批量处理,这套思路完全可以平移过去复用。
1. 先理清核心矛盾:为什么大批量发送不能直接for循环
很多人拿到“群发消息”的第一反应就是写个for循环,把用户列表遍历一遍,循环体里面调一次发送接口。这个方案在几十条、一百条的时候勉强能跑,但上了千条之后必然炸。原因倒不是Java循环慢,而是你忽略了两个最要命的东西:接口端的频率限制、单条发送的失败概率。
1.1 接口限制与业务痛点的拆解
微信群发消息这类API接口,包括公众号群发、企业微信群发、模板消息推送等,几乎都有明确的频率限制。以常见的接口规范为例,有的按分钟维度限制调用次数,有的按单次请求的最大接收人数限制批量规模,还有的会针对同一内容做去重校验。你的循环体调得再快,到了接口这一层一样会被拦住,返回限流错误码,甚至触发更严厉的临时封禁。
另外还有一个很容易被忽视的事实:一条消息发出去,底层要经过网络传输、平台内部路由、对方设备投递。网络一抖动,或者平台某个节点抽风,单条消息失败是常态,概率可能达到百分之一甚至更高。几千条消息就算成功率达到99%,也有几十条是失败的。如果不做重试,用户那边就会出现有人收到了、有人没收到的情况,这在线下业务场景里几乎等于事故。
我把问题总结成下面的表格,这也是我当时理清需求的起点:
| 痛点 | 直接原因 | 后果 |
|---|---|---|
| 接口限流 | 请求频率过高 | 报错、封禁、发送中断 |
| 单条失败 | 网络抖动、平台异常 | 消息丢失、用户投诉 |
| 重复发送 | 重试逻辑设计不当 | 用户反感、平台拦截 |
| 状态不可知 | 没有记录明细 | 排查困难、无法对账 |
1.2 整体设计思路:先拆再送,失败兜底
解决问题的思路说穿了一点都不玄乎:既然一批发不完,那就分批发;既然会失败,那就失败了再补一次。但具体到工程实现,有两个关键点决定了这套方案的上限。
第一,分片发送不能只按数量硬切。你得考虑接口的承载能力、发送的时间分布、以及平台对相同内容短时间内重复提交的限制。我后面会详细讲怎么确定分片大小和发送间隔,这里先记住一个原则:分片的目标不是“把大列表切成小列表”,而是“让发送速率平稳落在接口允许的范围之内”。
第二,失败重试不是简单地在catch里再调一次。你必须先区分哪些失败值得重试、哪些失败重试一万次也没用,还要设计重试的退避策略,防止同一批失败的消息在同一个时间点集体重试,直接把接口再次打爆。
这两点想清楚了,代码的骨架也就出来了:一个任务拆成分片,分片进入线程池调度发送,发送结果记录状态,失败的消息进入重试队列,重试也走同样的分片控制,超过重试上限的进入死信人工处理。整体流程图在我脑子里过一遍之后,落地就是Spring Boot工程里面几个互相配合的组件。
2. 分片发送机制:把大任务拆成接口能承受的小块
分片发送是整个优化方案的地基。地基打不好,后面重试做得再漂亮也没用。我这一节会讲分片参数怎么定、并发怎么控制、以及分片发送与平台API交互时的实际代码怎么写。
2.1 分片大小与发送间隔的确定
分片大小的确定,说实话第一版我也是拍脑袋定的,后来在压测环境里调了三轮才找到合理的做法。核心参考维度有三个:接口单次允许的最大接收人数、接口分钟级调用上限、以及单条消息发送的平均耗时。
举个例子,假设接口单次最多接收100个人,那分片大小就不能超过100,我习惯取80,留20%的余量给参数校验、用户解析这些额外消耗。假设接口限制每分钟调用60次,单次调用平均耗时300毫秒,那我每秒最多发3到4个分片。如果分片大小是80,每片耗时300毫秒,那么理论上每秒钟能处理240个人,一分钟就是14400个人,这个量级对大多数微信群发场景已经足够了。
真正要控制的是不同分片之间的发送间隔。代码里我直接用Thread.sleep()显然太粗暴,更好的做法是用限流器来控制发送速率。我第一版用的是ScheduledThreadPoolExecutor,直接把每个分片任务按固定间隔丢进去执行,后来发现这个方案对“速率控制”的理解更直观,代码也更简单。下面是一段核心实现:
public class ChunkSendScheduler { private static final int CHUNK_SIZE = 80; private static final int MAX_QPS = 4; // 每秒最多发4个分片,留足余量 private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); private final MessageSendClient messageSendClient; public void scheduleSend(List<String> userIds, String content) { List<List<String>> chunks = Lists.partition(userIds, CHUNK_SIZE); long delay = 0L; for (List<String> chunk : chunks) { scheduler.schedule(() -> sendChunk(chunk, content), delay, TimeUnit.MILLISECONDS); delay += 1000L / MAX_QPS; // 每个分片间隔250ms } } }这段代码做的事情很简单:先把用户列表按80人一批切开,然后让每个分片任务按250毫秒的间隔依次执行。用ScheduledExecutorService的好处是任务的触发节奏由调度器统一控制,不会因为某个分片执行慢了导致后面的请求瞬间堵在一起。
2.2 发送节奏控制的进阶:令牌桶思路
如果你对接的API接口限制更复杂,比如同时限制每秒调用数和每分钟调用数,上面这个固定间隔的方案就不够用了。我在另一个项目里用过令牌桶的思路,效果更稳。
令牌桶说白了就是“匀速往桶里放令牌,请求来了必须拿到令牌才能执行”。Guava的RateLimiter就是现成的实现。用起来代码非常简洁:
RateLimiter rateLimiter = RateLimiter.create(4.0); // 每秒放4个令牌 public void sendWithRateLimit(List<String> chunk, String content) { rateLimiter.acquire(); // 拿不到令牌就阻塞等待 messageSendClient.sendGroupMessage(chunk, content); }对比固定间隔的方案,令牌桶最大的优势是能应对执行耗时的波动。比如某一次接口响应特别慢,耗时从300毫秒涨到了800毫秒,固定间隔方案里后续任务依然按原节奏触发,会造成任务在线程池里堆积;令牌桶方案则会让acquire阻塞更久,实现动态拉长间隔,避免请求积压。实测下来,接口响应波动明显的场景,令牌桶成功率比固定间隔高出不少。
2.3 分片任务线程池的配置细节
分片任务不能直接丢进单线程的调度器里发,不然一个分片失败重试会堵住后面的所有发送。我单独建了一个发送线程池,调度器只负责任务的触发,真正的发送逻辑在线程池里执行。
线程池参数我踩过一次坑。最开始照着网上推荐的CPU密集/IO密集公式配,corePoolSize设8,maxPoolSize设20,队列容量塞了2000。结果高峰期所有分片任务全堆在队列里,发送延迟越来越大,最后接口限流错误和超时错误一起涌出来。后来我意识到了一个问题:这种场景下你根本不需要那么大的线程池,因为接口的QPS上限就摆在那里,线程再多也只会加重无效请求。
我这里最终确定的是:corePoolSize和maxPoolSize都设成4到6,队列容量设成分片总数的一半。为什么是这个数?因为前面限流已经限制了每秒最多4个分片,4到6个线程已经足够消化这些任务,线程再多反而会引入不必要的上下文切换。队列容量控制在一半则是有意为之,任务太多直接走拒绝策略往数据库落状态,而不是无限堆在内存里等。这是“宁可拒绝,不可积压”的思路,后面篇幅我会再展开说。
2.4 发送接口调用的落地实现
分片发送最后落到API接口调用时,有几个细节很值得注意。我先贴一段实际发送的代码,再逐一解释:
public ChunkSendResult sendChunk(List<String> userIds, String content) { long start = System.currentTimeMillis(); int retryCount = 0; while (retryCount <= MAX_RETRY) { try { SendResponse resp = messageSendClient.sendGroupMessage(userIds, content); if (resp.isSuccess()) { return ChunkSendResult.success(userIds.size(), System.currentTimeMillis() - start); } if (!resp.isRetryable()) { return ChunkSendResult.fail(userIds, resp.getErrorMsg(), false); } retryCount++; long waitMs = computeBackoffTime(retryCount); Thread.sleep(waitMs); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return ChunkSendResult.fail(userIds, "interrupted", true); } catch (Exception e) { retryCount++; long waitMs = computeBackoffTime(retryCount); try { Thread.sleep(waitMs); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return ChunkSendResult.fail(userIds, "interrupted", true); } } } return ChunkSendResult.fail(userIds, "exhausted", true); }发送异常这块最重要的是区分可重试与不可重试。我遇到的不可重试情况主要有三种:参数格式错误(比如用户ID传成了null)、权限错误(比如appSecret过期)、内容违规被拦截。这三种错误重试多少次结果都一样,白白消耗资源,还有可能把账号搞进更严厉的风控名单。
可重试的则包括:网络超时、服务端5xx错误、限流错误(返回特定的code)、以及一些临时性的系统异常。判断逻辑我用响应对象里的一个字段来标识,这比在catch里猜异常来源更精确。服务端明确告诉你“限流了”,跟你自己网络超时,处理策略应该是不一样的:限流重试要等待更长的时间,网络超时则可以相对快速地重试。
2.5 分片状态跟踪
每个分片发送完,我当时都会往数据库更新一条记录。字段不多,但非常关键:分片ID、任务ID、分片序号、接收人数、成功人数、失败人数、状态、耗时、错误信息、重试次数。
为什么要记录分片级别的状态,而不是只记录总状态?原因很实在:如果整个任务半路崩了,重启之后我需要知道哪个分片发出去了、哪个没发出去、哪个发了一半,才能决定是继续还是重新发。没有这些明细,唯一的选择就是把整个任务再跑一遍,重复发送的风险直接拉满。
数据库表结构我当时是这么设计的,可以参考:
CREATE TABLE send_chunk_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id VARCHAR(32) NOT NULL, chunk_index INT NOT NULL, total_count INT NOT NULL, success_count INT DEFAULT 0, fail_count INT DEFAULT 0, status TINYINT NOT NULL COMMENT '0待发送 1发送中 2成功 3失败待重试 4最终失败', retry_times INT DEFAULT 0, error_msg VARCHAR(500), cost_ms BIGINT DEFAULT 0, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_task_chunk (task_id, chunk_index) ) COMMENT '分片发送记录表';这里的唯一索引uk_task_chunk是防止重复的关键。后续不管是任务重启还是定时补偿,只要往这张表插数据,重复的分片自然会被数据库挡下来,这比在代码里用各种状态判断要可靠得多。
3. 失败重试机制:让发送失败的消息有机会被补救
如果说分片发送解决了“发得太快”的问题,那失败重试解决的就是“发了没收到”的问题。这一节我重点讲重试策略的选择、重试队列的设计、以及幂等性怎么保证。
3.1 可重试与不可重试的区分逻辑
前面发送代码里区分了可重试和不可重试,现在展开讲讲我的判断依据。总的思路就是一句话:重试要针对那些“这次失败但下次可能成功”的情况。
我遇到过的情况可以分为三类。第一类是平台返回明确错误码的,比如“当前调用过于频繁”“服务内部错误”“系统繁忙”,这些都是临时性的,过一会儿再试大概率能成功,属于可重试。第二类是网络层面的异常,比如SocketTimeoutException、ConnectException,网络抖动恢复之后请求就能成功,也属于可重试。第三类是业务层面的错误,比如某个用户ID无效、内容被判定违规、没有群发权限,这些属于永久失败,重试没有任何意义,甚至可能因为反复提交同样的内容触发更严重的账号风控。
判断逻辑放在哪个环节也很重要。我推荐在发送客户端就完成判断,并且把“是否可重试”作为响应对象的一个属性返回,而不是在重试框架里靠catch异常类型去猜。原因很简单:HTTP状态码200不代表发送成功,400也不代表绝对不能重试。只有真正解析过API响应结构的人,才知道哪些code背后到底是什么含义。
3.2 重试退避策略:指数退避加随机抖动
重试策略里最忌讳的就是“失败后立刻重试、再失败立刻再重试”。几十分片同时失败,如果全部立刻重试,接口瞬间收到同样数量的请求,等于再触发一次限流,形成恶性循环。
正确的做法是加上退避时间,让每次重试的间隔越来越长。我用的公式是:
private long computeBackoffTime(int retryCount) { long base = Math.min(30000L, 1000L * (1L << Math.min(retryCount, 5))); long jitter = ThreadLocalRandom.current().nextLong(0L, Math.max(1L, base / 5)); return base + jitter; }这里的逻辑是:第一次重试前等1秒左右,第二次等2秒左右,第三次4秒,以此类推,最多封顶30秒。jitter是随机抖动,目的是让同一批失败的分片不会在同一个毫秒级时间点上集体重试。这个抖动在别的场景里可能无所谓,但在批量重试场景里非常关键——系统设计上有一个“惊群效应”,几十上百个任务同时醒来打接口,跟定时炸弹没什么区别。
实际运行效果是这样:第一轮重试大约1.2秒后开始,第二轮大约2.5秒,第三轮大约4.8秒。整体重试节奏平稳,接口压力被自然地摊开了。
3.3 最大重试次数与消息生命周期
重试不能无限做下去。我最终定的策略是单条消息最多重试5次,超过5次就进入死信状态。这个数字不是拍脑袋定的,而是根据业务容忍度和接口恢复时间综合算出来的:如果接口连续5次都失败,大概率不是临时抖动而是持续性的故障,你再重试也只是增加无效请求,不如先把数据保住,等人工介入。
进入死信状态的消息怎么处理?我们当时有两个手段。一是定时任务扫描死信表,往企业微信群里推告警,由运营人员用管理后台手动重发。二是写了一个补偿接口,手动选择死信记录后,重新走一遍分片发送流程。这里有个细节:死信消息的重新发送要重新走限流逻辑,不能直接一条条硬调接口,不然等于绕过了分片控制,前功尽弃。
3.4 重试过程中的幂等性保证
做群发消息的人最怕两件事:消息没发出去,以及消息发了两遍。重试机制天然会带来“可能发了两遍”的问题,幂等性设计必须提前做好。
我的做法是给每条待发送的用户记录生成一个全局唯一的消息ID,格式可以是taskId + "_" + userId。发送请求时带上这个ID,平台接口如果支持去重,就会处理相同ID的重复请求;如果接口不支持,我在自己的数据库里用唯一索引挡住重复提交。
数据库层面的设计是这样的:发送明细表里有一个消息ID字段,加上唯一索引。每次重试之前先往明细表插入一条状态为“发送中”的记录,如果插入时违反唯一约束,说明这条消息已经处理过(哪怕前一次状态没来得及更新),直接跳过不再发送。这个方案在后端系统里非常常用,逻辑简单,效果可靠。
CREATE TABLE send_message_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, message_id VARCHAR(64) NOT NULL COMMENT '全局唯一消息ID', task_id VARCHAR(32) NOT NULL, user_id VARCHAR(64) NOT NULL, status TINYINT NOT NULL COMMENT '0发送中 1成功 2失败 3死信', retry_times INT DEFAULT 0, error_msg VARCHAR(500), create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_message_id (message_id) ) COMMENT '发送明细表';每次发送动作的事务边界是:先插入明细记录,再调用发送接口,最后更新明细状态。你把“插入动作”当成一次发送令牌的领取,抢到了才允许发送,抢不到就说明别人正在发或者已经发过了。这个思路比单纯依赖内存状态要稳,尤其是在多个实例部署的情况下。
4. 任务状态管理与监控:做到对每一条消息都心里有数
分片发送和失败重试这两块地基打完之后,你还缺一层“统筹全局”的能力。几千条消息的批处理任务,如果没有一个任务级别的状态管理体系,运行到一半你根本不知道整个任务处于什么阶段,更谈不上对账和排查。
4.1 任务总表与状态流转设计
我在分片记录表之外单独建了一张任务总表,记录每个群发任务的整体信息。字段相对简单:任务ID、任务名称、接收用户总数、分片总数、成功分片数、失败分片数、任务状态、开始时间、结束时间、创建人、备注。
任务状态的流转我定义为:待处理、处理中、部分成功、成功、失败。你可能会问,“部分成功”和“失败”有什么区别?区别在于失败是否还需要任务是“终态”。如果整个任务还有分片在重试中,状态就是处理中;所有分片都结束了,但有一部分失败无法重试,就是部分成功;所有分片都成功,才是真正意义上的成功。
这套状态流转保证了业务的准确性。运营后台对外展示的时候,用户看到的不是“成功”或“失败”这种一刀切,而是“成功980人,失败20人,其中15人重试成功,5人需要人工处理”,这个信息对业务决策非常关键。
4.2 定时补偿任务
光有状态记录还不够,因为系统可能在任何时候崩溃。我当时加了一个定时补偿任务,每5分钟扫描一次任务总表,把所有处于“处理中”但超过30分钟没有更新状态的任务捞出来,再把它们对应的分片记录查出来,看看卡在哪个环节。
补偿的逻辑也很简单:如果某个分片状态是“发送中”但超过10分钟没有变化,就把它重新置为“待发送”,重新丢进发送调度器。为了防止一个分片被两个线程同时处理,我上面说的唯一索引这时候就发挥作用了——重复的明细记录插入会被数据库挡住,不会出现真正意义上的重复发送。
这个定时补偿任务可以说是整套方案的“安全带”。有了它,我不再担心半夜告警响起来之后需要人工去数据库改状态,系统自己会把卡住的任务拉回正轨。
4.3 监控指标与日志规范
代码写得再好,没有监控你也只能在故障发生之后被动响应。我给这套群发系统加了三个最关键的监控指标。
第一个是发送成功率:成功消息数 / 总消息数。这个指标从90%掉到80%,就说明接口开始不稳定了,需要关注。第二个是分片平均耗时:如果从300毫秒涨到1000毫秒,说明接口响应变慢,可能接近限流阈值。第三个是重试率:重试消息数 / 总消息数。重试率超过20%,大概率是分片大小或者发送间隔设置得不合理,需要调参。
日志方面,我给自己定了一个铁律:每个分片发送结束,必须输出一条包含分片ID、任务ID、成功数、失败数、耗时的日志;每次重试动作也必须有日志,格式统一为[taskId][chunkIndex][retryTimes] errorMsg。这样排查问题的时候,直接按taskId全局搜索,就能完整还原这个任务的整个生命周期。
4.4 Spring Boot工程里的组件划分
上面讲的这些逻辑,落到代码工程里我在一个服务类里做了模块化处理。核心组件如下:
chunkSendScheduler:分片调度器,负责任务拆解与发送节奏控制。messageSendClient:API调用客户端,封装了微信群发消息的HTTP调用、响应解析与可重试判断。sendRecordService:分片记录与明细记录的读写服务。retryQueueHandler:重试队列处理器,负责从数据库中捞取待重试的分片,重新推入调度器。failedMessageHandler:死信处理器,负责超过重试上限的消息的告警与人工补偿入口。
这种划分的好处是每个组件可以独立测试。我当时在本地用Mock接口做了完整的单元测试,分别验证了限流场景、网络超时场景、重复提交场景,确保每个组件的行为符合预期之后,再联调真实接口,问题排查的范围一下就缩小了。
5. 实战问题排查与经验总结
任何架构设计都得经过真实流量的检验。这套方案上线后,我遇到了一些很有意思的问题,有的是设计阶段没想到的,有的是想到了但没有做到位的。这一节我把它们整理出来,相当于一份排错清单。
5.1 高频问题与解决方案速查表
| 问题现象 | 原因 | 解决方案 |
|---|---|---|
| 跑到一半任务没有任何报错就停了 | 线程池队列满了,任务被拒绝 | 调整队列容量,增加拒绝策略的告警 |
| 同一条消息收到两次 | 定时补偿与正常发送同时处理同一批次 | 依赖唯一索引挡住重复插入 |
| 重试成功率越来越低 | 退避间隔太短,重试请求依然撞上限流 | 加大基础退避时间,增加随机抖动 |
| 接口大量返回限流错误 | 分片间隔计算错误,实际QPS超过限制 | 用RateLimiter替换固定Sleep |
| 消息状态一直是“发送中” | 进程崩溃,状态没来得及回写 | 增加定时补偿任务,超时自动重置 |
| 数据库连接池耗尽 | 发送明细写入频繁,连接不够用 | 调大连接池,明细写入改批量插入 |
5.2 线程池与数据库连接池的联合考量
这个问题花了我不小的代价才彻底想明白,值得单独说说。
分片发送涉及两个池:线程池和数据库连接池。发送动作会调接口,耗时较长,这个期间线程是阻塞的,但它占用的数据库连接是释放了的。而插入明细记录的动作很快,但频率很高。如果这两者共用同一个连接池,大批量发送时,所有线程都在等待接口响应,数据库连接反而被“快速插入”动作大量占用,最终连接池被打满,插入动作反而成为瓶颈。
我的解决办法是给不同的操作单独建设连接池,或者至少给明细表写入设置独立的连接池参数。发送线程池持有少量的连接用于低频状态更新,明细表写入使用另一个池,pool size设得更充裕一些。这个调整之后,数据库连接池耗尽这个问题就再没有出现过。
5.3 Spring事务与长任务不能共存
还有一个问题在初版代码里出现过,就是我把整个分片的发送逻辑包在了一个@Transactional方法里。理由现在看来很天真:担心数据不一致,想用事务兜底。结果发送接口要等300毫秒,网络超时要等几十秒,事务迟迟不提交,数据库连接一直被占用,加上Spring事务默认隔离级别下的锁行为,直接拖垮了数据库性能。
后来我把事务去掉,改用分布式状态管理。每个明细记录的插入和更新都是单独的短事务,发送动作完全放在事务之外。数据不一致的风险由前面说的幂等性设计和定时补偿任务来兜底,效果远好于一个大事务包住一切。
5.4 测试方法与回放机制
最后聊聊测试。群发消息的接口肯定是不能直接拿正式用户来测的,尤其是压测的时候,一条测试消息发出去用户真能收到。我当时的做法是搭了一个Mock发送服务,模拟平台的限流、超时、5xx错误,然后把测试数据量拉到1万人,完整地跑一遍分片发送和失败重试的全流程。
这套Mock服务还支持故障注入:可以指定某个分片永远失败、指定某个错误码触发限流、甚至模拟进程中途宕机。每次改动上线之前,我都会跑一遍这三种场景,确认系统能够按照预期进行状态流转和补偿。
还有一个值得说的细节:真实线上出过一次问题之后,我把当时的请求参数、错误码、时间点全部导出,写了一个故障回放工具。用同一份数据重新跑一遍优化后的代码,对比前后行为差异。这个方法帮我验证了多个修复方案的有效性,建议做消息类系统的朋友也可以试试“故障回放”这个思路。
最后再分享一个我的实操心得
这套分片发送与失败重试机制,我前后迭代了好几版,从最初简单的循环加重试,到现在调度器、限流器、补偿任务、幂等表完整配合,稳定运行的时长已经超过一年。我个人在实际操作中最深的体会是:批量处理优化,本质上不是把代码写得更快,而是把失败处理得更稳。
Java里一个for循环跑几千次其实很快,真正慢的是接口的响应等待,真正不稳定的也是网络和外部接口。分片解决的是“别太快”,重试解决的是“别丢消息”,幂等解决的是“别重复”。把这三件事想清楚,代码上的东西其实都是水到渠成的细节实现。
如果你现在正好要做微信群发消息的批量接口,建议第一步先把自己要对接的接口文档翻一遍,把限流规则、错误码表、字段约束都搞清楚,再回来对照这篇文章里的设计。先把分片和重试的主流程跑通,再逐步加状态管理、监控和补偿任务。不要一上来就想着把系统做得面面俱到,稳定性和可观测性是边跑边补的,核心是先让批量发送这个动作变得可控、可回放、可排查。
后续如果业务量继续上涨,还可以考虑把重试队列从数据库搬到消息队列中间件里,把分片调度的节奏配置化,配合压测平台做自动化容量验证。这些扩展我在另一个项目里已经落地了一部分,等有时间再专门写一篇记录。