第一章:重试机制失效的典型现象与认知误区
重试机制常被误认为是“兜底万能药”,但实际生产环境中,大量服务雪崩、数据不一致和超时级联问题恰恰源于重试逻辑的隐性失效。开发者往往只关注“是否重试”,却忽略“何时不该重试”“重试是否安全”“下游是否幂等”等关键前提。
常见失效现象
- 指数退避未生效:配置了
maxRetries=3,但因未启用退避策略(如固定间隔或指数增长),三次重试在 100ms 内密集发出,加剧下游压力; - 非幂等操作重复执行:对支付接口发起重试,导致同一笔订单被扣款多次;
- 上游超时掩盖真实错误:客户端设置 2s 超时,而重试耗时 1.8s + 1.9s + 1.7s = 5.4s,最终返回
TimeoutException,日志中却无任何 HTTP 5xx 或业务异常记录; - 熔断器与重试冲突:Hystrix 熔断开启后,重试仍持续触发,形成无效探测流量。
根深蒂固的认知误区
- “重试能提升可用性” → 实则在下游过载时,重试会放大故障半径;
- “HTTP 500 必须重试” → 500 可能由数据库死锁、OOM 等不可恢复错误引发,重试只会恶化状态;
- “所有 RPC 框架默认支持安全重试” → gRPC 默认不重试流式调用;OpenFeign 需显式配置
RetryableException白名单,否则IOException才重试,RuntimeException直接透出。
快速验证重试行为的诊断方法
在 Spring Cloud OpenFeign 中,可通过启用 DEBUG 日志观察重试过程:
logging:
level:
org.springframework.cloud.openfeign.RetryableFeignLoadBalancer: DEBUG
执行后,若日志中连续出现类似 Retrying [GET] ... attempt #2 且响应码始终为 503,则表明重试正在盲目进行,需立即检查下游健康状态与重试策略匹配度。此外,建议在重试拦截器中注入 RetryContext 并打印 context.getRetryCount() 与 context.getLastThrowable().getClass(),以识别是否在捕获非预期异常类型。
第二章:Go标准库context模型的深层行为解构
2.1 context.WithTimeout/WithCancel在重试链中的生命周期穿透分析
在多层重试调用中,context.WithTimeout 或 context.WithCancel 创建的子上下文需贯穿整个调用链,否则超时/取消信号无法向下透传。
生命周期穿透关键机制
- 子goroutine必须接收并传递原始
ctx(而非context.Background()) - 每次重试调用需复用同一
ctx,不可新建 - 中间件、HTTP client、数据库驱动等须主动监听
ctx.Done()
典型错误示例
func doWithRetry(ctx context.Context) error {
for i := 0; i < 3; i++ {
// ❌ 错误:每次重试新建独立timeout,父级取消失效
retryCtx, _ := context.WithTimeout(context.Background(), 5*time.Second)
if err := callAPI(retryCtx); err == nil {
return nil
}
}
return errors.New("retry exhausted")
}
该写法导致外层ctx的Done()通道被忽略,重试过程无法响应上游取消请求。
正确透传模式
func doWithRetry(ctx context.Context) error {
for i := 0; i < 3; i++ {
// ✅ 正确:复用原始ctx,超时由上层统一控制
if err := callAPI(ctx); err == nil {
return nil
}
select {
case <-ctx.Done():
return ctx.Err() // 提前终止
default:
}
}
return errors.New("retry exhausted")
}
逻辑分析:callAPI(ctx)内部应使用http.Client等支持context的组件,其底层会监听ctx.Done()并中断阻塞操作;参数ctx承载了完整的取消链路与deadline,确保任意层级触发cancel均能穿透至最深调用点。
| 组件 | 是否支持ctx透传 | 说明 |
|---|---|---|
net/http.Client |
✅ | Do(req.WithContext(ctx)) |
database/sql |
✅ | db.QueryContext(ctx, ...) |
time.Sleep |
❌ | 需替换为select{case <-time.After():}或timer.Reset() |
2.2 context.DeadlineExceeded错误被静默吞没的底层调用栈追踪
当 context.DeadlineExceeded 在中间件链中未被显式检查,它常被 nil 错误覆盖或忽略。
错误传播断点示例
func callWithTimeout() error {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, err := http.DefaultClient.Do(req.WithContext(ctx))
return err // 若err==context.DeadlineExceeded,此处直接返回,但上层可能只判err!=nil就log.Error("failed")而不区分类型
}
该函数返回原始 context.DeadlineExceeded,但若调用方仅做 if err != nil { log.Println(err) },则语义丢失——无法区分超时与网络故障。
静默吞没常见场景
- 中间件捕获错误后统一返回
fmt.Errorf("service unavailable") errors.Is(err, context.DeadlineExceeded)未被调用- defer 中 recover() 捕获 panic 后丢弃原始 error
| 位置 | 是否检查 errors.Is(err, context.DeadlineExceeded) |
后果 |
|---|---|---|
| gRPC Server | ❌ | 返回 UNKNOWN 状态 |
| HTTP Handler | ✅(推荐) | 返回 408 或自定义标头 |
graph TD
A[HTTP Request] –> B[WithTimeout Context]
B –> C[DB Query]
C –> D{err != nil?}
D –>|Yes| E[errors.Is(err, DeadlineExceeded)]
E –>|False| F[Log as failure]
E –>|True| G[Return 408 with retry-after]
2.3 retry.WithMaxRetries与context.Err()返回值的竞态条件复现实验
竞态触发场景
当 retry.WithMaxRetries 在重试过程中,context 恰在 retry.Do 内部调用 ctx.Err() 前被取消,将导致错误来源模糊:是重试耗尽?还是上下文提前超时?
复现代码片段
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
err := retry.Do(ctx, func() error {
time.Sleep(5 * time.Millisecond) // 模拟不稳定调用
return errors.New("transient failure")
}, retry.WithMaxRetries(3))
// 此处 err 可能为 context.DeadlineExceeded 或 transient failure,取决于时序
逻辑分析:
retry.Do在每次重试前检查ctx.Err(),但time.Sleep后的错误返回与ctx.Err()检查无同步保护。若ctx在Sleep结束瞬间超时,retry可能尚未进入下一次ctx.Err()判断,导致最终错误为原始 transient 错误而非context.DeadlineExceeded。
关键参数说明
WithMaxRetries(3):最多尝试 4 次(首次 + 3 次重试)ctx.Timeout = 10ms:总窗口极小,放大竞态概率
| 触发条件 | 错误类型优先级 |
|---|---|
| ctx.Err() 先于重试判定 | context.DeadlineExceeded |
| transient error 先返回 | 原始错误(如 "transient failure") |
时序依赖示意
graph TD
A[Start retry loop] --> B{ctx.Err() == nil?}
B -- Yes --> C[Execute op]
C --> D[Sleep 5ms]
D --> E[Return error]
B -- No --> F[Return ctx.Err()]
E --> G{Is this last attempt?}
G -- Yes --> H[Return op error]
G -- No --> I[Backoff & retry]
2.4 基于pprof与go tool trace的重试goroutine阻塞路径可视化诊断
当重试逻辑因锁竞争或 channel 阻塞陷入停滞,pprof 的 goroutine profile 可快速定位阻塞 goroutine 栈:
go tool pprof http://localhost:6060/debug/pprof/goroutine?debug=2
输出中筛选
RUNNABLE或WAITING状态的重试 goroutine,重点关注sync.(*Mutex).Lock、runtime.gopark或chan receive调用链。
结合 go tool trace 深入时序分析:
go tool trace -http=localhost:8080 trace.out
关键诊断路径
- 在 Trace UI 中搜索
retryHandler,观察其 goroutine 生命周期 - 查看“Synchronization”视图识别 mutex 争用热点
- 使用“Flame Graph”定位重试函数中耗时最长的阻塞调用点
常见阻塞模式对比
| 场景 | pprof 表现 | trace 中典型信号 |
|---|---|---|
| 互斥锁争用 | 多 goroutine 停在 Mutex.Lock |
Goroutine 长时间处于 Gwaiting,伴随 sync.Mutex 事件簇 |
| channel 满载 | 停在 chan send/recv |
“Network”或“Synchronization”视图中出现持续 chan park |
graph TD
A[重试goroutine启动] --> B{是否获取到资源锁?}
B -->|否| C[阻塞于 Mutex.Lock]
B -->|是| D[执行HTTP请求]
D --> E{响应失败?}
E -->|是| F[select等待退避timer或重试channel]
F -->|channel满| G[永久阻塞于 chan send]
2.5 自定义Context-aware重试器:绕过标准库边界限制的工程实践
标准重试库(如 tenacity 或 retrying)通常忽略调用上下文,导致在分布式事务、租户隔离或请求链路追踪场景中行为失准。
核心设计原则
- 每次重试携带
contextvars.Context快照 - 策略决策可读取
request_id、tenant_id、deadline_ms等动态上下文 - 避免闭包捕获导致的 context 泄漏
示例:带上下文感知的指数退避重试器
import asyncio
import contextvars
from tenacity import Retrying, retry_if_exception_type, stop_after_attempt
# 上下文变量声明
request_id = contextvars.ContextVar('request_id', default=None)
class ContextAwareRetrier:
def __init__(self, max_attempts=3):
self.max_attempts = max_attempts
def __call__(self, fn):
async def wrapper(*args, **kwargs):
ctx = contextvars.copy_context() # 捕获当前上下文快照
for attempt in Retrying(
stop=stop_after_attempt(self.max_attempts),
retry=retry_if_exception_type((ConnectionError, TimeoutError))
):
with attempt:
# 在每次重试中恢复原始上下文
token = contextvars.copy_context().run(lambda: None)
# ⚠️ 实际应使用 ctx.run(fn, *args, **kwargs),此处简化示意
return await fn(*args, **kwargs)
return wrapper
逻辑分析:
contextvars.copy_context()确保重试间不污染父上下文;ctx.run()可还原初始request_id和tenant_id,使日志关联与熔断策略具备租户粒度。参数max_attempts支持 per-request 覆盖,而非全局静态配置。
适用场景对比
| 场景 | 标准重试器 | Context-aware 重试器 |
|---|---|---|
| 多租户API调用 | ❌ 共享退避策略 | ✅ 按 tenant_id 独立计数 |
| 链路超时敏感操作 | ❌ 忽略 deadline | ✅ 动态检查 deadline_ms |
graph TD
A[发起重试] --> B{读取当前Context}
B --> C[提取request_id/tenant_id/deadline]
C --> D[决策:是否重试?退避多久?]
D --> E[执行函数]
E --> F{成功?}
F -->|否| A
F -->|是| G[返回结果]
第三章:retry.WithMaxRetries源码级失效归因
3.1 backoff.Retry调用链中maxRetries参数的丢失时机与位置定位
关键调用路径分析
backoff.Retry 接收 backoff.BackOff 接口实例,但不直接接收 maxRetries ——该参数实际由具体实现(如 backoff.WithMaxRetries)注入到 BackOff 实例内部状态中。
参数丢失的典型场景
- 调用方误传裸
backoff.NewExponentialBackOff()(无封装),其MaxRetries字段默认为(即无限重试); - 中间层包装函数未透传或重置
BackOff实例,导致MaxRetries被覆盖为零值。
// ❌ 错误:NewExponentialBackOff() 返回实例的 MaxRetries=0
bo := backoff.NewExponentialBackOff()
bo.MaxInterval = time.Second
err := backoff.Retry(operation, bo) // maxRetries 实际为 0 → 无限重试
此处
bo未显式设置MaxRetries,Retry函数内部仅读取bo.MaxRetries(始终为 0),参数在构造阶段即“丢失”。
修复方式对比
| 方式 | 代码示意 | 是否保留 maxRetries |
|---|---|---|
WithMaxRetries |
backoff.WithMaxRetries(bo, 3) |
✅ 显式注入 |
| 手动赋值 | bo.MaxRetries = 3 |
✅(需确保非零值) |
| 直接 New | backoff.NewExponentialBackOff() |
❌ 默认 0 |
graph TD
A[backoff.Retry] --> B{bo.MaxRetries == 0?}
B -->|Yes| C[无限重试 - 参数已丢失]
B -->|No| D[按设定次数终止]
3.2 retryable函数返回error时未校验context.Err()导致的无限重试漏洞
问题根源
当 retryable 函数仅检查错误类型而忽略 ctx.Err(),即使上下文已超时或取消,重试循环仍持续执行。
典型错误模式
func unreliableCall(ctx context.Context) error {
for i := 0; i < 3; i++ {
if err := doSomething(); err != nil {
time.Sleep(100 * time.Millisecond)
continue // ❌ 忽略 ctx.Err()
}
return nil
}
return errors.New("failed after retries")
}
逻辑分析:doSomething() 失败后未调用 select { case <-ctx.Done(): return ctx.Err() },导致 ctx.DeadlineExceeded 被绕过,触发无限重试(若重试间隔短且错误恒定)。
正确校验方式
- ✅ 每次循环前检查
ctx.Err() != nil - ✅ 在
select中同步监听ctx.Done() - ✅ 将
ctx.Err()作为最高优先级退出条件
| 错误场景 | 后果 |
|---|---|
ctx.Cancel() |
重试持续至手动 kill |
ctx.Timeout() |
超时后仍重试 N 次 |
3.3 标准库io包与net/http客户端在context取消后仍触发重试的隐蔽路径
问题根源:io.Copy 的非中断感知行为
当 http.Client 使用 context.WithTimeout 发起请求,且底层 io.Copy 在 context.Done() 触发后仍尝试从 response.Body 读取残留数据时,可能因 Read 返回 io.EOF 被误判为临时错误而触发重试逻辑(尤其在封装了 retry middleware 的场景中)。
关键隐蔽路径
net/http.Transport.RoundTrip完成响应后,response.Body.Close()可能被延迟调用io.Copy内部循环未检查context.Err(),仅依赖Read返回 error- 若
Body是*gzip.Reader等包装器,其Read可能在 EOF 后继续尝试解压,触发额外系统调用
示例:隐式重试触发点
resp, err := client.Do(req)
if err != nil { return err }
defer resp.Body.Close() // ← 此处 Close 可能晚于 context cancel
_, _ = io.Copy(io.Discard, resp.Body) // ← 不检查 ctx.Err(),EOF 后无感知
io.Copy使用copyBuffer,其循环仅终止于n == 0 && err != nil;context.Cancelled不会传播至Read调用,导致上层重试器将io.EOF误认为网络抖动。
修复策略对比
| 方案 | 是否阻断隐蔽重试 | 需修改调用方 | 说明 |
|---|---|---|---|
http.Request.WithContext(ctx) |
✅ | 否 | 仅作用于连接建立阶段 |
io.CopyN + 自定义 reader |
✅ | 是 | 需注入 ctx.Err() 检查 |
http.MaxIdleConnsPerHost = 0 |
❌ | 否 | 仅减少连接复用,不解决读取阶段问题 |
graph TD
A[Client.Do req] --> B[Transport.RoundTrip]
B --> C[resp.Body = &gzip.Reader{...}]
C --> D[io.Copy → Read]
D --> E{Read returns io.EOF?}
E -->|Yes| F[上层retry middleware 误判为 transient error]
E -->|No| G[正常结束]
第四章:构建健壮重试边界的工业级方案
4.1 Context-Aware RetryPolicy:融合deadline、cancel、maxRetries三重约束的设计模式
传统重试策略常孤立配置 maxRetries,易导致超时请求持续占用资源。Context-Aware RetryPolicy 将上下文感知能力注入重试决策核心,动态协同三重约束:
- deadline:全局截止时间(如
System.nanoTime() + 5_000_000_000L),保障端到端 SLO - cancel:外部取消信号(
CancellationException或AtomicBoolean),支持服务优雅下线 - maxRetries:最大尝试次数(如
3),防止指数退避失控
决策优先级逻辑
if (context.isCancelled()) throw new CancellationException();
if (nanoTime() > context.deadline()) throw new DeadlineExceededException();
if (retryCount >= context.maxRetries()) return false; // 不再重试
逻辑分析:按「取消 > 截止时间 > 次数」严格降序校验,确保高优约束即时生效;
nanoTime()避免系统时钟回拨风险;所有判断无锁、无副作用。
约束协同效果对比
| 约束组合 | 超时响应延迟 | 取消响应延迟 | 重试可控性 |
|---|---|---|---|
| 仅 maxRetries | 不可控 | 不支持 | 弱 |
| deadline + maxRetries | ≤ deadline | 不支持 | 中 |
| 三重约束融合 | ≤ deadline | 强 |
graph TD
A[Retry Attempt] --> B{isCancelled?}
B -->|Yes| C[Throw CancellationException]
B -->|No| D{Deadline Expired?}
D -->|Yes| E[Throw DeadlineExceededException]
D -->|No| F{retryCount ≥ maxRetries?}
F -->|Yes| G[Fail Fast]
F -->|No| H[Proceed with Backoff]
4.2 基于errgroup.WithContext的并发重试熔断与优雅退出实现
核心设计思想
errgroup.WithContext 天然支持上下文传播与首个错误返回,是构建可取消、可等待的并发任务的理想基座。结合指数退避重试与失败阈值熔断,可实现高鲁棒性服务调用。
熔断与重试协同逻辑
- 重试次数上限(如
maxRetries = 3)防止雪崩 - 每次失败后
time.Sleep(1 << uint(i) * time.Second)实现指数退避 - 错误率超阈值(如 50%)自动触发熔断,跳过后续请求
关键代码实现
func DoWithCircuitBreaker(ctx context.Context, endpoints []string) error {
g, ctx := errgroup.WithContext(ctx)
sem := make(chan struct{}, 2) // 并发限流为2
for _, ep := range endpoints {
ep := ep // 避免闭包变量捕获
g.Go(func() error {
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return ctx.Err()
}
var lastErr error
for i := 0; i < maxRetries; i++ {
if err := callEndpoint(ctx, ep); err == nil {
return nil
} else {
lastErr = err
if i < maxRetries-1 {
time.Sleep(time.Second << uint(i)) // 指数退避
}
}
}
return lastErr
})
}
return g.Wait() // 任一goroutine出错即返回,且所有goroutine受ctx统一取消
}
逻辑分析:
errgroup.WithContext统一管理子goroutine生命周期;sem通道实现轻量级并发控制;重试中ctx.Done()检查确保超时/取消时立即退出;g.Wait()自动聚合首个错误并终止所有待运行任务,实现真正优雅退出。
状态流转示意(熔断决策)
graph TD
A[请求开始] --> B{成功?}
B -->|是| C[返回结果]
B -->|否| D[计数失败+1]
D --> E{失败率 ≥ 阈值?}
E -->|是| F[进入熔断态]
E -->|否| G[按退避策略重试]
| 组件 | 作用 | 典型值 |
|---|---|---|
errgroup.WithContext |
协同取消 + 错误聚合 | 必选基础 |
time.Sleep(1<<i) |
避免重试风暴 | 起始1s,最大4s |
信号量通道 sem |
控制并发数防压垮下游 | 2~5 |
4.3 使用golang.org/x/time/rate实现带速率限制的退避重试控制器
核心设计思路
将速率限制(rate.Limiter)与指数退避(Exponential Backoff)耦合,既防止突发重试压垮下游,又避免固定间隔导致的资源浪费。
关键组件组合
rate.Limiter控制每秒最大重试请求数time.Sleep实现退避延迟,基于失败次数指数增长- 原子计数器跟踪连续失败次数
示例控制器实现
func NewRateLimitedBackoffController(r rate.Limit, burst int) *BackoffController {
return &BackoffController{
limiter: rate.NewLimiter(r, burst),
baseDelay: 100 * time.Millisecond,
maxDelay: 5 * time.Second,
}
}
type BackoffController struct {
limiter *rate.Limiter
baseDelay time.Duration
maxDelay time.Duration
}
func (c *BackoffController) Retry(attempt int, op func() error) error {
if !c.limiter.Allow() {
return fmt.Errorf("rate limit exceeded")
}
delay := time.Duration(math.Min(float64(c.baseDelay<<uint(attempt)), float64(c.maxDelay)))
time.Sleep(delay)
return op()
}
rate.NewLimiter(r, burst):r为每秒令牌生成速率(如10QPS),burst为突发允许的最大令牌数(缓冲区大小)。Allow()消耗一个令牌,失败则立即拒绝;Sleep()确保每次重试前等待动态计算的退避时长,避免竞态放大。
适用场景对比
| 场景 | 是否适用 | 说明 |
|---|---|---|
| 高频 API 调用限流 | ✅ | 防止触发下游熔断 |
| 弱网络下的消息投递 | ✅ | 结合退避提升最终一致性 |
| 单次关键事务重试 | ❌ | 应优先保障强一致性 |
4.4 在gRPC拦截器与HTTP中间件中注入上下文感知重试逻辑的标准化封装
统一重试策略抽象层
定义 RetryPolicy 接口,支持基于 context.Context 的动态决策(如 deadline、cancel、traceID):
type RetryPolicy struct {
MaxAttempts int
BackoffFunc func(attempt int) time.Duration
ShouldRetry func(ctx context.Context, err error) bool
}
ShouldRetry利用grpc_ctxtags或http.Request.Context()提取X-Request-ID和retryable=true标签,实现业务语义感知(如幂等写操作可重试,非幂等读则否)。
拦截器与中间件共用核心逻辑
| 组件 | 注入点 | 上下文提取方式 |
|---|---|---|
| gRPC Server | UnaryServerInterceptor | grpc_ctxtags.Extract(ctx) |
| HTTP Handler | Middleware | r.Context().Value("trace") |
重试流程控制(mermaid)
graph TD
A[请求进入] --> B{ShouldRetry?}
B -->|Yes| C[Sleep + Backoff]
B -->|No| D[返回错误]
C --> E[重试调用]
E --> B
第五章:从无限重试到确定性重试——架构思维升维
在某大型电商订单履约系统中,2023年双11前夕,支付回调服务因下游银行网关抖动,触发了无限制指数退避重试(最大重试10次,间隔 100ms → 5s),导致大量消息积压、线程池耗尽,最终引发雪崩。事后复盘发现:重试不是容错的万能解药,而是需要被精确建模的业务契约。
什么是确定性重试
确定性重试指在重试前即明确回答三个问题:是否可重试?重试几次?每次间隔多久? 并将答案固化为策略配置或代码契约。例如,对「库存扣减失败」场景,若返回 ERR_STOCK_NOT_ENOUGH,则立即终止重试并触发补偿;若返回 ERR_NETWORK_TIMEOUT,则执行最多3次、间隔 200ms/400ms/800ms 的退避重试。
策略驱动的重试引擎实现
我们基于 Resilience4j 改造出策略注册中心,支持 YAML 声明式定义:
retry-policies:
payment-callback:
max-attempts: 3
wait-duration: 200ms
retry-on-exceptions:
- "java.net.SocketTimeoutException"
- "org.springframework.web.client.ResourceAccessException"
ignore-on-exceptions:
- "com.example.biz.PaymentAlreadyConfirmedException"
与 Saga 模式的协同演进
当重试边界被清晰划定后,长事务拆解自然浮现。如下图所示,原单体支付流程被重构为可验证的 Saga 链路:
graph LR
A[支付请求] --> B[扣减余额]
B --> C{余额足够?}
C -->|是| D[冻结库存]
C -->|否| E[返回余额不足]
D --> F{库存可用?}
F -->|是| G[生成订单]
F -->|否| H[释放余额]
G --> I[通知履约]
H --> J[发送告警]
生产环境灰度验证数据
我们在灰度集群中对比两种模式(2024Q1,日均订单量 800 万):
| 指标 | 无限重试(旧) | 确定性重试(新) |
|---|---|---|
| 平均重试次数/请求 | 4.7 | 1.2 |
| 因重试引发的超时率 | 12.3% | 0.8% |
| 线程阻塞峰值 | 982 线程 | 47 线程 |
| 重试后最终成功占比 | 61.5% | 89.2% |
运维可观测性增强
重试行为不再隐式发生,所有重试动作自动注入 OpenTelemetry Trace 标签:
retry.attempt: 当前第几次重试retry.policy: 绑定策略名(如payment-callback)retry.decision:ALLOWED/ABORTED/IGNORED
运维平台据此构建「重试健康度看板」,实时监控各策略的失败根因分布,例如发现 inventory-lock 策略中 73% 的 ABORTED 事件关联 ItemNotInStockException,推动前端增加预占校验。
架构决策的显性化沉淀
团队建立《重试策略基线库》,强制要求 PR 中附带 retry-decision.md 文档片段:
场景:用户地址变更同步至物流中台
不可重试异常:AddressInvalidFormatError(格式错误需人工介入)
可重试异常:NetworkUnreachable(限3次,固定间隔1s)
补偿机制:若重试失败,写入address_sync_dead_letter表,由定时任务每5分钟扫描并推送企业微信告警
该文档随代码提交,经架构委员会评审后合并,成为后续服务演进的约束依据。
