kevy4.0

持久化

kevy 如何让数据扛过重启——AOF、快照、fsync 策略、重写/压实、崩溃恢复,以及让你把这一切随时看在眼里的内省接口。

何时需要这份文档

遇到下面这些情况时来查这一页:

如果只想要“kill -9 能不能扛住”的快速答案:能,默认策略下最多丢一秒的写入。

核心思路

每个 shard 在持久化目录下各有两个文件:一份记录全部写命令的追加式日志(aof-<id>.aof),和一份可选的二进制快照(dump-<id>.rdb)。单凭 AOF 就是一份完整的耐久记录;快照的唯一作用是给重放时间设上限。启动时 kevy 先加载快照(如有),再回放 AOF;快照成功后 AOF 会重置,两个文件合起来恰好覆盖完整历史一次。

启动时目录不存在就自动创建(v3.17);路径创建失败会报成一条明确的启动错误,而不是让先碰到它的某个子系统抛一个裸 ENOENT。

实战示例

服务器模式

把下面的内容写进 kevy.toml,然后用 kevy --config kevy.toml 启动:

# kevy.toml
[server]
data_dir = "/var/lib/kevy"
port     = 6379
threads  = 4

[persistence]
aof = true
# AOF durability — see the knobs table below for the full set.
appendfsync                 = "everysec"   # always | everysec | no
auto_aof_rewrite_percentage = 100          # rewrite when the AOF doubles since the last rewrite
auto_aof_rewrite_min_size   = "64mb"       # …and is at least this big

通过 RESP 用标准的 Redis 风格命令操作:

$ redis-cli -p 6379 BGSAVE
Background saving started

$ redis-cli -p 6379 BGREWRITEAOF
Background append only file rewriting started

$ redis-cli -p 6379 INFO persistence
aof_enabled:1
appendfsync:everysec
aof_rewrite_in_progress:0
aof_rewrites_total:3

CONFIG SET appendfsync always 可以在线改策略,不用重启。

嵌入模式

Cargo.toml 里加入 crate:

[dependencies]
kevy-embedded = "*"

然后在 main.rs

use std::time::Duration;
use kevy_embedded::{AppendFsync, Config, KevyMetric, Store};

fn main() -> kevy_embedded::KevyResult<()> {
    let cfg = Config::default()
        .with_persist("/var/lib/myapp/kevy")
        .with_appendfsync(AppendFsync::EverySec)
        .with_auto_aof_rewrite(100, 64 * 1024 * 1024)
        .with_metric_sink(|m| match m {
            KevyMetric::Replay { commands, bytes, elapsed_ms } => {
                eprintln!("kevy replay: {commands} cmds / {bytes} B in {elapsed_ms} ms");
            }
            KevyMetric::Rewrite { keys, before_bytes, after_bytes, elapsed_ms } => {
                eprintln!(
                    "kevy rewrite: {keys} keys, {before_bytes} -> {after_bytes} B in {elapsed_ms} ms"
                );
            }
            _ => {}
        });

    let store = Store::open(cfg)?;

    store.set(b"hello", b"world")?;
    store.expire(b"hello", Duration::from_secs(300))?;

    // Point-in-time snapshot. Returns after the file is on disk; per-shard
    // locks are held only for the view freeze and the final rename.
    store.save_snapshot()?;

    // On-demand AOF compaction. Same lock discipline as save_snapshot.
    let _stats = store.rewrite_aof()?;

    // Live introspection.
    let info = store.info();
    println!("{} keys, {} bytes AOF", info.keys, info.aof_bytes);

    Ok(())
}

默认配置下,新建的嵌入式 store 只写 AOF—— save_snapshot 跑过之前不会出现快照文件。这是预期行为:单凭 AOF 已足够重建键空间。

配置旋钮

耐久性与 AOF 增长

旋钮服务器(TOML / CONFIG SET嵌入式(Config::…默认值备注
AOF fsync 策略appendfsyncalways / everysec / nowith_appendfsync(AppendFsync::…)EverySec服务器侧可在线调整。
AOF 开关aoftrue / falsewith_persist(...) 隐式开启true(服务器);嵌入式在调用 with_persist 前关闭关闭后跳过一切磁盘持久化。
自动重写百分比auto_aof_rewrite_percentagewith_auto_aof_rewrite(pct, min) 的第一个参数100设为 0 关闭自动重写。
自动重写最小体积auto_aof_rewrite_min_sizewith_auto_aof_rewrite(pct, min) 的第二个参数67108864(64 MiB)两个阈值同时满足才触发自动重写。
持久化目录data_dir / 环境变量 KEVY_DIRwith_persist(path)服务器 ./data;嵌入式无每个 kevy 实例一个目录。
reactor / reaper 节拍reactor tick,约 100 ms后台 reaper,或自行调用 Store::tick约 100 ms驱动 EverySec 刷盘、自动重写检查、TTL 清理。

触发面

动作服务器嵌入式阻塞形态
同步快照SAVEStore::save_snapshot()文件落盘后返回;锁只在冻结 + rename 期间持有。
后台快照BGSAVE在工作线程里调用 save_snapshot立即返回;磁盘写完后一个 reactor tick 内提交落地。
AOF 重写BGREWRITEAOFStore::rewrite_aof()原子 rename 后返回;序列化期间键空间照常在线。
在线调整 fsyncCONFIG SET appendfsync everysec重建 Config
有序停机`SHUTDOWN [SAVE\NOSAVE]`(或 SIGTERM)drop 最后一个 Store clone逐 shard 排空:在途持久化任务落地、AOF 尾巴强制 fsync,然后进程退出。SAVE 额外为每个 shard 拍一张最终快照。不发送回复——客户端观察到连接关闭(Redis 行为)。

fsync 策略语义

策略耐久性代价
Always零丢失——每次写入先 fsync 再回复吞吐约砍半
EverySec(默认)崩溃最多丢约 1 秒的写入开销小
No交给 OS 页缓存刷盘开销最小

取舍与限制

各策略的吞吐与数据丢失。Always 让每条回复都等 fsync 完成,是唯一能在 kill -9 下做到零命令丢失的策略,代价是在典型 NVMe 上把 SET 密集的吞吐砍掉约一半。EverySec 由后台每秒刷一次盘,崩溃最多丢这一秒窗口内的写入——之所以选它当默认,正因为它与 Redis 的取舍一致,且丢失窗口通常可以接受。No 交给内核定夺:吞吐最高,但崩溃可能丢掉还留在页缓存里的一切,时间跨度可能达数秒。

AOF 重放成本与快照加载成本。没有快照时,启动耗时随 AOF 字节数线性增长:本地 NVMe 上,4 GiB 的 AOF 几秒钟回放完,40 GiB 就要一分钟以上。快照能封住这个上限——加载只是一次流式读,外加快照之后那一小段 AOF 尾巴——但代价是一次短暂的视图冻结(O(keys),每键纳秒级,因为集合值靠引用计数共享),外加快照落盘期间首次改动的集合各拷贝一次。写密集负载下,更推荐靠自动重写压住 AOF 体积,而不是定期跑 BGSAVE:重写给出同样的启动时间上限,还省掉第二个文件的管理。

后台任务并发。每个 shard 同一时刻最多跑一个后台保存或重写。任务进行中再来的重复请求会记一条日志然后跳过,绝不排队。

TTL 持久化。TTL 以绝对的 Unix 毫秒截止时间落盘(AOF 里写 PEXPIREAT,快照格式里是一个绝对时间字段),所以无论重启多少次,键都保持原来的过期时刻,进程停机的时长也能正确扣除。记录相对剩余时间的旧版 AOF 仍可加载(载入时按相对时间处理);新写入一律是绝对时间。EXPIREATPEXPIREAT 都作为客户端命令开放。

shard 布局变更崩溃幂等。修改 --threads / shards 时,新快照先写到 .reshard 临时名下,经由一份耐久的 reshard.journal 提交;迁移若中断,下次启动会向前滚完。源文件保留为 .premigration.<unix_ts> 备份;journal 是提交点,绝不能手工删除。

哪些东西不持久化。Pub/sub 频道、订阅和未投递的消息只活在内存里。BLPOP 之类阻塞命令的等待者和阻塞式 XREAD 属于连接状态,不是数据。这两类都不写 AOF、不进快照,也不参与回放。

FAQ

AOF 文件一直在涨——怎么压实?

在服务器上跑 BGREWRITEAOF,嵌入式模式下调用 Store::rewrite_aof()。重写把日志重建为能还原当前键空间的最小命令集——每个键一条 SET / HSET 等,带 TTL 的键再加一条 PEXPIREAT——然后原子地换入新文件。对 hot 的一万次覆盖会坍缩成一条 SET hot <latest>

无人值守的运维场景,自动重写保持默认即可——相对上次重写体积增长 100%,且不低于 64 MiB——reactor 会自行触发压实。设 auto_aof_rewrite_percentage = 0 则关闭自动重写,完全手动驱动。

重写不阻塞键空间:序列化和 fsync 进行时读写照常流动,期间落地的写入会 tee 进一个 diff 缓冲区,最后追加到压实后的镜像上。重写中途崩溃不影响原 AOF(换入是一次原子 rename),残留的 aof-<id>.aof.rewrite 临时文件删掉即可。

能彻底关掉持久化吗?

可以,两条路:

如果想要持久化、又希望两次快照之间 AOF 完全不增长,这种组合不支持——kevy 的耐久模型是 AOF 优先,快照只为限定 AOF 重放,不是 AOF 的替代品。

高写入负载下做一次快照要付出什么?

阻塞部分很小。每个 shard 的键空间冻结是 O(keys) 而非 O(bytes)——集合值有引用计数,和在线 store 共享——百万键的 shard 冻结只要个位数毫秒。序列化本身在键空间在线时进行,写入不会暂停。

真正的临时开销在内存。快照写出期间发生改动的集合(list、hash、set、sorted-set)各克隆一次,好让在线 store 不打扰冻结视图、继续前进。负载以 SET 普通字符串键为主时,这点额外内存可以忽略;若集中用 HSET / LPUSH 打少数几个巨型集合,这些集合的常驻内存可能短暂翻倍。

快照成功后还会重置 AOF——日志原先承载的内容如今全在快照里,日志只从冻结之后落地的写入重新记起。之后重启加载快照 + 日志,不会把历史应用两遍。

下次启动时按什么顺序恢复?

每个 shard 依次执行:

  1. 加载快照。dump-<id>.rdb 存在,流式载入键空间。已过期的 TTL 在加载时直接丢弃。
  2. 回放 AOF。aof-<id>.aof 开头逐帧应用。
  3. 处理尾部。文件完好就全量应用。尾部截断(追加到一半崩溃)则丢掉残缺的末帧,应用前面的部分。遇到损坏帧,坏字节会挪到 aof-<id>.aof.panic-quarantine.<unix_ts>,不再阻碍后续启动,然后应用前缀。隔离出去的尾巴永远不会重新应用;想从里面抢救内容就手工检查。
  4. 打出一行摘要日志,含挂钟耗时:

``text kevy: AOF /data/kevy/aof-0.aof replayed 145313 commands from 418261733 bytes in 247 ms (clean) ``

  1. 回放 reshard.journal,把中断的 shard 布局迁移向前滚完。

盯住这行重放耗时,用自动重写把它压在预算内——重放时间随未重写的 AOF 体积线性增长。

嵌入式宿主进程内部怎么监控持久化?

两个入口。

轮询。store.info() 返回 KevyInfo 结构体,字段有 keysused_memoryaof_bytesexpire_pendingevictionsexpired_keys。同样的信息也有更细粒度的方法:

store.dbsize();                 // live key count
store.ttl(key);                 // Option<Duration> (None = no key / no TTL)
store.ttl_ms(key);              // Redis PTTL semantics: -2 no key, -1 no TTL, else ms
store.expire_pending_count();   // live keys carrying a TTL
store.used_memory();            // resident-bytes estimate
store.expired_keys_total();     // total expired (lazy + reaper)
store.evictions_total();        // total evicted by maxmemory

期望有 TTL 却看到 expire_pending_count() == 0,是 TTL 子系统没登记上你那些键的经典信号。

推送。注册 Config::with_metric_sink(...),AOF 重放(启动时)和每次 AOF 重写(压实)都会送来 KevyMetric 事件。sink 在发出事件的线程上同步执行(后台重写发自 reaper 线程),回调要快。KevyMetric 标了 #[non_exhaustive]——匹配时永远留一个 _ 分支,保证向前兼容。

持久化目录里每个文件都是什么?

模式含义
aof-<id>.aofshard <id> 的在线 AOF。
dump-<id>.rdbshard <id> 的二进制快照。
shards.meta记录的 shard 数量与路由方案。
dump-<id>.rdb.tmp写出中的快照。确认陈旧后可安全删除。
aof-<id>.aof.rewrite进行中的 AOF 重写/重置。确认陈旧后可安全删除。
dump-<id>.rdb.reshard + reshard.journal进行中的 shard 布局迁移。下次启动向前滚完;journal 绝不能手工删除。
*.premigration.<unix_ts>迁移前的源文件备份,留作回滚。
aof-<id>.aof.panic-quarantine.<unix_ts>恢复时隔离出来的损坏 AOF 尾部。想抢救内容就手工检查;kevy 不会重新应用它。
elect.meta(+ 瞬态的 elect.meta.tmp选举耐久性(v3.15):选举器的 (epoch, votedFor) 二元组,在任何投票应答离开节点*之前*先持久化,崩溃重启因此绝不会重复投票。写法是 tmp + fsync + rename——保存中途崩溃只会留下旧值或新值,绝无撕裂的文件。仅在配置了 [cluster] 多数派时出现。

耐久性契约(v2.1)

appendfsync × 写入路径,说明“调用返回 OK”各自保证了什么。“durable”= 已落到稳定存储(fdatasync 已完成);“windowed”= 还在 OS 页缓存里,只有*机器*(而不只是进程)在窗口内死掉才会丢。

写入路径alwayseverysecno
服务器命令回复回复离开 shard 前已 durable(按批次组提交)windowed ≤ 1 s由 OS 定节奏
嵌入式门面操作(setzadd、…)返回即 durablewindowed ≤ 1 s由 OS 定节奏
嵌入式 atomic / atomic_all_shards提交即 durable(每个触及的 shard 一次 fsync)windowed ≤ 1 s由 OS 定节奏
嵌入式 Pipeline::commit返回即 durable,fsync 按 shard 合批windowed ≤ 1 s由 OS 定节奏
…以上任一 + Store::fsync_aof()无操作屏障处即 durable屏障处即 durable

Store::fsync_aof() 是逐写入粒度的耐久性逃生口(Postgres 按事务 synchronous_commit 那一路):部署跑 everysec 换吞吐,再把屏障放在少数几笔“一经确认就必须扛住机器崩溃”的写入之后。代价:每个脏 shard 一次 fdatasync

进程崩溃(SIGKILL)在 always 下绝不丢已确认的写入,其他策略最多丢一个 fsync 窗口;AOF 尾巴在下次打开时回放,撕裂的末帧会隔离(panic-quarantine),绝不静默应用。

有序停机SHUTDOWN 或 SIGTERM)在任何策略下都零丢失:排空过程会在退出前强制 fsync AOF 尾巴,所以崩溃可能丢掉的 everysec 窗口对干净停机不适用。

原子性章程(嵌入式 serving-store,v2.1)

恢复点(v2.3)

启用变更 feed([feed] enabled = true,见 cdc.md)后,每份快照都记下采集那一刻的 feed 游标——与快照数据本身在同一个禁追加窗口里冻结。由此得到恢复点契约:

快照 S + 从 S 所记游标起的 feed 帧 = 之后任意游标处的精确状态。

kevy_persist::read_snapshot_cursor(path) 能把游标读回来(v2.3 之前的快照返回 None——格式 v4 及更早不携带游标,但仍可完整加载)。这份契约的可执行形式是 bench/restore-drill.sh,作为一条 diskgate 项运行:写入 → SAVE → 再写入 → kill → 只用 dump 恢复 → 回放捕获的 feed 帧 → 逐键逐字节校验。

范围说明:feed 窗口就是内存中的 backlog。老过窗口的帧已经不在了——快照若老过窗口所及,就只是一次普通的快照恢复(S 时刻的状态),当不了 PITR 基点。依赖精确时间点恢复的话,打快照的频率至少要跟上窗口的翻转速度。

复制链路上的快照(v3.15)

副本掉出 backlog 窗口时,主节点内联推送给它的就是同一种快照格式(见 replication.md)。有一个语义值得记住:推送过来的快照会替换副本的本地状态,而不是合并——副本加载前先清空自己的键空间。这是刻意设计:重新加入的前主节点若带着分叉后缀(从未复制出去的写入),重同步就必须真正丢掉这条分叉,而不是在只做 upsert 的加载下把它留成残渣。