文章851
标签121
分类10

Protobuf 语法通关教程:写给后端开发者的实战指南(PHP / Go 双视角)

适合人群:会一门后端语言(PHP / Go / Java 都行),听说过 gRPC 但被 .proto 文件劝退过的人。
读完你能得到:看懂任意 proto 文件、能自己写服务契约、知道怎么改 schema 不把生产搞挂。

目录

  1. Protobuf 是什么:三分钟版
  2. 环境准备与第一个 .proto
  3. 文件骨架五个关键字(syntax / package / import / option / extend)
  4. 类型系统:标量、字段编号与默认值
  5. 复合结构:repeated / map / oneof / 嵌套
  6. 枚举 enum:规则与坑
  7. import 进阶与 Well-Known Types
  8. service 与 rpc:四种调用模式
  9. google.api.http:一份契约同时暴露 gRPC 和 REST
  10. Schema 演进规则:怎么改不把生产搞挂
  11. proto2 vs proto3 速览
  12. 工程化:用 Buf 管理 proto
  13. 踩坑清单 10 条
  14. 参考资源

1. Protobuf 是什么:三分钟版

Protocol Buffers(简称 Protobuf)是 Google 开源的数据序列化协议 + 接口描述语言(IDL)。它由两部分组成:

  • 一门描述语言:你在 .proto 文件里声明"数据长什么样、服务有哪些方法"
  • 一套二进制编码 + 代码生成器:编译器(protocbuf)把 .proto 翻译成 Go / PHP / Java / Python 等语言的代码,并提供紧凑的二进制序列化格式

和 JSON 对比

维度JSONProtobuf
格式文本,肉眼可读二进制,不可读(需工具解码)
Schema无约束,靠文档和自觉强 schema,编译期校验
体积字段名重复传输,大只传字段编号 + 值,约为 JSON 的 1/3 ~ 1/10
解析速度慢(文本解析)快(二进制 Varint 解码)
跨语言天然支持靠代码生成,支持几乎所有主流语言
演进能力字段随意增删,易翻车有明确的兼容规则(见第 10 章)
典型场景对外 REST API、配置文件服务间 RPC(gRPC)、消息队列 payload、存储格式

一句话理解: JSON 是"每次通信都把字典抄一遍",Protobuf 是"双方先约好字典长什么样(.proto),通信时只传字典的页码和值"。

核心心智模型(这决定了你后面能不能看懂一切)

线上跑的二进制流里只有「字段编号 + 值」,没有字段名。

uid = 10086 序列化后大致是 [编号1][类型][值10086] 几个字节。所以:

  • 字段改名不影响线上数据(名字不进二进制)
  • 字段改编号 = 老数据全被解析成别的字段 = 事故

记住这一条,第 10 章的演进规则你只看标题就能推出来。


2. 环境准备与第一个 .proto

2.1 安装

# macOS
brew install protobuf buf

# 验证
protoc --version   # libprotoc 25.x
buf --version

2.2 第一个文件 user.proto

syntax = "proto3";              // ① 语法版本声明

package user.v1;                // ② 命名空间, 防符号撞名

option go_package = "example.com/gen/user/v1;userv1";  // ③ 生成 Go 代码的包路径

// 一个消息 = PHP 里的一个 DTO 类 / Go 里的一个 struct
message User {
  int64  uid     = 1;           // 字段: 类型 名字 = 编号;
  string name    = 2;
  string email   = 3;
  bool   is_vip  = 4;
  repeated string tags = 5;     // 数组
}

// 一个服务 = 一组 RPC 方法的契约
service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
}

message GetUserRequest {
  int64 uid = 1;
}

message GetUserResponse {
  User user = 1;                // 消息可以嵌套引用
}

2.3 编译生成代码

# 生成 Go 代码(需要 protoc-gen-go 插件)
protoc --go_out=. --go_opt=paths=source_relative user.proto

# 生成 PHP 代码(gRPC 的 PHP 插件)
protoc --php_out=. --grpc_out=. --plugin=protoc-gen-grpc=$(which grpc_php_plugin) user.proto

生成出来的东西是什么? 就是普通的类/结构体:

  • Go 侧:type User struct { Uid int64 ... },带 GetUid() 等 getter
  • PHP 侧:class User extends \Google\Protobuf\Internal\Message,带 getUid() / setUid()

你业务代码里 new 这些类、调 getter/setter、传给 gRPC 客户端 stub,序列化/反序列化全部由生成代码内置完成——你永远不用手写二进制编解码


3. 文件骨架五个关键字(PHP 对照版)

打开任意 proto 文件,开头高频出现的关键字就五个。用 PHP 类比一次讲清:

proto 关键字作用PHP 里的对应物
syntax = "proto3"声明本文件按哪版语法解析,必须是第一句declare(strict_types=1);
package user.v1命名空间(点号分隔),防全局符号撞名namespace User\V1;(反斜杠分隔)
import "google/api/http.proto"文件路径引入另一个 proto 的全部符号require + use 的合体
option xxx = yyy贴元数据(文件级 / 方法级),给编译器和框架读PHP 8 Attribute / docblock 注解
extend XxxOptions给"别人定义的类型"外挂自定义字段没有完全对应物,最接近 Laravel macro()

逐个展开:

3.1 syntax

syntax = "proto3";

proto2 和 proto3 规则不同(默认值、必填字段、枚举行为都有差异),不写默认 proto2——新工程一律 proto3

3.2 package

proto 编译时所有文件在同一个全局符号池,没有 PSR-4 那种目录约定,全靠 package 硬隔离。两个团队都定义 Message,没有 package 就编译冲突。

package earn.v1;   // 引用时写全名: earn.v1.SubscribeRequest

3.3 import

import "google/protobuf/timestamp.proto";   // 写的是文件路径, 不是包名

message Order {
  google.protobuf.Timestamp created_at = 1;  // 用全名引用
}

和 PHP use 的区别:PHP 的 use 只给单个类起别名(加载靠 autoloader);proto 的 import 是把目标文件的符号纳入可引用范围,之后用全名引用,没有别名层。

3.4 option

两个层级:

// 文件级: 给代码生成器的输出配置(对 proto 逻辑零影响)
option go_package = "example.com/gen/earn/v1;earnv1";
option java_package = "com.example.earn.v1";

// 方法级: 就是我们第 9 章要讲的 HTTP 注解
service PositionService {
  rpc Subscribe(SubscribeRequest) returns (SubscribeResponse) {
    option (google.api.http) = {post: "/api/v1/position/subscribe" body: "*"};
  }
}

方法级 option ≈ PHP 8 的 #[Route(...)]:贴在方法上的元数据,框架启动时反射读取。

3.5 extend

最陌生的一个。proto 是强 schema 二进制协议,不允许像 PHP 那样 __set 动态加属性,所以"给别人的类型加字段"必须显式声明:

// google/api/annotations.proto 的全部核心内容就这几行:
import "google/protobuf/descriptor.proto";   // MethodOptions 定义在这里
import "google/api/http.proto";              // HttpRule 定义在这里

extend google.protobuf.MethodOptions {
  HttpRule http = 72295728;   // 宣布: MethodOptions 多一个可选字段 http, 类型 HttpRule, 编号 72295728
}

人话:Google 在 descriptor.proto 里定义了 MethodOptions(方法的选项容器),我改不动它;但我可以声明"它现在多了一个扩展字段"。之后任何人写 option (google.api.http) = {...},就是在往这个扩展字段里填值。字段编号必须用 1000 以上的大段,避免和官方保留编号冲突。

PHP 里最接近的心智模型:

// 你自己定义一个 PHP 8 Attribute 类 —— 本质也是"注册一种新的、允许贴在方法上的元数据"
#[Attribute(Attribute::TARGET_METHOD)]
class HttpRoute {
    public function __construct(
        public string $method,
        public string $path,
        public string $body = '*',
    ) {}
}

Attribute::TARGET_METHODextend MethodOptions,意思都是"这种元数据只允许贴在方法上"。


4. 类型系统:标量、字段编号与默认值

4.1 标量类型总表

proto 类型GoPHP说明
doublefloat64float8 字节浮点
floatfloat32float4 字节浮点
int32int32int变长编码;存负数效率低(恒 10 字节),负数多用 sint32
int64int64int变长编码;同上
uint32 / uint64uint32/uint64int无符号变长
sint32 / sint64int32/int64intZigZag 编码,负数场景首选
fixed32 / fixed64uint32/uint64int定长 4/8 字节;值恒大于 2^28 时比变长省空间
sfixed32 / sfixed64int32/int64int定长有符号
boolboolbool
stringstringstring必须是合法 UTF-8
bytes[]bytestring(二进制)任意字节流,最长 2^32

金额场景的忠告: proto 没有 decimal 类型。金融系统通行做法是string 传十进制字符串"123.45000000"),业务代码里用 decimal 库(Go 用 shopspring/decimal,PHP 用 brick/math)解析计算。千万别用 double 存金额——0.1 + 0.2 != 0.3 的浮点误差在账务系统里是对不平的账。

4.2 字段编号(tag)规则

message User {
  int64 uid = 1;        // 等号右边这个 1 就是编号
}
  • 取值范围:1 ~ 536,870,911(2^29 - 1)
  • 19000 ~ 19999 是 protobuf 保留段,用了直接编译报错
  • 编号 1~15 只占 1 个字节,16~2047 占 2 字节 → 高频字段、核心字段尽量放 1~15
  • 编号一旦上线终身绑定(见第 10 章)

4.3 默认值与"字段 presence"(proto3 最大的坑之一)

proto3 里,字段不写就是零值,且零值不会进二进制流

类型默认值
数值0
string""
boolfalse
bytes
枚举第一个值(必须是编号 0 的那个)
repeated / map空集合
messagenull(消息类型有 presence)

坑: 对方传了 count = 0根本没传 count,在你的代码里都读出来 0——分不清。两种解法:

message Query {
  // 方案 1: proto3 optional(3.15+), 生成代码多出 HasCount()/count 指针
  optional int32 count = 1;

  // 方案 2: 包装类型(Well-Known Types), 生成代码里是可空对象
  google.protobuf.Int32Value limit = 2;
}

PHP 侧感受:方案 1 生成 getCount() + hasCount();方案 2 生成 getLimit() 返回 ?Int32Value,为 null 就是没传。


5. 复合结构:repeated / map / oneof / 嵌套

5.1 repeated —— 数组

message Product {
  repeated string tags        = 1;   // string[]
  repeated PriceTier rate_tiers = 2; // 消息数组, PHP 里读起来是 RepeatedField(可 foreach)
}

5.2 map —— 关联数组

message Config {
  map<string, string> labels = 1;
}
  • key 只允许:整数家族 / bool / string不允许浮点、bytes、消息
  • 遍历顺序不保证(底层就是 hashmap,PHP 同学注意:不是 PHP 数组那种插入序)
  • map 字段不能再标 repeated(它本身就隐含多条)

5.3 oneof —— 多选一,同时只有一个生效

message Payment {
  oneof method {
    string card_id     = 1;   // 设了 card_id, crypto_address 自动被清空
    string crypto_address = 2;
  }
}

语义:设其中一个,其它的自动置空。生成的代码里通常带 GetMethod() / WhichOneof() 判断当前是哪个。典型场景:请求参数互斥、结果类型互斥。

坑: oneof 里的字段无法再用 optional 修饰判断 presence——它本身就是 presence 语义。

5.4 嵌套定义

message Order {
  message Item {          // 嵌套消息, 全名 Order.Item
    string sku = 1;
    int32  qty = 2;
  }
  repeated Item items = 1;

  enum Status {           // 枚举也可以嵌套
    STATUS_UNSPECIFIED = 0;
    STATUS_PAID       = 1;
  }
  Status status = 2;
}

6. 枚举 enum:规则与坑

enum OrderDirection {
  ORDER_DIRECTION_UNSPECIFIED = 0;   // 铁律: 第一个值必须编号 0(零值语义 = "未设置")
  ORDER_DIRECTION_BUY  = 1;
  ORDER_DIRECTION_SELL = 2;
}

规则:

  1. 第一个值必须是编号 0——proto3 字段默认零值,枚举零值必须有名
  2. 底层就是 int32,跨语言生成的是各语言的枚举/常量
  3. 想让两个名字共用同一编号,要显式开 option allow_alias = true
  4. 建议每个枚举都定义 XXX_UNSPECIFIED = 0,把"未设置"和"值为 0 的某个业务态"区分开

坑(演进相关):proto3 收到不认识的枚举值不会报错,会保留原数字。老版本服务读到新版本加的枚举值,读出来是个"没名字的整数"——升级顺序要留意。


7. import 进阶与 Well-Known Types

7.1 三种 import

import "google/protobuf/timestamp.proto";        // 常规: 引入符号
import public "legacy/base.proto";               // 转口: 别人 import 我, 就能连带用 base.proto 的符号
import weak "vendor/plugin.proto";               // 弱引用(极少用, 知道有这个词就行)

7.2 Well-Known Types(官方自带,直接 import 就能用)

类型import 路径用途PHP/Go 里的样子
Timestampgoogle/protobuf/timestamp.protoUTC 时间点(秒 + 纳秒)Go timestamppb.Timestamp;PHP Google\Protobuf\Timestamp
Durationgoogle/protobuf/duration.proto时长
Anygoogle/protobuf/any.proto任意消息(存 type URL + 字节),事件总线/插件场景
Struct / Valuegoogle/protobuf/struct.proto任意 JSON 结构
StringValue 等 wrappersgoogle/protobuf/wrappers.proto可空标量(解决 presence 问题)
Emptygoogle/protobuf/empty.proto无字段占位(健康检查类接口)
FieldMaskgoogle/protobuf/field_mask.proto部分更新时声明"只改这几个字段"

时间字段建议: 统一用 Timestamp,别用 int64 存 Unix 秒——Timestamp 转 JSON 是 RFC3339 字符串(2026-07-30T08:00:00Z),可读性和时区语义都正确;裸整数到前端还得猜单位是秒还是毫秒。


8. service 与 rpc:四种调用模式

service TradeService {
  // ① 一元(Unary): 一问一答, 最常见 ≈ 普通 HTTP 请求
  rpc PlaceOrder(PlaceOrderRequest) returns (PlaceOrderResponse);

  // ② 服务端流: 一次请求, 服务端持续推送(行情订阅典型场景)
  rpc SubscribeTicker(TickerRequest) returns (stream Ticker);

  // ③ 客户端流: 客户端连续上传, 服务端最后一次性应答(批量导入)
  rpc ImportOrders(stream OrderRow) returns (ImportSummary);

  // ④ 双向流: 双方随时互发(撮合推送 + 下单通道合一)
  rpc TradeStream(stream ClientMsg) returns (stream ServerMsg);
}
模式关键字类比
一元一次 curl
服务端流returns (stream X)WebSocket 订阅 / SSE
客户端流stream X 入参分片上传完给回执
双向流两侧都 stream全双工 WebSocket

PHP 同学的限制提示: PHP 的 gRPC 扩展做客户端时只支持一元 + 服务端流两种;客户端流和双向流用不了(Go / Java 四种全支持)。PHP 服务要消费流式场景,通常让 Go 网关中转一层。


9. google.api.http:一份契约同时暴露 gRPC 和 REST

gRPC 走 HTTP/2 + 二进制,浏览器和外部合作方直接用不了。Google 的方案:在 proto 里给方法贴注解,声明它对应的 HTTP 路由,由框架(Kratos / grpc-gateway / Envoy)在启动时读出注解、自动注册 HTTP 端点——一次定义,gRPC 和 REST 双暴露。

syntax = "proto3";
package earn.v1;

import "google/api/annotations.proto";   // ≈ PHP 里 use Route; 只是让注解语法合法

service PositionService {
  // POST /api/v1/position/subscribe, 整个请求 JSON 映射为 SubscribeRequest
  rpc Subscribe(SubscribeRequest) returns (SubscribeResponse) {
    option (google.api.http) = {post: "/api/v1/position/subscribe" body: "*"};
  }

  // 路径参数: {uid} 从 URL 提取; 未进路径、未进 body 的字段自动成为 query 参数
  rpc GetPosition(GetPositionRequest) returns (GetPositionResponse) {
    option (google.api.http) = {get: "/api/v1/positions/{position_id}"};
  }

  // 一个 RPC 暴露多个 HTTP 端点
  rpc Redeem(RedeemRequest) returns (RedeemResponse) {
    option (google.api.http) = {
      post: "/api/v1/position/redeem"
      body: "*"
      additional_bindings {post: "/api/v1/positions/{position_id}/redeem" body: "*"}
    };
  }

  // 不贴注解 = 只走 gRPC, 不暴露 HTTP(对内接口)
  rpc EnsureSpendable(EnsureSpendableRequest) returns (EnsureSpendableResponse) {}
}

body 的三种取值:

写法含义
body: "*"路径参数之外的所有字段都进 HTTP body(POST 动作类接口常用)
body: "message"只有指定字段进 body,其余进 query
省略没有 body,字段全走路径 + query(GET/DELETE 常用)

这套注解的本质(呼应第 3.5 节):annotations.protoextend MethodOptions 注册了一个叫 http 的扩展字段;option (google.api.http) = {...} 就是填这个字段;Kratos 启动时通过 proto 反射读出来注册路由——和 Symfony 扫 #[Route] Attribute 是同一个套路。


10. Schema 演进规则:怎么改不把生产搞挂

回忆第 1 章的心智模型:二进制流里只有编号和值。演进规则全是它的推论。

10.1 安全 vs 危险操作速查

操作安全性说明
新增字段(用新编号)✅ 安全老代码读到不认识的编号直接忽略
删除字段,然后 reserved 编号和名字✅ 安全(推荐流程)防止后人复用
字段改名⚠️ wire 安全,代码危险名字不进二进制,但生成代码的方法名全变,调用方要跟着改
int32int64uint64bool 互转⚠️ 兼容(varint 家族)值超出新类型范围会被截断
stringbytes⚠️ 兼容前提是内容是合法 UTF-8
改字段编号❌ 事故老数据会被解析成别的字段
改成不兼容类型(如 int32string❌ 事故解码直接错
singularrepeated 切换❌ 对数值类型危险packed 编码格式不同
给 oneof 增删字段⚠️ 谨慎老代码不认识新分支会拿到空
枚举新增值⚠️ 谨慎老代码读出不认识的整数(见第 6 章)

10.2 删字段的正确姿势:reserved

message User {
  reserved 5, 8 to 10;        // 这些编号永久封存, 谁用谁编译报错
  reserved "old_name", "fax"; // 名字也封存(防止名字被复用导致语义混淆)

  int64 uid    = 1;
  string name  = 2;
  // email 原来在 5, 已下线
}

为什么名字也要 reserved?编号防的是二进制层面的事故;名字防的是——半年后新同事看到 fax 空出来,以为是新功能位就填进去了。

10.3 上线顺序口诀

  1. 先发「能读新字段」的服务端/消费方
  2. 再发「会写新字段」的客户端/生产方
  3. 永远假设新旧版本长期共存(滚动发布、客户端不升级都是常态)

11. proto2 vs proto3 速览

维度proto2proto3
必填字段required(后被证明是灾难)删除,只有普通字段
默认值可自定义 default = 100固定零值,不可自定义
字段 presence所有字段都有 hasXxx()标量没有(需 optional 或 wrappers)
枚举首值任意编号首值必须是 0
扩展 extend支持任意消息只允许扩展 descriptor.proto 里的 Options 家族
JSON 映射无官方规范有官方 JSON Mapping
现状存量老系统新项目唯一选择

12. 工程化:用 Buf 管理 proto

protoc 手工拼参数、vendor third_party 的时代过去了,现代做法是 Buf(≈ proto 世界的 Composer/npm):

# buf.yaml —— 依赖与代码检查
version: v2
modules:
  - path: proto
deps:
  - buf.build/googleapis/googleapis   # google.api.http 等官方 proto 直接引用, 不用拷进仓库
lint:
  use: [STANDARD]
breaking:
  use: [FILE]                          # CI 里自动做破坏性变更检查(第 10 章规则的自动化)
# buf.gen.yaml —— 代码生成
version: v2
plugins:
  - local: protoc-gen-go
    out: gen/go
  - local: protoc-gen-go-grpc
    out: gen/go
buf lint          # 风格检查(文件包名、目录结构、枚举首值 0 等)
buf breaking --against '.git#branch=main'   # 和 main 分支对比, 有破坏性变更直接 fail
buf generate      # 一键生成全部语言

目录布局建议:

proto/
  earn/v1/position.proto      # package earn.v1
  earn/v1/product.proto
  user/v1/user.proto          # 目录结构与 package 对齐(buf lint 会强制)

13. 踩坑清单 10 条

  1. 枚举第一个值忘了设 0 → 编译报错 The first enum value must be zero
  2. double 存金额 → 浮点误差,账务对不平。金额用 string 传十进制 + 业务侧 decimal 库。
  3. 以为 proto3 能分清"没传"和"传了 0" → 分不清。要区分用 optionalgoogle.protobuf.*Value 包装类型。
  4. 复用已删除字段的编号 → 老数据/老客户端把新字段解析成乱码。删字段必须 reserved
  5. map 当 PHP 数组用,依赖遍历顺序 → proto map 不保证顺序,要顺序用 repeated message {key, value}
  6. 核心字段编号用到 16 以上 → 每个字段多浪费 1 字节。高频字段守住 1~15。
  7. int64 到前端 JS 变精度 → proto3 JSON 映射里 int64 会序列化成字符串(防 JS 2^53 精度丢失),前端别当 number 处理。
  8. 负数用 int32 而不是 sint32 → 每个负数恒占 10 字节,带宽浪费。
  9. Timestamp 换成 int64 存时间 → 前端猜单位、时区出一天差。统一 Timestamp(UTC)。
  10. 以为改了 proto 只影响自己 → proto 是跨团队契约。改之前 buf breaking,发版遵守"先读后写"顺序(第 10.3 节)。

14. 参考资源

我让 6 个大模型参加了 2025 高考数学:3 个满分,最低 94 分

如果把一整套高考数学卷交给当前主流大模型,并且禁止联网、禁止互相抄答案、只允许提交一次,它们能考多少分?

这一次,我没有问大模型几个零散的数学问题,而是认真布置了一间“AI 高考考场”。

统一试卷、统一规则、同时开考,甚至还安排了一个单独的模型担任监考官。

最终结果有些出乎意料:

  • GPT-5.6:150 分
  • Opus 5:150 分
  • Kimi K3:150 分
  • Codex:143 分
  • Cursor Grok 4.5:141 分
  • GLM 5.2:94 分

同一张试卷,最高分与最低分相差了 56 分

AI GAOKAO · 2025
当 6 个大模型走进
高考数学考场
同一张试卷 · 禁止联网 · 禁止互抄 · 只准交卷一次
150
最高分
3
满分模型
56
最高最低分差
19
试题总数

一、先准备一张所有模型都能读懂的试卷

本次使用的是 2025 年普通高等学校招生考试(新高考 I 卷)数学,适用地区包括山东、广东、湖南、湖北、河北、江苏、福建、浙江、河南、江西、安徽。

原始 PDF 不只是文字和公式,还包含表格、坐标图与立体几何图形。如果直接读取 PDF,部分模型可能遇到根号、分数、希腊字母或图形信息识别错误。

因此,正式开考前,我先把整套试卷整理成统一的 Markdown:

  • 全卷共 19 题;
  • 数学公式统一使用 LaTeX;
  • 表格转换为结构化 Markdown 表格;
  • 原卷图形保留为图片;
  • 每张图额外补充纯文字描述;
  • 清除 PDF 文字层中的乱码、断行和 OCR 错误。

所有模型读取的都是同一份文件:

exam_2025_math_clean.md

这一步非常重要。否则最后比较的可能不是模型的数学能力,而是谁更擅长猜测乱码。

试卷标准化流水线
INPUT
原始 PDF
公式乱码、断行、图形与文字层混合
PROCESS
识图与人工校订
LaTeX 公式、结构化表格、图形文字描述
OUTPUT
统一 Markdown
所有模型读取完全相同的 19 道题

二、考场规则:不联网、不互抄、只能交一次

为了尽量接近一次真实的限时考试,我给所有参赛模型设置了相同规则。

我的原始要求

  1. 所有模型统一使用 exam_2025_math_clean.md
  2. 考试期间禁止网络检索答案;
  3. 只能依靠模型自身能力完成试卷;
  4. 目标答卷时间为 5 分钟;
  5. 禁止读取或参考其他模型的答案;
  6. 每个模型只允许提交一次;
  7. 提交后不允许二次演算或修改答案;
  8. 只提交最终答案,不展示完整推理过程;
  9. 不允许修改原始试卷文件;
  10. 由 Composer 2.5 担任独立监考官。

原计划参赛阵容包括:

  • GPT-5.6
  • Opus 5
  • Kimi K3
  • Codex
  • Gemini 3.5 Flash
  • GLM 5.2
  • Cursor Grok 4.5

不过,实际执行时 Gemini 3.5 Flash 不在当前可用模型列表中。为了不擅自更换型号,本次没有用其他 Gemini 版本代替,所以最终共有 6 名考生入场。

考场纪律
STRICT MODE
01 · 禁止联网
考试期间不得检索答案
02 · 独立作答
不得读取其他模型答卷
03 · 目标 5 分钟
同批启动,限时交卷
04 · 只交一次
提交后不得修改答案
05 · 只写答案
不展示完整推理过程
06 · 独立监考
Composer 2.5 只监考不答题

三、开考:6 个模型同时作答

6 个模型在同一个并行批次中启动,分别独立读取试卷。

监考官 Composer 2.5 不参与答题,只负责核对规则和记录可观察到的行为。它确认了试卷路径有效,并且自身没有修改试卷或输出答案。

但这里必须说明实验边界:

  • 系统没有记录每个模型精确到秒的作答耗时;
  • 聊天时间戳只能确认整批答卷在约 6 分钟的窗口内返回;
  • 因此不能严格证明每个模型都在 5 分钟内完成;
  • 每个模型都只有一次提交记录,没有再次交卷或修改答案;
  • 模型内部是否自行验算,无法从外部技术审计;
  • Kimi K3 的答卷中可以看到少量中间式,其他模型主要提交最终结果或简短结论。

换句话说,我能够确认“没有二次提交”,但不能把“模型内部从未回头检查”当成已经证实的事实。

考生状态 · 同批并行启动
GPT-5.6
已交卷 · 1 次
Opus 5
已交卷 · 1 次
Kimi K3
已交卷 · 1 次
Codex
已交卷 · 1 次
Cursor Grok 4.5
已交卷 · 1 次
GLM 5.2
已交卷 · 1 次
监考官:Composer 2.5 · 未作答 · 未修改试卷 · 未发现可证实违规

四、阅卷标准:满分 150 分

考试结束后,阅卷阶段允许联网,用于交叉核验公开答案和题型分值。

本卷采用新高考 I 卷常见的 150 分结构:

题型题号分值
单项选择题1—840 分
多项选择题9—1118 分
填空题12—1415 分
解答题15—1977 分
总计1—19150 分

解答题采用以下题目总分:

  • 第 15 题:13 分
  • 第 16 题:15 分
  • 第 17 题:15 分
  • 第 18 题:17 分
  • 第 19 题:17 分

为了比较模型的最终解题结果,本次排名使用的是 “最终答案正确度评分”:最终结论正确,就按照对应小问计分。

这不等于真实高考卷面分。

真实高考明确要求解答题写出文字说明、证明过程或演算步骤。由于我在考试规则中要求模型“只提交答案”,证明题缺少完整过程,若严格按照真实高考阅卷,它们不可能仅凭一句“结论成立”拿到全部过程分。

因此,本文成绩代表的是:

模型最终答案的正确程度,而不是一张符合高考书写规范的正式答题卡得分。

五、成绩公布:3 个满分

排名模型客观题解答题总分
1GPT-5.673/7377/77150
1Opus 573/7377/77150
1Kimi K373/7377/77150
4Codex73/7370/77143
5Cursor Grok 4.573/7368/77141
6GLM 5.257/7337/7794

最醒目的结果不是第一名,而是前三名同时拿到了满分。

GPT-5.6、Opus 5 和 Kimi K3 的答案完全命中。更值得注意的是,它们不仅基础题和客观题正确,后面的数列、立体几何、椭圆和三角函数压轴题也给出了正确结果。

Codex 和 Grok 4.5 的客观题同样全部正确,差距主要出现在解答题最后阶段。

GLM 5.2 则从第 11 题开始出现明显失误,最终停在 94 分。

最终答案正确度
满分 150 · 分数越高,横条越长
3 个满分
GPT-5.6150
Opus 5150
Kimi K3150
Codex143
Cursor Grok 4.5141
GLM 5.294
评分口径:最终答案命中分,不等同于真实高考过程分。
交互式成绩面板:2025 Math Model Grading

六、它们分别错在了哪里?

GPT-5.6、Opus 5、Kimi K3:全部命中

三者在最终答案层面没有发现错误,均得到 150 分。

但需要再次强调:其中涉及证明的题目只提交了结论,不能据此断言它们在真实高考阅卷中也会获得满分。

Codex:倒在椭圆题最后一问

Codex 前 17 题以及第 19 题均与正确答案一致。

它唯一的错误出现在第 18 题第(2)问第②小问:

  • Codex 作答:约 $5.515$
  • 核验答案:$3\sqrt3+3\sqrt2$

该小问扣 7 分,最终得分 143 分

Cursor Grok 4.5:两处失分

Grok 4.5 的客观题全部正确,但解答题出现两处问题:

  1. 第 15 题独立性检验的 $\chi^2$ 统计量多了一位数量级,但“检查结果与患病有关”的结论正确,因此只扣部分分;
  2. 第 18 题最后一问最大值错误。

最终得分 141 分

GLM 5.2:错误开始连锁出现

GLM 5.2 的主要失分包括:

  • 第 11 题多选题出现错选;
  • 第 12、14 题填空错误;
  • 第 16 题第(2)问结果错误;
  • 第 17 题空间角结果错误;
  • 第 18 题后两问错误;
  • 第 19 题第(1)、(3)问错误。

它的前 10 题与其他模型保持一致,但进入后半卷后,错误数量明显增加,最终得分 94 分

真正拉开差距的是第 18 题
第(2)问第②小问:求 |PQ| 的最大值
正确答案
3(√3 + √2)
GPT-5.6 · Opus 5 · Kimi K3
CODEX
≈ 5.515
该小问扣 7 分
GROK 4.5
5 + 3√2
该小问扣 7 分
GLM 5.2
√10
第 18 题后两问均错误

七、这场测试能说明什么?

1. 当前头部模型已经具备很强的高中数学结果输出能力

在统一输入、禁止联网、完整试卷和短时间约束下,3 个模型给出了全卷正确答案。

至少从“得到最终结果”这一维度看,它们已经能稳定处理:

  • 复数、集合与函数;
  • 向量和解析几何;
  • 概率统计与数列;
  • 立体几何;
  • 椭圆综合题;
  • 三角函数压轴题。

2. 客观题已经很难拉开头部模型的差距

GPT-5.6、Opus 5、Kimi K3、Codex 和 Grok 4.5 的客观题全部得到 73 分。

真正产生差距的是解答题,尤其是第 18 题椭圆综合题。Codex、Grok 4.5 和 GLM 5.2 都在这道题的后半部分失分。

3. “答案正确”与“会不会证明”不是同一件事

本次测试为了控制输出长度,要求模型只提交答案。

这适合比较最终结果,却不适合评价完整的数学表达、推导严谨性和证明能力。一个模型可以猜中或算出结论,但如果不能提供可检查的推理链,就不能直接等同于真实考生的满分答卷。

4. AI 测评首先要控制输入质量

如果每个模型读取到的公式、表格和图形不一致,那么排行榜没有意义。

这次测试中,先把 PDF 清洗为统一 Markdown,再让所有模型同时读取,是整个实验成立的基础。

八、这次实验仍有哪些局限?

这不是一项严格的学术基准测试,至少存在以下限制:

  1. 每个模型只运行了一次,无法反映多次采样下的稳定性;
  2. 没有记录单模型精确耗时;
  3. 不能审计模型内部是否自行验算;
  4. 模型只提交答案,无法完整评价证明过程;
  5. 解答题小问分值采用模拟拆分,并非教育考试院公开的逐步采分细则;
  6. Gemini 3.5 Flash 因当前环境不可用,未实际参赛;
  7. 本次成绩只对应当前试卷、当前提示词和当前模型版本,不能直接推广到所有数学任务。

如果要继续完善这项测试,下一轮至少应该加入:

  • 每个模型重复测试 3—5 次;
  • 记录准确开始时间、结束时间和首字延迟;
  • 同时保留“只交答案组”与“完整过程组”;
  • 使用盲审方式对证明题单独评分;
  • 在不改变原意的前提下,准备不同排版版本,测试模型对输入形式的敏感程度。

九、最后

这场 AI 高考最有意思的地方,并不是“某个模型拿了满分”。

真正值得关注的是:当输入被认真整理、规则被统一之后,头部模型之间的差距已经被压缩到少数几道综合题上。

它们在基础题上的表现越来越接近,决定排名的,开始变成复杂推导中的稳定性。

而这也提醒我们:

测试大模型,不能只随手问一道题。
要给它完整试卷、统一规则、独立考场,以及一套能够被复核的评分标准。

下一次,我准备让它们挑战另一年的高考数学。

到时候,是继续出现多个满分,还是会有新的模型翻车?

NEXT EXAM
下一场:2026 高考数学
同样的规则,更难的新卷。
下一次还会出现三个满分吗?
To be continued

附:本次实验信息

  • 试卷:2025 年新高考 I 卷数学
  • 试卷格式:清洗后的 Markdown
  • 实际参赛模型:6 个
  • 监考模型:Composer 2.5
  • 考试阶段网络:禁止
  • 阅卷阶段网络:允许,仅用于交叉核验
  • 提交次数:每个模型 1 次
  • 精确作答耗时:未记录
  • 评分口径:最终答案正确度评分
  • 满分:150 分

参考资料

  1. 2025 年普通高等学校招生全国统一考试(新高考 I 卷)
  2. 2025 年高考全国一卷数学真题及计分规则
  3. 2025 新高考 I 卷第 18 题解析
  4. 2025 新高考 I 卷第 19 题研究与解析
注:公开答案和解析可能来自教育机构或教师整理,本文已结合独立演算交叉核验。解答题模拟小问分值不代表教育考试院正式评分细则。

一次线上 Nginx `malloc failed (12: Cannot allocate memory)` 故障排查与修复

环境:Nginx 反向代理(10.0.12.13)→ 后端 API 服务(10.0.4.10:8901)
现象:接口大面积报错,错误日志刷屏

一、故障现象

某天下午,监控告警显示交易所后台的资产类接口(/ApiInt/Assets/depositWithdrawList/Contract/openPositionList 等)大量返回 502,查看 Nginx error.log 发现日志被同一类错误刷屏:

2026/07/22 17:40:59 [emerg] 4257#0: *6286680928 malloc(1048576) failed
(12: Cannot allocate memory) while reading response header from upstream,
client: 10.0.2.19, server: localhost,
request: "POST /ApiInt/Assets/depositWithdrawList HTTP/1.1",
upstream: "http://10.0.4.10:8901/ApiInt/Assets/depositWithdrawList",
host: "10.0.12.13:8901"

几个关键信息值得注意:

  1. 日志级别是 [emerg],这是 Nginx 最高级别的错误,说明问题已经影响到进程正常工作;
  2. malloc(1048576) failed,即 Nginx 向操作系统申请 1MB(1048576 字节) 内存被拒绝,errno 12 = ENOMEM;
  3. while reading response header from upstream,发生在读取后端响应头的阶段——这个 1MB 正是 proxy_buffer_size 对应的缓冲区;
  4. 连接编号已经到了 *6286680928(62 亿+),说明这个 worker 进程运行时间很长、承载的请求量巨大。

二、原因分析

2.1 这 1MB 是谁申请的?

Nginx 作为反向代理,每收到一个上游响应,都会先分配一块 proxy_buffer_size 大小的缓冲区来存放响应头。检查配置后果然发现:

proxy_buffer_size 1m;
proxy_buffers 8 1m;
proxy_busy_buffers_size 2m;

proxy_buffer_size 1m 意味着每一个活跃的代理连接都要先 malloc 1MB。做个简单的算术:

10,000 并发连接 × 1MB = 约 10GB 内存(仅响应头缓冲)
再叠加 proxy_buffers 8×1m,峰值时单连接理论上限可达 9MB

而这类接口返回的是 JSON,响应头通常只有几百字节到几 KB,1MB 的头部缓冲纯属浪费,却在高并发下把内存活活吃光。

2.2 系统层面的验证

登录机器确认内存状态:

# 查看整体内存,available 接近 0 即为耗尽
free -h

# 按内存占用排序,看是谁吃掉了内存
ps aux --sort=-rss | head -20

# 查看是否触发过 OOM Killer
dmesg -T | grep -i -E "oom|out of memory"

# 查看 nginx worker 的资源限制(排除 ulimit -v 限制)
cat /proc/$(pgrep -f "nginx: worker" | head -1)/limits | grep -i "address\|memory"

典型的结论有两种:

  • 系统内存真的耗尽:available 趋近于 0,可能还伴随 OOM Killer 杀进程的记录;
  • 内存没满但分配被拒:多半是进程被 ulimit -v(虚拟内存上限)、cgroup memory limit(容器场景)或 vm.overcommit_memory=2 的严格模式限制住了。

本例属于前者:过大的 proxy 缓冲配置 × 高并发,把物理内存打满,且机器没有配置 swap,malloc 直接失败。

三、修复方案

3.1 紧急止血

# 1. 临时释放页缓存,缓解压力(治标)
sync && echo 1 > /proc/sys/vm/drop_caches

# 2. 若无 swap,先加一块应急 swap 兜底,避免 malloc 直接失败
fallocate -l 4G /swapfile
chmod 600 /swapfile
mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
swap 只是安全网,不是解药。真正的问题在配置上。

3.2 根治:调整 Nginx 缓冲区配置

对于返回 JSON 的 API 网关,合理的配置量级是 KB 而不是 MB:

# 响应头缓冲:64k 足以覆盖绝大多数带大 Cookie/Token 的响应头
proxy_buffer_size        64k;

# 响应体缓冲:8 块 × 64k = 512k,超出部分自动落盘临时文件
proxy_buffers            8 64k;
proxy_busy_buffers_size  128k;

# 限制落盘临时文件大小,防止磁盘被打爆
proxy_max_temp_file_size 512m;

修改后验证并平滑重载:

nginx -t && nginx -s reload

调整后单连接的头部缓冲从 1MB 降到 64KB,内存占用直接下降约 16 倍,同样 10,000 并发只需要约 640MB。

如果确实有个别接口响应头超大(例如网关透传了巨型 Set-Cookie),应该单独为该 location 提高 proxy_buffer_size,而不是全局放大。收到 upstream sent too big header 报错时再针对性调整即可。

3.3 系统与内核层面加固

# 1. 确认 overcommit 策略(默认 0 即可,谨慎使用 2)
sysctl vm.overcommit_memory

# 2. 容器/systemd 场景:确认没有过低的内存限制
systemctl show nginx | grep -i memory

# 3. 适当调低 swappiness,让 swap 只在真正紧张时使用
sysctl -w vm.swappiness=10

3.4 建立监控防复发

  • available memory、swap 使用率设置阈值告警(如 available < 10% 告警);
  • 对 Nginx error.log 中的 [emerg][alert][crit] 关键字做日志告警;
  • 定期压测验证:并发数 × 单连接缓冲上限,估算内存水位是否在安全范围内。

四、验证结果

配置修改并 reload 后:

  1. free -h 显示 available 内存恢复到正常水位;
  2. error.log 不再出现 malloc failed;
  3. 502 告警消除,depositWithdrawList 等接口恢复正常响应。

五、总结与经验

  1. malloc(N) failed 中的 N 往往能直接定位到配置项。本例的 1048576 = 1m,一眼就能对应到 proxy_buffer_size 1m;
  2. 缓冲区配置要按"单位连接成本 × 峰值并发"来估算,不能拍脑袋给个大数字图省事;
  3. API 类反向代理的 proxy_buffer_size 用 16k~64k 就足够,MB 级配置几乎都是误用;
  4. 没有 swap 的机器,内存耗尽时是"硬着陆"——malloc 直接失败、OOM Killer 随机杀进程。加一块小 swap 作为缓冲能给你争取到告警和处理的时间;
  5. [emerg] 级别日志一旦出现就应该立刻告警,而不是等业务方反馈 502。

PostHog 实战指南:会话录屏与用户动线分析的使用及注意事项

PostHog 实战指南:会话录屏与用户动线分析的使用及注意事项

本文面向需要在产品中引入行为埋点、会话录屏(Session Replay)和用户动线分析的技术团队,重点覆盖接入方式、脱敏配置、成本控制与合规注意事项。对数据敏感型业务(金融、交易类产品)尤其适用。

一、PostHog 是什么

PostHog 是一个开源的产品分析平台,核心能力包括:

模块说明
Product Analytics事件埋点、漏斗、留存、路径分析
Session Replay会话录屏,回放用户真实操作过程
Heatmaps点击热力图、鼠标移动轨迹、死点击/怒点检测
Autocapture自动采集点击、输入、表单提交等前端事件
Feature Flags / Experiments功能开关、灰度发布、A/B 测试
Surveys应用内问卷

与 Mixpanel / Amplitude 相比,它的差异化在于:开源可自部署(数据可完全留在自己边界内)、录屏与分析数据打通(可以从漏斗流失直接跳到对应用户的录屏),以及按量付费、免费额度较大。

录屏的实现原理

PostHog 的录屏不是视频录制,而是基于 rrweb 记录 DOM 快照 + 增量变更(Mutation),回放时在播放器中重建页面。这带来两个重要特性:

  1. 体积小、对页面性能影响可控;
  2. 脱敏发生在采集端——被打码的内容根本不会离开用户浏览器,而不是存下来后再遮挡。

二、快速接入

2.1 Web 端(JS Snippet 或 npm)

npm install posthog-js
import posthog from 'posthog-js'

posthog.init('<YOUR_PROJECT_API_KEY>', {
  api_host: 'https://us.i.posthog.com',   // 欧洲区用 https://eu.i.posthog.com
  defaults: '2026-05-30',                  // 锁定默认配置版本,避免 SDK 升级引入行为变化

  // ---- 录屏相关 ----
  session_recording: {
    maskAllInputs: true,          // 所有输入框内容打码(强烈建议保持默认 true)
    maskTextSelector: '.ph-mask', // 额外需要打码的文本元素
    blockSelector: '.ph-no-capture', // 完全不录制的元素(连结构都不采集)
  },

  // ---- 动线相关 ----
  autocapture: true,              // 自动采集点击/输入/提交事件
  capture_pageview: true,
  capture_dead_clicks: true,      // 死点击检测
  enable_heatmaps: true,
})

识别用户与手动埋点:

// 登录后绑定用户(用你系统内的稳定 ID,不要用邮箱/手机号明文)
posthog.identify('user_8f3a2c', { plan: 'pro', kyc_level: 2 })

// 关键业务事件建议手动埋,不要完全依赖 autocapture
posthog.capture('order_submitted', {
  symbol: 'BTC-USDT',
  order_type: 'limit',
  // 注意:金额、余额等敏感数值三思后再上报
})

2.2 移动端

iOS / Android / React Native / Flutter 均有官方 SDK,录屏需在初始化时显式开启。注意两点:

  • 移动端录屏计费是 Web 的 2 倍单价
  • 移动端脱敏配置与 Web 不同(如 iOS 的 maskAllTextInputsmaskAllImages),接入前务必单独过一遍移动端隐私配置文档。

2.3 反向代理(推荐)

直连 *.posthog.com 的上报请求容易被广告拦截插件屏蔽,导致数据缺失。建议通过自己域名做反向代理:

# 示例:将 https://ph.yourdomain.com 转发到 PostHog
location / {
    proxy_pass https://us.i.posthog.com;
    proxy_set_header Host us.i.posthog.com;
}

然后把 api_host 改为自己的代理域名。这同时能提升国内网络环境下的上报成功率。


三、Session Replay:配置与脱敏(重点)

3.1 脱敏的三个层级

层级手段效果
输入内容maskAllInputs: true(默认)所有 input/textarea 内容替换为 *
文本元素maskTextSelector 或元素加 .ph-mask文本显示为打码占位
整块屏蔽元素加 .ph-no-capture该 DOM 子树完全不采集

金融/交易类产品的最低要求

<!-- 余额、持仓、资产总额:整块屏蔽 -->
<div class="asset-overview ph-no-capture">...</div>

<!-- KYC 表单页:整页屏蔽或干脆对该路由禁用录屏 -->
<form class="kyc-form ph-no-capture">...</form>

<!-- 用户昵称、邮箱等 PII 文本:打码 -->
<span class="ph-mask">user@example.com</span>

按路由禁用录屏:

// 在敏感页面(KYC、提现、银行卡绑定)主动停止录制
if (SENSITIVE_ROUTES.some(r => location.pathname.startsWith(r))) {
  posthog.stopSessionRecording()
} else {
  posthog.startSessionRecording()
}

3.2 上线前的脱敏验收清单

  • [ ] 密码、验证码输入框在回放中显示为打码
  • [ ] 余额 / 持仓 / 资产数字不可见
  • [ ] KYC 证件照片、上传图片不被采集(图片默认按占位处理,但需实测确认)
  • [ ] Network 面板记录中不含 Authorization 头、token、请求体敏感字段(如开启了网络请求采集,检查 recordHeaders / recordBody 配置)
  • [ ] Console 日志采集不会打印出敏感对象(生产环境建议收敛 console 输出)
  • [ ] 用真实测试账号走一遍完整交易流程,然后逐帧回放检查
原则:脱敏配置宁可过度,不可不足。 打码多了最多损失一些排障信息,漏了则是数据泄露事故。

3.3 录屏采样与触发策略

全量录屏在大流量产品下既贵又没必要。常用策略:

posthog.init('<KEY>', {
  session_recording: {
    // 方式一:全局采样,只录 10% 会话
    sampleRate: 0.1,
  },
})

更推荐基于条件触发(在 PostHog 后台的 Replay 设置中配置):

  • 只录发生了报错 / rageclick 的会话;
  • 只录命中某个 feature flag(如新版下单页灰度用户)的会话;
  • 最小会话时长过滤,丢弃秒开秒走的无效会话。

这样能把录屏量压缩到真正有排障和分析价值的那部分。


四、动线分析:Autocapture、热力图与挫败信号

4.1 Autocapture 的取舍

自动埋点开箱即用,但有两个代价:

  1. 事件量大 → 直接影响账单,建议配置 autocapture 的元素白名单/黑名单(例如忽略高频无意义的滚动区域点击);
  2. 语义弱 → 自动事件是"点击了 button.btn-primary",而不是"提交了订单"。核心转化事件必须手动埋点,autocapture 只作为兜底和探索用途。

推荐分层:手动埋点覆盖核心漏斗(注册 → 入金 → 首次下单 → 复购),autocapture 覆盖长尾探索。

4.2 热力图与挫败信号

  • Clickmap:基于 autocapture 展示元素级点击分布;
  • Heatmap:基于坐标的点击 / 鼠标移动密度图;
  • Rageclick(怒点):1 秒内在约 30px 范围内点击 3 次,典型的用户挫败信号;
  • Dead click(死点击):点击后页面无任何响应——发现"用户以为能点但实际不能点"的 UI 误导的利器,需在配置中开启 capture_dead_clicks: true

排除某元素的死点击误报(比如一个纯展示但长得像按钮的标签):

<div class="badge ph-no-deadclick">VIP</div>

4.3 推荐工作流

User Paths 路径分析发现异常流失页
        ↓
该页 Heatmap:定位 dead click / rageclick 聚集的元素
        ↓
筛选出现该行为的用户录屏,逐个回放(结合 Console + Network)
        ↓
定位根因 → 修复 → 用同一漏斗 + Replay 验证

录屏回看建议对照确认后再下结论——rageclick 有时只是用户习惯性连点,单一信号不要直接当成产品缺陷证据。


五、成本控制

PostHog Cloud 按产品分别计量(2026 年中的公开价格,以官网 pricing 页为准):

项目免费额度/月超出单价(起)
分析事件100 万~$0.000198/条,阶梯递减
Web 录屏5,000 条$0.005/条(50 万条以上降至 $0.0015)
移动端录屏Web 的 2 倍
Feature Flag 请求100 万$0.0001/次起
问卷回复1,500$0.10/份起

实践建议:

  1. 每个产品单独设 Billing Limit(硬性消费上限)——尤其是录屏。实际超支往往不是事件费,而是设计、客服团队都开始用录屏排障后录屏量暴涨;
  2. 事件侧:合并语义重复的事件、给 autocapture 设过滤规则、匿名事件比识别事件便宜(能不 identify 的流量别急着 identify);
  3. 录屏侧:用采样 + 条件触发替代全量录制;
  4. 月事件量到千万级以上时,认真对比一下自部署的服务器成本 vs Cloud 账单。

六、合规注意事项

这部分对金融/交易类产品是硬约束,不是可选项:

  1. 用户同意(Consent):在欧盟等地区,录屏类追踪普遍被认定为需要用户明示同意(consent-gated)。正确姿势是默认不启动录制,在用户于 Cookie/隐私弹窗中同意后再调用 posthog.startSessionRecording(),拒绝则调用 posthog.opt_out_capturing()
  2. 数据驻留:PostHog Cloud 分 US(us.posthog.com)与 EU(eu.posthog.com)两个区域,注册时选定。若你的用户主体在欧洲或有 GDPR 要求,选 EU 区;若监管要求数据不得出境/不得交第三方,直接考虑自部署。
  3. 隐私政策同步更新:接入录屏后,隐私政策中必须披露会话记录行为、数据用途与保留期限。
  4. 数据保留期:免费/标准计划下录屏与事件有保留期限制(如 1 年),敏感行业注意与自身的数据留存合规要求对齐——既有"至少存多久"的要求,也有"最多存多久"的要求。
  5. 员工访问控制:录屏里即使脱敏后仍是用户行为记录,内部应按最小权限原则开放 Replay 访问,并利用项目/组织权限隔离。

七、Cloud 还是自部署?

维度PostHog Cloud自部署(Self-hosted)
运维成本零运维需维护 ClickHouse、Kafka 等组件,量大时不轻松
数据边界数据在 PostHog 的 AWS 集群(US/EU)数据完全在自己基础设施内
功能全功能,更新最快开源版功能有子集限制,部分企业功能不可用
成本模型按量付费,量大后账单显著服务器 + 人力成本,量大后反而可能更划算
适合中小团队、快速验证数据敏感(金融/医疗)、超大事件量、有合规硬约束

一个常见的判断方法:看你敢不敢把"含用户行为录屏的数据存在第三方美国服务器上"写进合规评审文档。写不进去,就自部署。


八、常见坑总结

  1. 忘了配脱敏就上线 → 上线前必须逐帧回放验收(见 3.2 清单);
  2. 被广告拦截插件屏蔽 → 上报域名走自己的反向代理;
  3. 全量录屏导致账单失控 → 采样 + 条件触发 + Billing Limit 三件套;
  4. 完全依赖 autocapture → 核心漏斗事件必须手动埋点,否则语义混乱、后期无法分析;
  5. identify 使用明文 PII 作 distinct_id → 用内部稳定 ID,邮箱/手机号即使要传也放属性且评估必要性;
  6. 单页应用(SPA)路由切换不触发 pageview → 确认 SDK 版本的默认配置已处理 history 路由,否则手动 posthog.capture('$pageview')
  7. 移动端直接套用 Web 的脱敏认知 → 移动端 SDK 的脱敏配置项和默认行为不同,需单独验证;
  8. 把 rageclick 直接当产品缺陷证据 → 结合录屏确认后再定性。

参考

本文价格与功能信息基于 2026 年 7 月的公开资料,接入前请以官方文档与 pricing 页为准。

分布式事务深度分析:Saga + Outbox

2026-07-21T05:58:44.png

分布式事务深度分析:Saga + Outbox

文档定位:讲清「跨服务一致性」里最常用的组合拳——Saga(编排/补偿)+ Outbox(可靠投递)
刻意剥离:不绑定理财 / 资产 / 订单等具体域名;用抽象角色(发起方、参与方、资源方)说明。
阅读目标:知道各自解决什么问题、为何常一起用、失败怎么收、何时不该用。

目录

  1. 问题本质:分布式下没有免费的 ACID
  2. 先分清两层问题
  3. Saga:业务级长事务与补偿
  4. Outbox:本地提交与对外通知的原子性
  5. 组合拳:Saga + Outbox 如何配合
  6. 业务中真实出现的一致性场景(抽象)
  7. 失败模型与恢复策略
  8. 落地骨架(表 / 状态机 / Worker)
  9. 与其它分布式方案对照
  10. 选型决策树与反模式
  11. 设计检查清单

1. 问题本质:分布式下没有免费的 ACID

单体时代,一笔业务多表变更可以包在同一个数据库事务里:

BEGIN
  写业务单
  改库存/额度
  记流水
COMMIT   ← 全成或全败

拆成微服务后,每个服务只拥有自己的库。跨服务调用无法共享同一个 DB 事务。于是出现经典半成功:

现象含义
A 已提交,调 B 超时不知道 B 有没有做成
A、B 都成功,发 MQ 失败下游永远收不到事件
补偿做到一半进程挂了留下「中间态」

分布式事务在工程上通常不是「恢复 XA」,而是回答三件事:

  1. 前进:多步怎么按序做完?
  2. 后退:某步失败,已成功的步骤怎么撤销(或冲正)?
  3. 通知:本地真相变更后,怎么保证外部一定能感知?

Saga 主要覆盖 1+2;Outbox 主要覆盖 3。二者正交,经常叠加。


2. 先分清两层问题

flowchart TB
  subgraph L1["业务一致性层"]
    S["Saga / TCC / 同步收口 + 反查"]
  end
  subgraph L2["可靠通信层"]
    O["Outbox / 本地消息表 / 事务消息"]
  end
  subgraph L3["最终兜底层"]
    R["对账 / 冲正工单 / 人工介入"]
  end
  L1 --> L2
  L2 --> L3
层级典型手段解决的问题不解决的问题
业务一致性Saga、TCC、同步原子服务多参与方「整体成功或可接受终态」事件是否发出
可靠通信Outbox、Inbox、事务消息「本地已提交 ⇒ 消息最终可达」对方业务是否成功
最终兜底对账、红冲、告警漏网差异被发现并抹平主路径实时正确

常见误解:以为上了 Outbox 就不需要补偿。
事实:Outbox 只保证「消息发出」;消息消费失败、下游拒绝,仍要 Saga/冲正/重试。


3. Saga:业务级长事务与补偿

3.1 定义

Saga = 一组本地事务按序执行;任一步失败,按逆序(或预定义策略)执行补偿事务,使系统进入可接受的终态(成功完成或明确失败,而非永久悬挂)。

注意:Saga 追求的是 业务语义上的一致性(最终一致),不是数据库级隔离级别下的全局原子性。执行过程中其它读者可能看到中间态——这是设计时必须接受并产品化处理的点(例如展示「处理中」)。

3.2 两种形态

编排式 Orchestration协作式 Choreography
谁推进中心协调器(服务 / 状态机 / Workflow 引擎)各服务听事件自行推进
优点补偿顺序清晰、易观测、易超时治理无单点协调器、解耦
缺点协调器成为关键路径链路难追、补偿顺序难控、易环依赖
推荐资金/账务类优先编排通知、积分、弱一致旁路可用协作

3.3 抽象时序(编排式)

sequenceDiagram
  participant C as Coordinator
  participant A as Participant_A
  participant B as Participant_B
  participant D as Participant_D

  C->>A: Step1 Do
  A-->>C: OK
  C->>B: Step2 Do
  B-->>C: OK
  C->>D: Step3 Do
  D-->>C: FAIL

  Note over C: 进入补偿分支
  C->>B: Compensate Step2
  B-->>C: OK
  C->>A: Compensate Step1
  A-->>C: OK
  C->>C: saga_status = COMPENSATED

3.4 补偿设计要点(深度)

  1. 补偿必须幂等
    网络重试会导致补偿被调用多次;Compensate(x) 第 2 次应返回成功且不产生副作用。
  2. 补偿不是「物理回滚」
    很多步骤不可撤销(已发短信、已对外付款)。常见做法是:

    • 冲正:记一笔反向业务(金额、状态、审计齐全)
    • 标记作废:原单 VOIDED,不再参与后续计算
    • 人工工单:技术无法自动撤销时升级
  3. 空补偿 / 跳过补偿
    若某步「只读校验」或「尚未产生外部副作用」,失败时无需补偿,避免假补偿逻辑。
  4. 中间态必须一等公民
    状态机至少区分:PENDINGRUNNINGSUCCEEDED | COMPENSATINGCOMPENSATED | FAILED
    「处理中」对用户可见时,要有超时与恢复 Job,否则会永久卡死。
  5. 隔离与并发
    Saga 执行期间,其它请求可能读到中间数据。手段:

    • 资源预留(接近 TCC 的 Try)
    • 业务锁(按主体维度串行)
    • 语义接受最终一致(展示延迟)

3.5 Saga 与「同步收口」的关系

若跨服务变更能收口到一个具备本地事务的原子服务(一次 RPC 内完成多账户变更),则可大幅缩短 Saga:

短 Saga:本地落单 → 调原子服务 → 本地终态
长 Saga:本地落单 → 调服务A → 调服务B → 调服务C → …

短 Saga 仍需要:超时反查、幂等键、卡住恢复——只是步骤更少。


4. Outbox:本地提交与对外通知的原子性

4.1 要解决的经典坑

错误写法:
  BEGIN; 写业务表; COMMIT;
  publish(Kafka);          ← 进程在此崩溃 ⇒ 事件丢失

或:
  publish(Kafka);
  BEGIN; 写业务表; COMMIT; ← 消息已发但本地回滚 ⇒ 幽灵事件

4.2 Outbox 模式

同一本地事务内:

  1. 写业务表(真相)
  2. outbox 行(待投递事件)
  3. COMMIT

另有 Relay / Worker(轮询或 CDC)把 PENDING 事件投递到 MQ,成功后标记 SENT(或删除)。

sequenceDiagram
  participant App as App
  participant DB as Local_DB
  participant Relay as Outbox_Relay
  participant MQ as Message_Broker
  participant Cons as Consumer

  rect rgba(251, 191, 36, 0.12)
    Note over App,DB: 本地事务
    App->>DB: BEGIN
    App->>DB: INSERT business_row
    App->>DB: INSERT outbox PENDING
    App->>DB: COMMIT
  end

  Relay->>DB: SELECT PENDING FOR UPDATE SKIP LOCKED
  Relay->>MQ: Publish event
  MQ-->>Relay: ACK
  Relay->>DB: UPDATE outbox SENT

  Cons->>MQ: Consume
  Cons->>Cons: 幂等处理 + Inbox 可选

4.3 Outbox 不保证什么

Outbox 保证Outbox 不保证
本地提交后事件最终会被投递(at-least-once)恰好一次(exactly-once)语义
投递顺序在单聚合根内可设计保证全局全序
与业务行同生共死消费者一定处理成功

因此消费端必须:

  • 幂等(业务唯一键 / Inbox 去重表)
  • 可重试
  • 失败进 DLQ + 告警

4.4 实现变体

变体做法取舍
轮询 Outbox 表Worker SKIP LOCKED 拉取实现简单;有投递延迟
CDC(如 Debezium)听 WAL 变 outbox 行延迟低;运维复杂
事务消息(部分 MQ)半消息 + 回查少自建表;绑定 MQ 能力
Inbox消费前先落库去重防重复消费;多一张表

4.5 与「本地消息表」的关系

「本地消息表」与 Outbox 同构:都是「业务事务内写待发消息」。业界常把二者当同一模式的不同叫法;差别多在投递实现细节。


5. 组合拳:Saga + Outbox 如何配合

5.1 分工一句话

组件一句话
Saga管「多步业务做完或退干净」
Outbox管「某步本地成功后,下一步/旁路一定能被驱动」

5.2 典型组合拓扑

flowchart LR
  subgraph Coord["协调服务"]
    SM["Saga 状态机"]
    OB["outbox 表"]
    SM --> OB
  end
  OB -->|Relay| MQ["Broker"]
  MQ --> W1["Worker: 调参与方 A"]
  MQ --> W2["Worker: 调参与方 B"]
  W1 -->|结果回调/事件| SM
  W2 -->|结果回调/事件| SM

两种推进方式(可混用):

  1. 同步推进:协调器 RPC 调参与方;仅把「领域事件」经 Outbox 发给旁路(通知、审计、读模型)。
  2. 异步推进:协调器每完成一步只写状态 + Outbox;Worker 消费后调下一参与方,再回写 Saga 状态。

资金敏感路径更常见 同步推进 + Outbox 发领域事件;高吞吐批处理更常见 异步推进

5.3 组合后的端到端语义

用户请求
  → 协调器开启 Saga(本地事务:写 saga_instance + 业务草稿)
  → 执行 Step N(参与方本地事务)
  → 成功则:更新 saga 进度 + Outbox「StepN_DONE」(同事务)
  → Relay 投递 → 触发 Step N+1 或通知下游
  → 若 Step 失败:进入补偿链(每步补偿同样建议可 Outbox 驱动或同步调用)
  → 终态:SUCCEEDED / COMPENSATED / FAILED_NEED_MANUAL

5.4 为何「只 Saga 不够」

没有 Outbox 时,协调器常见写法:

更新 saga 状态为 STEP2_READY
publish("do_step2")   ← 崩溃则 Step2 永不触发,Saga 永久卡住

有 Outbox:状态与「待执行下一步」同事务落库,崩溃后由 Relay/恢复 Job 续跑。

5.5 为何「只 Outbox 不够」

Outbox 保证消息发出,但:

  • 参与方执行失败需要补偿编排
  • 多步依赖顺序与超时策略需要状态机
  • 半成功需要反查与冲正

这些都不是 Outbox 的职责。


6. 业务中真实出现的一致性场景(抽象)

下列场景刻意去品牌化,对应各域「跨服务改账/改单」的共性。

场景 A:双写资源(服务内缓存 + DB)

  • 问题:先改缓存成功、写库失败 → 缓存脏。
  • 常用解:同请求内顺序约束 + 失败回滚缓存;或 DB 为真相、缓存可重建;对账 Job 兜底。
  • Saga/Outbox 角色:通常不需要跨服务 Saga;Outbox 可用于「缓存失效事件」。

场景 B:本域落单 + 调外部账务服务

本域:创建业务单 PROCESSING
外部:扣减 / 冻结 / 入账
本域:业务单 SUCCESS + 写流水
故障点处理
外部明确失败本域标 FAILED;若已占本地资源则补偿释放
外部超时未知禁止盲补偿;用幂等键反查外部状态,再补做或补偿
外部成功、本域崩溃恢复 Job:反查成功 → 补完成本域终态;写 Outbox 通知

这是 短 Saga + 幂等反查 +(可选)Outbox 的高频形态。

场景 C:多参与方链式变更

校验 → 预留资源 → 账务变更 → 写业务终态 → 通知

任一步失败需逆序释放/冲正 → 编排 Saga
每步成功对外广播 → Outbox

场景 D:批处理(计费 / 结算 / 派发)

生成待处理行(幂等唯一键)
  → Outbox / 队列异步执行外部入账
  → 回写成功凭证
  → 失败重试 / DLQ / 次日补偿

批处理更依赖:幂等键 + Outbox/队列 + 对账;Saga 可简化为「单行状态机」。

场景 E:已对外生效后的纠错

技术补偿窗口已过(用户已消费、报表已出)→ 业务冲正单,而不是回头跑 Saga。
冲正本身仍建议:本地冲正单 + Outbox 事件 + 外部 Reverse 接口(幂等)。


7. 失败模型与恢复策略

7.1 故障分类

类型例子策略
确定性业务失败余额不足、状态非法立即补偿或拒绝;不要无限重试
瞬时基础设施失败超时、连接重置有限次重试 + 退避
未知结果请求已发出,响应丢失反查,禁止假设失败就补偿
进程崩溃停在任意步骤恢复 Job 按状态机续跑
投递失败Outbox 长期 PENDING老化告警 + 重试 + 人工
消费失败下游持续报错DLQ + 熔断 + 工单

7.2 「未知结果」是资金类最高危点

flowchart TD
  T["RPC 超时 / 连接断开"] --> Q{"能否用业务幂等键反查?"}
  Q -->|能| S["GetStatus / 查原单"]
  S --> A{"外部状态"}
  A -->|SUCCESS| Fix["补齐本地终态 · 发 Outbox"]
  A -->|FAILED / NOT_FOUND| Comp["走补偿或安全重放"]
  A -->|PROCESSING| Wait["退避再查 · 超时告警"]
  Q -->|不能| Manual["禁止自动乱补偿 · 告警人工"]

铁律:对「可能已成功」的外部写操作,补偿前必须先反查;否则可能造成重复入账或重复扣款

7.3 恢复 Job 的职责

与 Outbox Relay 互补:

组件扫什么做什么
Outbox Relayoutbox.status=PENDING投递到 Broker
Saga Recoversaga 超时未终态反查参与方、续跑或补偿
Reconcile双边流水/余额差异告警、自动抹平或工单

8. 落地骨架(表 / 状态机 / Worker)

以下为领域无关的最小骨架,落地时改 schema 名即可。命名对齐团队惯例:uid_at TIMESTAMPTZ(6)、软删 deleted_at、金额带业务前缀。

8.1 Saga 实例表(示意)

-- [skill: go-team-standards · 数据库设计] saga 实例表示意(非某域正式 DDL)
CREATE TABLE demo.saga_instances (
    id              BIGINT PRIMARY KEY,
    saga_type       VARCHAR(64)  NOT NULL,  -- 业务编排类型
    biz_key         VARCHAR(128) NOT NULL,  -- 业务幂等键
    current_step    VARCHAR(64)  NOT NULL,
    status          SMALLINT     NOT NULL,  -- 1运行 2成功 3补偿中 4已补偿 5失败待人工
    payload_json    JSONB        NOT NULL,
    created_at      TIMESTAMPTZ(6) NOT NULL,
    updated_at      TIMESTAMPTZ(6) NOT NULL,
    deleted_at      TIMESTAMPTZ(6)
);
-- UNIQUE (saga_type, biz_key) WHERE deleted_at IS NULL

8.2 Outbox 表(示意)

CREATE TABLE demo.outbox (
    id              BIGINT PRIMARY KEY,
    aggregate_type  VARCHAR(64)  NOT NULL,
    aggregate_id    VARCHAR(64)  NOT NULL,
    event_type      VARCHAR(64)  NOT NULL,
    payload_json    JSONB        NOT NULL,
    status          SMALLINT     NOT NULL,  -- 1待投递 2已投递 3投递失败
    retry_count     INT          NOT NULL DEFAULT 0,
    next_retry_at   TIMESTAMPTZ(6),
    created_at      TIMESTAMPTZ(6) NOT NULL,
    sent_at         TIMESTAMPTZ(6),
    deleted_at      TIMESTAMPTZ(6)
);

8.3 同事务写入伪代码

// [skill: go-team-standards · 技术方案] 本地事务内业务行 + outbox
err := db.Transaction(func(tx *gorm.DB) error {
    if err := tx.Create(&bizRow).Error; err != nil {
        return fmt.Errorf("insert biz: %w", err)
    }
    if err := tx.Create(&outboxRow).Error; err != nil {
        return fmt.Errorf("insert outbox: %w", err)
    }
    return nil
})

8.4 状态机最小集合

NEW
  → RUNNING
      → SUCCEEDED
      → COMPENSATING → COMPENSATED
      → FAILED_RETRYABLE  →(恢复 Job)回到 RUNNING 或 COMPENSATING
      → FAILED_TERMINAL / NEED_MANUAL

8.5 可观测性必打点

指标用途
saga_running_age_seconds卡住发现
outbox_pending_age_seconds投递老化
saga_compensate_total补偿频率异常
outbox_publish_fail_totalBroker/权限问题
日志字段 trace_id / saga_id / biz_key串联排查

9. 与其它分布式方案对照

方案一致性强度锁/阻塞业务改造典型用途
本地事务单服务单库
2PC / XA极少;同资源管理器
TCC较强(预约)中(预留)高(三接口)强预留场景
Saga最终 + 补偿中(补偿逻辑)跨服务长流程
Outbox通信可靠事件必达
事务消息通信可靠绑定特定 MQ
同步原子服务 + 反查边界内强资金收口
对账 + 冲正最终低~中一切方案的兜底

Saga + Outbox 的定位:在放弃全局 2PC 的前提下,用可补偿的业务流程 + 可靠的状态推进/通知,换取可扩展的微服务架构。


10. 选型决策树与反模式

10.1 决策树

变更是否只在一个服务的一个库?
  ├─ 是 → 本地事务(不要 Saga)
  └─ 否
       能否收口到一个原子写服务?
         ├─ 是 → 同步调用 + 幂等 + 超时反查(短链路);旁路事件用 Outbox
         └─ 否
              参与方是否支持「预留 → 确认/取消」?
                ├─ 是 → 优先 TCC
                └─ 否 → 编排 Saga + 补偿
                     每步成功是否需要驱动下一步或通知外部?
                       └─ 是 → 叠加 Outbox(或等价可靠投递)
务必保留:对账 / 冲正 / 人工终态

10.2 反模式

反模式为何有害正确做法
先发 MQ 再写库幽灵消息Outbox:先同事务落库
超时即补偿可能重复冲正已成功外部操作先反查
补偿不可幂等重试放大资金事故补偿与正向共用幂等键
无中间态 / 无恢复 Job永久卡单状态机 + Recover
用 Outbox 替代业务补偿消息到了业务仍可能失败分层设计
协作式 Saga 做资金主路径难追责、难补偿排序资金用编排
忽略对账静默资金漂移定期双边核对

11. 设计检查清单

写某域技术方案涉及跨服务写时,逐项打勾:

Saga

  • [ ] 步骤列表与成功条件写清
  • [ ] 每步补偿动作写清(含「无需补偿」)
  • [ ] 补偿与正向均幂等
  • [ ] 超时 / 未知结果走反查,不盲补偿
  • [ ] 状态机含中间态与人工终态
  • [ ] 有 Recover Job 与告警阈值

Outbox

  • [ ] 业务行与 outbox 同行同事务提交
  • [ ] Relay 使用 SKIP LOCKED 或等价租约,防多实例抢同一行
  • [ ] at-least-once + 消费端幂等
  • [ ] PENDING 老化告警、失败重试上限、DLQ
  • [ ] payload 含足够溯源字段(biz_key / trace_id

兜底

  • [ ] 对账口径与差异阈值
  • [ ] 冲正 / 人工路径
  • [ ] 禁止用 float 金额;时间 UTC _at

附录 A:概念速查

术语含义
本地事务单库 ACID 事务
补偿抵消已成功步骤副作用的业务操作
冲正记账意义上的反向单据(常用于不可物理回滚)
幂等同一业务键执行任意次,效果与一次相同
at-least-once至少投递一次,允许重复
Inbox消费端去重落库
DLQ死信队列,承接反复失败消息

附录 B:一页纸结论

┌─────────────────────────────────────────────────────────┐
│  Saga     = 多步业务的前进 + 失败补偿(业务一致性)      │
│  Outbox   = 本地提交与对外事件的绑定(通信可靠性)      │
│  组合     = 状态机推进不丢、旁路通知不丢                │
│  仍需要   = 幂等 · 反查 · 恢复 Job · 对账 · 冲正        │
│  不要用   = 把 Outbox 当分布式事务;超时盲补偿          │
└─────────────────────────────────────────────────────────┘

">