Skip to content

fix(multi-replica): 修复多副本部署下的状态互相破坏与重复执行 - #3

Open
Pengap wants to merge 2 commits into
mainfrom
fix/multi-replica-safety
Open

fix(multi-replica): 修复多副本部署下的状态互相破坏与重复执行#3
Pengap wants to merge 2 commits into
mainfrom
fix/multi-replica-safety

Conversation

@Pengap

@Pengap Pengap commented Aug 14, 2026

Copy link
Copy Markdown

背景

排查「这个项目能不能上 K8s、能不能 replicas>1」时发现:底子是按共享 PostgreSQL + Redis 设计的(迁移有 advisory lock、JWT 密钥落库收敛、扣费原子、约 25 个后台任务已有选主),但仍有六类问题会在多副本下真实出错。

在本地用 3–4 副本共享同一套 PG + Redis 的环境逐项复现并修复。修复前 2/14 项验收通过,修复后 14/14。

已 rebase 到最新 main。BackupService 的定时备份选主 main 上已有既有提交修复,本 PR 不重复,只保留 main 尚未覆盖的部分。

修了什么

1. 启动清扫会删掉存活同伴的在途并发槽 ⚠️ 最严重

ProvideConcurrencyService 在每个进程启动时无条件执行一次全局清扫,判据是「成员前缀 != 本进程随机前缀」。这个近似只在单实例下成立——同伴 Pod 的在途槽位前缀同样 != 本进程前缀,会被一并删除。每次扩容 / 滚动更新 / OOM 重启都会架空账号与用户的并发上限,造成超发准入。

「持有进程是否还活着」这个事实只有 Redis 看得到(唯一能观察到全部实例的地方),所以把它显式建模在那一层:

  • 新增 concurrency:instances 存活实例注册表(ZSET,member = 进程前缀,score = 心跳时间)
  • 清扫改为只删「不属于任何存活实例」的成员,前缀解析在 Lua 内完成
  • 新增独立的 20s 心跳循环 —— 不能挂在 StartSlotCleanupWorker 上,那个间隔可配置甚至可设 0 关掉,心跳一断本进程的槽位就会被同伴回收
  • 等待计数只在「本实例是唯一存活者」时才清零

代价:崩溃副本的槽位改为等心跳超时(90s)后回收,不再是重启即清。期间无人重启时由 acquire 路径的 ZREMRANGEBYSCORE 在一个 slot TTL 内兜底。

2. 两个周期性任务没有跨实例互斥

服务 多副本后果
ScheduledTestRunnerService 每个到期计划被 N 个副本各跑一次 → N 倍真实上游计费请求
ChannelMonitorRunner N 倍探测上游、N 倍历史行

渠道监控按 monitor 维度加锁,不同监控仍可分散到不同副本并行,避免整体锁把探测串行化到单个副本。

3. 启动回收会删掉同伴正在跑的备份产物

recoverStaleRecords 把所有 running 记录判定为「我上次崩溃留下的」,会改写同伴正在进行的备份记录并删除其 S3 对象。

BackupRecord 增加 OwnerInstanceID,回收规则按可确定性分级:空 owner(历史记录)与本实例的记录立即回收;同伴的记录只有超过 2 小时才能断定已死,StartedAt 无法解析时不动。不对称是刻意的 —— 误判「还活着」只是记录多挂一会儿,误判「已死」是删数据。

4. 四家管理端 OAuth 的 PKCE 会话只在进程内存

Claude / OpenAI / Gemini / Antigravity 的 generate-auth-urlexchange-code 是两次独立 HTTP 请求,中间隔着用户在浏览器授权的数分钟,多副本下第二次会落到任意副本,成功率退化为 1/N

抽出 redissession.Mirror 泛型(共享 Redis + 写失败降级本地),四个包接入。Grok 通道此前已用同款方案修复,这四家是漏的。

5. 冷启动引导竞态导致 CrashLoopBackOff

多副本空库同时启动时,AUTO_SETUPCREATE DATABASE 与建管理员都是 check-then-act,输家撞唯一约束后经 log.Fatalf 直接退出。验收过程中真实撞到:

Auto setup failed: admin user creation failed:
pq: duplicate key value violates unique constraint "users_email_unique_active"

唯一约束本来就是这件事的仲裁者,之前只是把它的裁决误读成了错误:INSERT 改为 ON CONFLICT DO NOTHING 并按影响行数判定,CREATE DATABASE 容忍 42P04。

6. 进程内快照无跨实例失效 + 优雅退出不可配

  • 渠道定价 / 模型映射 / 模型白名单与约 10 项系统设置,只在处理管理请求的那个副本立即生效,其余副本要等 TTL(10 分钟 / 60 秒)。对计费数据这是正确性问题:同一用户连续两个请求打到不同副本会被按不同单价扣费。新增 snapshot:invalidate:<topic> 广播(订阅端只做本地失效,绝不回广播,否则消息会在副本间无限回弹)。
  • 优雅退出超时由硬编码 5 秒改为可配置(SERVER_SHUTDOWN_TIMEOUT,默认 30s),新增 SERVER_SHUTDOWN_DRAIN_DELAY/readyz。收到 SIGTERM 后 /readyz 立即返回 503 供 K8s 摘除 endpoint,而 /health 保持 200 —— 它是 liveness 与 Dockerfile HEALTHCHECK 在用,关闭期翻转会被 kubelet 提前杀死,在途流反而截断得更早。

验收

本地 3–4 副本共享同一套 PG + Redis:

检查项 修复前 修复后
3 副本空库冷启动全部存活 概率性 CrashLoop
存活同伴的在途并发槽 ❌ 被清空 ✅ 保留
已死进程残留回收
定时任务跨副本选主 ❌ 无 ✅ 4 个竞选者争同一把锁
实例心跳 ❌ 无 ✅ 100 次 / 95 秒
OAuth 会话跨副本可用 ❌ session not found ✅ 副本2 带 code_verifier 调上游
快照失效广播订阅 ❌ 无 ✅ 4/4
关闭期 /readyz → 503 ❌ 无端点
合计 2/14 14/14

每一项都是先写失败测试复现、再修、再验证。新增回归测试覆盖:并发槽多实例语义(integration,真实 Redis)、实例心跳、任务选主、备份记录归属、四家 OAuth 会话跨实例、快照失效广播、就绪探针、引导幂等。

rebase 到最新 main 后重新验证:go build ./... + unit + no-tag + integration 全部通过。

兼容性

单实例部署行为不变 —— 未注入 Redis 时全部退化为原有的纯本地语义。

多副本部署要点

readinessProbe:  { httpGet: { path: /readyz, port: 8080 } }
livenessProbe:   { httpGet: { path: /health, port: 8080 } }
terminationGracePeriodSeconds: 60   # > SHUTDOWN_TIMEOUT + DRAIN_DELAY
env:
  - SERVER_SHUTDOWN_DRAIN_DELAY=8
  - SERVER_SHUTDOWN_TIMEOUT=30      # 跑长流式请求的部署调大
  - JWT_SECRET / TOTP_ENCRYPTION_KEY  # 必须显式注入
  - AUTO_SETUP=true 或 SKIP_SETUP=true
  - DATABASE_MAX_OPEN_CONNS × 副本数 不能超过 PG max_connections

本次未处理

进程内限流器(图片生成并发、防 API Key 爆破、Grok team 级限流)仍是每进程一份,实际全局上限 = 配置值 × 副本数。部署时把这几个值除以副本数即可,改成 Redis 计数器是个独立的重构。

backend-security / frontend-security 两个 CI job 的失败与本 PR 无关:前者是 govulncheck 报 go1.26.5 标准库漏洞(fixed in 1.26.6,涉及的调用点本 PR 一个都没碰),后者是 nanoid 的新公告缺少 audit 例外。两者都是 advisory 数据库随时间更新导致的既有问题。

在真实多副本环境(3-4 副本共享同一套 PostgreSQL + Redis)复现并修复了
六类问题。修复前 2/14 项验收通过,修复后 14/14。

## 1. 启动清扫会删掉存活同伴的在途并发槽

ProvideConcurrencyService 在每个进程启动时无条件执行一次全局清扫,判据是
「成员前缀 != 本进程随机前缀」。这个近似只在单实例下成立:同伴 Pod 的在途
槽位前缀同样 != 本进程前缀,会被一并删除。后果是每次扩容/滚动更新/重启都
会架空账号与用户的并发上限,造成超发准入。

「持有进程是否还活着」这个事实只有 Redis 看得到(唯一能观察到全部实例的
地方),因此把它显式建模在那一层:

- 新增 concurrency:instances 存活实例注册表(ZSET,member=进程前缀,
  score=心跳时间)
- 清扫改为只删「不属于任何存活实例」的成员,前缀解析在 Lua 内完成
- 新增独立的 20s 心跳循环。不能挂在 StartSlotCleanupWorker 上——那个间隔
  可配置甚至可关闭,心跳一断本进程的槽位就会被同伴回收
- 等待计数只在「本实例是唯一存活者」时才清零;有同伴时保留,索引成员的
  去留改为同时考虑剩余槽位与等待数

代价:崩溃副本的槽位改为等心跳超时(90s)后回收,不再是重启即清;期间无人
重启时由 acquire 路径的 ZREMRANGEBYSCORE 在一个 slot TTL 内兜底。

## 2. 两个周期性任务没有跨实例互斥

- ScheduledTestRunnerService:每个到期计划被 N 个副本各跑一次,直接产生
  N 倍的真实上游计费请求。抽出 runScheduledOnce 并加 leader lock
- ChannelMonitorRunner:按 monitor 维度加锁,不同监控仍可分散到不同副本
  并行,避免整体锁把探测串行化到单个副本

(BackupService 的定时备份选主已由 main 上的既有提交修复,本次不重复。)

## 3. 启动回收会删掉同伴正在跑的备份产物

recoverStaleRecords 把所有 running 记录判定为「我上次崩溃留下的」,会改写
同伴正在进行的备份记录并删除其 S3 对象。给 BackupRecord 增加
OwnerInstanceID,回收规则按可确定性分级:空 owner(历史记录)与本实例的
记录立即回收;同伴的记录只有超过 2 小时才能断定已死,时间无法解析时不动。
不对称是刻意的——误判「还活着」只是记录多挂一会儿,误判「已死」是删数据。

## 4. 四家管理端 OAuth 的 PKCE 会话只在进程内存

Claude/OpenAI/Gemini/Antigravity 的 generate-auth-url 与 exchange-code 是
两次独立 HTTP 请求,中间隔着用户在浏览器授权的数分钟,多副本下第二次会
落到任意副本,成功率退化为 1/N。抽出 redissession.Mirror 泛型(共享 Redis
+ 写失败降级本地),四个包接入。Grok 通道此前已用同款方案修复。

## 5. 冷启动引导竞态导致 CrashLoopBackOff

多副本空库同时启动时,AUTO_SETUP 的 CREATE DATABASE 与建管理员都是
check-then-act,输家撞唯一约束后经 log.Fatalf 直接退出。唯一约束本来就是
这件事的仲裁者,之前只是把它的裁决误读成了错误:INSERT 改为
ON CONFLICT DO NOTHING 并按影响行数判定,CREATE DATABASE 容忍 42P04。

## 6. 进程内快照无跨实例失效 + 优雅退出不可配

- 渠道定价/模型映射/模型白名单与约 10 项系统设置只在处理管理请求的那个
  副本立即生效,其余副本要等 TTL(10 分钟 / 60 秒)。对计费数据这是正确性
  问题:同一用户连续两个请求打到不同副本会被按不同单价扣费。新增
  snapshot:invalidate:<topic> 广播(订阅端只做本地失效,绝不回广播,
  否则消息会在副本间无限回弹)
- 优雅退出超时由硬编码 5 秒改为可配置(SERVER_SHUTDOWN_TIMEOUT,默认 30s),
  新增 SERVER_SHUTDOWN_DRAIN_DELAY 与 /readyz。收到 SIGTERM 后 /readyz
  立即返回 503 供 K8s 摘除 endpoint,而 /health 保持 200——它是 liveness
  与 Dockerfile HEALTHCHECK 在用,关闭期翻转会被 kubelet 提前杀死,
  在途流反而截断得更早

## 兼容性

单实例部署行为不变:未注入 Redis 时全部退化为原有的纯本地语义。
@Pengap
Pengap force-pushed the fix/multi-replica-safety branch from b0dd910 to 914ca57 Compare August 14, 2026 09:30
@Pengap

Pengap commented Aug 14, 2026

Copy link
Copy Markdown
Author

CI 结论:

  • test / golangci-lint / frontend / shell 全部通过
  • backend-security / frontend-security 失败,与本 PR 无关

已验证而非断言:把未经修改的 main 推到一个临时分支跑同一个 workflow,两个 job 同样失败 —— https://github.com/an-epiphany/sub2api/actions/runs/31789082437

原因是 advisory 数据库随时间更新:

  • backend-security:govulncheck 报 go1.26.5 标准库漏洞(fixed in 1.26.6)。6 条 trace 涉及的调用点(wxpay.gohttp_upstream.goaliyun_captcha_verifier.gobatch_image_provider_vertex.goopenai_ws_client.go)本 PR 一个都没碰
  • frontend-securitynanoid 新公告 GHSA-2v37-7h3g-55p8 缺少 audit 例外。本 PR 未改动任何 frontend/ 文件与 lockfile

修法是升 Go toolchain 到 1.26.6 / 补 audit 例外,属于独立的仓库维护,建议另开 PR。

上一个提交建立的机制在几个边界上仍不成立:注册表建立之前的旧副本、
Pub/Sub 的投递不保证、各副本定时器的相位差、以及恢复复用了备份的元数据。

## 1. 滚动升级期把「空注册表」当成「只有我」

从不带注册表的版本滚上来时,仍在服务的旧副本一个都不上报心跳,第一个新副本
看到「只有我」,会把它们的在途槽位与等待计数全部清掉——正是上个提交要修的
那个故障,只是挪到了升级窗口里发生。

只保护「第一个写下注册表的副本」不够:第二、三个新副本看到注册表非空,照样会
删掉剩余旧副本的槽位。而「滚动升级结束了没有」这个事实在编排层,进程内看不到,
因此只能用时间兜底:

- 心跳脚本增加 SET epoch NX,记下注册表首次建立的 Redis 时间,只写一次、不过期
- 建立不足 instanceRegistryRolloutGrace(30 分钟)时整个启动清扫跳过,
  含 sweepLegacyWaitKeysOnce——那个一次性清扫会把旧副本的等待计数一起抹掉
- 注册表键改名带 hash tag(concurrency:{instances}),与 epoch 键同 slot,
  心跳一条脚本改两个键

代价:宽限期内崩溃残留退回按 score 过期(≤1 个 slot TTL)。启动清扫本来也只是
把这件事提前,且宽限期在一套 Redis 数据的生命周期里只经历一次。

## 2. PUBLISH 成功不等于本进程已失效

Pub/Sub 不保证投递:本进程的订阅正在启动或断线重连时消息直接丢失,写入方自己的
快照会陈旧到 TTL(渠道 10 分钟)过期;订阅健康时回调也在另一条 goroutine 上,
写入方紧接着的读依然可能读到旧值。

改为「先广播、再无条件同步本地失效」——广播是毫秒级 PUBLISH,本地失效往往要回读
数据库重建快照,让同伴排在后面只会拉长传播延迟。广播改用去取消化的 ctx:写库
已经落盘,把请求的取消传给广播只会让同伴永远错过这次变更。

## 3. 监控的跨实例锁只挡住了重叠

各副本定时器相位不同:A 跑完释放锁,几秒后 B 的定时器到点照样再探一次,
同一轮窗口仍是 N 倍探测,每一次都是真实的上游计费请求。

「这个监控上一次何时被探测」的所有者是 channel_monitors.last_checked_at,
判定就放在那一层:新增 TryClaimCheck,一条条件 UPDATE 原子决出本轮唯一执行者,
窗口取 interval - jitter(单副本可能产生的最短合法间隔)。leader lock 保留,
职责收窄为「不重叠」。

没用 Redis 窗口键:leader lock 的降级路径是 PG advisory lock,没有 TTL,
表达不了「持有整个窗口」;而 DB 本来就有这个状态,RunCheck 也本来就要写库。
声明条件里带上 enabled,于是在别的副本上被停用/删除的监控,同伴残留的定时器
自然探不动了。

节奏代价:集群平均间隔略大于 interval(无 jitter、N 副本时约 interval + interval/N)。

## 4. 恢复复用了备份的执行者元数据,且回收只跑一次

- StartRestore 不写自己的 owner/started_at,回收拿备份的元数据去判恢复:
  一份三小时前的备份正被同伴恢复时,会被直接标记 failed。新增
  RestoreOwnerInstanceID / RestoreStartedAt,恢复分支只看它们
- 进程 ID 每次启动都重新生成,自己上一代的记录也落在「同伴」分支要等 2 小时,
  而阈值到达的那一刻没有任何启动事件,记录会永远卡在 running。新增 15 分钟、
  leader 选主的周期复检(备份记录是 settings 里的单行 JSON,多实例同时读-改-写
  会互相覆盖)

阈值维持 2 小时不变:备份与恢复的 ctx 都硬性封顶 30 分钟,2 小时已远超任何活操作
的生命周期,误判「已死」的风险可以忽略。

## 5. 本副本回读失败会顺带跳过广播

部分更新要回读一次数据库重建快照,回读失败(典型是请求 ctx 在写库之后才超时)
时提前 return,把广播一起跳过了,每个同伴副本继续用旧设置直到各自 TTL 过期。
广播改为 defer,与「本副本能否重建自己的快照」解耦;配合第 2 条,广播还会驱动
本副本用独立 ctx 再回读一次,失败分支因此能自愈。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant