high-availability-proposal

Lucerna 的高可用 —— 一份提案

上级:key-designs

状态:提案(2026-09-28)。"方案"以下的内容都没有实施。

"现状"一节是事实,对照 ~/Develop/projects/Otium 的代码和 ops/ 核实过。之后的内容都是设计推理:用来回答"你会怎么把它做成高可用",不是做过的事的记录。

现状(事实)

部件今天的状态丢了它会怎样
主机一台 Coolify 服务器跑着 Lucerna 所有生产服务,同时还跑着别的租户的生产环境。所有在线功能同时停。一次全机操作会波及所有租户(2026-07-16 一次全机日志截断就弄坏了 Lucerna 生产的日志)。
Postgres(生产)一个 Coolify 管理的容器。ops/ 里没有备份任务。服务端数据(书、笔记、词汇、SRS 状态)有风险;没有按时间点恢复。
Appwrite(身份)自建 1.7.4,25 个容器,自带 MariaDB 和 Redis;文件在本机 Docker volume 上;_APP_OPENSSL_KEY_V1 只存在这台机器上(ops/appwrite-migration.md)。没人能登录注册;丢了密钥,所有存储文件都解不开。
mainbackend(Go)无状态 HTTP;pgxpool 16 个连接;/api/v1/health 返回 200,或返回 503 并写明哪个上游挂了。启动时跑 goose 迁移,没有加锁(cmd/server/main.go:77)。同步、词汇、复习停。两个副本同时启动会抢着跑迁移。
auth(Go)无状态,只有一个靠 cron 调用的游客清理(/cron/cleanup-guests,bearer 密钥)。登录停。
nlp-api(Python)无状态,但启动时加载 spaCy 和一个翻译模型。试点决定(2026-10-13):M2M-100 418M,用 CTranslate2 int8 在 CPU 上跑;NLLB 留在磁盘上,零成本可切回。实测(2026-10-10,FLORES-200 devtest,每方向 200 句):翻成英文 NLLB 大胜,翻成中文 M2M 胜;NLLB 蒸馏 600M 版翻中文有系统性提前截断。(2026-10-28 勘正,原记 13 亿参数 NLLB。)查词、语法、翻译停;冷启动慢。打包记录见 translation。
客户端桌面端离线优先:从本地 SQLite 读;离线写在同一事务里进 outbox;服务端对重放幂等(offline-first-outbox)。web 端在线优先:只有读缓存,没有 outbox。桌面端读者停机时照常读、照常保存;web 读者保存会失败。
R2 / VerdaccioR2 是托管服务。Verdaccio 只在构建时需要。不在阅读路径上。

这里的不对称很关键:对一个阅读 app,产品大部分在客户端。摄入在客户端做,打开过的书存在本地。所以可用性与其说是保住一台服务器,不如说是三个承诺:

  1. 读一本已经打开过的书,永远不需要服务器。
  2. 存下的单词或笔记永远不丢。
  3. 登录、上传、查词和翻译可以短暂降级,并且自己恢复。

方案(提案)

建议目标:数据丢失(RPO)先 ≤ 5 分钟,再到零;恢复时间(RTO)阶段 0 之后 ≤ 1 小时,阶段 2 之后以分钟计。按"单位投入的收益"排序 —— 这是一个两个人的项目。

阶段 0 —— 丢了主机也能活

  • 备份离开这台机器。 Postgres:持续 WAL 归档到 R2(wal-g 或 pgBackRest),得到按时间点恢复。Appwrite:每晚把 MariaDB dump 到 R2,文件存储改到 R2(ops/appwrite-migration.md 的路线 A)。把 Appwrite 的密钥存进密码管理器 —— 迁移文档已经把这条列为"成本最低、风险最大"的修复。
  • 做恢复演练,不只是备份。 每月一次,脚本把最新备份恢复到一个临时环境,再跑冒烟测试。花掉的时间就是 RTO;没验证过的备份只是一个愿望。
  • 从外面监控。 外部探测每分钟打 /api/v1/health、auth、nlp-api 和 web,失败就呼人。健康接口已经会写明哪个上游挂了。
  • 离开共享主机,或至少给每个容器限 CPU 和内存、轮转日志,让别的租户的失误打不挂 Lucerna。
  • 主机可以复现:Coolify 配置(labels 和环境变量键已经在 ops/coolify 里)足够用备份在一台新机器上重建。

阶段 1 —— 没有单个应用实例

  • mainbackend、auth、web、nlp-api 各两个以上副本,分在两台主机上,前面一个按现有健康接口路由的负载均衡。挂一台主机是容量减半,不是停机。
  • 迁移不再在启动时无保护地跑。 要么由一个发版任务单独跑,要么启动时拿 Postgres advisory lock(goose 的 Provider API 支持 WithSessionLocker)。迁移按"扩展 → 迁移数据 → 收缩"来写,新旧副本在滚动部署期间能同时服务 —— 零停机部署靠的就是这个。
  • 游客清理只由一个调度器跑。 删 24 小时以上的游客跑两次也安全,所以重复执行无害。
  • nlp-api 软失败。 它单独占节点(重的是模型),客户端给它加超时和熔断:它挂了,阅读器照常工作,查词弹窗显示"暂时不可用"。
  • 把桌面端的 outbox 给 web 用上。 web 已经有 SQLite(wa-sqlite)缓存;桌面端的 Hybrid*Repository + outbox 模式可以延伸到 web,这样 web 上停机时也能保存。对读者来说,这是整个方案里可用性收益最大的一项。

阶段 2 —— 没有单个数据库

  • Postgres 带备库和自动切换:托管服务配一个同步备库;如果继续自建,就用 Patroni + etcd;前面放 PgBouncer;应用只看到一个地址。切换后应用重连并重试。outbox 重放的写入重试是安全的,因为服务端幂等;web 直接调用的每个写接口都要先有同样的保证(每次写入带一个幂等键)。
  • 身份服务搬离自建。 把 25 个容器的 Appwrite 做成高可用,意味着 MariaDB 复制、Redis 切换、共享存储 —— 对两个人不值得。迁到 Appwrite Cloud(迁移文档的路线 B):自定义域名让已发版桌面端里写死的 appwrite.lucernaread.com 继续有效,project id 也能沿用 —— 两点都要按文档先在 dev 上验证。

怎么证明,而不是声称

  • 在 staging 跑 e2e 时杀掉 Postgres 主库;量恢复时间,核对没有丢写入。
  • 跑 e2e 时每个服务停掉一个副本;这一轮必须保持全绿。
  • 每月的恢复演练报告实测的 RTO 和 RPO。
  • 现有的 outbox e2e 在后端停机时跑:保存进队列,恢复后排空。

不做什么

  • 多地域双活。 SRS 调度每个用户只有一个写入方;两个可写地域换来的是冲突处理,不是可用性。
  • 这个团队规模用 Kubernetes —— 两台主机上的 Coolify 加一个负载均衡就能覆盖阶段 0–1。
  • 自建高可用 Appwrite —— 见阶段 2。
  • 分片 —— 数据量小;瓶颈在模型,不在数据库。
about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →

high-availability-proposal — Sijie Wang