ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++ JSON库选型:rapidjson解析序列化性能与Unicode避坑

C++ JSON库选型:rapidjson解析序列化性能与Unicode避坑

简介:RapidJSON 源码包面向需要高性能 JSON 处理的 C++ 开发者,适用于大规模数据解析、序列化以及 Qt 等 Unicode 环境下的项目集成。其采用原生 C++ 类型与模板元编程优化内存管理和解析算法,实测速度优于 jsoncpp、lib_json,并兼容多字节与 Unicode 编码,可降低跨字符集适配成本。压缩包共 366 个文件,约 1.91MB,以 104 个 json 测试数据、64 个 cpp 与 62 个 h 源码及头文件为主,另含 42 个 md 文档和 22 个 png 图示,覆盖实现代码、单元测试与使用说明。已有 927 人学习下载,适合直接阅读源码、对照测试用例理解解析流程并按项目需求裁剪编译的读者,完整源码也便于排查编码与性能问题,可作为自研 JSON 模块的参考实现。

1. 三份 JSON 源码摆一起:我为什么最后留下 rapidjson

去年做一条离线数据通道,单机每天要处理两亿多条 JSON 记录,峰值 QPS 涨上来之后,最先变瓶颈的是 CPU 里那段反序列化。当时手边同时摆着三份源码:rapidjson、jsoncpp,还有一份 lib_json 的现代 C++ 封装。压测跑完,rapidjson 这套 C++ 里公认好用、速度最快的 JSON 序列化解析器确实把另外两个甩开了,多字节和 Unicode 这条路上也最省心。这篇不写官网的介绍,只讲我按什么维度比的、源码里哪些开关会直接影响结论、以及哪些坑踩完才知道值不值。如果你正在三个库里做选择,或者已经把 rapidjson 拿进项目但对它的内存和编码行为心里没底,下面这些对比记录能让你少走一段弯路。

2. 解析和序列化谁更快:四个维度把 rapidjson、jsoncpp、lib_json 摆上同一张表

速度这个东西最容易被测错。同一份代码,换个编译选项、换个缓冲区策略、换个数据形态,排名就能翻过来。我一般会先把变量固定住,再分四个维度看:编译期开关、反序列化路径、序列化路径、数据形态。这四个维度定下来,谁快谁慢就不是玄学了。

2.1 先固定编译期开关,否则你测的都不是同一个 rapidjson

很多人第一次对比就翻车在这里:debug 构建下 rapidjson 的RAPIDJSON_ASSERT走 assert 分支,jsoncpp 那边也没有NDEBUG,两边都在带检查跑,测出来的时间跟 release 完全不是一个量级。我的做法是三个库统一-O2 -DNDEBUG,绑定同一个编译器版本,同一份 JSON 样本,跑之前先做一次 warmup。

# 三个库统一优化级别和断言开关,否则比较没有意义 g++ -std=c++17 -O2 -DNDEBUG bench.cpp -I./rapidjson/include -o bench_rapidjson g++ -std=c++17 -O2 -DNDEBUG bench.cpp -I./jsoncpp/include -ljsoncpp -o bench_jsoncpp # lib_json 一派多为 header-only,只需要指定头文件目录 g++ -std=c++17 -O2 -DNDEBUG bench.cpp -I./lib_json/include -o bench_libjson

-O2之外还有几个宏值得单独说明。RAPIDJSON_SSE42和RAPIDJSON_SSE2会让跳过空白字符的循环走 SIMD 指令,解析器在长空白、格式化输出场景下更快,但要求目标机器指令集支持,线上机器不统一就别开。RAPIDJSON_HAS_STDSTRING打开后std::string能直接参与读写,省掉一次c_str()加长度的手工传递,用起来顺手,代价是多一层模板实例化。还有RAPIDJSON_NOMEMBERITERATORCLASS,它砍掉MemberIterator类,能压一点编译产物体积,代价是遍历成员时只能走指针式迭代。

这些宏不是调优参数,是语义开关。开着 SSE 测出来的数和关着测出来的数不能放在一张表里比,这点我吃过亏:早期一版基准里 rapidjson 领先幅度大得离谱,回头查发现对比工程一个开了 SSE42、一个没开,结论直接作废。

2.2 反序列化:SAX 流式与 DOM 全量的分界点在内存

rapidjson 的反序列化有两条路,选错了性能差异比换库还大。SAX 走Reader加自定义Handler,边读边回调,不建树,内存占用跟 JSON 大小基本无关;DOM 走Document::Parse,一次性把整棵树建在内存池里,之后随便取。

// SAX 路径:只统计 key 数量,不建树,内存占用与原文件大小无关 struct CountHandler : rapidjson::BaseReaderHandler<rapidjson::UTF8<>, CountHandler> { size_t keys = 0; bool Key(const char*, rapidjson::SizeType, bool) { ++keys; return true; } }; rapidjson::Reader reader; CountHandler h; // StringStream 只读不拷贝,比 StringBuffer 少一次整串复制 rapidjson::StringStream ss(json_text); rapidjson::ParseResult ok = reader.Parse(ss, h); if (!ok) { // ok.Code() 和 ok.Offset() 直接给出错误码与字节偏移 }

Reader::Parse的第二个参数是 Handler,StringStream把const char*包成流,全程不复制输入。ParseResult是可布尔判断的,失败时Code()返回ParseErrorCode枚举,Offset()是出错的字节位置。这条路适合「读一遍就丢」的场景,比如日志过滤、字段抽取、消息转发。

DOM 路径则是Document doc; doc.Parse(text, len);,之后doc["user"]["id"].GetInt64()随便取。代价是整棵树的内存,包括所有 key 和字符串值的副本,都从Document自带的MemoryPoolAllocator里分配,Document析构时一次性释放。jsoncpp 的Json::Value是另一套模型:每个节点是独立的Value对象,字符串走std::string,容器走std::map/std::vector,插入删除时零散分配。小 JSON、层级浅的时候差别不明显,一旦节点数上到几十万,分配器调用次数就是数量级差异。

lib_json 那一派用的是标准容器加值语义,写法最舒服,auto v = json::parse(text);一行搞定,异常处理也符合现代 C++ 习惯。它的代价同样在分配:值语义意味着拷贝,容器意味着节点独立,解析大文档时的内存峰值和分配次数都比内存池方案高。

2.3 序列化:Writer 的写入路径和缓冲区复用

序列化这边,rapidjson 的Writer是模板化的输出流包装,StringBuffer做内存输出,FileWriteStream做文件输出,PrettyWriter负责带缩进的可读格式。性能关键点不在 Writer 本身,在缓冲区怎么用。

rapidjson::StringBuffer buf; buf.Reserve(64 * 1024); // 预留容量,避免逐次扩容时的整块搬移 rapidjson::Writer<rapidjson::StringBuffer> writer(buf); for (const auto& item : batch) { buf.Clear(); // 复用同一块缓冲区,跨轮次只清长度不释放内存 writer.Reset(buf); build_doc(item).Accept(writer); sink.write(buf.GetString(), buf.GetSize()); }

Reserve一次把容量要到预估大小,后续Clear只把size归零,底层内存不动,循环里就没有反复realloc的开销。writer.Reset(buf)在复用缓冲区时是必须的,它把 Writer 内部状态清干净,不然上一轮的栈残留会带进下一轮。这个写法我在批量导出场景里跑过,比每轮新建StringBuffer加Writer稳定得多,GC 压力(如果是混合栈)和内存碎片都少。

jsoncpp 那边对应的是Json::StreamWriterBuilder加writeString,每次调用返回一个新的std::string,想复用就得自己接operator<<到std::ostringstream。lib_json 是json::dump(),返回std::string,同样每轮一次分配。不是致命问题,但在每秒几万次序列化的路径上,这层分配会积成可观的耗时。

2.4 一份能自己跑的对比:把数据形态也当成变量

只看总耗时容易得出片面结论,因为 JSON 的数据形态对解析器影响极大。我一般准备四组样本:纯数字数组、短 key 的扁平对象、深层嵌套对象、含大量\u转义和中文字符串的混合文档。前两组考的是数字解析和哈希查找,后两组考的是字符串处理和编码转换。

同一台机器、同一份数据、同一套编译选项下,我记录到的相对耗时大致是这个量级(以 rapidjson DOM 解析纯数字数组为 1.0):

场景rapidjsonjsoncpplib_json备注
纯数字数组解析1.02.62.1数字分支和分配次数差异
扁平对象解析1.03.12.4key 查找结构不同
深层嵌套解析1.02.21.9嵌套越深,内存池优势越明显
含中文与转义解析1.03.42.8编码转换路径差异最大
序列化同文档1.02.82.3缓冲区复用带来的差距

这些相对值只用来表达量级差,不当作绝对值引用,换机器、换数据分布都会变。真正要抄的是方法:固定编译选项、分数据形态、跑 warmup、看内存峰值而不只看耗时。内存峰值这一项在容器环境里比耗时更要命,rapidjson 的 DOM 峰值大致是「原文字节数 × 一个系数」,因为 key 和字符串都进了同一个池子;jsoncpp 因为std::map节点开销和std::string的 SSO 边界,峰值通常更高。

3. 多字节与 Unicode:rapidjson 源码里编码是怎么一层层落下去的

编码是三个库里最容易埋雷的地方。平时测的都是英文 key,一上真实数据就是中文、日文、emoji,代理对、非法字节、BOM 全冒出来。rapidjson 对这块的处理是显式设计,源码里能看到清晰的编码层,这也是它在多字节场景下更省心的原因。

3.1 三种源编码与 TargetEncoding 的选择

rapidjson 把编码做成了类型参数。UTF8<>、UTF16<>、UTF32<>、ASCII<>、AutoUTF<>都是编码类,带Decode(字节序列转码点)和Encode(码点转字节序列)两个静态方法。Document默认是GenericDocument<UTF8<>>,绝大多数场景用默认的就够了,因为 JSON 标准规定传输编码是 UTF-8。

需要换编码的是两种情况:输入已经是 UTF-16(比如从某些系统 API 拿到的宽字符缓冲),或者输出要 UTF-16/UTF-32。前者用GenericDocument<UTF16<>>,后者换 Writer 的TargetEncoding。

// doc 是默认的 UTF-8 文档,这里把它序列化成 UTF-16 缓冲区 rapidjson::GenericStringBuffer<rapidjson::UTF16<> > u16buf; rapidjson::Writer<rapidjson::GenericStringBuffer<rapidjson::UTF16<> >, rapidjson::UTF8<>, // SourceEncoding:文档本身的编码 rapidjson::UTF16<> > // TargetEncoding:输出目标的编码 u16w(u16buf); doc.Accept(u16w); // u16buf.GetString() 得到 UTF-16 码元序列,长度用 GetSize() 取

Writer的模板参数依次是输出流、源编码、目标编码、栈分配器、写入标志。编码转换在这一层完成,Encode会把码点按目标编码写出去。AutoUTF用于运行时才知道编码的场景,代价是每次编解码多一次分支判断,能静态确定就别用。

3.2\u转义、代理对和非法序列怎么处理

JSON 里的\uXXXX是必考项。BMP 内的字符一个转义就够,超出 U+FFFF 的要写成代理对,\uD83D\uDE00这种。rapidjson 在解析字符串时遇到\u会先把四位十六进制转成码点,如果落在0xD800–0xDBFF区段,会继续向后读低位代理,拼成完整码点;低位不合法时抛kParseErrorStringUnicodeSurrogateInvalid,位置直接用GetErrorOffset()拿到。

这套行为对我最有价值的地方是它不猜。很多解析器遇到代理对错误会悄悄替换成 U+FFFD 继续走,数据落库之后才发现某几个字段变成问号,回头查已经找不到源头。rapidjson 直接把错误码和偏移给你,配合原始报文能精确定位到第几个字节。

另一半是编码校验。kParseValidateEncodingFlag打开后,解析器会校验输入字节序列是否为合法 UTF-8,非法序列报错而不是放过。不加这个标志时,非法字节可能一路穿到业务层,最后在某个前端页面上显示成乱码。这个标志有性能代价,我一般在外层入口(接收外部数据的地方)打开,内部可信链路关掉。

3.3 三个库的编码行为对照

维度rapidjsonjsoncpplib_json 一派
内部字符串存储UTF-8(默认),可换 UTF16/UTF32UTF-8,不做校验UTF-8,容器直接存
\u代理对严格校验,非法报错并给偏移宽松处理,可能替换通常抛异常或替换
非法 UTF-8 检测需显式开kParseValidateEncodingFlag基本不检测部分实现校验
输出编码切换换 Writer 的 TargetEncoding输出统一 UTF-8输出统一 UTF-8
转义输出策略可配置是否转义非 ASCII默认转义非 ASCII可配置

最后一行值得展开。rapidjson 的 Writer 默认会把非 ASCII 字符原样输出成 UTF-8 字节,中文不会变成\u4e2d;需要转义时用Writer的写入标志或在业务层自行处理。jsoncpp 默认倾向于把非 ASCII 转义,体积会膨胀,但对只认 ASCII 的下游更友好。这不是谁对谁错,是默认值取向不同,选型时得对着自己的下游系统确认一遍。

4. 内存池、断言和数值精度:决定能不能上生产的三个细节

跑分好看不等于能上生产。三个库在我手上都出过线上问题,回头看,问题集中在内存生命周期、错误处理、数值边界这三块。rapidjson 在这三块给了足够多的旋钮,旋钮多意味着默认值不一定适合你。

4.1MemoryPoolAllocator和kParseInsituFlag的真实代价

Document默认带一个MemoryPoolAllocator,所有节点、key、字符串副本都从池子里切。池子按 chunk 向底层CrtAllocator要内存,默认 chunk 大小是个常量,节点特别多时会连续扩容。它的好处是析构一次全放,坏处是生命周期必须严格包住所有引用:doc一析构,doc["name"].GetString()拿到的指针立刻悬空。我见过把GetString()的返回值存进std::string_view然后跨函数用的写法,跑测试没事,压测必崩。

kParseInsituFlag更激进,它在原缓冲区上就地解析:字符串不需要复制,直接指向原文,遇到转义时把解码结果写回原位置。省掉全部字符串复制,速度提升明显,尤其在大文档上。代价有三条:缓冲区必须可写(不能是const char*或字符串字面量)、必须以\0结尾、解析后原缓冲区的内容被破坏不能再用。

std::string text = load_file(path); // 可写副本 text.push_back('\0'); // insitu 要求以 \0 结尾 rapidjson::Document doc; // 用 &text[0] 而不是 text.c_str(),因为需要可写指针 doc.ParseInsitu(&text[0]); if (doc.HasParseError()) { // 错误处理照旧 } // 此处的 text 已经被改写,不能再当原始报文使用

ParseInsitu内部走的就是kParseInsituFlag。用它的前提是「原文用完就丢」,日志解析、消息消费这类场景完全合适;如果解析完还要把原文落盘做审计,就别碰它。这一条是我认为 rapidjson 源码里最需要提前读一遍的注释,注释里写得很清楚,但很多人是踩了段错误才回去看。

4.2 断言之外的错误码:ParseResult才是线上该看的东西

RAPIDJSON_ASSERT默认映射到assert,NDEBUG一开就变成空语句。这意味着 release 构建下,参数错误、越界访问不会给你任何提示,直接是未定义行为。线上环境不能靠断言,得靠错误码。

rapidjson::Document doc; doc.Parse(json.data(), json.size()); // 传长度,允许内容中间出现 \0 if (doc.HasParseError()) { rapidjson::ParseErrorCode code = doc.GetParseError(); size_t off = doc.GetErrorOffset(); // GetParseError_En 只内置英文描述,中文需要自己映射 fprintf(stderr, "parse error: %s at offset %zu\n", rapidjson::GetParseError_En(code), off); return false; }

Parse有两个重载,传长度的那版更安全,因为不依赖\0结尾,二进制安全。GetParseError()返回枚举,GetParseError_En()把它转成英文短句,GetErrorOffset()是字节偏移。把这三个拼进日志,线上出问题时能直接定位到报文的哪一段坏了。常见的几个码值得记住:kParseErrorDocumentEmpty(空文档)、kParseErrorDocumentRootNotSingular(根节点后还有多余内容,通常是拼接了两条 JSON)、kParseErrorStringUnicodeSurrogateInvalid(代理对非法)、kParseErrorNumberTooBig(数值溢出)。kParseErrorDocumentRootNotSingular在消息队列场景特别高频,多半是上游把两条记录粘在一起发了。

4.3 数值精度和 int64:默认值会咬人

数字这块有个默认行为必须知道:rapidjson 解析浮点时默认走快速路径,追求速度,某些十进制小数转double时末位可能和strtod的结果差一位。要做精确数值比对(比如金额、科学计算中间值),得开kParseFullPrecisionFlag,代价是浮点解析慢一截。

整数这边,Document的IsInt()只在值落在int范围内才为真,19 位的订单号、雪花 ID 用GetInt()会直接失败或拿到错误值。正确姿势是先判IsInt64()/IsUint64(),再取GetInt64()/GetUint64()。更稳的做法是对 ID 类字段开kParseNumbersAsStringsFlag,让解析器把数字原样当字符串保留,业务层自己转,彻底绕开精度和范围问题。

// 对 ID 类字段保留原始字符串,避免 int64 边界和前端精度问题 rapidjson::Document doc; doc.Parse<rapidjson::kParseNumbersAsStringsFlag>(json.data(), json.size()); // 此时 doc["order_id"] 是字符串类型,IsString() 为真 std::string id = doc["order_id"].GetString();

这个标志打开后,所有数字都以字符串形式存在文档里,需要数值时得自己转换。取舍很清楚:ID 类字段要的是「不丢位」,数值计算要的是「能算」,同一份文档里两类字段混在一起时,我一般用两次解析或者干脆在业务层做字段级处理。

5. 避坑与排查:五个把我卡到后半夜的 rapidjson 问题

下面这五条都是我自己或者身边人真实撞过的,按「现象 → 原因 → 解决」写,撞上了能直接对照。

5.1ParseInsitu传了字符串字面量,进程直接没了

现象:本地调试正常,切到某条走常量字符串的代码路径时进程崩溃,堆栈停在解析器内部,看不出所以然。

原因:ParseInsitu要求可写缓冲区,字符串字面量在只读段,写入时触发段错误。这类崩溃在 debug 下有时被断言拦住,release 下就是硬崩,而且崩溃点离调用点很远。

解决:凡是用 insitu,输入必须是std::string或std::vector<char>这类可写容器,并且手动补\0。更省心的做法是给 insitu 单独封一个函数,参数类型就定成std::string&,编译期就把字面量挡在外面。

5.2 JSON 中间有\0,解析到一半就断了

现象:某些二进制字段里嵌了\0,解析不报错,但后面的字段全丢了,GetParseError()也拿不到东西。

原因:用了doc.Parse(const char*)这个重载,它靠\0判结尾,遇到内容里的\0就当成文档结束,剩余部分被当成「根节点后的多余内容」,某些情况下甚至不报错。

解决:统一用doc.Parse(ptr, len)两参数版本,长度从std::string::size()来。这条我写进了团队规范,凡是解析外部数据的地方,必须传长度。

5.3 release 下崩溃查不到原因,因为断言被编译掉了

现象:debug 构建一切正常,release 构建偶发崩溃,日志里什么都没有。

原因:NDEBUG下RAPIDJSON_ASSERT变成空语句,原本由断言拦住的非法访问静默通过,变成未定义行为。典型触发点是doc["missing_key"]这种访问不存在成员后直接GetString(),debug 下断言报错,release 下读越界。

解决:所有成员访问先判HasMember/IsString/IsArray,不要相信输入结构。另外把GetParseError()和偏移打进日志,哪怕是 release 也要能定位。断言是开发期工具,不是线上防线。

5.4 19 位 ID 变成 0 或者负数,前后端对不上账

现象:接口返回的订单号在 C++ 侧读出来是 0,前端展示的又是另一个值。

原因:GetInt()拿一个超出int范围的数,或者 JS 侧Number精度只有 53 位,19 位整数本身就存不下。两边各错一次,对账时看起来完全对不上。

解决:ID 类字段全程走字符串,解析时开kParseNumbersAsStringsFlag,或者序列化时就写字符串。真要数值计算,用IsInt64()判过再GetInt64()。

5.5 内存一路涨,MemoryPoolAllocator成了黑匣子

现象:长跑进程 RSS 持续上涨,重启就好,压测看不出来。

原因:两种典型情况。一是每轮都新建Document但又把GetString()的指针存进了长生命周期的结构,池子释放后指针悬空,表现为脏读加内存异常;二是把Document的 allocator 传给了外部对象,生命周期错配,池子该释放时没释放。

解决:定死一条规则,Document及其派生指针不跨函数边界存活,需要留存的数据一律std::string拷贝出来。如果确实要共享池子,把 allocator 提到外层,生命周期显式包住所有使用者。这条规则听起来笨,但比事后查内存泄漏便宜太多。

6. 结论:这三份源码分别该进什么项目

把上面这些维度跑完,我的结论比较明确,不太需要纠结。

你的场景建议理由
高吞吐消息解析、日志处理rapidjson 的 SAX 路径不建树、内存恒定,速度优势最大
需要 DOM 且数据量大、字段多rapidjson 的 DOM 加内存池分配次数少,峰值可控
有 UTF-16/UTF-32 输入输出rapidjson编码作为类型参数,转换在编译期确定
团队偏现代 C++、追求开发效率lib_json 一派值语义、异常、API 顺手,性能差距在小数据量下无感
遗留工程已有 jsoncpp 依赖维持 jsoncpp迁移成本高于收益,除非热点明确在 JSON 层
对非法编码零容忍rapidjson 加kParseValidateEncodingFlag显式校验,错误码可定位

真正让我留下 rapidjson 源码的原因不是跑分,是它的行为可预期。哪个开关影响性能、哪个标志改变语义、出错时给什么信息,源码里都写得很直白,翻reader.h和writer.h两遍基本能摸清。相比之下,遇到编码问题只能靠猜的库,在生产环境里代价太高。

上手建议按这个顺序:先把默认 DOM 用法跑通,再把错误码和偏移接进日志,然后对热点路径切 SAX 或者开 insitu,最后才考虑 SSE 和kParseFullPrecisionFlag这类调优开关。顺序反了容易在还没搞清楚语义的情况下调出一堆不可复现的数字。

从那以后我每接一个新 JSON 库,都强制走一遍同样的流程:固定编译选项、分四类数据形态、看耗时也看峰值、把所有错误码打印一遍、拿一份含中文和代理对的脏数据跑一遍。这套流程救过我至少两次,都是在别人觉得「库都差不多」的地方翻出问题。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进