当你坐在工位上,面对一个新的技术方案评审,怎么快速决定用什么序列化格式?
一、六大决策维度
在给出具体场景之前,先建立评估框架。任何序列化选型都要回答这六个问题:
| 维度 | 关键问题 | 高分格式 |
|---|---|---|
| 性能 | 每秒处理多少请求?数据包多大?延迟要求多低? | Protobuf、FlatBuffers、MessagePack |
| 可读性 | 需要人类直接阅读或调试吗? | JSON、YAML、TOML |
| 跨语言 | 涉及几种编程语言?是否需要自动生成代码? | JSON、Protobuf、Avro、MessagePack |
| Schema 演进 | 字段会频繁增删改吗?老版本兼容要求多高? | Protobuf、Avro、JSON(松散) |
| 生态 | 团队熟悉度如何?工具链成熟度如何? | JSON(无敌)、Protobuf(gRPC 生态) |
| 安全 | 数据来源可信吗?反序列化攻击面大吗? | Protobuf(无动态类型)、JSON(需校验) |
核心原则:不要为不需要的维度付费。内部微服务不需要人类可读性,就别用 JSON;配置文件不需要极致性能,就别用 Protobuf。
二、七场景选型决策树
场景 1:前后端 API
约束:浏览器只能发 HTTP/HTTPS,前端开发者需要直接阅读响应内容调试接口。
选型 :JSON(必要时启用 Gzip/Brotli 压缩)
go
// Go 标准库内置压缩支持
import "compress/gzip"
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Encoding", "gzip")
gz := gzip.NewWriter(w)
defer gz.Close()
json.NewEncoder(gz).Encode(response)
}
为什么不是 Protobuf/gRPC? 浏览器原生不支持 gRPC(需要 gRPC-Web 代理),前端开发者调试二进制 payload 极其痛苦。JSON 是 Web 的通用语,这个生态惯性不是性能能颠覆的。
例外:如果数据体积极大(如批量导出)且前后端都是可控环境,可以协商用 MessagePack + Base64,但复杂度会上升。
场景 2:微服务内部通信
约束:高并发、低延迟、多语言(Go/Java/Python)、需要版本兼容。
选型 :Protobuf + gRPC
protobuf
// user.proto
syntax = "proto3";
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (stream User);
}
message GetUserRequest {
int64 id = 1;
}
message User {
int64 id = 1;
string name = 2;
string email = 3;
int64 created_at = 4;
}
Go 服务端:
go
type server struct {
pb.UnimplementedUserServiceServer
}
func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
user, err := db.GetUser(ctx, req.Id)
if err != nil {
return nil, status.Errorf(codes.NotFound, "user not found: %d", req.Id)
}
return &pb.User{
Id: user.ID,
Name: user.Name,
Email: user.Email,
CreatedAt: user.CreatedAt.Unix(),
}, nil
}
func main() {
lis, _ := net.Listen("tcp", ":50051")
s := grpc.NewServer()
pb.RegisterUserServiceServer(s, &server{})
s.Serve(lis)
}
为什么不是 JSON/HTTP? 内部服务间的调用频率通常是前端 API 的 10-100 倍,JSON 的字段名冗余和字符串解析开销会被放大。Protobuf 的强 Schema 还能在编译期发现接口变更,避免运行时契约破裂。
为什么不是 MessagePack? MessagePack 无 Schema,字段增删全靠约定,微服务数量多了之后极易出现"字段对不上"的诡异 bug。
场景 3:缓存(Redis/Memcached)
约束:读写极频繁、数据生命周期短、字段结构可能变化、重启后丢失可接受。
选型 :MessagePack(动态场景)或 Protobuf(结构稳定)
go
// MessagePack 方案:灵活,无需 .proto
import "github.com/vmihailenco/msgpack/v5"
func SetCache(ctx context.Context, key string, val interface{}) error {
data, err := msgpack.Marshal(val)
if err != nil {
return err
}
return redisClient.Set(ctx, key, data, time.Hour).Err()
}
func GetCache(ctx context.Context, key string, dest interface{}) error {
data, err := redisClient.Get(ctx, key).Bytes()
if err != nil {
return err
}
return msgpack.Unmarshal(data, dest)
}
为什么不是 JSON? 缓存数据通常体积不大但数量极大,JSON 的字段名冗余会显著增加 Redis 内存占用。MessagePack 省 30% 空间,意味着同样的内存可以缓存更多数据。
什么时候用 Protobuf? 如果缓存的数据结构极其稳定(如用户基础信息),且团队已经有 .proto 文件,Protobuf 可以进一步节省空间并提升解析速度。但要注意:修改 .proto 后需要重新生成代码、重新部署所有服务,缓存中的旧数据可能无法解析。
场景 4:日志 / 大数据管道
约束:海量数据、Schema 可能演化、需要被多种消费端解析(实时报警、离线分析、数据湖)。
选型 :JSON Lines(简单场景)或 Avro(复杂管道)
JSON Lines 格式(每行一个独立 JSON 对象):
json
{"timestamp":"2026-05-28T15:00:00Z","level":"INFO","msg":"order created","order_id":1001}
{"timestamp":"2026-05-28T15:00:01Z","level":"ERROR","msg":"payment failed","order_id":1001,"error":"timeout"}
Go 写入:
go
encoder := json.NewEncoder(logFile)
for entry := range logCh {
encoder.Encode(entry) // 自动追加换行
}
为什么不是纯 JSON 数组? 日志是流式追加的,JSON 数组需要开头写 [、结尾写 ],流式场景下不现实。JSON Lines 每行独立解析,容错性更好(某行损坏不影响其他行)。
什么时候用 Avro? 如果日志需要进入 Kafka → Flink → 数据湖这条管道,且字段频繁增加(如业务埋点),Avro 的 Schema Registry 机制可以让消费者自动适配新字段,无需停服升级。
go
// Avro + Kafka 伪代码
schema := avro.MustParse(`{
"type": "record",
"name": "LogEntry",
"fields": [
{"name": "timestamp", "type": "long"},
{"name": "level", "type": "string"},
{"name": "msg", "type": "string"}
]
}`)
// Schema 注册到 Confluent Schema Registry
// 消费者通过 Schema ID 自动获取解析规则
场景 5:配置文件
约束:人类频繁编辑、需要注释、层级结构、版本控制友好。
选型 :YAML(云原生/复杂配置)或 TOML(应用级/简单配置)
Kubernetes 风格 YAML:
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 3
template:
spec:
containers:
- name: app
image: myapp:v1.2.3
env:
- name: DB_HOST
value: "postgres.internal"
Go 应用配置 TOML:
toml
[server]
host = "0.0.0.0"
port = 8080
[database]
host = "localhost"
port = 5432
pool_max_open = 25
pool_max_idle = 5
[log]
level = "info"
format = "json"
Go 解析:
go
import "github.com/pelletier/go-toml/v2"
type Config struct {
Server struct {
Host string `toml:"host"`
Port int `toml:"port"`
} `toml:"server"`
Database struct {
Host string `toml:"host"`
Port int `toml:"port"`
PoolMaxOpen int `toml:"pool_max_open"`
PoolMaxIdle int `toml:"pool_max_idle"`
} `toml:"database"`
}
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := toml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
为什么不是 JSON? JSON 不支持注释,不能 trailing comma,配置一复杂就写得想死。{"db_host": "localhost"} 和 db_host = "localhost" 哪个更易读,一目了然。
YAML vs TOML 怎么选? 配置层级深、需要表达复杂结构(如 K8s 资源定义)→ YAML。配置扁平、讨厌缩进敏感、追求明确类型 → TOML。
场景 6:游戏 / 实时系统
约束:延迟极度敏感(毫秒级)、读取频率远高于写入、内存有限。
选型 :FlatBuffers
FlatBuffers 的核心优势是零拷贝解析。传统流程:
序列化字节流 → 反序列化 → 内存对象 → 访问字段
FlatBuffers 流程:
序列化字节流 → 直接通过偏移量访问字段(无反序列化步骤)
go
// FlatBuffers Go API(简化示意)
import "github.com/google/flatbuffers/go"
// 构建
builder := flatbuffers.NewBuilder(1024)
name := builder.CreateString("player1")
PlayerStart(builder)
PlayerAddId(builder, 42)
PlayerAddName(builder, name)
PlayerAddScore(builder, 9999)
player := PlayerEnd(builder)
builder.Finish(player)
// 读取(零拷贝!)
buf := builder.FinishedBytes()
player := GetRootAsPlayer(buf, 0)
fmt.Println(player.Id()) // 直接读取,无解析开销
fmt.Println(player.Name()) // 直接读取,无解析开销
为什么不是 Protobuf? Protobuf 虽然快,但仍然需要一次反序列化步骤(把字节流解析成 Go 结构体)。FlatBuffers 连这一步都省了,直接把字节流当内存数据库查。
代价:写入 API 极其繁琐,需要预先计算所有字段的偏移量。只适合"构建一次、读取无数次"的场景。
场景 7:Go 内部进程通信(IPC)
约束:同一台机器上的进程间通信,跨语言需求低,追求极致性能。
选型 :gob(Go 原生)或共享内存
go
// gob 编码
import "encoding/gob"
func encodeGob(v interface{}) ([]byte, error) {
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
if err := enc.Encode(v); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func decodeGob(data []byte, v interface{}) error {
return gob.NewDecoder(bytes.NewReader(data)).Decode(v)
}
为什么不是 JSON/MessagePack? gob 是 Go 标准库内置的格式,专为 Go 进程间通信优化。它利用 Go 的类型系统,编码效率比跨语言格式高得多。
局限:gob 不是跨语言格式,只有 Go 能解析。如果未来可能需要 Java/Python 接入,应提前改用 Protobuf。
三、混合架构:不同层用不同格式
真实的大型系统不会只用一种格式。一个典型的电商系统可能长这样:
javascript
┌─────────────┐ JSON ┌─────────────┐
│ 前端 Web │ ◄──────────► │ API Gateway │
└─────────────┘ └──────┬──────┘
│ Protobuf + gRPC
┌──────┴──────┐
│ 微服务集群 │
│ ┌─────────┐ │
│ │ User Svc │ │
│ │ OrderSvc │ │
│ │ Pay Svc │ │
│ └────┬────┘ │
└──────┼──────┘
│ MessagePack
┌──────┴──────┐
│ Redis │
└─────────────┘
│ Protobuf
┌──────┴──────┐
│ 日志收集器 │
└──────┬──────┘
│ Avro
┌──────┴──────┐
│ 数据湖/S3 │
└─────────────┘
每一层选择最适合的格式,而不是追求统一。API Gateway 是 JSON 和 Protobuf 的翻译官,微服务内部用 Protobuf 追求性能,缓存用 MessagePack 省内存,日志用 Avro 进数据湖。
四、未来准备:可替换的序列化抽象层
技术栈会演进,今天的 Protobuf 可能被明天的某个新格式替代。如何在代码中预留替换空间?
4.1 定义序列化接口
go
package serializer
import "context"
// Serializer 是序列化抽象接口
type Serializer interface {
Marshal(v interface{}) ([]byte, error)
Unmarshal(data []byte, v interface{}) error
ContentType() string // 如 "application/json", "application/x-protobuf"
}
// 具体实现
type JSONSerializer struct{}
type ProtobufSerializer struct{}
type MessagePackSerializer struct{}
4.2 通过配置切换
go
// config.toml
[serializer]
type = "protobuf" // 可切换为 json、msgpack
go
func NewSerializer(cfg Config) (Serializer, error) {
switch cfg.Serializer.Type {
case "json":
return &JSONSerializer{}, nil
case "protobuf":
return &ProtobufSerializer{}, nil
case "msgpack":
return &MessagePackSerializer{}, nil
default:
return nil, fmt.Errorf("unknown serializer: %s", cfg.Serializer.Type)
}
}
4.3 在 RPC 框架中注入
go
type Client struct {
serializer Serializer
conn *grpc.ClientConn // 或 *http.Client
}
func (c *Client) Call(ctx context.Context, method string, req, resp interface{}) error {
data, err := c.serializer.Marshal(req)
if err != nil {
return err
}
// 发送请求,接收响应
// ...
return c.serializer.Unmarshal(responseData, resp)
}
这种设计的价值在于:当业务需要把内部通信从 Protobuf 迁移到 FlatBuffers 时,只需要新增一个实现类,修改配置,无需改动业务代码。
五、一张图总结
scss
序列化选型决策树
┌─────────────────────┐
│ 数据给谁用? │
└──────────┬──────────┘
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
人类阅读/调试 机器高频通信 配置文件
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ JSON │ │ 跨语言? │ │ 层级深? │
│ (Web API) │ └──────┬──────┘ └──────┬──────┘
└─────────────┘ │ │
┌─────────┴─────────┐ ┌────┴────┐
▼ ▼ ▼ ▼
是 → Protobuf 否 → YAML TOML
+ gRPC (K8s) (应用)
(微服务)
│
▼
极致延迟?
│
┌────┴────┐
▼ ▼
是 → FlatBuffers
(游戏/实时)
│
否 → MessagePack
(缓存/动态)
六、总结
最核心的认知 :序列化不是技术炫技,而是工程约束下的权衡艺术。性能、可读性、生态、安全,没有万能解,只有当前最优解。