2026-10-28·par Sijie Wang#lucerna#software#architecture#proposal

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。
  • 分片 —— 数据量小;瓶颈在模型,不在数据库。
à propos de cette entrée

L'une des entrées de wiki de sijie. L'IA sur ce site est fondée sur le même corpus et répond à la voix de sijie, avec citations revenant à des entrées comme celle-ci — répondre coûte argent à sijie, donc cela attend derrière un code : entrer un code d'accès →