日志的未来:必也正名乎
名不正,则言不顺;言不顺,则事不成。——《论语·子路》
题记:最近这阵子我在做一个 serverless framework。越往深处推,越觉得日志这件事会变成生死命门。serverless 每一次调用都会顺手吐出十几条日志;如果照传统做法老老实实全收下来,访问量一上来,光 observability 这一项就能把 aws 账单吹成天文数字。我趴在草稿纸上画字段、算保留、估基数,反复问自己同一个问题:日志这件事,真的只能是字符串吗?
字符串地基的尽头
打开任何一个用 tracing / log4j / zap 写出来的服务,你看到的本质都是同一件事:
info!(user_id = %uid, action = "retry", "after upstream failure");
它的样子像数据,骨子里是字符串。user_id 这个 key 不存在于任何地方,只存在于这一行调用里。下一个工程师去复制粘贴,可能写成 user、userId、uid、u_id,谁也不会被编译器拦下来。等真到上线那天,你在 Loki / Datadog / Splunk 里查的 PromQL、Lucene、SQL 全部基于这些口耳相传的字段名。
这就是当下日志的真实状态:没有 schema,只有 convention;没有约束,只有默契。 几百号工程师、十几种语言、上千个调用点拼出来的“默契”,注定会被时间磨损。重构、改名、合并、迁移,每一次都在偷偷地动这套默契里的某根线。监控能继续工作,纯属幸存者偏差。
更让人难受的是基数(cardinality)。一个不经心的 request_id = %rid 写进 metric label 里,Prometheus 在那一刻就埋下一颗炸弹,可能下个月某天才在内存表里炸开。这种事故每个用过 Prometheus 的团队都至少经历过一次,事后所有人都承诺再也不犯,过半年又会有新人犯一次。原因不是工程师笨,而是 label 的基数从来没有在代码里被表达过。编译器看不见它,CI 看不见它,PR review 也看不见它,等基数上去了,唯一能告诉你的,是凌晨三点的内存告警。
第三件糟心事是费用。在多租户平台上,日志的体量是吓人的。前阵子我在自己的 framework 上粗算了一下:平均一次函数调用会顺手产生十几条日志,全发到对象存储,每月光存这堆“info / debug 文本”就要烧五位数美刀,而其中能被人查到的不到 1%。剩下的 99% 是给“万一”准备的,可“万一”压根不来。
字符串日志的三个老毛病:演化静默失败、基数无人看管、成本与价值倒挂。它不是某个产品的问题,而是这个数据形态本身的问题。十年前 Splunk / ELK / Loki 选择“自由文本 + 索引”这条路,因为当时只有这条路能让人快速上手;今天我们继续走,是因为惯性。
是时候问一下:在 AI 时代,如果重新设计,日志应该长什么样?
把 Schema 还给观测
我的答案很直接:所有日志、指标、审计事件,都用 protobuf 定义;代码里只能 emit 已经定义好的 message。 生态里那些不归我们管的散装日志,由一个统一的兜底 message 收编。
为了讲明白,我用一个最简单的 todo 应用做例子。在 proto/todo/v1/events/ 下放一份事件目录:
syntax = "proto3";
package todo.v1.events;
import "todo/obs/options.proto";
// 一个待办被创建的事件
message TodoCreated {
option (todo.obs.tier) = TRACE;
option (todo.obs.retention_days) = 30;
string list_id = 1 [(todo.obs.label) = true];
string priority = 2 [(todo.obs.label) = true]; // "low" | "med" | "high"
string source = 3 [(todo.obs.label) = true]; // "web" | "ios" | "api"
string todo_id = 4 [(todo.obs.tier) = TRACE]; // 高基数,进 trace
}
// 同步到云端失败
message TodoSyncFailed {
option (todo.obs.tier) = LOG;
string list_id = 1 [(todo.obs.label) = true];
ErrorCategory category = 2 [(todo.obs.label) = true];
string error_message = 3 [(todo.obs.cardinality) = UNBOUNDED];
}
调用层只剩下一个宏:
emit!(TodoCreated {
list_id: list_id,
priority: "high",
source: "web",
todo_id: todo_id,
});
字段拼错?编译失败。漏了必填字段?编译失败。把 todo_id 当 label 用?编译失败,因为它的 tier 是 TRACE,宏会把它路由到 trace attribute,而不是 metric label。整个可观测性的“形”,从一行字符串提升到了一份带版本、带 owner、带注释的 schema。
听起来像是给开发者上枷锁。但这副枷锁,恰恰是观测性十年来最缺的那块骨头。下面我把它的好处一条条拆开。
老痛点在编译期消失
演化语义来自 protobuf 本身
protobuf 的 schema evolution 规则成熟得令人发指:加字段向后兼容、删字段必须先 reserved、改名是显式的两步迁移、整个 message 升版本就改成 TodoCreatedV2。这些规则不是新东西,是 Google 二十年踩坑攒下来的工程财富。
把它套到观测上,几个老痛点直接消失:
- 字段改名?破坏 schema,CI 拒绝合并。
- 加字段?additive,老消费者无感。
- 删字段?必须先标 deprecated,所有 dashboard / alert 的引用会被工具扫出来,再删才放行。
最关键的是,dashboard、alert、告警规则都直接引用 schema 而不是字符串。改一个字段名,仪表盘 CI 立刻爆红。这个反馈环过去要等到值班同学被叫醒才闭合,现在在 PR review 阶段就闭合了。
基数从“运行时炸弹”变成“编译期约束”
protobuf 的 option 机制让 cardinality 这件事第一次有了可声明的位置:
string list_id = 1 [(todo.obs.cardinality) = LOW]; // label
string priority = 2 [(todo.obs.cardinality) = LOW]; // label
string todo_id = 3 [(todo.obs.cardinality) = UNBOUNDED]; // attribute
string user_id = 4 [(todo.obs.cardinality) = HIGH]; // trace attribute
CI 阶段的 lint 工具拿到 schema 之后,可以做几件运行时永远做不到的事:
- 把所有 LOW 字段做笛卡尔积估算 label 组合数,如果超过预算(比如 100k)就拒绝合并。
- 把 UNBOUNDED 字段路由到 trace 或日志层,永远不让它出现在 metric label 里。
- 给每个租户做基数账本,超出额度的事件 schema 上线时直接拦下。
这是一个范式转变。过去基数问题靠人盯着 dashboard 上的“top series count” 猜,靠运维事后清理;现在基数问题在编译期就被解决了。
预聚合从“运行时优化”变成“结构性属性”
如果每一次 emit 都是一个有类型的 proto message,节点本地完全可以在内存里做预聚合:
counters[(TodoCreated, list_id=X, priority=high, source=web)] += 1
之前的字符串日志也可以做这件事,但是要靠采样、要靠正则、要靠运维去猜哪些日志可以聚。猜错了就丢真东西,猜对了也不全。proto 之上的预聚合就直接得多:低基数字段拼成 key,计数器加一,每个 scrape 周期上报一次差量,wire 上的事件量直接降下两到三个数量级。
我自己大致的估算:一个中等规模的系统,原始日志一天 5TB;按“低基数字段聚合 + 高基数字段单独走 trace”这套方案重整一下,落地存储能压到 50GB 出头,里面 90% 是 trace,10% 是 metric counter delta。一个数量级的成本省下来,全靠 schema 提前告诉系统“什么是骨架,什么是肉”。
审计、合规、跨语言
审计
审计事件是 schema-first 最强的支点。它本来就要求严格的字段、严格的保留策略、严格的分类。今天大多数公司怎么做的?运维写一份“审计字段约定”的 wiki 页面,每个团队自己实现,半年后就开始慢慢漂移,合规审计的时候再来一次大扫除。
写成 proto,审计就退化为一段 schema:
syntax = "proto3";
package todo.v1.audit;
message TodoAuditAction {
option (todo.audit.retention_days) = 2555; // 7 年
option (todo.audit.classification) = PII;
string actor = 1 [(todo.audit.field) = SUBJECT];
string list_id = 2 [(todo.audit.field) = RESOURCE];
string action = 3 [(todo.audit.field) = ACTION]; // CREATE | UPDATE | DELETE | SHARE
AuthzDecision decision = 4 [(todo.audit.field) = OUTCOME];
string source_ip = 5 [(todo.audit.cardinality) = UNBOUNDED];
}
合规审计不再是“翻 500 个微服务的日志调用点”,而是“review proto 目录”。这是云厂商,大厂以及几乎所有金融系统内部本来就在做的事,只是大多数中小公司没有能力/精力把这条路走下去。
跨语言变成代码生成的副产品
我做的这套 framework,宿主用 Rust,用户函数要支持 JS、Python、Go。每多一种语言 SDK,传统做法都要重新发明一套日志规范、一套 metric helper、一套 audit 客户端。proto-first 之后,每多一种语言只意味着:跑一遍 protoc,包一层语法糖,然后所有事件、所有 dashboard、所有 alert 直接通用。
一份 schema,多种 binding,这是 protobuf 在 RPC 上已经验证过的红利。把同一套机制延伸到观测,是顺水推舟,而不是另起炉灶。
自文档化的事件目录
protoc --descriptor_set_out 把整个事件目录构建出一份描述符。这份描述符可以喂给:
- Grafana:自动生成 dashboard 模板。
- Alertmanager:自动生成告警规则骨架。
- 文档站:proto 注释直接变成事件文档。
- IDE:emit 时拥有完整的字段补全。
- 合规系统:一键导出“该服务发了哪些 audit 事件”。
这种“一份描述符,多个工具消费”的格局,OpenAPI 在 API 上做到了,proto 在 RPC 上做到了,唯独观测一直在用字符串苦撑。把 schema 还给观测,等于把这一切自动化的可能性也一并还回来。
为什么以前不这么做
好处说完了,现在聊聊缺点。
开发体感的回退
今天敲一行 info!("retry happened for user={uid}") 是五秒钟的事。使用 schema-first 后:打开 proto 文件 → 加一个 message → 跑一遍生成 → 写 emit 调用。如果还要审 cardinality,PR review 又要多一轮。这是十倍的负担。
工程师会用脚投票。最常见的逃避就是定义一个万能的 DebugEvent { string payload = 1; },把所有不想写 schema 的字段塞进去。schema-first 在第一周就被打回原形。
后面我会讲,agent 时代这个痛点会几乎消失。但即使在今天,它也不是死局:你需要一条受控的 dev 通道(dev_event!),它在 prod 编译时直接失败;调试期可以用,发版前必须认真考虑。把短期摩擦换成长期可治理性,这笔账是划算的。
Schema 膨胀
一个中等规模的代码库会有几千个不同的“日志意图”。如果每个都升格成 proto message,几年下来事件目录可能膨胀到上万个 message。proto 编译变慢,repo 文件数变多,重复定义难免出现。
这个痛点没法绕过去,只能靠两件事:
- 模块化分文件:
todo/v1/events/list.proto、todo/v1/events/sync.proto、todo/v1/events/auth.proto,按模块拆,使爆炸半径局部化。 - 抽象通用模式:
HttpCallFailed、RetryAttempted、ResourceExhausted这种场景,做成可复用的事件骨架,避免每出一处重试都新造一个 message。
事件目录的“消肿”是一项持续的工作。但比起字符串日志的“暗增长”(你看不到,但每次部署都在增),proto 的膨胀是显式的、可见的、可被定期 review 的。
凌晨三点的“未知未知”
这是最让我犹豫的一条。
凌晨被叫醒,你不知道要找什么。日志的森罗万象可以为你兜底,比如,你可以 grep -i "slow query" node.log | grep alice 碰碰运气。任何字段、任何措辞、任何意外打印的栈,都可能成为破案的线索。这种“未知的未知”是字符串日志一不小心攒出来的财富。
proto-first 把这条路掐断了。你只能查已经定义过的事件。如果上周新加的某种错误路径还没来得及定义事件,今天的故障就毫无信号。schema-first 在已知故障域上的覆盖率是 100%,在未知故障域上的覆盖率是 0%。
这是 schema-first 必须给出的答案,也引出了我下一节要讲的事:留一扇逃生舱。
第三方库
你的代码可以全部 emit!,但 reqwest、tokio、sqlx 这些上游 crate 不会,它们一律 tracing::warn!。schema-first 的纯洁性在引入第一个第三方依赖时就破功了。
这个问题没有完美答案,但有可行答案:装一个 tracing subscriber,把所有非 proto 的 tracing 事件捕获下来,包成一个统一的 LegacyTracingEvent,按“日志层”处理(短保留、严格限速、不进 metric)。三方库的可观测性永远是二等公民,这点必须接受。
ForensicEvent:逃生舱
要让 schema-first 真正可用,必须在主干旁边留几条受控的旁路。我把它们叫做“逃生舱”。最重要的一个就是 ForensicEvent。
syntax = "proto3";
package todo.v1.events;
message ForensicEvent {
option (todo.obs.tier) = LOG;
option (todo.obs.rate_limit_per_min) = 1000;
option (todo.obs.retention_days) = 7;
string component = 1 [(todo.obs.label) = true];
string category = 2 [(todo.obs.label) = true];
string trace_id = 3 [(todo.obs.tier) = TRACE];
string payload = 4 [(todo.obs.cardinality) = UNBOUNDED];
}
它的设计哲学是几句话:
- 它是 schema 的一部分,不是 schema 的反义词。 它有名字、有 owner、有保留策略、有限速。它出现在事件目录里,可以被审计、被监控、被统计使用次数。
- 它默认只在错误路径上 emit。 健康路径走定义好的事件;错误路径在 emit 完正常事件之后,可以再带一份 ForensicEvent 把现场状态打包进去。
- 它跟 trace 采样绑定。 一个出错的请求,它路径上 emit 的 ForensicEvent 全部保留;一个健康的请求,路径上的 ForensicEvent 全部丢弃。这样既能保留现场,又不会把存储拉爆。
- 它的使用量是一项第一类指标。 哪个模块每天 emit 多少次 ForensicEvent、什么 category 占比最大,全部可见。如果某个模块开始把它当成万能日志桶,dashboard 会先发现,治理就有抓手。
围绕 ForensicEvent,再加两个旁路就完整了:
dev_event!宏:dev / debug 编译档可用,release 档直接 compile error。逛代码、改 bug 时用,发版前必须升级成预定义事件。LegacyTracingBridge:把第三方库的 tracing 事件统一收编成一个 message,落到日志层。
三条旁路加在一起,不是绕开 schema-first,而是用 schema 的方式管理所有 schema 之外的东西。它是把“未知”也命名,而不是把“未知”放养。
有了 ForensicEvent,“凌晨三点找不到日志”这件事的形态变了:你不会一无所有,你会拿到一份 ForensicEvent 流,里面有错误路径的现场快照;并且,这次故障一旦定位,下一个 PR 就把对应的事件加进 schema,下回这种故障一上来就有结构化信号。事件目录会沿着“曾经发生过的故障”方向自然生长,schema 跟生产环境共同进化。
Agent 时代的反转
如果到这里你还觉得这个方案“理想,但太重”,那我想说一件让我转变想法的事:这个方案的所有“痛点”,几乎都是给人类工程师准备的;当主要的代码作者从人变成 agent 之后,痛点会消失大半,价值会放大数倍。
把上一节那几个痛点重新过一遍:
开发负担。 开 proto 文件、加 message、跑 codegen、写 emit,对人是三十秒的上下文切换;对 agent 是一次工具调用。agent 没有“心流”,没有“懒得起身”,没有“哎反正先打个 print 算了”。它会把每个事件清晰定义,因为定义是它最自然的工作模式。
Schema 膨胀。 几千上万个 message 对人类是负担,对 agent 是数据。“事件目录里有没有跟我现在这个意图重复的?“这个问题人类靠脑子和 grep,agent 靠语义匹配,后者准确度高一个量级。膨胀本身依然要管,但 dedup、合并、归类,agent 比任何专门的”observability PM“都做得更稳定。
调试期摩擦。 人类喜欢临时 println,是因为 println 比加 schema 便宜。agent 那里两者一样便宜。dev_event! 这条逃生舱在 agent 写代码时几乎用不到,因为正路就是它的快路。
多语言。 多一种 guest SDK 对人是几周工程,对 agent 是一次模板生成。
治理。 给所有 emit 调用做 review、查 cardinality 预算、找重复事件、追 deprecate 字段,这些“重复但需要细心”的工作正是 agent 的舒适区。
说到底,人类不愿走 schema-first,无非是嫌多敲键盘、嫌多走一道流程。这两件事 agent 都不在乎,顺手就做了。于是问题反过来了:今天我们容忍字符串日志,是因为它对人廉价、对系统昂贵;明天 agent 来写代码,schema 对它一样廉价,那为什么还要让系统继续承担昂贵的代价?
这是我在 AI 时代的判断:proto-first 观测性,正在从“应该但太贵”变成“显然该这么做”。 拐点不是某个新工具发布,而是写代码的主体悄悄换了人。
新问题:语义漂移
agent 时代,在使用了 schema-first 后不是没有新问题,只是问题换了形状。其中最大的问题是语义(理解)问题。
agent 在写一个授权失败分支的时候,emit 了 AuthzDenied,但实际上这是用户根本不存在的情况,应该 emit UserNotFound。schema 都是对的,字段都填全了,但事件名跟语义错位。所有挂在“AuthzDenied”上的告警都开始误报。
两个 agent 在两个并行的 PR 里各自定义了一个事件,一个叫 RequestThrottled,一个叫 RateLimited,描述的其实是同一件事,但落在不同 dashboard 里。监控分裂,没人发现。
agent 重构一段代码的时候,判断某个事件“看起来冗余”,把它删了。所有依赖这个事件的告警安静地停摆。
这些问题,过去靠“代码 owner 的直觉”挡住。一个老司机看到 PR 里删掉了 AuthzDenied,眉头一皱就会问“等等,dashboard X 还在用这个吧”,但 agent 如果没有相应的上下文,就没有这种直觉。它的判断基于代码,缺一份“这个事件对外承诺了什么”的语义图。
这才是 schema-first + agent 这套范式真正要补齐的能力。几个解决方案:
- 事件要有自己的测试。 既然 emit 是一个有类型的事件,就可以为每个事件写一个“行为契约”测试:当满足某个前置条件时,被测代码必须 emit 这个事件、字段满足某个断言。agent 极擅长写测试,让它把这件事一并做了。
- CI 要求 emit 覆盖率。 事件目录里每个事件,至少有一条测试路径会触发它。“事件被定义但从未在测试里 emit”,立刻被标红。无用的事件不应该在目录里游荡。
- 语义 review 是最后一道人类闸门。 语法 / 字段 / 类型 / cardinality 由机器把关,agent 之间互查。但“这个事件叫这个名合不合适?它对外承诺的语义是什么?“这种判断仍然值得人类停下来想十秒钟。这不是事必躬亲,而是给 agent 的工作流上一道语义 sanity check。
- 删除事件需要二次确认。 agent 提交的 PR 一旦动到事件目录的 deletion,自动扫所有 dashboard / alert / 下游消费者,附在 PR 描述里。一切都让人看见,再决定要不要点 merge。
跟人类时代相比,问题的形态从“没人写日志 / 写错日志”变成了“事件名命错了 / 事件之间语义重合 / 事件被默默删掉”。这是更难的问题,但也是更值得解决的问题。它指向的是“我们想从可观测性里得到什么”,而不再是“我们怎么从字符串里凑出可观测性”。
从十个事件开始
放回我自己手上这个 serverless framework,我心里的落地节奏大概是这样。
第一步,挑十个最高频的内部事件先写 proto。比如承载用户请求的 RequestServed / RequestRejected / UpstreamFailed,运行时的 FunctionInvoked / FunctionExited / ColdStartHappened,控制面的 DeploymentApplied / BreakerTripped。把它们的字段、cardinality、tier、retention 全部声明清楚,迁移现有调用点,跑一两周生产。这一步的目的是把成本曲线打出来,给团队一个“看得见的省钱”。
第二步,搭 emit! 宏 + 三层路由(metric / trace / log)+ cardinality lint。这一步是基础设施,做完之后后面所有事件都吃这套。
第三步,迁移 audit。审计本来就要走结构化,这一步几乎没有阻力,反而能顺手把现有审计的合规漏洞补一波。
第四步,把 metric emission 全部定义。现在用 prometheus crate 直接 inc 计数器的地方,全部走 emit!。这一步很机械,agent 接得住。
第五步,加 ForensicEvent + dev_event! + LegacyTracingBridge。逃生舱体系到位,正路才敢锁紧。
第六步,把 obs-lint 写进 CI。从这里开始,事件目录的所有变更都进入治理流程。
第七步,剩下的长尾日志慢慢迁。这部分不急,前六步走完,存储和告警的乱象已经收敛 80%。
走完这七步,整个 framework 的可观测性就有一份真正的“目录”:dashboard 自动生成,alert 模板自动生成,租户 cardinality 账本上线,多语言 SDK 共享同一份 schema。再往后,新加的每个 feature 都默认带着事件 schema 一起进 main,事件定义变成肌肉记忆。
观测的下一站
把日志当字符串,是上一代基础设施的妥协。我们在那个妥协里活了数十年,已经把房子的地基都搬到了流沙上。今天再不去补 schema 这堂课,未来每一笔 observability 账单、每一次告警事故、每一个被 agent 改名的字段,都会接着提醒我们当初少做了一步。
protobuf 不是唯一的答案,schema-first 也未必只能用 protobuf。但 proto 已经在 RPC 这个相邻领域跑了二十年,工程上稳,工具链全,跨语言天然,annotation 机制干净。把它延伸到观测,是顺势而非另立。
ForensicEvent 这扇逃生舱,是这个方案能落地的关键。它告诉团队两件事:事件定义是默认;但我们也有有限的逃生舱。 凡是没事件定义的,都搭着 ForensicEvent 走;凡是搭得太多的,dashboard 会替你看见;凡是值得事件定义的,下一个 PR 就把它定义出来。schema 在生长,目录在演化,跟产品一起活下去。
agent 时代会把这件事推上正轨。人类工程师每天打字的痛苦曾是这条路上最大的阻力,现在阻力解除。剩下的真问题,比如语义漂移、未知未知、目录治理,都是更高一层的问题,是产品问题、是设计问题,是值得我们生而为人值得花脑子去解决的问题。
孔子答子路那句“必也正名乎”,今天读起来有种工程上的精确感。日志的下一站,是给每一件被观测的事一个名字。名字定下来,言才能顺,事才能成。 草稿纸上那笔让我心慌的账单,第一次有了一个能落地的答案。