登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go json.RawMessage 为什么需要复制后再长期保存

来源:17golang原创

时间:2026-09-27 20:30:12 330浏览 收藏

把一段 JSON 先保留为 json.RawMessage,等知道事件类型后再解析,是 Go 中很实用的延迟解码方式。真正容易出问题的地方不是 JSON 语法,而是这段字节由谁拥有、能保存多久。结论是:通过 json.Unmarshal 解码到 RawMessage 时,标准库已经复制了输入;如果直接把一个可能复用的 []byte 转成 RawMessage,长期保存前就必须建立自己的副本。

官方文档:https://pkg.go.dev/encoding/json

先记住三点
  • RawMessage 本质是 []byte,类型转换不会自动复制底层数组。
  • UnmarshalJSON 的约定是把输入数据复制到消息自身,所以不要无条件二次复制。
  • 来自网络帧、对象池或临时读取区的字节,如果要跨越缓冲区复用周期保存,先用 bytes.Clone 建立独立所有权。

RawMessage 到底复制了什么

json.RawMessage 是一个字节切片类型,同时实现了 Marshaler 和 Unmarshaler。当它作为结构体字段参与 json.Unmarshal 时,标准库会调用它的 UnmarshalJSON 方法;该方法把传入的 data 复制到接收者中。这里的复制让解码后的消息不再依赖解码器当时使用的输入区。

json.Unmarshal 与 RawMessage 复制边界的静态结构说明图
图1:RawMessage 字节所有权说明图,区分标准库解码时的复制边界与延迟解析对象。
type Envelope struct {
    Kind string          `json:"kind"`
    Body json.RawMessage `json:"body"` // 先保留原始 JSON,稍后按 Kind 决定目标类型
}

func decodeEnvelope(input []byte) (Envelope, error) {
    var env Envelope
    // UnmarshalJSON 会把 Body 的 data 复制到 RawMessage 中,返回后可独立保存。
    if err := json.Unmarshal(input, &env); err != nil {
        return Envelope{}, err // 输入不是合法 JSON 时保留错误,不缓存半成品
    }
    return env, nil
}

相反,下面的写法只是把切片换了一个名字。raw 与 input 可能指向同一块底层数组;如果调用方随后复用 input,长期缓存里的内容也会被改写。

func viewAsRaw(input []byte) json.RawMessage {
    // 类型转换只改变切片的静态类型,不会创建新的底层数组。
    return json.RawMessage(input)
}

长期保存时如何建立自己的字节副本

消息来自网络读取缓冲区、对象池或批量解码器时,通常不能假设输入切片会一直不变。长期保存的边界可以是事件队列、内存缓存、异步 goroutine,甚至只是函数返回后仍要使用的对象。此时把复制动作放在“取得所有权”的位置最清楚。

可复用输入缓冲区与 RawMessage 独立副本的静态结构说明图
图2:长期保存原始 JSON 的结构说明图,查看可复用缓冲区与独立副本的边界。
import (
    "bytes"
    "encoding/json"
)

func retainRaw(input []byte) json.RawMessage {
    // Clone 让返回值拥有独立底层数组,可跨过 input 的复用周期保存。
    owned := bytes.Clone(input)
    return json.RawMessage(owned)
}

func decodeBody(raw json.RawMessage, target any) error {
    // 延迟解析只读取 RawMessage,不修改原始字节;错误由调用方决定是否丢弃事件。
    return json.Unmarshal(raw, target)
}

如果项目仍需兼容没有 bytes.Clone 的旧工具链,也可以使用 append([]byte(nil), input...)。不要写成 append(input[:0], input...) 后再把结果当成无条件安全:当容量足够时,它仍可能复用输入的底层数组。

复制前先看来源、容量和生命周期

“复制后保存”不是要求每次拿到 RawMessage 都再复制一次。判断时看三个问题:它是否来自标准库的 Unmarshal;来源切片是否会被写入或复用;保存对象的生命周期是否超过来源缓冲区。只有后两项存在风险时,才需要在边界处复制。

取得方式是否默认独立长期保存建议
json.Unmarshal(input, &raw)是,RawMessage 已复制直接保存,避免无意义二次分配
json.RawMessage(input)否,可能共享数组输入会复用时使用 bytes.Clone
raw[:n] 或从大缓冲区截取否,仍受原数组影响确认所有权后再复制
json.Marshal(value) 返回的字节由返回切片持有交给异步任务前不要让其他代码改写它

还要留意大数组的“隐性持有”:只截取一小段并不能释放原来的大容量,长期对象可能把整块缓冲区留在内存中。需要隔离容量和生命周期时,复制小片段往往同时解决数据竞争风险与内存滞留问题。

延迟解析的最小完整写法

下面的例子把事件的公共字段和可变类型的主体分开。解码入口使用标准库复制语义,后续只在确定类型后解析一次;如果事件来自外部复用缓冲区,则把 retainRaw 放在进入队列前。

type LoginBody struct {
    UserID string `json:"user_id"`
}

type LogoutBody struct {
    Reason string `json:"reason"`
}

func parseEvent(input []byte) (string, any, error) {
    var env Envelope
    // 先解析公共字段和 RawMessage;Body 在这里已经拥有自己的字节副本。
    if err := json.Unmarshal(input, &env); err != nil {
        return "", nil, err
    }

    var target any
    switch env.Kind {
    case "login":
        target = new(LoginBody)
    case "logout":
        target = new(LogoutBody)
    default:
        return env.Kind, nil, fmt.Errorf("unsupported event kind: %s", env.Kind) // 未知类型不猜结构
    }
    // 按已确认的类型解析;畸形主体直接返回错误,不把半解析对象放入缓存。
    if err := json.Unmarshal(env.Body, target); err != nil {
        return env.Kind, nil, err
    }
    return env.Kind, target, nil
}

审查这类代码时,优先追踪切片来源,而不是看到 RawMessage 就机械添加复制。标准库解码、直接类型转换和大缓冲区截取是三条不同路径。

四个容易混淆的边界

  • “RawMessage 是值类型”不等于“字节自动深拷贝”。切片值包含指针、长度和容量,复制切片头仍可能共享数组。
  • “Unmarshal 会复制”不等于“所有入口都会复制”。它只描述实现了 UnmarshalJSON 的解码路径。
  • “当前没有并发”不等于“可以长期引用输入”。对象池归还、下一帧读取和复用写入都可能在异步代码之外发生。
  • “复制越多越安全”也不成立。不必要的二次复制会增加分配;应在所有权交接处做一次有目的的复制。

结语:把复制放在所有权交接处

Go json.RawMessage 是否需要复制,关键不在类型名称,而在字节的来源和生命周期。标准库从 JSON 解码到 RawMessage 时已经完成一次安全复制;手动把外部 []byte 转为 RawMessage 时,则要在进入长期对象、队列或缓存前用 bytes.Clone 建立独立副本。沿着这条边界设计,既能保住延迟解析的灵活性,也能避免共享缓冲区导致的内容漂移和大数组滞留。

相关问答

json.RawMessage 需要每次 json.Unmarshal 后再 bytes.Clone 吗?

通常不需要。解码到 RawMessage 的标准路径已经复制输入;只有当你无法确认它来自该路径,或后续又把外部切片直接赋给它时,才在所有权交接处复制。

用 append 复制 RawMessage 时最重要的注意点是什么?

目标必须是新的或明确独立的切片,例如 append([]byte(nil), src...);不要依赖对已有切片的原地 append,因为它可能复用原底层数组。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>