标签 标准库 下的文章

Go json/v2实战:告别内存爆炸,掌握真流式Marshal和Unmarshal

本文永久链接 – https://tonybai.com/2025/08/09/true-streaming-support-in-jsonv2

大家好,我是Tony Bai。

Go 开发者长期以来面临一个痛点:标准库 encoding/json 在处理大型 JSON 数据时,即使使用 Encoder/Decoder,也因其内部的全量缓冲机制而导致巨大的内存开销。备受期待的 encoding/json/v2 提案(#71497)旨在从根本上解决这一问题。通过引入全新的底层包 encoding/json/jsontext,v2 实现了真正的流式处理能力。本文将通过具体的、可量化的基准测试,向你展示 v1 的内存陷阱,并演示如何使用 json/v2 高效地实现流式处理大规模 JSON 数据,彻底告别内存爆炸的烦恼。

json/v1 的“伪流”之痛:一个内存陷阱基准

为了直观地感受 json/v1 在处理大数据时的局限性,我们来建立一个基准测试。我们将分别进行编码(Marshal)和解码(Unmarshal)操作,并观察其内存使用情况。

关于内存评估:我们通过比较操作前后的 runtime.MemStats.TotalAlloc 来精确测量该操作自身导致的堆内存总分配量。一个真正的流式处理,其内存分配量应该是一个很小的常数(例如,I/O 缓冲区的大小),而与数据总量无关。

场景一:v1 编码一个巨大的 JSON 数组

我们创建一个包含 100 万个空结构体的 slice,然后使用 json.Encoder 将其写入 io.Discard。

// jsonv2-streaming/v1/marshal.go
package main

import (
    "encoding/json"
    "io"
    "log"
    "runtime"
)

func main() {
    const numRecords = 1_000_000
    in := make([]struct{}, numRecords)
    out := io.Discard

    // 多次 GC 以清理 sync.Pools,确保测量准确
    for i := 0; i < 5; i++ {
        runtime.GC()
    }

    var statsBefore runtime.MemStats
    runtime.ReadMemStats(&statsBefore)

    log.Println("Starting to encode with json/v1...")
    encoder := json.NewEncoder(out)
    if err := encoder.Encode(&in); err != nil {
        log.Fatalf("v1 Encode failed: %v", err)
    }
    log.Println("Encode finished.")

    var statsAfter runtime.MemStats
    runtime.ReadMemStats(&statsAfter)

    allocBytes := statsAfter.TotalAlloc - statsBefore.TotalAlloc
    log.Printf("Total bytes allocated during Encode: %d bytes (%.2f MiB)", allocBytes, float64(allocBytes)/1024/1024)
}

分析:当你运行此程序,会看到总分配字节数是一个巨大的数字,通常是几兆字节 (MiB)。这是因为 json.Encoder 必须先在内存中将整个 slice 序列化成一个完整的、约几MB({} 乘以 100 万)的 JSON 字符串,然后才开始写入。这个巨大的字符串就是内存分配的来源。

在我的Mac上,这个程序的输出如下:

$go run unmarshal.go
2025/08/09 13:38:47 Starting to decode with json/v1...
2025/08/09 13:38:47 Decode finished.
2025/08/09 13:38:47 Total bytes allocated during Decode: 8394096 bytes (8.01 MiB)

下面再来看看v1版本的json解码。

场景二:v1 解码一个巨大的 JSON 数组

现在,我们构造一个代表百万空对象的 JSON 字符串,并使用 json.Decoder 解码。

// jsonv2-streaming/v1/unmarshal.go
package main

import (
    "encoding/json"
    "log"
    "runtime"
    "strings"
)

func main() {
    const numRecords = 1_000_000
    // 构造一个巨大的 JSON 数组字符串,约 2MB
    value := "[" + strings.TrimSuffix(strings.Repeat("{},", numRecords), ",") + "]"
    in := strings.NewReader(value)

    // 预分配 slice 容量,以排除 slice 自身扩容对内存测量的影响
    out := make([]struct{}, 0, numRecords)

    for i := 0; i < 5; i++ {
        runtime.GC()
    }

    var statsBefore runtime.MemStats
    runtime.ReadMemStats(&statsBefore)

    log.Println("Starting to decode with json/v1...")
    decoder := json.NewDecoder(in)
    if err := decoder.Decode(&out); err != nil {
        log.Fatalf("v1 Decode failed: %v", err)
    }
    log.Println("Decode finished.")

    var statsAfter runtime.MemStats
    runtime.ReadMemStats(&statsAfter)

    allocBytes := statsAfter.TotalAlloc - statsBefore.TotalAlloc
    log.Printf("Total bytes allocated during Decode: %d bytes (%.2f MiB)", allocBytes, float64(allocBytes)/1024/1024)
}

分析:同样,你会看到数兆字节的内存分配。json.Decoder 在反序列化之前,会将整个 JSON 数组作为一个单独的值完整地读入其内部缓冲区。这个缓冲区的大小与输入数据的大小成正比。

运行该代码,我机器上的输出如下:

$go run unmarshal.go
2025/08/09 13:38:47 Starting to decode with json/v1...
2025/08/09 13:38:47 Decode finished.
2025/08/09 13:38:47 Total bytes allocated during Decode: 8394096 bytes (8.01 MiB)

从上面两个例子,我们看到:json/v1 的 Encoder 和 Decoder 提供的只是API 层面的流式抽象,其底层实现是全量缓冲的,导致内存复杂度和数据大小成线性关系 (O(N))。这就是“伪流”之痛。

json/v2 的革命:用正确的 API 实现真流式处理

现在,让我们见证 json/v2 的魔力。我们将使用 v2 推荐的流式 API,来完成与 v1 示例完全相同的任务,并进行内存对比。

场景一:v2 流式编码一个巨大的 JSON 数组

我们将模拟从数据源逐条获取数据,并使用 jsontext.Encoder 手动构建 JSON 数组流。

// jsonv2-streaming/v2/marshal.go
package main

import (
    "io"
    "log"
    "runtime"
    "encoding/json/v2"
    "encoding/json/jsontext"
)

func main() {
    const numRecords = 1_000_000
    out := io.Discard

    for i := 0; i < 5; i++ {
        runtime.GC()
    }

    var statsBefore runtime.MemStats
    runtime.ReadMemStats(&statsBefore)

    log.Println("Starting to encode with json/v2...")

    enc := jsontext.NewEncoder(out)

    // 手动写入数组开始标记
    if err := enc.WriteToken(jsontext.BeginArray); err != nil {
        log.Fatalf("Failed to write array start: %v", err)
    }

    // 逐个编码元素
    for i := 0; i < numRecords; i++ {
        // 内存中只需要一个空结构体,几乎不占空间
        record := struct{}{}
        if err := json.MarshalEncode(enc, record); err != nil {
            log.Fatalf("v2 MarshalEncode failed for record %d: %v", i, err)
        }
    }

    // 手动写入数组结束标记
    if err := enc.WriteToken(jsontext.EndArray); err != nil {
        log.Fatalf("Failed to write array end: %v", err)
    }
    log.Println("Encode finished.")

    var statsAfter runtime.MemStats
    runtime.ReadMemStats(&statsAfter)

    allocBytes := statsAfter.TotalAlloc - statsBefore.TotalAlloc
    log.Printf("Total bytes allocated during Encode: %d bytes (%.2f KiB)", allocBytes, float64(allocBytes)/1024)
}

分析:运行此程序,你会看到总分配字节数是一个非常小的数字,通常是几十千字节 (KiB),主要用于 Encoder 内部的 I/O 缓冲。v2 将每个元素编码后立即写入,没有在内存中构建那个 2MB 的巨大字符串。

在我的机器上运行该示例,编码过程实际分配的内存仅有不到15KB:

$GOEXPERIMENT=jsonv2 go run marshal.go
2025/08/09 13:45:50 Starting to encode with json/v2...
2025/08/09 13:45:50 Encode finished.
2025/08/09 13:45:50 Total bytes allocated during Encode: 15328 bytes (14.97 KiB)

场景二:v2 流式解码一个巨大的 JSON 数组

我们将使用 jsontext.Decoder 和 jsonv2.UnmarshalDecode 的组合来逐个解码元素。

// jsonv2-streaming/v2/unmarshal.go
package main

import (
    "errors"
    "io"
    "log"
    "runtime"
    "strings"
    "encoding/json/v2"
    "encoding/json/jsontext"
)

func main() {
    const numRecords = 1_000_000
    value := "[" + strings.TrimSuffix(strings.Repeat("{},", numRecords), ",") + "]"
    in := strings.NewReader(value)
    _ = make([]struct{}, 0, numRecords) // out 变量在实际应用中会用到

    for i := 0; i < 5; i++ {
        runtime.GC()
    }

    var statsBefore runtime.MemStats
    runtime.ReadMemStats(&statsBefore)

    log.Println("Starting to decode with json/v2...")

    dec := jsontext.NewDecoder(in)

    // 手动读取数组开始标记 '['
    tok, err := dec.ReadToken()
    if err != nil || tok.Kind() != '[' {
        log.Fatalf("Expected array start, got %v, err: %v", tok, err)
    }

    // 循环解码数组中的每个元素
    for dec.PeekKind() != ']' {
        var record struct{}
        if err := json.UnmarshalDecode(dec, &record); err != nil {
            if errors.Is(err, io.EOF) {
                break
            }
            log.Fatalf("v2 UnmarshalDecode failed: %v", err)
        }
        // 在实际应用中,这里会处理 record,例如:
        // out = append(out, record)
    }
    log.Println("Decode finished.")

    var statsAfter runtime.MemStats
    runtime.ReadMemStats(&statsAfter)

    allocBytes := statsAfter.TotalAlloc - statsBefore.TotalAlloc
    log.Printf("Total bytes allocated during Decode: %d bytes (%.2f KiB)", allocBytes, float64(allocBytes)/1024)
}

分析:同样,你会看到总分配字节数非常小。UnmarshalDecode 在循环中每次只读取并解码一个 {} 对象,而 jsontext.Decoder 的内部缓冲区大小是固定的,不会因输入流的增大而膨胀。

下面是我机器上运行的结果,和v2编码一样,仅需要15KB左右的缓冲区:

$ GOEXPERIMENT=jsonv2 go run unmarshal.go
2025/08/09 13:55:29 Starting to decode with json/v2...
2025/08/09 13:55:29 Decode finished.
2025/08/09 13:55:29 Total bytes allocated during Decode: 15248 bytes (14.89 KiB)

内存占用对比总结

操作 json/v1 (伪流) 内存分配 json/v2 (真流) 内存分配 结论
Marshal ~8 MiB (与数据大小成正比) ~15 KiB (固定开销) 数量级差异
Unmarshal ~8 MiB (与数据大小成正比) ~15 KiB (固定开销) 数量级差异

这个直接的、数据驱动的对比,无可辩驳地证明了 json/v2 在流式处理方面的革命性突破。它将内存复杂度从 O(N) 降低到了 O(1),为 Go 语言处理海量 JSON 数据铺平了道路。

正如提出issue #33714的开发者flimzy所说的那样:json/v2正是json流处理的答案!

小结

encoding/json/v2 的提案和实现,标志着 Go 在大规模数据处理能力上的一个巨大飞跃。通过将语法和语义分离,并提供底层的、真正的流式 API,v2 彻底解决了 v1 长期存在的内存瓶颈问题。

对于 Go 开发者而言,这意味着:

  • 处理超大规模 JSON 不再是难题**:无论是生成还是解析 GB 级别的 JSON 文件或流,都将变得轻而易举,且内存占用可控。
  • 更高的性能和效率:根据 v2 的基准测试,新的实现在解码(Unmarshal)方面比 v1 有 2.7 到 10.2 倍的性能提升。
  • 更灵活的控制力:底层的 jsontext 包为需要进行精细化 JSON 操作的开发者提供了前所未有的能力。

虽然 Go 1.25 json/v2 会以 GOEXPERIMENT 形式落地,但它所展示的强大能力和优秀设计,已经预示了Go JSON 处理的新纪元。我们有充分的理由期待它在未来的 Go 版本中正式发布,成为所有 Go 开发者处理 JSON 数据的首选工具。

本文涉及的示例代码可以在这里下载。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

Rust 的安全神话?数据库 CEO 为何在关键系统中仍选 C++

本文永久链接 – https://tonybai.com/2025/07/22/cedardb-choose-cpp-rather-than-rust

大家好,我是Tony Bai。

近年来,Rust 语言无疑是技术圈最炙手可热的明星。它以“内存安全”的核心承诺,向统治了系统编程领域数十年的 C++ 发起了强有力的挑战。无数文章和布道者都在宣扬一个近乎“神话”的观点:用 Rust,你就能告别段错误和内存泄漏。

然而,就在这股“Rust 替换一切”的热潮中,一个来自行业核心的声音,给我带来了深深的思考。

Moritz Sichert,高性能数据库 CedarDB 的联合创始人兼 CEO,最近发表了一个“反直觉”的观点:

“Rust 是一门很棒的语言,我真的很喜欢用它。然而,当涉及到数据库系统时,我仍然会选择 C++ 而不是 Rust。”

一个数据库 CEO,在一个对数据安全和系统稳定性要求极致的领域,放弃了以“安全”著称的 Rust,转而拥抱充满了“历史遗留问题”的 C++。这究竟是为什么?

这并非一次简单的“厚此薄彼”,而是一场关于工程现实与语言哲学之间深刻权衡的精彩案例。

理论安全 vs 工程现实:unsafe 的“逃生舱口”

Moritz 首先承认了 Rust 的优点:理论上,Rust 通过其所有权系统和借用检查器,提供了远超 C++ 的默认安全性。这正是大家喜爱 Rust 的原因。

但问题在于,像 CedarDB 这样的高性能数据库,其开发工作远不止是处理业务逻辑。它需要深入到硬件的毛细血管中,榨干每一滴性能。这意味着:

  • 使用大量底层的 CPU 特性。
  • 实现复杂的侵入式数据结构。
  • 进行带有验证的、乐观且激进的内存访问。

在这些场景下,Rust 的安全检查反而成了“束缚”。为了完成任务,你必须使用 Rust 提供的“逃生舱口”——unsafe 关键字。

而 Moritz 抛出的重磅炸弹正在于此:

“一旦你在 Rust 中写下 unsafe 代码,所有的赌注都将失效。在 unsafe 代码中,你遇到未定义行为(UB, Undefined Bahavior)的风险,甚至比在 C++ 中还要高。”

unsafe Rust:一个更危险的“黑暗森林”?

这个论断足以颠覆许多人的认知。unsafe Rust 怎么会比 C++ 还危险?

关键在于理解 unsafe 到底意味着什么。它不是简单地“关闭”安全检查,而是程序员向编译器立下了一个契约:“编译器,请相信我,接下来的这段代码,我将手动保证它完全遵守 Rust 的内存模型和别名规则(Aliasing Rules)。

这正是问题的核心所在。
* C++ 的危险是“已知的”:C++ 就像一片广阔的雷区,但经过几十年的探索,老兵们已经绘制出了详细的“排雷图”。虽然处处是坑,但如何避免、如何调试,已经积累了大量的工程实践和模式。
* unsafe Rust 的危险是“微妙的”:unsafe 区域就像一个“黑暗森林”,你不仅要面对 C++ 程序员熟悉的裸指针、内存布局等问题,更要命的是,你还必须手动维护 Rust 那套比 C++ 更为严格的别名规则(例如,在任何给定时间,你只能有一个可变引用 &mut T,或者任意数量的不可变引用 &T,但不能两者都有)。

对于一位经验丰富的 C++ 开发者而言,在 unsafe 块里很容易不自觉地写出 C++ 风格的指针操作,却无意中违反了 Rust 的核心不变量,从而触发 UB。这种“新规则”下的自由,反而成了一个更容易跌落的陷阱。

Moritz 的观点是:在一个必须大量使用 unsafe 的项目中,与其在一个受严格规则约束的环境里“戴着镣铐跳舞”,不如直接选择那个虽然危险、但规则更为人熟知且自由度更高的 C++。

聊聊 Go:一种不同的安全哲学

那么,聊了这么多 Rust 和 C++,我们作为 Gopher 能从中得到什么启发呢?这恰好能让我们更深刻地理解 Go 的设计取舍。

Go 语言同样有一个 unsafe 包。但与 Rust 的设计哲学截然不同。

Rust 的 unsafe 是语言的一等公民,是其系统编程能力的重要组成部分,是为了实现“零成本抽象”而设计的必要工具。

而 Go 的 unsafe,从其文档的第一句话起,就在极力地“劝退”你:

“任何使用了 unsafe 包的程序都可能在未来 Go 语言版本中无法移植……使用 unsafe 的代码,其安全性需要程序员手动保证,我们不建议使用。”

在 Go 的世界里,unsafe 更像一个被严格限制的“后门”,而非一个功能特性。它的存在主要是为了实现标准库(如 runtime、sync),或在极少数与 C 库交互、进行极致性能微调的场景下使用。

我们可以这样总结三者的哲学:
* C++:默认给予你全部的权力与危险,安全是你的责任。
* Rust:默认提供极致的安全,但给你一个 unsafe 的选项,让你在立下严格契约为前提下重获权力。
* Go:默认通过 GC 和简单的内存模型提供高度的工程安全与效率,unsafe 是一个官方不鼓励、非标准的“应急方案”。

Go 的选择是,在绝大多数场景下,宁愿牺牲掉那部分通过复杂手动内存管理换来的极限性能,也要保证语言模型的简单性和大规模团队协作的工程效率与安全。

小结:没有神话,只有权衡

Moritz Sichert 的观点,并非是对 Rust 的全盘否定,更不是在鼓吹 C++ 的回归。

它有力地击碎了“Rust=绝对安全”的技术神话,揭示了一个冰冷的工程现实:在任何复杂的系统中,安全都是一个连续的光谱,而不是一个二元的开关。

当你的业务场景把你推向性能金字塔的顶尖,逼迫你大量使用 unsafe 时,Rust 的安全优势就会被削弱,其复杂的底层规则甚至可能成为新的风险来源。

这个案例告诉我们,技术选型永远没有“银弹”,只有基于特定问题、特定团队、特定目标的深刻权衡。作为开发者,我们最重要的能力,就是理解自己手中工具的设计哲学、优势边界以及它为之付出的代价。

对于绝大多数的后端服务、云原生应用而言,Go 和 Rust 提供的现代安全模型无疑是巨大的进步。但请记住,当有人在讨论“是否选择 C++”时,他可能不是在开历史的倒车,而是在思考一个我们尚未触及的、更深层次的工程问题。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言进阶课 Go语言精进之路1 Go语言精进之路2 Go语言第一课 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats