celld 的保证
celld 作出两项承诺。同一时刻,恰好有一个节点为一个单元提供服务,因此两台机器绝不会写入同一个数据库。celld 只有在写入能够经受故障后才确认,因此已确认写入永不丢失。两项承诺都要求存储桶正确支持条件写入和范围读取,并且有进程监管程序负责重启进程。
存储桶必须提供的能力#
- 条件创建:对象已存在时,写入必须失败。
- 条件覆盖:读取后对象发生变化时,写入必须失败。
- 写后读一致性:成功写入后的读取必须返回该次写入。
- 范围读取:读取必须返回所请求的字节范围及该范围中的字节。
- 仅纪元垃圾回收(
CELLD_LTX_RETENTION_SECS)需要写后列举一致性:成功写入后的列表必须包含写入的对象。Amazon S3、Cloudflare R2、Google Cloud Storage 和 Azure Blob Storage 提供此保证。Tigris Global 或 Dual-region 存储桶仅在写入所在区域提供该保证,因此集群节点跨多个区域时,不要为这类存储桶启用纪元垃圾回收。
通过验证的存储服务包括 Amazon S3、Cloudflare R2、Tigris、Google Cloud Storage 和 Azure Blob Storage。发布测试在 R2 上运行;S3 路径使用相同客户端和头部。Azure 于 2026-08-18 在单节点下完成验证,分别使用账户密钥、虚拟机托管身份和 AKS 工作负载身份。Azure App Service 或 Azure Container Apps 的托管身份不可用,详见限制。
Backblaze B2、Hetzner Object Storage 和 DigitalOcean Spaces 未实现所需的条件写入,因此在这些服务上可能出现两个节点同时拥有一个单元。存储服务如果接受条件头却忽略条件,会在之后静默失败,因此必须运行存储测试。
MinIO 社区版通过了存储测试,但尚未获得生产环境验证。RELEASE.2025-09-06T17-38-46Z 对不存在对象的条件创建返回 NoSuchKey,导致首次部署失败(denoland/celld#162)。应使用 RELEASE.2025-09-07T16-13-09Z 或更晚版本。
gs:// 存储桶使用 Cloud Storage XML API、x-goog-if-generation-match 和 OAuth 凭据,因为 Cloud Storage 不对 PUT 应用 If-Match。az:// 存储桶中的 NAME 是容器名称。
存储测试#
celld diagnose 向存储桶发送四次条件写入:
ok bucket conditional write (create, reject-create, update, reject-stale)其中两次必须失败。如果存储服务接受了任意一次本应失败的写入,命令会报错退出,并指出该服务。凭据没有写权限时,请使用 celld diagnose --read-only。
每个节点在提供服务前都会执行相同写入,再读取第二个对象的一段范围并验证范围与字节。节点不能禁用此测试。操作因原因不明确而失败时,最多用新对象尝试三次,之后节点带警告启动,因为临时故障可能恢复。如果所需条件写入或范围读取不受支持、存储服务忽略条件或 Range 头,或返回错误范围或字节,节点会立即停止。
进程在测试中途停止时,可能在 probe/ 下留下小对象;celld 不会读取它。
celld 保留 probe/、cells/、nodes/、node-cells/、fleet/、deploy/、deploy-blobs/、log/、wake/ 和 telemetry/,并会删除其中部分前缀下的对象。应用不能在这些前缀下写入。
进程监管#
应通过能重启进程的监管程序运行 celld,例如 systemd、带重启策略的 Docker 或 Kubernetes。节点失去租约后会自行隔离并退出,没有重启机制时,集群会失去这部分容量。监管程序必须不限制重启尝试次数,并在两次尝试之间至少等待一个租约生命周期。
实现机制#
所有权记录#
每个单元在存储桶中都有一条所有权记录,包含拥有者节点的会话和隔离纪元。记录不存在时,节点通过条件创建获取单元;记录已存在时,通过比较并交换获取,因此两个节点不能同时获取同一个单元。每次激活,无论是接管还是本地唤醒,都会推进纪元,因此一个纪元永远不会有两个写入者。
纪元前缀#
复制器通过无条件 PUT,将各单元的 SQLite 数据复制到 cells/<cell>/ltx/e<epoch>/。键中的纪元就是隔离屏障:失去所有权的节点可以继续写入,但只能写入被取代的前缀,恢复过程则选择当前的数据谱系。分层路径可以先将多个单元的段合并到节点日志包中,再将每个段转存到各单元的前缀。只有单元前缀覆盖包内所有段时,日志包才会删除。合并失败后先等待 30 秒重试,再逐步退避到最多 300 秒。
确认规则(RPO=0)#
门控会暂扣每个响应,直到持久性证明覆盖它可能暴露的所有写入:写入响应;对象有已提交但尚未覆盖的写入时的只读响应;错误响应,因为抛出的消息可能包含处理函数读到的值;R2 修改,以免源写入持久化之前改变应用存储桶;原始 TCP 连接、写入、TLS 升级或关闭;以及流式响应体的每个分块。因此,客户端无法基于崩溃仍可能丢失的值采取行动。
完成存储桶证明后,celld 读取一次所有权记录,只有它仍指向当前节点及当前纪元时才确认。网络分区中的节点可以本地提交并复制到已被取代的前缀,但不会确认。检查依赖记录而不是时钟,因此暂停的进程或偏差时钟无法绕过检查。
集群证明不需要这次读取。拥有者将每次写入发送给另外一个或两个节点,即跟随节点;拥有者和跟随节点共同组成复制组,每个跟随节点都必须执行 fsync。接管在恢复前封存先前的节点日志会话,因此旧拥有者无法再完成新的集群证明。
未完成的 SQL 写游标可能在 SQLite 提交前返回行。在显式事务之外,应用必须在输出或 storage.sync() 前消费这些行;写游标未完成时,celld 拒绝输出。
复制组至少需要两个节点#
节点从不将自己计为跟随节点,而一个跟随节点就足够,因此集群至少需要两个正在运行的 celld 节点,才可能完成集群证明。CELLD_DURABILITY=fleet 是默认值,因此单节点集群虽然请求集群持久化模式,实际却无法获得该模式。
一个节点最多招募两个跟随节点,因此三个或更多节点的集群会保留已确认写入的三份副本。只要还剩一个跟随节点,复制组就能继续确认。只有一个跟随节点的节点,会在其他节点可用时招募第二个。在此之前,尚未进入存储桶的写入只存在于拥有者和那个跟随节点上。
没有复制组的节点仍然保持正确性,只是改用存储桶证明确认每次写入,代价是延迟:对象存储的一次往返远慢于跟随节点的 fsync。
接管恢复门控#
在集群模式下,celld 可以在存储桶上传完成前确认写入。因此,每个进程会话在首次作出集群持久化确认之前,都会条件创建节点日志记录。
冷激活在读取存储桶前,会检查先前拥有者的日志记录。记录缺席证明该会话从未确认尚未进入存储桶的写入;已封存记录证明恢复完成。开放或恢复中的记录会强制先恢复、再还原:比较并交换记录以隔离旧会话,封存可访问的跟随节点,将其保留的段和日志包上传到各单元前缀,并将记录标记为已封存。
单元可能在其节点会话仍开放时停止;下次激活会收集单元前缀之外所有已确认的尾部数据。日志纪元一旦激活(发生在首次集群证明之前),至少一个当前跟随节点必须返回其完整保留范围,否则激活失败,并继续保留恢复要求。
恢复具有以下限制:
- 跟随节点的 HTTP 错误不能证明其数据缺席,即使租约已经过期也是如此,因此持续错误可能阻塞恢复和启动。
- 重启后的跟随节点,如果某个范围包含损坏批次或缺口,在有效数据覆盖之前无法证明该范围完整。
- 旧节点只包含条目的尾部格式(
CLT1)既不能证明完整,也不能证明丢失,因此混合版本升级期间,恢复或启动可能停滞。 - 如果没有后续批次或持久化的结束记录保存最终批次的范围,那么最终批次被删除时无法检测到。
- 含未确认写入的撕裂批次,看起来与确认后发生的损坏相同,因此即使没有已确认写入丢失,丢失记录也可能报告某个范围无法认证。
重启中的节点在自身前任恢复完成前,就会响应经过认证的跟随节点封存和尾部请求,因此同时重启的节点可以从仍存活的跟随节点磁盘恢复已确认写入。只有启动完成后,节点才接受应用请求和新的跟随日志追加。
大型故障节点的恢复可能需要数分钟。恢复按最多 512 MiB 的窗口读取保留日志包,每个窗口上传并释放后才读取下一个,因此内存不会随会话大小增长。某个单元的行跨多个窗口时,每个窗口都会为它生成一个对象。失败或超时的尝试不会立即使等待中的请求失败:单元会退避重试(CELLD_RECOVERY_RETRY_MS,默认 1000),只有达到 CELLD_RECOVERY_RETRIES(默认 240)次尝试后,请求才会以解析错误失败。
纪元链恢复#
恢复从最新的包含 LTX 数据的纪元前缀向前串联,直到一个以完整数据库快照开始的纪元。按需分页恢复的纪元,从分页时的截点继续其前任历史;如果前任没有恰好在该截点结束,就不属于这条链。旧的 e<epoch>.seal.json 对象不限制恢复链。
分页单元不会预先读取整条链。它在稀疏本地文件上打开数据库,首次使用每个页面时从对象中读取,其余页面在后台填充;填充完成后只读取本地文件。小于 CELLD_LTX_PAGED_MIN_MB 的链会完整下载。
纪元垃圾回收#
CELLD_LTX_RETENTION_SECS 为正值时,单元拥有者会删除任何恢复都不再读取的纪元前缀,两种 CELLD_DURABILITY 模式均适用:
- 基于全部纪元前缀构建恢复链,只有最新纪元是自己的纪元时才继续。此时自己的第一个对象已经出现在列表中,因此后续拥有者会从同一纪元或更高纪元的基底恢复。
- 分页单元等待本地文件填充完整。
- 只有所有权记录仍指向当前节点和当前纪元时才继续。
- 写入包含恢复基底的
retired.json,删除基底以下的前缀,但保留自己的纪元、前一个纪元,以及年龄小于配置时长的所有纪元。
顺序至关重要,否则被隔离的拥有者可能删除继任者的恢复基底。延迟删除是安全的,因为低于基底的纪元永远不会重新进入恢复链。这依赖写后列举一致性。
被隔离节点可能向旧前缀追加未确认尾部数据。继任者通过快照启动后,后续恢复可能暴露这些数据;这不违反契约,因为未收到确认并不能证明写入不存在。如果继任者按需分页,恢复链会在截点裁剪旧前缀,而接管恢复门控会在继任者恢复前封存旧节点日志会话,因此截点之后的写入无法再被确认。
自我隔离#
每个节点在存储桶中持有带到期时间的租约,在生命周期的三分之一处续期(CELLD_TTL_MS,默认 10000 ms)。只要已公布的到期时间尚未到达,失败的续期就会重试。
到期后,节点会自行隔离:停止每个活跃单元,使所有未完成请求失败。租约记录消失,或不再匹配自己公布的记录时,也会立即隔离。隔离过程不写入任何内容;其他节点已经将租约视为失效或被替换,并通过所有权记录获取单元。celld 对每个路由请求检查已公布的到期时间,因此即使隔离流程尚未运行,请求仍然安全。
入口对每个新请求,依据观察到的租约截止时间检查缓存的远程路由;到期后重新读取所有权,即使旧拥有者仍保持连接也是如此。恢复仍需要存储桶及必要的持久化数据。排空中的入口可以转发给存活的远程拥有者,但拒绝新获取无主单元或拥有者已过期的单元。入口不会重放已经发给旧拥有者的请求,因为处理函数可能已经提交写入;调用者可以取消请求。
被隔离节点记录以 SELF-FENCE: 开头的日志,并以退出码 3 退出。其他内部失败也使用同一前缀和退出码;日志会注明原因:node_lease_watchdog_fence(过期)、node_lease_record_missing_fence,或 node_lease_record_mismatch_fence(不会指出写入者,因为节点无法证明是谁写了记录)。隔离状态不可恢复,只有重启才能通过正常冷激活路径让节点返回。测试页面介绍了该路径的强制终止测试。
RUST_LOG=celld=info,store=debug 会为针对节点自身租约记录的每次存储请求记录 node_lease_read 或 node_lease_write 事件,outcome 为 found、missing、applied、rejected 或 error。error 会在 error 字段携带存储失败,found 会携带记录的 generation。使用其他日志过滤条件时,此日志目标没有开销。
闹钟发现与唤醒格式#
SQLite 保存闹钟截止时间、消费状态、重试状态和安装标识。闹钟提示只会让 celld 读取 SQLite,绝不会直接授权处理函数运行。
每次已提交的安装都会在 wake/entries/ 下产生一个对象,以所有权纪元和 SQLite 中持久化的序列号标识。闹钟响应必须等待该对象发布的 PUT 和输出证明;同一分钟内更新也需要新的 PUT。
清理通过条件写入,在 wake/retired/ 下发布退役记录。记录需要持久性证明、当前拥有者;如果仍有已设置的闹钟,还需要确认替代条目已经发布。清理只删除更旧的标识或已证明消费完成的标识,因此旧 DELETE 无法移除后来的安装,存储桶也不需要条件 DELETE。删除过时的发布条目不会阻塞闹钟响应。
每轮清理最多列举 128 个对象,同时最多处理八个单元;下一轮继续列举,最后一页之后重新开始。DELETE 失败或 PUT 延迟完成可能需要再进行一次完整扫描。间隔默认 60 秒,CELLD_WAKER_TICK_MS 同时设置它和到期扫描间隔。清单较大时可能需要多个间隔,每增加一个节点都可能重复相同的读取和删除。
wake/format.json 选择格式 2,wake/waker.json 保存唤醒者角色的建议性租约。新节点会自动初始化空集群,或升级已停止的 v0.4.1 集群。不支持的格式,或旧名称 wake-format.json、wake-v2/、wake-retired-v2/,会阻止启动。保留命名空间以外的应用对象不会阻止启动。
使用此格式启动集群#
空集群会自动初始化此格式。从 v0.4.1 升级会保留存储桶和节点数据目录,但必须先停止集群,因为 v0.4.1 无法读取新的发现条目。
- 停止应用流量和部署写入者。停止所有旧节点及其监管程序,再等待所有节点租约过期。
- 备份存储桶和节点数据。保留节点名、节点间地址和数据目录。跟随节点磁盘可能包含尚未进入存储桶的已确认写入。
- 阻止旧二进制重新启动或写入存储桶:撤销其凭据或访问权限。格式标记无法阻止旧程序。
- 在每个节点上使用相同配置、数据和地址启动新二进制。等待集群健康后,再恢复流量。
节点可以同时启动。存活的节点租约会阻止迁移,运维者必须防止旧写入者返回。
迁移保留数据库、节点日志、所有权记录、部署和应用对象,并为每个已存储单元创建发现种子,每页最多 128 项。种子让恢复从 SQLite 派生安装标识,而不改变截止时间或重试状态;只有持久化恢复完成后才删除,因此迁移中断不会丢失闹钟。处理种子时,恢复可能会加载具有未来闹钟的单元。
节点在迁移中途停止时,另一个启动中的节点会继续迁移。在完整清单处理成功前,所有节点都不会提供服务,因此闹钟可能延迟触发。后续启动不会再次迁移。
回滚时必须恢复完整的已停机集群备份,绝不能让旧二进制访问升级后的集群。恢复会丢失备份之后的写入,因此应另行保留这些数据。
重启和所有权转移都会保留闹钟历史。每个恢复的写入者获得新纪元,并保留已存储的序列号和消费状态,因此旧修改无法作用于后来的安装。