三周前我写过一篇博客,讲我怎么把 Cloudflare R2 的账单从九块钱按回零,写的时候还挺得意,觉得一行配置就把事情了结了(Litestream 默认 1s sync 把 R2 Class A 刷到 164 万次)。这个月账单又来了,Class A 操作 141 万次,照样越过了 100 万的免费线。我第一反应是配置被哪次部署冲掉了,登上服务器一看,那行 sync-interval: 1h 好端端地待在配置里,没有任何漂移,那就只剩一个解释,我上次根本没修对,只是恰好赶上一段写得少的日子让账单看着降了。
为什么那行配置没用
我上次的修复建立在一个想当然的假设上,以为把 sync-interval 从 1s 拉到 1h,litestream 就会把一小时里攒下的改动合并成更少的 PUT,所以更省钱。这个假设对老版本(v0.3.x 的 WAL 段模型)也许成立,但对我跑的 v0.5.x(LTX 分层压缩模型)完全不成立,因为 sync-interval 控制的是上传器多久醒一次,而不是醒来上传几个对象,它醒来时会把积压一次性排空,该是多少个 PUT 还是多少个。litestream 自己的 issue #1171 就是这个症状,有人在 sync-interval: 1h、完全零写入的情况下,仍然每小时被刷 ~480 个 PUT,而调小调大这个值都不管用;反过来,真正决定 PUT 数量的是写入本身和压缩行为,不是上传频率。所以一个配置项调了没效果的时候,不该再去加大它的剂量,而该回头怀疑这个配置到底管不管你以为它管的那件事。
这次我不猜,先把证据摆齐
截图只给了账户级的总数,没说是哪个桶、什么进程在刷,所以这次我没有顺着”大概是 litestream 吧”往下写,而是先确认是哪个桶。账户下有两个 R2 桶,我用 Cloudflare 的 API 把每个桶的对象数和大小拉出来,这个端点只要 R2 读权限的 token 就能调,不需要额外的 analytics 权限:
ACCT=<ACCOUNT_ID>for b in platform-backups oss-platform; do curl -s -H "Authorization: Bearer $(cat ~/.cf/api-token)" \ "https://api.cloudflare.com/client/v4/accounts/$ACCT/r2/buckets/$b/usage"done结果一眼就分出了主次,存真实文件的 oss-platform 攒了 3.99 GB 却只有 5 个对象,根本产生不了百万级的操作,而 litestream 备份用的 platform-backups 只有 1.31 GB,却塞着 944 个小对象,元凶是后者。这里还有个旁证能让我更有底气,账单里 Class A 是 141 万,Class B 只有 16.46K,差了将近 85 倍,而这正是 litestream 的特征,它只写不读,正常复制只发 PUT 和 LIST 几乎不 GET 对象,所以 Class B 极低;如果是 OSS 在被人频繁上传下载,验证用的 HEAD 和下载用的 GET 都会把 Class B 抬上去,比例不可能这么悬殊,所以光看这个比例也能反过来确认是备份桶而不是文件桶。
确认了桶,我登上服务器看 litestream 到底在写什么,第一件事是确认版本,因为 v0.5.x 和 v0.3.x 行为差得很远,litestream version 告诉我是 0.5.11。然后我把最近一天的上传按库数了一遍:
sudo journalctl -u litestream --since "24 hours ago" --no-pager \ | grep "ltx file uploaded" | grep -oE "db=[a-z]+\.db" | sort | uniq -c 8 db=auth.db 2 db=deployer.db 24630 db=registry.dbregistry.db 一天两万四千多次,auth 和 deployer 各几次,差距大到不需要再算就知道问题全在 registry 这一个库上。真正让我反应过来的是单条日志的细节,每一条上传都是 level=0,而且 minTXID 和 maxTXID 相等、逐 1 递增:
msg="ltx file uploaded" db=registry.db level=0 minTXID=...e33ac maxTXID=...e33ac size=9682msg="ltx file uploaded" db=registry.db level=0 minTXID=...e33ad maxTXID=...e33ad size=9455msg="ltx file uploaded" db=registry.db level=0 minTXID=...e33ae maxTXID=...e33ae size=25101litestream 官方文档把 L0 描述得比较含糊,只说它是按 sync-interval 刷新的批,但我这台机器的日志说得很直白,一个 SQLite 事务对应一个 L0 LTX 文件、对应一个 PUT,sync-interval: 1h 在这里完全没拦住数量,它只是把这些 PUT 攒成每小时一阵爆发,我抓到过 13 秒内连发 40 个上传,但总数一个不少。既然每个事务 TXID 加一、每个事务一个 PUT,那 TXID 的增量就是 PUT 数,我取计费周期两端的日志一减,0x0e345f(930,911) 减 0x073cb2(474,290) 等于 456,621,也就是光 registry 这一个库,在这个计费周期就实打实产生了 45.6 万次 PutObject,这是数出来的精确值,不是估的。
账单是 141 万,我数出来的 PUT 才 45.6 万,中间差着将近 90 万,剩下的从哪来,答案藏在 retention 的报错里:
msg="l0 retention enforcement failed" error="... S3: ListObjectsV2, StatusCode: 429 ... Reduce your concurrent request rate for the same object."ListObjectsV2 在 R2 同样算 Class A,litestream 的 retention 和 compaction 循环在不停地 LIST 这个桶,频繁到被 R2 用 429 限流。为了把 PUT 和 LIST 的真实占比看清楚,我临时用一个 systemd drop-in 把 litestream 的日志级别覆盖成 trace、抓完一个窗口立刻还原(没动 /etc/litestream.yml),数了数里面的 S3 请求方法,PUT 有 157 个对应着 ltx 上传,GET 有 34 个而且全是 ListObjectsV2 的分页(S3 的 LIST 走 HTTP GET),没有一个是真的在下载对象,这也顺带解释了 Class B 为什么那么低。到这一步证据就齐了,这张账单是逐事务的 PUT 加上 retention/compaction 的 LIST,两样都是 litestream 在 registry 这个库上的复制行为,都是 Class A。
真正的问题是在备份不该备份的东西
数到这里我才意识到,问题根本不在 litestream 太勤快,而在我让它备份了一堆压根不需要备份的东西。registry 一天两万多个事务是从哪来的,看一眼心跳接收的代码就清楚了,十几个服务通过 SDK 每隔十几秒就心跳一次,registry 每收到一次就在一个事务里写两笔,一笔更新 heartbeats 表,一笔往 audit_log 里记一行”谁在什么时候还活着”,十几个服务乘以这个频率,一天就是两万多个事务,litestream 忠实地把每一个都打包传上了 R2。
这件事想明白以后是有点荒谬的,备份的边界该按”这份数据值不值得恢复”来划,而不是把一个库整个扔进备份就算完。我那个 registry 库里混着两种性质完全相反的东西,一种是用户、PAT、ACL 这种丢了就再也找不回来的目录数据,另一种是心跳这种每三十秒就自动重报一次的活性状态,litestream 却把它们当成同一个文件无差别地复制到 R2。心跳、缓存、TLS 握手、限流计数这类东西丢了几十秒就能自己重建,备份它们等于花钱存一份马上就作废的快照,而用户和 PAT 这种一旦丢了就重建不出来的数据,才是备份真正要兜底的。所以正确的做法从来不是去调备份频率,而是先对每张表问一句它到底值不值得恢复,再把不值得的那部分物理隔离到一个根本不进备份的库里。
修复:把心跳搬出被复制的库
具体做法是把高频易变的 heartbeats 表,连同每次心跳那行 audit,从被 litestream 复制的 registry.db 挪进一个独立的、不备份的 registry-runtime.db,registry 进程启动时用 SQLite 的 ATTACH 把它挂成 runtime schema:
def attach_runtime(conn, runtime_path): conn.execute("ATTACH DATABASE ? AS runtime", (str(runtime_path),)) conn.execute("PRAGMA runtime.journal_mode = WAL") conn.executescript("CREATE TABLE IF NOT EXISTS runtime.heartbeats (...)")litestream 的配置里列的是显式路径,registry-runtime.db 不在其中,所以它永远不会被备份,而这恰恰是安全的,因为这个库丢了也无所谓,下次启动 CREATE TABLE IF NOT EXISTS 会把空表建回来,活着的服务几十秒内就重新把它填满,可丢可重建本来就是”不值得备份”的另一面。剩下几个连带的处理也都顺着同一个逻辑,一支迁移把旧的心跳行搬过去一次再删掉原表,所有 services LEFT JOIN heartbeats 的查询改成 JOIN runtime.heartbeats(ATTACH 之后跨 schema 的 JOIN 是支持的),原表那个 ON DELETE CASCADE 因为 SQLite 不支持跨库外键而改成删服务时显式删一行,而每次心跳那行 audit 直接去掉,因为它本来就落在被复制的库里、会把我要消除的写入重新引回来,何况一天两万行心跳审计本身就是噪声,heartbeats 表里的 last_seen 和 status 已经是活性记录了。
验证:上传归零
部署完我没有靠”应该好了”收尾,而是回到服务器上数 litestream 对 registry 库的上传:
sudo journalctl -u litestream --since "6 min ago" --no-pager \ | grep "ltx file uploaded" | grep -c "db=registry.db"0六分钟零次。心跳每十几秒就一次,如果它还在写 registry 库,几秒内日志里就会冒出上传,是 0 就说明心跳已经彻底不碰被复制的库了,与此同时 registry 的 /health 仍然报 services_count: 17,活性检测一切正常,心跳照常进 runtime 库、查询照常 JOIN。月运行率从大约 214 万次 Class A 掉到了接近零的量级,稳稳压在免费线以内。
我学到的
这次最值得记下来的不是某个 litestream 参数,而是一个判断成本类修复的习惯,结论必须用真实的操作数来兜底而不能靠”理论上更省”的直觉。上一篇我就栽在这,sync-interval: 1h 看着合理、文档也支持这个方向,但我没有回去数过 PUT,而直觉对老版本成立、对 v0.5.x 不成立,这中间隔着一个我没验证的版本差异;这次我每一步都落到 journalctl 的计数和 TXID 的减法上,哪怕多花十分钟,也比再吃一张账单便宜。
第二件事是要学会区分一个开关是在改症状还是在改本质,litestream 的 sync-interval 属于前者,它改的是上传的时机而不是上传的数量,这种开关你调到极限也只是把同样多的请求挤进更短的窗口里;真正能改数量的是写入模式本身,也就是到底有多少东西、多频繁地被写进那个被复制的库。一个配置调了没有效果的时候,与其换更激进的值,不如先确认自己有没有看错它作用的层级。
最后一件最朴素,按操作计费的对象存储,要盯的是操作数而不是存储费。我那个存了 3.99 GB 真实文件的桶整月才十几次 Class A 几乎免费,而那个只有一点几个 GB 的备份桶却因为写得太碎吃掉了钱,更何况 R2 的计费是向上取整的,月度 Class A 一旦越过 100 万,账单是按整百万跳的而不是按溢出量线性涨,所以护城河始终是把操作数压在免费线以内,而压操作数的正确姿势是减少写入,不是去调上传频率。