ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

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 -stdc17 -O2 -DNDEBUG bench.cpp -I./rapidjson/include -o bench_rapidjson g -stdc17 -O2 -DNDEBUG bench.cpp -I./jsoncpp/include -ljsoncpp -o bench_jsoncpp # lib_json 一派多为 header-only只需要指定头文件目录 g -stdc17 -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::BaseReaderHandlerrapidjson::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的第二个参数是 HandlerStringStream把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::Writerrapidjson::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. 多字节与 Unicoderapidjson 源码里编码是怎么一层层落下去的编码是三个库里最容易埋雷的地方。平时测的都是英文 key一上真实数据就是中文、日文、emoji代理对、非法字节、BOM 全冒出来。rapidjson 对这块的处理是显式设计源码里能看到清晰的编码层这也是它在多字节场景下更省心的原因。3.1 三种源编码与 TargetEncoding 的选择rapidjson 把编码做成了类型参数。UTF8、UTF16、UTF32、ASCII、AutoUTF都是编码类带Decode字节序列转码点和Encode码点转字节序列两个静态方法。Document默认是GenericDocumentUTF8绝大多数场景用默认的就够了因为 JSON 标准规定传输编码是 UTF-8。需要换编码的是两种情况输入已经是 UTF-16比如从某些系统 API 拿到的宽字符缓冲或者输出要 UTF-16/UTF-32。前者用GenericDocumentUTF16后者换 Writer 的TargetEncoding。// doc 是默认的 UTF-8 文档这里把它序列化成 UTF-16 缓冲区 rapidjson::GenericStringBufferrapidjson::UTF16 u16buf; rapidjson::Writerrapidjson::GenericStringBufferrapidjson::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 内的字符一个转义就够超出 UFFFF 的要写成代理对\uD83D\uDE00这种。rapidjson 在解析字符串时遇到\u会先把四位十六进制转成码点如果落在0xD800–0xDBFF区段会继续向后读低位代理拼成完整码点低位不合法时抛kParseErrorStringUnicodeSurrogateInvalid位置直接用GetErrorOffset()拿到。这套行为对我最有价值的地方是它不猜。很多解析器遇到代理对错误会悄悄替换成 UFFFD 继续走数据落库之后才发现某几个字段变成问号回头查已经找不到源头。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默认映射到assertNDEBUG一开就变成空语句。这意味着 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.Parserapidjson::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::vectorchar这类可写容器并且手动补\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 库都强制走一遍同样的流程固定编译选项、分四类数据形态、看耗时也看峰值、把所有错误码打印一遍、拿一份含中文和代理对的脏数据跑一遍。这套流程救过我至少两次都是在别人觉得「库都差不多」的地方翻出问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表