第一章:Go逻辑测试安全红线总览
在Go语言工程实践中,逻辑测试不仅是验证功能正确性的手段,更是保障系统安全边界的首道防线。忽视测试中的安全约束,可能导致未被覆盖的越界访问、竞态条件、敏感数据泄露或权限绕过等高危问题。以下列出开发与测试过程中必须坚守的核心安全红线。
测试数据必须隔离且不可污染生产环境
所有单元测试和集成测试应使用内存数据库(如 sqlmock)、临时文件路径(os.MkdirTemp("", "test-*"))或纯内存结构(如 sync.Map),严禁硬编码指向 /etc/passwd、/var/log 或真实数据库连接字符串。示例:
func TestUserAuth_WithMockDB(t *testing.T) {
db, mock, err := sqlmock.New() // 创建隔离的SQL模拟器
if err != nil {
t.Fatal(err)
}
defer db.Close() // 确保资源释放,避免泄漏
// 预期查询不触发真实I/O
mock.ExpectQuery("SELECT password_hash FROM users WHERE email = ?").
WithArgs("admin@test.com").
WillReturnRows(sqlmock.NewRows([]string{"password_hash"}).AddRow("$2a$10$..."))
// 执行待测逻辑
_, err = Authenticate(db, "admin@test.com", "secret123")
if err != nil {
t.Error("Authentication should not fail on mocked DB")
}
}
并发测试需显式检测竞态条件
启用 -race 标志是强制要求,任何CI流水线中缺失该标志的测试均视为无效。运行命令如下:
go test -race -vet=atomic ./...
若发现 WARNING: DATA RACE,须立即重构为 sync.Mutex、sync/atomic 或 chan 控制访问,禁止依赖“概率性不触发”的侥幸逻辑。
敏感字段不得出现在测试日志或错误消息中
测试断言失败时,禁止直接打印明文密码、token、私钥等内容。应使用掩码工具处理:
| 原始值 | 掩码后输出 | 工具建议 |
|---|---|---|
"sk_live_abcd1234..." |
"sk_live_****1234..." |
strings.ReplaceAll(val, "sk_live_", "sk_live_****") |
[]byte{0x01, 0xFF, ...} |
[0x00 0x00 ...] (len=32) |
bytes.Repeat([]byte{0}, len(b)) |
所有测试用例必须通过 go vet -tags=test 检查,确保无未使用的变量、空分支及潜在panic点。安全不是附加项,而是测试存在的前提。
第二章:Mock滥用导致的控制流劫持风险
2.1 Go test 中 interface mock 的隐式契约破坏(含 go:generate 与 mockgen 冲突复现)
隐式契约的脆弱性
Go 接口无需显式声明实现,仅靠方法签名匹配即视为满足契约。当被 mock 的接口后续新增方法,而 mock 实现未同步更新时,编译仍通过,但运行时 panic。
// user_service.go
type UserService interface {
GetByID(id int) (*User, error)
// ⚠️ 后续新增:Delete(id int) error —— mock 未实现!
}
此处
mockgen生成的 mock 仍只含GetByID,调用Delete会触发panic("unimplemented"),因 mock 实现未随接口演化。
go:generate 与 mockgen 的冲突场景
| 现象 | 原因 |
|---|---|
go generate 未触发 mock 更新 |
//go:generate mockgen -source=user_service.go 被注释或路径变更 |
| 生成 mock 缺失新方法 | mockgen 缓存旧 AST 或未重读源文件 |
# 手动验证 mock 完整性
mockgen -source=user_service.go -destination=mocks/user_mock.go -package=mocks
必须确保
go:generate注释活跃且路径精确;否则 mock 始终滞后于接口定义。
graph TD A[接口变更] –> B{mockgen 是否重执行?} B –>|否| C[Mock 缺失方法] B –>|是| D[Mock 同步更新] C –> E[测试通过但运行时 panic]
2.2 基于 testify/mock 的方法调用顺序伪造引发的状态机越界(含 CVE-2023-27182 复现实验)
数据同步机制
CVE-2023-27182 根源于状态机对 Start() → Sync() → Stop() 严格时序的依赖,而 testify/mock 允许任意重排调用序列。
复现关键代码
mockDB := new(MockDB)
mockDB.On("Start").Return(nil) // ① 正常启动
mockDB.On("Sync").Return(nil) // ② 同步
mockDB.On("Stop").Return(nil) // ③ 正常终止
// ❌ 攻击者注入非法序列:
mockDB.On("Sync").Once().Return(nil) // Sync 在 Start 前被触发!
mockDB.On("Start").Once().Return(nil)
逻辑分析:
Sync()调用前未校验state == Running,直接访问未初始化的缓冲区指针,触发 panic 或内存越界读。参数Once()强制首次调用即匹配,绕过状态守卫。
状态跃迁违规路径
| 步骤 | 实际调用 | 预期状态 | 实际状态 | 后果 |
|---|---|---|---|---|
| 1 | Sync() |
Idle |
Idle |
空指针解引用 |
| 2 | Start() |
Idle |
Running |
已晚于崩溃 |
graph TD
A[Idle] -->|Start| B[Running]
B -->|Sync| C[Syncing]
C -->|Stop| D[Idle]
A -->|Sync ❌| E[Crash]
2.3 HTTP handler 层 mock 绕过中间件校验链导致权限逻辑坍塌(含 Gin/Echo 框架实测对比)
当单元测试中直接调用 handler(c *gin.Context) 或 handler(echo.Context) 而未构建完整中间件链时,JWT 鉴权、RBAC 检查等前置中间件被跳过,权限上下文(如 c.Get("user_id"))为空,导致 handler 内部逻辑误判为“匿名可操作”。
Gin 中典型绕过示例
// ❌ 错误:手动构造 context,跳过 Use(authMiddleware(), rbacMiddleware())
req, _ := http.NewRequest("GET", "/api/admin/users", nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
handler(c) // authMiddleware 根本未执行!
此调用绕过整个 Gin 中间件栈,
c.MustGet("claims")panic,handler 依赖的userID := uint(c.GetInt64("user_id"))取到 0,触发越权路径。
Echo 对比表现
| 框架 | 默认测试方式是否隐式加载中间件 | e.NewContext(req, rec) 后调用 handler 的权限上下文状态 |
|---|---|---|
| Gin | 否(需显式 engine.ServeHTTP()) |
空(无中间件注入) |
| Echo | 否(同理需 e.ServeHTTP()) |
空(c.Get("user") == nil) |
安全修复要点
- ✅ 始终通过
engine.ServeHTTP()触发完整中间件链 - ✅ 在测试中使用
e.NewContext()后手动c.Set("user", &User{ID: 1, Role: "admin"})补全上下文 - ❌ 禁止裸调 handler 函数
graph TD
A[测试发起] --> B{调用方式}
B -->|handler(c)| C[跳过所有中间件]
B -->|engine.ServeHTTP| D[执行完整校验链]
C --> E[权限上下文缺失 → 逻辑坍塌]
D --> F[鉴权/审计/限流全部生效]
2.4 time.Now() 与 context.WithDeadline 的 mock 同步失配引发超时误判(含 race detector 验证路径)
数据同步机制
当单元测试中同时 mock time.Now() 和使用 context.WithDeadline(parent, deadline) 时,若 mock 时间未同步更新 deadline 计算基准,会导致 ctx.Err() 提前返回 context.DeadlineExceeded。
典型失配代码
// 错误示例:mock Now() 但未重算 deadline
clock := &mockClock{now: time.Unix(100, 0)}
timeNow = clock.Now // 覆盖全局 time.Now
ctx, cancel := context.WithDeadline(context.Background(), time.Unix(150, 0))
// ⚠️ deadline 仍基于真实系统时钟解析,与 mock 不一致
逻辑分析:
WithDeadline内部调用time.Until(deadline),该函数依赖真实time.Now();若仅 mock 外部调用点,context包内未感知,造成时间视图分裂。
验证路径
启用 -race 可捕获 time.Now() 替换引发的非同步读写竞争(如并发 goroutine 中混用 mock 与原生时钟)。
| 检测项 | 是否触发 race | 原因 |
|---|---|---|
| 并发调用 mock.Now + time.Sleep | 是 | mockClock.now 无锁访问 |
| 单 goroutine 顺序执行 | 否 | 无竞态资源 |
2.5 数据库事务 mock 忽略 isolation level 导致并发一致性漏洞(含 sqlmock + pgx 实例反模式分析)
问题根源:isolation level 在 mock 中被静默忽略
sqlmock 默认不校验事务隔离级别,调用 BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) 后,mock 仍返回普通事务——实际语义丢失。
反模式代码示例
// ❌ 错误:mock 不校验 isolation level,测试通过但生产环境失效
mock.ExpectBegin().WithIsolationLevel(sql.LevelRepeatableRead) // ← sqlmock 不支持此断言!
tx, _ := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})
sqlmock.ExpectBegin()无WithIsolationLevel方法,该调用被完全忽略;pgx 生产环境将启用真正可重复读,而 mock 始终模拟ReadCommitted,导致幻读逻辑未被覆盖。
隔离级别兼容性对比
| 驱动 | 支持 LevelRepeatableRead |
mock 是否校验 | 生产行为差异 |
|---|---|---|---|
pgx/v5 |
✅(映射为 SERIALIZABLE) |
❌(sqlmock 忽略) | 幻读暴露风险 |
database/sql + pq |
✅(需 backend 支持) | ❌ | 同上 |
正确应对路径
- 使用
sqlmock.NewWithQueryMatcher(sqlmock.QueryMatcherEqual)+ 自定义QueryMatcher拦截BEGIN TRANSACTION ISOLATION LEVEL... - 或改用
dockertest启动真实 PostgreSQL 容器进行集成测试。
第三章:Test Double 语义失真引发的业务逻辑漂移
3.1 依赖倒置失效:mock 实现违背接口 Liskov 替换原则的静态检测方案
当 mock 类擅自修改方法签名、抛出未声明异常或弱化前置条件时,即违反 Liskov 替换原则(LSP),导致依赖倒置(DIP)形同虚设——高层模块看似依赖抽象,实则被具体 mock 行为绑架。
静态检测核心维度
- 方法签名一致性(参数类型、返回类型、
throws声明) - 不变量守卫(如
@NonNull、@Min(1)等契约注解) - 异常谱系兼容性(mock 不得引入父接口未声明的受检异常)
示例:违规 mock 片段
// 接口定义
public interface PaymentProcessor {
Result charge(@NotNull String orderId, BigDecimal amount) throws InsufficientBalanceException;
}
// ❌ 违反 LSP 的 mock 实现(静态检测应报错)
public class UnsafeMockProcessor implements PaymentProcessor {
@Override
public Result charge(String orderId, BigDecimal amount) { // 缺失 throws 声明
if (amount == null) throw new RuntimeException("NPE masked"); // 引入新异常类型
return Result.success();
}
}
该实现破坏了调用方对异常契约的静态推断能力,编译器无法捕获 RuntimeException 意外逃逸,且 @NotNull 约束在 mock 中被静默忽略。
检测规则映射表
| 检查项 | 接口契约要求 | mock 实现风险行为 |
|---|---|---|
| 异常声明 | 必须精确匹配 throws |
擅自省略或扩展异常类型 |
| 参数约束注解 | 运行时/编译时生效 | 忽略 @NotNull 等校验逻辑 |
graph TD
A[源码解析] --> B{方法签名比对}
B --> C[异常类型谱系校验]
B --> D[注解继承性分析]
C & D --> E[生成 LSP 违规报告]
3.2 异步事件 mock 缺失 delivery guarantee 导致最终一致性断言失效
数据同步机制
典型场景:订单服务发布 OrderCreated 事件,库存服务异步消费并扣减库存。测试中使用内存队列 mock,但未模拟重试、乱序或丢包。
关键缺陷暴露
- Mock 不保证至少一次(at-least-once)投递
- 无幂等校验时,重复事件引发超扣;无重试机制时,单次失败即丢失更新
// 错误示例:无重试 + 无确认的 mock 生产者
mockEventBus.publish(event); // ✗ 返回 void,不抛异常,不记录投递状态
逻辑分析:该调用仅将事件加入内存列表,若消费者未轮询或进程提前退出,事件静默丢失;参数 event 无 traceId 或 retryCount 字段,无法追踪生命周期。
对比:真实消息中间件保障能力
| 特性 | Kafka / RabbitMQ | 当前 Mock |
|---|---|---|
| 消息持久化 | ✓ | ✗ |
| ACK 确认机制 | ✓ | ✗ |
| 重试/死信队列支持 | ✓ | ✗ |
graph TD
A[OrderService] -->|publish| B[Mock EventBus]
B --> C[In-Memory Queue]
C --> D[InventoryService consume?]
D -.->|无ACK反馈,无超时重发| E[事件可能永不抵达]
3.3 第三方 SDK mock 返回非幂等响应引发重试逻辑误触发(含 Stripe/Alipay SDK 模拟陷阱)
问题根源:Mock 行为违背真实 SDK 语义
真实 Stripe/Alipay SDK 在网络超时或 502/504 时返回明确错误(如 StripeConnectionError),但常见 mock 实现(如 Jest mock 或 WireMock)常统一返回 200 OK + { success: true },掩盖了「请求可能未抵达服务端」的关键语义。
非幂等响应示例
// ❌ 危险 mock:每次调用都生成新 order_id,破坏幂等性
jest.mock('stripe', () => ({
charges: {
create: jest.fn().mockResolvedValue({
id: `ch_${Math.random().toString(36).substr(2, 9)}`, // 非幂等!
status: 'succeeded',
amount: 1000,
}),
},
}));
逻辑分析:
Math.random()导致每次 mock 调用返回不同id,而真实 Stripe 在幂等键(Idempotency-Key)重复时返回相同响应体。重试逻辑因检测到「响应 ID 变化」误判为「首次成功」,造成重复扣款。
关键参数对照表
| 参数 | 真实 Stripe 行为 | 常见 Mock 缺陷 |
|---|---|---|
id |
幂等键相同时严格复用 | 每次随机生成 |
created |
时间戳固定 | 动态 Date.now() |
| HTTP 状态码 | 409 Conflict(幂等冲突) |
恒为 200 |
正确模拟策略
// ✅ 幂等感知 mock:基于 idempotency_key 决定响应
const mockCharges = new Map();
jest.mock('stripe', () => ({
charges: {
create: jest.fn().mockImplementation((params) => {
const key = params.idempotency_key;
if (mockCharges.has(key)) return Promise.resolve(mockCharges.get(key));
const resp = { id: `ch_${key.slice(0,8)}`, status: 'succeeded' };
mockCharges.set(key, resp);
return Promise.resolve(resp);
}),
},
}));
第四章:测试基础设施层未审计的 Mock 逃逸通道
4.1 go:embed 文件 mock 绕过真实资源加载路径导致配置解析逻辑偏差
当使用 go:embed 嵌入配置文件(如 config.yaml)并配合测试 mock 时,若在单元测试中通过 io/fs.FS 替换为内存文件系统(如 fstest.MapFS),会绕过 embed 编译期绑定的真实路径,导致 filepath.Dir()、filepath.Join() 等路径推导失效。
配置解析路径依赖陷阱
// config.go
import _ "embed"
//go:embed config.yaml
var configData []byte
func LoadConfig() (*Config, error) {
dir := filepath.Dir("config.yaml") // ❌ 永远返回 ".",非嵌入上下文路径
base := filepath.Join(dir, "base.yaml") // 错误拼接,实际 embed 无目录结构
// ...
}
filepath.Dir("config.yaml")在 embed 场景下不反映编译时包路径,mock FS 更彻底剥离了路径语义,使相对引用逻辑失效。
典型影响对比
| 场景 | filepath.Dir("f.yaml") |
是否触发 base.yaml 加载 |
|---|---|---|
| 真实 embed | "." |
否(硬编码路径断裂) |
| mock MapFS | "." |
否(且 base.yaml 不在 FS 中) |
graph TD
A[LoadConfig] --> B{调用 filepath.Dir}
B --> C["返回 \".\""]
C --> D[Join \"./base.yaml\"]
D --> E[Open 失败:no such file]
4.2 net/http/httptest.Server 与 real HTTP client 混用引发 TLS 握手状态污染
httptest.Server 默认使用 http:// 协议,无 TLS 层;若测试中误将其地址(如 http://127.0.0.1:34567)传给启用了 TLS 的真实客户端(如 http.DefaultClient.Transport = &http.Transport{TLSClientConfig: ...}),客户端仍会尝试发起 TLS 握手——向明文 HTTP 服务发送 ClientHello。
关键现象
- Go 的
crypto/tls库在失败握手后不重置连接底层读写缓冲区 - 后续复用该连接(如 HTTP/1.1 keep-alive)时,残留的 TLS 记录碎片被当作 HTTP 请求解析 →
malformed HTTP request
复现代码片段
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
}))
defer ts.Close()
// ❌ 错误:real client 强制 TLS,但 ts 无 TLS
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
_, _ = client.Get(ts.URL) // 触发 TLS 握手污染
逻辑分析:
ts.URL返回http://...,但TLSClientConfig强制启用 TLS;net.Conn缓冲区残留0x16 0x03 0x01...(ClientHello),服务端解析为非法 HTTP 方法,污染后续请求流。
| 成分 | 协议栈位置 | 是否参与 TLS 握手 | 风险点 |
|---|---|---|---|
httptest.Server |
应用层(纯 HTTP) | 否 | 无法响应 TLS 记录 |
http.Client with TLSClientConfig |
传输层前置 | 是 | 向非 TLS 端点发起握手 |
graph TD
A[Real HTTP Client] -->|Sends TLS ClientHello| B[httptest.Server]
B -->|Reads raw bytes as HTTP| C[Parse error: “\x16\x03\x01...”]
C --> D[Connection buffer polluted]
D --> E[Next HTTP request fails]
4.3 os/exec.CommandContext mock 忽略 signal propagation 导致子进程生命周期误判
根本诱因:mock 未模拟 os.Process.Signal 行为
当使用 gomock 或 testify/mock 替换 exec.Cmd 时,常忽略对 Process.Signal() 的模拟——而 CommandContext 正依赖该方法向子进程转发 ctx.Done() 触发的 os.Interrupt 或 os.Kill。
典型错误 mock 示例
// 错误:未实现 Signal 方法,或直接返回 nil 错误
cmd := &exec.Cmd{
Process: &os.Process{Pid: 123},
}
// 缺失:cmd.Process.Signal(syscall.SIGTERM) → 实际应触发子进程退出
该 mock 使 CommandContext(ctx, ...).Run() 在 ctx 超时后不终止子进程,导致 Wait() 永久阻塞或误判为“已正常退出”。
信号传播链断裂影响对比
| 场景 | 真实进程行为 | Mock 缺失 Signal 的表现 |
|---|---|---|
ctx 超时(5s) |
子进程收到 SIGTERM → 优雅退出 → Wait() 返回 *exec.ExitError |
Wait() 返回 nil(伪成功),进程持续运行 |
修复要点
- mock
os.Process并实现Signal(sig syscall.Signal) error,对SIGTERM/SIGKILL做状态标记; - 在测试中验证
cmd.Wait()是否在ctx取消后返回非-nil error。
4.4 sync.Map / atomic.Value mock 屏蔽竞态本质,掩盖 data race 真实暴露窗口
数据同步机制的表象与陷阱
sync.Map 和 atomic.Value 常被误用作“线程安全替代品”,但二者不提供内存可见性保障的完整语义——仅对自身字段操作原子,不约束关联数据的读写顺序。
典型误用示例
var cfg atomic.Value // 存储 *Config 结构体指针
type Config struct { Timeout int; Host string }
// goroutine A:更新配置
cfg.Store(&Config{Timeout: 5, Host: "api.example.com"})
// goroutine B:读取后直接解引用字段(竞态点!)
c := cfg.Load().(*Config)
fmt.Println(c.Timeout) // ✅ 原子读指针
fmt.Println(c.Host) // ❌ 非原子读 c.Host —— data race!
逻辑分析:
atomic.Value.Store/Load仅保证指针本身读写原子;c.Host访问触发对堆内存的非同步读,Go Race Detector 可能因内存布局/调度偶然漏报,形成虚假安全感。
关键对比:真正同步 vs 表面安全
| 方案 | 保护范围 | 是否防止 c.Host 竞态 |
Race Detector 敏感度 |
|---|---|---|---|
sync.RWMutex |
整个结构体字段 | ✅ | 高(稳定触发) |
atomic.Value |
仅存储/加载指针 | ❌ | 低(依赖内存对齐与时机) |
graph TD
A[goroutine A 写入 newCfg] -->|atomic.Store| B[atomic.Value]
C[goroutine B Load 得到 *newCfg] --> D[并发读 newCfg.Host]
D --> E[无同步屏障 → data race]
第五章:构建可验证、可审计、可回溯的 Go 测试治理范式
在金融级支付网关项目 payd 的 CI/CD 流水线升级中,团队遭遇了测试信任危机:PR 合并后线上偶发 0.3% 的幂等性校验失败,但本地 go test 和 Jenkins 单次运行均无法复现。根本原因在于测试环境状态漂移、覆盖率盲区未被监控、历史测试结果缺乏结构化归档——这倒逼我们设计一套可验证、可审计、可回溯的 Go 测试治理范式。
测试元数据标准化注入
所有测试函数通过 testing.T 的 Helper() 与 Setenv() 配合注入结构化元信息:
func TestTransfer_WithIdempotentKey(t *testing.T) {
t.Setenv("TEST_CASE_ID", "PAY-2024-087")
t.Setenv("TEST_OWNER", "backend-auth-team")
t.Setenv("TEST_RISK_LEVEL", "HIGH")
// ... 实际测试逻辑
}
CI 脚本在 go test -json 输出流中提取这些字段,写入统一审计日志表 test_audit_log(PostgreSQL),字段包括 run_id, case_id, commit_hash, go_version, os_arch, duration_ms, exit_code, stderr_truncated。
可验证的覆盖率基线管控
采用 go tool cover 生成 coverage.out 后,通过自研工具 covguard 强制校验三类阈值: |
模块类型 | 行覆盖率最低要求 | 分支覆盖率最低要求 | 关键路径覆盖率要求 |
|---|---|---|---|---|
| 核心交易引擎 | 92% | 85% | Transfer.Validate() 必须 100% |
|
| 幂等性中间件 | 96% | 90% | IdempotencyStore.Get() 必须 100% |
|
| 日志上报模块 | 80% | 70% | 无 |
若任一阈值未达标,make verify-test 目标返回非零码并阻断合并。
全链路测试事件溯源
基于 OpenTelemetry 构建测试执行追踪链:每个 TestXxx 启动独立 trace.Span,自动关联 Git 提交哈希、K8s Pod UID、Jenkins Build Number。使用 Mermaid 渲染典型失败链路:
flowchart LR
A[go test -run TestRefund] --> B[Span: TestRefund]
B --> C[Span: DB.BeginTx]
C --> D[Span: Redis.SetNX idempotent_key]
D --> E[Span: HTTP POST /v1/refund]
E --> F[Span: Kafka.Publish refund_event]
style F stroke:#e63946,stroke-width:2px
当 refund_event 发送失败时,可通过 trace_id 在 Jaeger 中下钻至具体 Kafka broker 网络超时日志,并关联该次测试运行的完整容器日志、内核 dmesg 缓冲区快照及 Prometheus 测试期间的 go_goroutines 指标突变曲线。
审计友好的测试报告归档
每次 go test 执行后,生成带数字签名的 test-report-v1.jsonl.gz 文件,内容为每行一个 JSON 对象(符合 JSONL 规范),包含 test_name, start_time_unix_nano, end_time_unix_nano, panic_stack, coverage_delta 等字段。该文件经 cosign sign 签名后推送到专用 OCI 仓库 ghcr.io/payd/test-reports,镜像 tag 格式为 commit-<sha256>-<timestamp>,确保任意历史版本均可按需拉取、验证、比对。
回溯驱动的缺陷根因分析
当线上发现 ErrIdempotentConflict 错误时,运维人员执行:
test-backtrack --error-code ErrIdempotentConflict \
--from-time "2024-06-15T08:00:00Z" \
--to-time "2024-06-15T09:00:00Z"
工具自动检索所有匹配时间窗口内的测试报告,筛选出 TestTransfer_WithIdempotentKey 执行记录,比对其 coverage.out 差异、t.Setenv("TEST_RISK_LEVEL") 值、以及关联的 git diff HEAD~3 HEAD -- internal/idempotent/ 变更,最终定位到某次重构中遗漏了 Redis TTL 设置导致的竞态窗口扩大。
