记录编解码与 CRC 校验:当前 RecordCodec 的真实实现

基于当前源码分析 seastar-log-engine 的文本行记录格式、可选结构化字段、CRC class、校验流程与恢复扫描语义。

先把格式说清楚

当前 RecordCodec 不是固定长度 header 加 payload 的二进制格式。

真实实现更接近一行文本记录:

[optional crc prefix]\t[field=value]\t[field=value]\t...payload=...

如果没有开启任何结构化字段,也没有开启 CRC,记录会退化成最简单的形式:

sanitized payload\n

这里的 sanitized 指的是把 payload 中的 \n\r\t 替换成空格,避免一条业务日志破坏按行读取和 tab 分隔字段的解析语义。

结构化字段是按配置打开的

encode_record_buffer() 会先判断当前是否需要结构化记录:

const bool crc_wanted = config.record_crc_enabled &&
    config.record_crc_class != CrcClass::none;
const bool any_structured = crc_wanted ||
    config.record_timestamp_enabled ||
    config.record_shard_id_enabled ||
    config.record_sequence_enabled ||
    config.record_level_enabled;

如果 any_structured == false,编码器只写 payload 和换行。

如果启用了结构化字段,当前可能出现的字段包括:

ts=YYYY-MM-DD HH:MM:SS.ffffff
shard=<shard_id>
seq=<sequence>
level=INFO|WARN|ERROR
payload=<sanitized payload>

需要注意的是,route_key 不会被写进记录本身。它在写入入口用于 ShardRouter 路由,决定消息落到哪个 shard,但当前 record format 不保存 route key。

CRC 前缀有三种语义

当前配置里 record_crc_enabled=true 之后,还要看 record_crc_class

enum class CrcClass {
    none,
    header,
    payload_hash,
    full,
};

对应到文件里的前缀格式是:

CrcClass前缀形态校验范围
headercrc=h:<hex32>\t只校验 metadata,不校验 payload 字节
payload_hashcrc=x:<hex32>:<hex64>\tmetadata + payload 的 XXH64 摘要
fullcrc=<hex32>\t校验完整 body

none 不写 CRC 前缀。

这种设计的重点不是“所有日志都必须 full CRC”,而是允许在完整性和吞吐之间做分级选择。full 覆盖范围最强;header 避免 payload 上的 CRC 成本;payload_hash 用 payload hash 把大 payload 的处理方式拆开。

编码路径的关键细节

编码时,正文从 base + prefix_size 开始写,CRC 前缀的位置先预留出来:

auto buffer = seastar::temporary_buffer<char>(prefix_size + body_estimate + 1);
auto* const base = buffer.get_write();
char* body = base + prefix_size;
char* out = body;

结构化字段通过 append_field_prefix() 写入。字段之间用 tab 分隔,字段值使用 append_literal()append_decimal() 直接追加。

payload 总是通过 append_sanitized_payload() 写入。这个函数会扫描 payload,把换行、回车和 tab 替换为空格;如果 CRC 指针不为空,也会把替换后的实际字节纳入 CRC。

最后,编码器根据 record_crc_class 回填开头的 CRC 前缀:

write_crc_prefix(base, crc ^ 0xffffffffU, config.record_crc_class, payload_hash);

也就是说,CRC 不在行尾,而是在行首前缀里。这一点和很多“payload 后追加 checksum”的格式不同。

CRC32 的实现方式

当前 CRC32 使用查表实现,并且有 slicing-by-8 风格的 8 字节块更新路径:

constexpr auto crc32_tables = make_crc32_tables();

std::uint32_t crc32(std::string_view data) noexcept {
    return crc32_update(0xffffffffU, data) ^ 0xffffffffU;
}

crc32_update() 会先按 8 字节块推进,最后处理剩余字节。这部分实现确实是性能敏感路径,但不能把它孤立理解成“CRC 已经很便宜”。

仓库现有 benchmark 反而说明:在当前 record-format 路径里,CRC 仍然是稳定主成本之一。payload=256 的 profile 中,record-crc-on 相对 baseline 吞吐下降约三分之一;在更大 payload 下,CRC 对 submit latency 的影响也更明显。

所以更准确的结论是:

当前 CRC 内核已经做了基础优化,但只靠微调 CRC 实现,很难彻底消除 record-format 路径上的 CRC 成本。

校验路径不是抛异常模型

读路径不会通过 CorruptedRecordError 这类异常把一条坏记录向上抛出。当前核心接口更克制:

bool verify_record_line(std::string_view line);
std::optional<ParsedRecord> parse_record_line(std::string_view line);

校验逻辑按前缀分流:

  1. crc=x::解析 CRC 和 payload hash,重新计算 payload 的 XXH64,再计算 metadata + hash 的 CRC。
  2. crc=h::只对 metadata 部分重新计算 CRC。
  3. crc=:对完整 body 重新计算 CRC。
  4. 没有 CRC 前缀:尝试按普通记录解析。

解析成功后,ParsedRecord 会包含:

struct ParsedRecord {
    std::uint32_t crc = 0;
    std::string timestamp;
    unsigned shard = 0;
    bool has_sequence = false;
    std::uint64_t sequence = 0;
    LogLevel level = LogLevel::info;
    std::string payload;
    std::string raw_line;
};

这也是查询接口能返回 crctimestampshardsequencelevelpayloadraw_line 的原因。

恢复扫描如何使用 RecordCodec

scan_log_content()LogManager 里的 streaming scan 都按行推进。

它们的核心策略是:

  1. 找到下一行。
  2. 非空行调用 verify_record_line()
  3. 如果校验失败或遇到不完整尾行,标记 clean_end=false 并停止。
  4. 记录最后一个可信的 valid_size
  5. 如果行里有 seq=,用它更新下一条 sequence。

这意味着恢复不是“遇到坏记录后跳过继续读”,也不是靠一套可配置的 skip/truncate/retry 策略。当前更保守:active log 的可信边界停在第一处坏行或不完整尾部之前,然后后续恢复逻辑再基于这个 valid_size truncate 到一致状态。

与 checkpoint 的关系

checkpoint sidecar 记录的是 logical_sizesequencerotation_index

恢复时并不会盲信 checkpoint。LogManager::recover_active_file() 会先扫描 active log 得到 verified 状态,再比较 checkpoint 的 logical_size 是否等于 verified valid_size

如果匹配,就可以复用 checkpoint 里的 sequence 和 rotation index。

如果 checkpoint 残缺或过旧,就记录 fallback,并使用 verified scan 的结果。这也是为什么 RecordCodec 的校验结果会影响恢复语义:它提供了 active log 当前真正可信的边界。

指标和观测边界

当前 reader 侧指标关注的是读路径结果,而不是单独暴露 crc_validationscrc_failures 这类计数。

已经存在的 reader 统计包括:

segments_read
archive_segments_read
active_segments_read
records_returned
corrupted_segments
corrupted_lines
gzip_read_errors

这些统计会进入查询服务的 /v1/status、gRPC GetStatus 以及 Prometheus 观测面。换句话说,CRC 失败最终会通过坏行、坏 segment、健康状态等方式反映出来,而不是通过一个独立的 CRC 告警系统体现。

性能上该怎么理解

当前仓库已有 profile 给出的结论比较明确:

  1. timestamp 成本经过缓存优化后已经不是最大热点。
  2. CRC 仍然是 record-format 路径里最稳定的主成本之一。
  3. checkpointrotate 在部分 profile 下不是主要瓶颈。
  4. 大 payload 会继续放大 CRC、sanitize 和内存搬运成本。

因此,如果目标是提高吞吐,不应该简单写成“生产环境总是开启 full CRC”。更合理的工程建议是:

  1. 明确日志类别对完整性的要求。
  2. 对关键审计类数据使用 full
  3. 对高吞吐、可丢弃或可重建的业务日志评估 headernone
  4. 用仓库 benchmark 脚本在真实 payload 分布下做 A/B。

总结

当前 RecordCodec 的设计可以概括为:

文本行格式 + 可选结构化字段 + 可选 CRC 前缀 + 保守恢复扫描

它不是二进制 record header,也不是独立的异常驱动 decoder。它更像一层为日志引擎量身定制的轻量格式:写入时足够简单,查询时能解析字段,恢复时能找到可信边界,性能上也允许通过 CRC class 在完整性和吞吐之间做取舍。

这篇文章最需要强调的是当前实现事实,而不是泛化的 CRC 教程。对 seastar-log-engine 来说,CRC 的价值不只是“检查数据有没有坏”,更重要的是参与 active log 的可信边界判断,并最终影响恢复后文件能否继续以一致状态追加。