
1. 为什么“数据交换格式”这件事值得单独拎出来聊我做后端和数据处理相关的活儿有十来年了接触过的项目从单机小工具到每天跑几十亿条记录的管道都有。这些年里数据交换格式这个看起来特别基础的话题反而是最容易在深夜把人叫起来加班的东西。它不像架构设计那样有存在感也不像算法优化那样能出彩但只要选错一次、配错一次后面半年都得围着它打补丁。所以我想把手上这些格式从头到尾捋一遍不讲教科书定义只讲什么场景该用什么、为什么这么选、哪儿会出事。先给个直白的范围界定这篇东西适合三类人看。第一类是刚入行的同学写接口时只会JSON.stringify遇到“为什么不能传大整数”“为什么时间格式对不上”就懵了第二类是做数据工程的每天在 CSV、Parquet、Avro 之间倒腾想知道这些格式的取舍边界在哪第三类是做架构或者接口规范的需要拍板定一套团队内部的序列化标准。不管你属于哪一类看完至少能在选型讨论会上说清楚自己的依据而不是凭感觉站队。我打算这么安排先把格式选型的底层逻辑讲清楚然后按“文本派”和“二进制派”分成两大块细聊再拿一个真实的跨语言交换场景走一遍完整流程最后把这些年踩过的坑整理成速查表。中间会穿插不少代码和参数说明都是我实际跑过的你可以直接抄。1.1 格式的本质把内存里的对象变成一串字节不管什么格式干的事情本质上是同一件把内存里的数据结构对象、数组、字典、结构体编码成一段连续的字节让它能写进文件、扔进消息队列、塞进网络包到了另一端再把这串字节还原回内存里的结构。这个来回叫序列化和反序列化或者叫编码解码。听起来简单但麻烦恰恰出在“还原”这一步。内存里的东西是很丰富的有 32 位整数、64 位整数、有符号无符号、单精度双精度浮点、布尔、字节数组、时间戳、枚举、嵌套对象、循环引用。而一段字节流本身什么信息都没有它就是一堆数字。所以格式的真正差别在于它愿意为“保留多少类型信息”付出多少代价。举个例子JSON 只有五种值类型数字统一按双精度浮点理解。这意味着你在 Python 里一个1234567890123456789的整数丢进 JSON到了 JavaScript 那边用JSON.parse读出来就变成了1234567890123456800。这不是 bug是格式的设计使然——它压根没打算区分整数和浮点也没打算保证超过 53 位精度的整数不失真。你要用 JSON 传雪花 ID就得提前约定“大整数一律转成字符串”这条不加进接口文档上线必炸。理解了这一层后面所有的选型判断都能推导出来想要人类可读、跨语言方便就得接受类型信息少、体积大想要体积小、解析快、类型严格就得接受需要 schema、可读性差、调试麻烦。天下没有白拿的好处。1.2 选型的五个隐藏维度比“好不好看”重要得多大部分人讨论格式的时候就盯着一个点JSON 好看Protobuf 快。这太糙了。我在实际项目里做决策时会按下面五个维度横向比第一个维度是自描述性。也就是拿到一段字节不带任何外部文档能不能读懂。JSON、XML、YAML 属于强自描述字段名就在数据里Protobuf、Avro 是弱自描述没有.proto文件或者 schema你拿到二进制就是天书。自描述性直接决定了排查问题的难度——线上服务出故障你去翻消息队列里堆积的消息JSON 能直接看Protobuf 得先找 schema 再写脚本解。第二个维度是 schema 契约强度。有 schema 是好事也是坏事。好事是类型明确、文档即代码、跨语言生成的类不会手抖写错字段名坏事是改字段要重新生成代码上下游版本不一致的时候容易出兼容事故。JSON 这种无 schema 的格式字段说加就加灵活但容易悄悄漂移半年后没人知道status字段到底有几个合法值。第三个维度是体积和解析开销。这两项在内部服务之间不敏感但到了海量数据落盘、移动端弱网、IoT 设备上报的场景就是生死线。文本格式普遍比二进制格式大 1.5 到 5 倍解析耗时也普遍高一个数量级这个后面我会给具体量级。第四个维度是兼容性演进能力。这是最容易被低估的一点。系统是活的字段一定会增删改。有些格式天然支持向前向后兼容有些格式改一下就全链路崩。Protobuf 靠字段编号和reserved关键字撑起了这套机制Avro 靠 schema registry 做读写 schema 解析而 CSV 你加一列的位置不对下游所有按列号读取的程序全废。第五个维度是生态与工具链。格式再好语言支持烂也是白搭。JSON 是所有语言标准库自带XML 有几十年积累的解析器和 XSD 工具Protobuf 主流语言都有官方实现而一些小众格式在冷门语言里可能只有个人维护的库出了问题没人管。1.3 一个真实场景为什么同一个系统里会同时躺着四种格式我参与过一个物联网数据平台从设备上报到最终报表中间经过了四种格式设备端用CBOR把传感器读数打包上报因为设备算力和带宽都有限网关收到后转成JSON转发给业务服务因为网关层要写日志、要方便调试业务服务清洗聚合后落进消息队列用Avro编码因为量大且需要严格 schema 管理最后离线分析时写成Parquet存到对象存储因为要按列扫描和压缩。这不是炫技每一层换格式都有明确理由。设备端要是发 JSON报文体积大概膨胀 2.5 倍一块电池撑不了那么久网关要是直接用 CBOR 转发运维半夜排查问题得先写个解码脚本成本更高消息队列要是用 JSON每天多花在存储和网络上的钱够养一个人分析层要是用 Avro 行存每次只查两个指标列却要读全量字段扫描量翻十倍。所以我的观点一直是格式没有优劣只有匹配不匹配。一个系统里出现多种格式是正常的关键在于每一处切换都要有清晰的理由并且把转换边界固定下来。最怕的是没有章法地混用A 模块用 JSONB 模块用 YAMLC 模块直接塞了个 pickle 二进制那才是灾难。2. 文本三巨头JSON、XML、YAML 的细节与坑文本格式的共同优势是可读、可 diff、可手改、跨语言无门槛。代价是体积大、解析慢、类型弱。这三家占据了日常开发 90% 以上的场景但它们各自的坑分布非常不均匀下面逐个拆。2.1 JSON事实标准但这几个坑必须提前知道JSON 的规范是 RFC 8259定义了六种值对象、数组、字符串、数字、布尔、null。就这么简单简单到所有语言都能在半天内实现一个解析器这也是它统治接口领域的根本原因。但简单也意味着表达能力有限下面几条是我在项目里反复遇到的真问题。数字精度问题。前面提过一次这里给个具体例子。后端 Java 生成了一个Long类型的订单号9007199254740993序列化成 JSON 字符串发给前端前端JSON.parse之后拿到的是9007199254740992差了一位。这个数字是 JavaScriptNumber.MAX_SAFE_INTEGER 2只要超过2^53 - 1就会进入不安全区间。解决方案只有两个要么接口层统一把大整数序列化成字符串要么前端引入json-bigint这类库做解析。我倾向第一种因为在接口文档里写清楚“ID 类型为字符串”是最省事的做法前后端都不会忘。NaN 和 Infinity 的野路子。JSON 标准里根本没有NaN和Infinity这两个值但很多语言的实现偷偷支持了。Python 的json.dumps默认allow_nanTrue会把float(nan)输出成裸的NaNJavaScript 的JSON.stringify(NaN)则老老实实变成nullGo 的encoding/json遇到NaN会直接报错。这就导致一个服务序列化出来的报文另一个语言的解析器读不了。我的做法是在序列化层统一加一个钩子把所有非有限浮点转成null或者字符串NaN同时接口文档里说明。这个坑不踩一次根本想不到。注释和尾逗号不支持。配置文件里想写注释JSON 不支持。列表最后一个元素后面想加逗号报错。于是有了 JSON5、JSONC、HJSON 这些方言。我的建议是数据交换用标准 JSON配置文件如果非要注释就换成 TOML 或者 YAML不要用方言 JSON因为方言的解析器生态参差不齐某天换个语言处理同一份文件就崩了。日期时间的表达混乱。JSON 没有日期类型只能塞字符串或数字。项目里常见的三种写法ISO 8601 带时区的2024-03-15T08:30:0008:00、不带时区的2024-03-15 08:30:00、Unix 毫秒时间戳1710462600000。三种混用是接口事故的高发区。我的统一原则是跨系统传输一律用 ISO 8601 带时区偏移或者用 UTC 的毫秒整数绝不用不带时区的本地时间字符串。因为后者一旦跨时区谁也说不清它到底是哪个时刻。流式处理要选对格式。一个几十兆的 JSON 数组直接JSON.parse会一次性占满内存。这种场景应该用NDJSON每行一个 JSON 对象或者JSON Lines逐行读取逐行解析内存恒定。日志采集、批量导入导出都适合这么干。import json # 处理大整数和非有限浮点的一个小工具 def safe_dumps(obj): def default(o): if isinstance(o, float) and (o ! o or o in (float(inf), float(-inf))): return None return str(o) return json.dumps(obj, defaultdefault, ensure_asciiFalse, sort_keysTrue)上面这个sort_keysTrue也值得说一句做签名校验的场景把键排序后再序列化能保证同样的数据在不同服务上得到完全相同的字节串否则键顺序不同签名就对不上。这行参数救过我不少次。2.2 XML老而弥坚标签自描述能力的代价XML 比 JSON 早了十几年在很多行业系统里仍然是强制标准尤其是金融、医疗、政务类接口。它的核心优势是真正的自描述加上可扩展的元信息元素能带属性能嵌命名空间能挂 XSD 做严格校验能用 XPath 精确定位能用 XSLT 做转换。这些能力 JSON 一个都没有。代价也很明显。体积膨胀是第一位的同样一份订单数据XML 的字节数通常是 JSON 的 1.5 到 2 倍因为每个字段都要写一遍开标签和闭标签。嵌套层级越深标签名的重复写入越多损耗越大。命名空间是最容易踩的坑。XML 的命名空间机制本身设计得挺严谨但实际用起来经常出问题。比如你写了个 XPath//order/id结果对方的 XML 里所有元素都带默认命名空间XPath 就查不到任何东西得改成//*[local-name()order]/*[local-name()id]才行。我遇到的第一次线上 XML 解析失败就是这个原因排查了两个小时。解析方式的选择直接影响内存。DOM 解析会把整个文档树加载进内存一个 100MB 的 XML 直接就是几个 G 的内存占用SAX 是事件驱动的流式解析内存恒定但写起来麻烦StAX 介于两者之间可以拉取式地读是 Java 里比较推荐的方式。给后台服务写 XML 处理逻辑时我基本不用 DOM除非文件确定在几兆以内。外部实体一定要关掉。解析来源不可信的 XML 时务必禁用 DTD 和外部实体加载。这一点在 Python、Java、Go 的各种 XML 库里都有对应的开关参数比如 Python 的defusedxml包就是专门为此准备的。这条不是可选项是必选项尤其是在处理用户上传文件的时候。# Python 里安全的 XML 解析方式 from defusedxml import ElementTree as SafeET tree SafeET.parse(upload.xml) root tree.getroot() for item in root.findall({http://example.com/ns}item): print(item.attrib.get(id))XSD 校验是个双刃剑。有 XSD 意味着接口契约非常明确字段类型、必填项、取值枚举都写得死死的但也意味着每次改接口都要改 XSD 并同步到所有相关方流程重。我的经验是对外公共服务值得上 XSD内部服务之间用 XML 的话往往就不做严格校验了靠代码层兜。2.3 YAML写给人类看的格式也是最容易写出事故的格式YAML 的设计目标是“对人类友好”在配置文件领域确实香支持注释、支持多行字符串、支持锚点和引用、缩进代替括号读起来非常干净。Kubernetes、Ansible、CI/CD 流水线、各类框架配置文件几乎都是 YAML。但它的隐式类型推断规则是实打实的事故来源。挪威问题。这是 YAML 圈子里最有名的梗。在 YAML 1.1 的隐式类型规则里NO、OFF、N、FALSE这些词会被解析成布尔falseYES、ON、Y、TRUE会被解析成true。于是你把国家代码写成country: NO解析出来country是布尔false不是字符串NO。这个坑在真实项目里出现过不止一次表现为“某个国家用户的下拉框永远是空”。正确写法是给值加引号country: NO。我在团队里推的规范是配置文件里所有看起来像字符串的值一律加引号别指望读的人记得住 1.1 的类型表。缩进用空格绝对不能用 Tab。YAML 明确禁止用 Tab 做缩进但很多编辑器默认 Tab 缩进改完文件保存后整份配置就解析失败了。报错信息通常是found character \t that cannot start any token第一次见完全摸不着头脑。建议是编辑器里对 YAML 文件强制设置expandtab。冒号后面必须有空格。key:value和key: value在 YAML 里是两回事前者会被当成一个普通的标量字符串后者才是键值对。这个细节在 URL 值上特别容易出问题比如url:http://example.com会被解析成一个字符串而不是url键对应一个值。时间戳会被自动解析。形如2024-03-15的值会被解析成日期对象2024-03-15 08:30:00会被解析成时间对象。如果你本意是传字符串就会在后续处理里拿到一个 date 对象导致序列化到 JSON 时格式变了。一样加引号解决。锚点和引用很好用但要节制。anchor定义*anchor引用可以减少重复配置。但一旦滥用配置文件会变得跟代码一样难追改一个地方影响一片。我一般在三处以上重复才考虑用锚点。安全加载。Python 里yaml.load不加参数可以构造任意对象是个老生常谈的风险点一律用yaml.safe_load。import yaml with open(config.yaml, encodingutf-8) as f: cfg yaml.safe_load(f) # 永远用 safe_load # 保留原始字符串避免隐式类型转换 cfg2 yaml.safe_load(f.read())另外提一句 YAML 的多文档特性用---分隔一个文件里可以放多个独立文档配合yaml.safe_load_all迭代读取。做批量资源定义的时候挺好用。2.4 文本三巨头横向对比对比项JSONXMLYAML可读性好紧凑一般标签冗长最好但缩进敏感类型表达六种基本类型全部文本类型靠 XSD隐式推断容易误判注释支持不支持支持!-- --支持#体积相对1.01.5 到 2.01.1 到 1.4典型单条解析耗时量级1x3x 到 8x5x 到 15x适合场景接口传输、日志行业标准接口、文档格式配置文件、编排描述主要风险大整数精度、无注释命名空间、体积、XXE隐式类型、缩进、Tab上面那行解析耗时是我在一台普通开发机上解析一份结构相近的 1MB 数据测出来的量级参考不是绝对数字硬件和数据形状不同会差很多但相对关系基本稳定YAML 解析器因为要做类型推断通常比 JSON 慢不少。3. 被低估的轻量文本格式CSV、TSV、INI、TOML这几种格式没那么“高级”但在特定场景里比 JSON 好用得多。它们的共同点是结构简单、工具链成熟几乎任何数据分析工具都能直接读。3.1 CSV/TSV结构化数据交换的“土办法”为什么一直活着CSV 的规范是 RFC 4180规定了逗号分隔、双引号包裹、双引号转义成两个双引号、行尾 CRLF。就这么几条但它活了五十年原因是它是最接近表格数据本体的格式。Excel、pandas、数据库导入导出、BI 工具全部原生支持一行代码就能读进来变成数据框。在数据分析场景里CSV 的效率优势其实不小。同一份 100 万行、20 列的数据存成 CSV 大概 200MB存成 JSON 通常是 600MB 到 800MB读进 pandas 的耗时 CSV 也更短因为解析逻辑简单没有嵌套结构要递归。Parquet 会比 CSV 快很多但 CSV 的通用性无可替代——任何一台电脑上不用装任何库就能打开看。坑也集中。编码问题是第一大坑。中文环境下从某些系统导出的 CSV 是 GBK 编码直接按 UTF-8 读会报UnicodeDecodeError或者一片乱码。更隐蔽的是BOM 头UTF-8 with BOM 的文件开头有三个字节EF BB BFPython 按utf-8读的时候第一个字段名会变成\ufeffid你以为在取id列实际取不到。解决办法是读取时显式指定encodingutf-8-sig这个-sig就是专门用来吃掉 BOM 的。换行符和引号的组合。字段内容里如果有逗号必须用双引号包裹如果字段里有双引号要写成两个双引号如果字段里有换行也要用双引号包裹。自己手写 CSV 生成代码很容易漏掉这些转义导致下游解析出错。所以生成 CSV 一定要用库Python 用csv模块别自己拼字符串。import csv # 写 CSV注意 newline 和 utf-8-sig with open(out.csv, w, newline, encodingutf-8-sig) as f: writer csv.writer(f, quotingcsv.QUOTE_MINIMAL) writer.writerow([订单号, 金额, 备注]) writer.writerow([10001, 199.00, 含逗号,的备注]) # 读 CSV处理可能的 BOM 和编码 with open(out.csv, encodingutf-8-sig) as f: for row in csv.DictReader(f): print(row)Excel 的科学计数法问题。一个纯数字的长串比如银行卡号或者订单号用 Excel 打开 CSV 会自动变成科学计数法末尾几位变零。这不是 CSV 的错是 Excel 的显示行为但用户会认为是你的系统导错了。规避办法是在导出时给这类字段加一个制表符前缀或者用123456的写法或者干脆导出成 xlsx。我一般是在导出界面加一句提示让用户用“数据导入”功能而不是直接双击。大文件的处理。Excel 单表上限约 104 万行超过就导不进去。上千万行的 CSV 只能靠 pandas、DuckDB 或者命令行工具处理。pandas 读大 CSV 可以配合chunksize分块读避免一次性爆内存。TSV 是制表符分隔的变体主要好处是字段内容里出现逗号的概率远高于制表符所以转义需求更少常见于日志和生物信息学数据。3.2 TOML 与 INI配置文件里的务实选择INI 是最古老的配置格式之一[section]加keyvalue简单到极致Windows 系统的配置文件至今还在用。缺点是类型全是字符串嵌套表达不了也没有数组的标准写法各家实现还不太一样。TOML 是 INI 的现代化版本目标很明确做配置文件格式不做数据交换格式。它有几个我很喜欢的特性类型是显式的字符串必须加引号所以没有 YAML 那种隐式推断问题原生支持日期时间类型带时区的2024-03-15T08:30:00Z和不带时区的都有支持数组和嵌套表写法清晰支持多行字符串。Rust 的Cargo.toml、Python 的pyproject.toml都选了它说明在配置这个场景里它的实用性得到了验证。[server] host 0.0.0.0 port 8080 debug false start_time 2024-03-15T08:30:00Z [[server.upstream]] name primary url http://api-a.internal weight 80 [[server.upstream]] name backup url http://api-b.internal weight 20上面这段里debug false是布尔值host是字符串start_time是日期时间类型一目了然不会像 YAML 那样需要猜。这是我给团队推荐 TOML 替代 YAML 做服务配置的主要理由减少隐式规则减少事故。TOML 的短板也明显深层嵌套的数组套数组写起来很啰嗦数据量一大就不适合了它是配置格式不是数据格式。另外它不支持引用和锚点重复内容只能重复写。3.3 数据清洗场景下的格式选择实操做数据清洗的时候格式选对了能省一半时间。我的一般原则是中间结果用 Parquet交换给人看用 CSV程序内部流转用 JSON。具体到落地一份来自上游的 CSV 数据我通常的处理链路是先用utf-8-sig读一遍dtypestr全部按字符串读进来避免 pandas 自动把订单号当成 int64 或者科学计数法浮点然后手动做类型转换明确哪些列是数值、哪些是日期、哪些是分类清洗完成后写成 Parquet后续查询都在 Parquet 上做。这样做的原因很实际CSV 的类型推断不可靠你永远不知道 pandas 会把某一列猜成什么一旦猜错前导零没了、大整数截断了问题往往在几小时后才暴露。import pandas as pd df pd.read_csv(raw.csv, dtypestr, encodingutf-8-sig, keep_default_naFalse) # 显式转换不让 pandas 自己猜 df[amount] pd.to_numeric(df[amount], errorscoerce) df[created_at] pd.to_datetime(df[created_at], errorscoerce, utcTrue) df[status] df[status].astype(category) df.to_parquet(clean.parquet, compressionzstd, indexFalse)keep_default_naFalse这个参数也很关键。默认情况下 pandas 会把NA、N/A、null、空字符串都当成缺失值如果某个字段本来就有个合法的NA值比如北美地区的缩写就被误吃了。显式关掉自己控制缺失值判定。4. 二进制格式追求体积与速度的另一个世界当数据量大到一定程度文本格式的成本就压不住了。这时候需要考虑二进制编码。这一块的选择比文本格式复杂因为要同时考虑 schema 管理、兼容性演进、工具链成熟度。4.1 Protocol BuffersIDL 驱动的强契约方案Protobuf 的核心思路是先用 IDL接口定义语言写.proto文件描述结构然后用编译器生成各语言的代码序列化时按字段编号把值写成一串紧凑的二进制。它不写字段名只写编号和值这是体积小的关键。syntax proto3; message Order { int64 order_id 1; string user_id 2; int32 amount_cents 3; Status status 4; repeated Item items 5; google.protobuf.Timestamp created_at 6; enum Status { STATUS_UNSPECIFIED 0; STATUS_PENDING 1; STATUS_PAID 2; STATUS_CANCELLED 3; } } message Item { string sku 1; int32 qty 2; }几个必须记住的规则字段编号 1 到 15 编码后占一个字节16 到 2047 占两个字节。所以最频繁出现的字段应该分配小编号。这个优化在字段多、消息量大的时候能省下可观的带宽我见过一个消息把十几个字段都用大编号的白白多了十几个字节。改字段只能加不能改编号删字段要留reserved。这是兼容性的命根子。你删掉字段 3如果有旧版本的服务还在发带字段 3 的消息新版本解析不了就会把它当未知字段丢掉但如果你后来又把字段 3 分配给一个不同类型的新字段那就彻底乱了。所以删除时必须写reserved 3; reserved old_field_name;新增字段必须是 optional 或者给默认值。proto3 里所有字段默认都有零值语义新增一个int32字段旧客户端读到的是 0业务上要能接受这个默认值。如果业务上必须区分“未设置”和“设置为 0”就要用optional关键字proto3 较新版本支持或者google.protobuf.Int32Value包装类型。枚举必须有 0 值。proto3 强制枚举第一个值编号为 0因为零值要作为默认值。这个 0 值一般命名为XXX_UNSPECIFIED业务代码里要做显式判断别拿 0 当合法状态用。实测数据上同一份结构化数据Protobuf 编码后大约是 JSON 的 30% 到 50%序列化耗时大约是 JSON 的三分之一到五分之一反序列化优势更明显因为不需要做字符串解析和类型推断。具体差距取决于数据里字符串字段的比例字符串多的话压缩空间小一些。4.2 MessagePack / BSON / CBORJSON 的二进制表亲这三个格式的共同目标是在保留动态类型的前提下把 JSON 变紧凑不需要 schema 定义用起来和 JSON 一样随意。MessagePack的编码规则是每个值前面加一个类型前缀字节整数按大小用 1、2、3、5、9 字节表示字符串前面写长度再写内容。一份 JSON 转成 MessagePack 通常能缩小 30% 到 50%解析速度提升 2 到 4 倍。缺点是它和 JSON 一样没有 schema字段名会被重复写入字段名越长浪费越大。BSON是 MongoDB 用的格式特点是每个文档前面写了总长度所以可以跳过不关心的文档支持比 JSON 更多的类型比如日期、二进制、正则。代价是它为了可遍历性加了不少长度前缀体积反而可能比 JSON 大一份小文档转 BSON 后体积增加 20% 是常事。所以 BSON 适合数据库内部存储不适合做网络传输。CBOR是 IETF 标准RFC 8949设计上考虑了 IoT 场景有确定长度的编码规范适合做签名和哈希校验。它的类型系统比 MessagePack 更丰富一点支持标签机制表达语义。设备端上报我一般选 CBOR 而不是 MessagePack就是因为标准化程度更高各语言实现更一致。这三个格式的通用建议是用在两端都是自己人、且对体积和速度有明确要求的内部链路上。对外接口还是老老实实用 JSON因为你不知道对方的技术栈支持什么。4.3 Avro 与 Parquet大数据管道里的两位主角这两个格式在大数据领域基本是标配但用途完全不同很多人会混。Avro 是行式格式一条记录的所有字段连续存放适合整行读写、适合流式写入、适合消息队列。它的最大特色是schema 与数据分离但强绑定写数据时把 schema 一起写进去或者存在 schema registry 里读的时候用读取方自己的 schema 去解析两者做兼容性检查。这套机制让 Avro 的 schema 演进非常灵活加字段、删字段、改默认值都有明确规则。Avro 的四种演进操作和对应规则值得记一下加一个有默认值的字段是允许的删一个有默认值的字段是允许的把字段类型从 int 改成 long 是允许的因为 widening把必填字段改成有默认值也是允许的。反过来的操作就得小心容易导致旧数据读不出来。Parquet 是列式格式同一列的所有值连续存放适合分析场景。好处有两个一是查询只涉及少数几列时可以跳过其他列的数据块减少 IO二是同一列的数据类型一致压缩率极高字典编码加上 Snappy 或 ZSTD 压缩一份 CSV 转成 Parquet 常常只有原来的十分之一到五分之一。Parquet 还有个实用特性是谓词下推查询引擎能利用文件里的统计信息每列的最大值最小值直接跳过不满足条件的行组。配合分区按日期或者其他维度分目录扫描量能再降一个数量级。这也是为什么数据仓库的底层文件格式基本都是 Parquet 或者 ORC。选型上我的经验是消息队列和流式写入选 Avro批处理和离线分析选 Parquet。两者都支持 schema 演进Avro 更强调流式Parquet 更强调扫描效率。列式格式不适合单行频繁读写每次写一行都要写很多列的数据块开销大。4.4 二进制格式的兼容性心法二进制格式最让人头疼的就是兼容性事故而且往往很难排查因为字节层面看不懂。我总结了三条心法基本能覆盖大部分情况。第一条schema 必须进版本管理和代码一起走。不要出现“线上的 schema 文件只存在于某个人电脑上”这种情况。用 schema registry 或者至少放在 Git 仓库里每次改动都要走代码评审。第二条兼容性测试要自动化。每次改 schema跑一遍向后兼容检查新代码能否读旧数据和向前兼容检查旧代码能否读新数据。Confluent 的 schema registry、Buf 这类工具都提供了检查能力接进 CI 流水线改错了直接卡住合并。第三条未知字段要保留。Protobuf 和 Avro 都会把不认识的字段保留下来Protobuf 的 proto3 现在也支持保留未知字段了这样中间层做透传的时候才不会丢数据。如果你写的是一个转发服务一定要确认序列化库开了这个开关。5. 实操一次跨语言数据交换的完整落地讲完格式本身我想拿一个实际项目走一遍从契约设计到代码实现到性能验证这样你能看到格式选择是怎么落到具体代码上的。5.1 接口契约先定再谈格式我负责过一个订单系统对接上游是 Java 服务下游是 Python 数据管道和 Go 的边缘网关三边语言不同。第一步不是选格式而是把数据契约写出来字段名、类型、是否必填、取值范围、语义说明。这份契约是格式无关的写清楚之后选 JSON 还是 Protobuf 只是编码方式的差别。契约里我特别标注了三条订单号是 64 位无符号整数但传输时统一用字符串金额用分为单位的整数避免浮点时间统一用 UTC 的 RFC 3339 格式字符串。这三条就是前面提到的那些坑的预防措施写在接口文档最前面。因为下游有 Python 和 Go且数据量在每天亿级我们最终选了 Protobuf 做服务间传输对外接口保留 JSON。理由Python 和 Go 都有成熟的 Protobuf 实现schema 统一管理三边生成的代码不会出现字段名拼写不一致每天省下的存储和带宽成本可观。5.2 序列化落地的三语言示例Go 这边用官方库注意json标签和protobuf标签是两套体系对外 JSON 接口和内部 Protobuf 传输要分开定义。type Order struct { OrderID string json:order_id protobuf:bytes,1,opt,nameorder_id AmountCents int64 json:amount_cents protobuf:varint,3,opt,nameamount_cents CreatedAt string json:created_at protobuf:bytes,6,opt,namecreated_at }Python 这边用生成的_pb2模块注意反序列化时要显式处理未知字段。from order_pb2 import Order from google.protobuf.timestamp_pb2 import Timestamp from google.protobuf.json_format import MessageToDict raw queue.consume() msg Order() msg.ParseFromString(raw) print(msg.order_id, msg.amount_cents) # 需要转成 JSON 给前端或者写日志时 as_dict MessageToDict( msg, preserving_proto_field_nameTrue, including_default_value_fieldsTrue, ) print(as_dict)Java 这边用 Jackson 处理 JSON用 Protobuf 的parseFrom处理二进制注意Long序列化成字符串要加注解。public class OrderDto { JsonSerialize(using ToStringSerializer.class) public Long orderId; public Long amountCents; JsonFormat(pattern yyyy-MM-ddTHH:mm:ssZ, timezone UTC) public Instant createdAt; }上面 Java 那段里ToStringSerializer就是解决大整数精度问题的标准做法Long类型序列化成 JSON 字符串前端读到的就是精确值。Instant配 UTC 时区保证时间不会漂。5.3 体积与耗时的实测对比我在一台普通开发机上做了一组对比数据是一份包含 20 个字段、其中有 6 个字符串字段、嵌套了两层结构、共 10 万条记录的订单数据。结果大致是格式文件体积序列化耗时反序列化耗时JSON无缩进约 62 MB1.0x1.0xJSON带缩进约 88 MB1.1x1.0xMessagePack约 38 MB0.7x0.5xProtobuf约 24 MB0.4x0.2xParquetZSTD约 6 MB1.6x0.15x这里有几个反直觉的点值得说。JSON 带缩进体积涨了 40%所以生产环境的 JSON 报文一定不要格式化格式化只用于调试。Parquet 的写入耗时最长因为它要做编码、压缩、统计信息计算但读取得极快这就是列存的典型特征写慢读快适合一次写多次读的分析场景。Protobuf 的反序列化优势最大因为它不做字符串解析直接按编号读字节几乎是内存拷贝级别。这组数字是量级参考不是基准测试结论。字段名长度、字符串中英文比例、嵌套深度都会显著影响结果。你要做正式选型应该拿自己的真实数据跑一遍用timeit或者 JMH 之类的工具别拿别人的数字拍板。5.4 落盘与传输的工程细节选好格式只是开始落地时还有几个细节决定成败。压缩要不要开。Protobuf 本身不压缩但链路层一般会开 gzip 或者 zstd。经验是小消息几百字节以内别压缩压缩头部的开销比收益还大大消息或者批量消息开 zstd 性价比很高压缩率接近 gzip 但速度快好几倍。批量还是单条。把多条记录打包成一个批次发送能摊薄协议头部和网络往返的成本。批次大小需要调优太小没效果太大会增加延迟和内存占用。我的常用起点是 1000 条一批或者 1MB 一批然后根据实际延迟指标调整。落盘文件别写太多小文件。这点在 Parquet 场景下尤其重要。一个目录里塞几十万个几十 KB 的小文件元数据开销会压垮查询引擎。一般建议单个 Parquet 文件在 128MB 到 1GB 之间写入时做攒批。格式转换要有明确的边界点。前面那个物联网平台的例子里格式转换只发生在三个明确的组件里其他地方严格不做转换。这样出问题时排查范围清晰不会满系统找是谁改的格式。6. 常见问题与排查技巧实录这一节是我这些年攒下来的故障记录按现象整理成表遇到问题时可以直接对照。6.1 高频故障速查表现象可能原因排查动作处理方式JSON 解析报Unexpected token报文体不完整或被截断打印原始字节长度检查传输层分包修复分包逻辑加长度校验大整数末尾几位变成 0超过双精度安全整数范围对比原始值和解析值序列化成字符串传输CSV 首列名带\ufeffUTF-8 BOM 未处理repr(列名)看首个字符读取时用utf-8-sigCSV 中文乱码文件是 GBK 编码用file命令或者十六进制看编码显式指定gbk或转码YAML 解析报 Tab 错误缩进用了制表符搜索文件里的\t编辑器设置 Tab 转空格YAML 里字符串变成了布尔隐式类型推断检查NO/ON/Y这类值给值加引号Protobuf 反序列化报错两端 schema 版本不一致对比.proto文件的字段编号统一 schema走兼容检查Protobuf 收到空值字段proto3 默认零值语义检查字段是否用了optional用包装类型或optionalXML 用 XPath 查不到节点命名空间未匹配打印根节点命名空间XPath 加前缀或者用local-name()Excel 打开 CSV 数字变科学计数Excel 自动类型识别用文本编辑器打开对比导出时给字段加引号前缀或改 xlsxParquet 读取报 schema 不匹配新旧文件列结构变了打印元数据里的 schema重写历史文件或做兼容读取JSON 里出现裸NaNPython 默认allow_nanTrue搜报文里的NaN字符串加default钩子统一转换这张表里的每一条我都在真实环境里遇到过其中大整数精度和 BOM 头这两条出现频率最高几乎每个跨端项目都会撞一次。6.2 排查思路从字节层面往下看格式类问题有个通用的排查方法就是把问题从“我看到的现象”下沉到“字节层面”。大部分人排查时停留在应用层的报错信息上来回试参数效率很低。我的习惯是三步走。第一步拿到原始字节。不管是报文还是文件先把原始内容存下来head -c 200 file | xxd看一眼前几十个字节。是7b开头{说明是 JSON是ef bb bf开头说明有 BOM是3c开头说明是 XML。这一步能快速排除大量猜测。第二步确认编码和字符集。用file -i或者 Python 的chardet检测编码别相信“肯定都是 UTF-8”这种假设。中文环境里 GBK 和 UTF-8 混用的情况很常见尤其是从旧系统导出的数据。第三步用最小的可复现样本验证。把出问题的数据缩小到几条写一个十几行的脚本单独跑一遍把中间变量全部打印出来。这一步能解决九成以上的问题因为大部分格式故障都是某个边界值触发的小样本一跑就现原形。我再补一个具体的技巧处理 JSON 的解析失败时把出错位置前后的字符串打印出来。大多数 JSON 库的报错信息里会带一个字符偏移量用content[pos-50:pos50]截取出来看通常一眼就能看出是缺引号、多了逗号还是被截断了。这个小技巧帮我省过很多时间。6.3 几条踩过坑总结出的经验经验一任何跨系统的字段类型约定都要写进接口文档不能靠口头约定。我见过太多“大家都知道订单号是字符串”但实际上没写文档的情况新人接手或者换个团队就出问题。文档里加一句“大整数一律用字符串表示”比事后排查三天划算太多。经验二所有序列化和反序列化都要有对称性测试。写一个测试用例对一批样本数据做“序列化再反序列化”断言结果和原始对象相等。这个测试能提前发现很多类型丢失、精度丢失、时区错乱的问题。Protobuf、Avro 这类有 schema 的格式尤其需要因为字段编号写错了肉眼看不出来。经验三不要在生产环境用 pickle 或者 Java 原生序列化做跨系统交换。前者有安全风险且不跨语言后者体积大、对类结构敏感、改一个字段就报serialVersionUID不匹配。这两种方案只在单进程内部缓存场景下考虑跨系统一律用标准格式。经验四格式选型要留退路。选了一个冷门格式就要保证任何时候都能快速转成 JSON 或者 CSV 给人看。我早年用过一个小众二进制格式出故障时手边找不到工具解码只能现场写脚本耽误了两个小时。现在我的原则是不管用什么格式都要有一个一键导出成 JSON 的调试通道。经验五性能对比要用自己的数据。网上流传的“Protobuf 比 JSON 快 10 倍”之类数字大多是特定结构下的结果。你的数据里如果有大量短字符串和深层嵌套差距可能只有两三倍如果是纯数值的大数组差距可能几十倍。选型前拿真实数据跑一组基准这个时间投入绝对值得。最后分享一个我一直在用的小工具思路写一个统一的codec模块把所有格式的序列化反序列化都封装成同一个接口比如encode(obj, fmt)和decode(bytes, fmt)内部按格式分发。这样业务代码不直接依赖任何具体格式将来要从 JSON 切到 Protobuf只改配置不改业务逻辑。这个设计在我们系统里用了三年中间换过一次传输格式改动量不到一百行。数据交换格式这件事越早抽象出边界后面越省事。