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

Go database/sql Scan 得到的 []byte 为什么不能直接长期保存

来源:17golang原创

时间:2026-10-13 01:26:20 343浏览 收藏

如果自定义 database/sql.Scanner 收到的是 []byte,默认应把它看成“驱动暂时借给你的输入”,而不是已经归你所有的数组。Go 官方文档明确说明,引用类型(例如 []byte)只在下一次调用 Scan 前有效;需要保留时,要在下一次扫描前复制内容。

官方文档:https://pkg.go.dev/database/sql#Scanner

不过,Rows.Scan 直接扫描到 *[]byte 时,database/sql 会保存一份由调用方拥有的副本。真正容易出错的是把这两种语义混在一起:自定义 Scanner 需要自己复制,普通的 *[]byte 目标则已经完成了复制。

先分清 Scanner 输入是谁拥有

自定义 Scanner 的 Scan(src any) 收到的数据来自数据库驱动。对于 []byte,接口文档把它归为引用类型:底层内存由驱动管理,下一次扫描可能复用或改变它。于是下面这种赋值虽然语法正确,却把一个短生命周期的切片头存进了长期对象。

数据库驱动把临时字节交给 Scanner,下一次 Scan 可能复用底层内存,而复制后的字节进入长期对象的结构说明图
图1:Scanner 字节所有权关系说明图,展示借用输入与持久化副本,不是运行截图。
// BadBlob 演示了错误的所有权转移:data 仍然指向驱动管理的内存。
type BadBlob struct {
	Data []byte
}

// Scan 只保存切片头,下一次 Scan 后 Data 可能观察到变化。
func (b *BadBlob) Scan(src any) error {
	data, ok := src.([]byte)
	if !ok {
		return fmt.Errorf("want []byte, got %T", src)
	}
	b.Data = data
	return nil
}

这个问题通常出现在把字段放入缓存、消息、异步任务或跨函数返回值时。当前行结束后马上读取,往往看不出问题;循环继续扫描下一行,或者驱动复用缓冲区后,旧对象才可能出现内容变动。因此判断标准不是“现在能不能读”,而是“这个字节是否要跨过下一次 Scan”。

Rows.Scan 的三种字节接收方式不要混用

普通查询代码还要区分目标类型。扫描到 *[]byte 时,文档规定 Scan 会保存对应数据的副本,副本由调用方拥有,可以修改并长期持有;扫描到 *sql.RawBytes 则是为了避免复制,生命周期限制更严格。扫描到 *any 时,如果源值是 []byte,也会复制一份。

Scan 目标是否复制适合场景
*[]byte会复制需要把字段保存到结构体、缓存或后续任务
*sql.RawBytes不保证长期有效只在当前行内读取,明确接受借用生命周期
*any 且源为 []byte会复制暂时不知道源字段具体类型的通用接收
自定义 Scanner由实现决定在 Scan 内自行建立所有权边界
// 直接使用 *[]byte,database/sql 会把当前列复制给调用方。
var payload []byte
if err := row.Scan(&payload); err != nil {
	return fmt.Errorf("scan payload: %w", err)
}

// payload 可以交给后续函数或保存到结构体,不依赖下一次 Scan。
saved := append([]byte(nil), payload...)

如果只是处理当前行,可以使用 sql.RawBytes 减少一次复制,但必须把它限制在当前行的同步逻辑里。不要把它放入循环外的结果集合,也不要把它直接交给会异步执行的 goroutine。

自定义 Scanner 中要在入口处复制

自定义类型最稳妥的做法是在收到 []byte 时立刻复制,并在复制前处理 nil。使用接收对象已有容量可以减少分配,但要先把新数据复制进去,再替换长度;不要把原始切片直接赋值给字段。

package main

import (
	"database/sql/driver"
	"fmt"
)

// BlobValue 明确表示已经由业务对象拥有的二进制值。
type BlobValue struct {
	Data []byte
}

// Scan 在 Scanner 边界复制驱动输入,避免下一次 Scan 改变已保存内容。
func (b *BlobValue) Scan(src any) error {
	switch value := src.(type) {
	case nil:
		// NULL 代表没有值;清空旧内容,避免复用对象时残留上一行数据。
		b.Data = nil
		return nil
	case []byte:
		// 复用 b.Data 的容量,但不复用驱动拥有的底层数组。
		b.Data = append(b.Data[:0], value...)
		return nil
	default:
		return fmt.Errorf("BlobValue.Scan: unsupported source %T", src)
	}
}

// Value 只返回业务对象自己的副本,避免调用方修改内部状态。
func (b BlobValue) Value() (driver.Value, error) {
	return append([]byte(nil), b.Data...), nil
}

append(b.Data[:0], value...) 只复用 BlobValue 自己已经持有的容量;当 b.Data 为空时,append 会创建新的底层数组,不会把驱动切片挂进来。Value 是否需要再复制,取决于驱动对返回值的使用约定;在通用类型中返回副本更容易保持边界清晰。

Rows.Scan 按目标类型分成 *[]byte 复制、sql.RawBytes 借用、*any 复制和自定义 Scanner 自行复制的决策关系图
图2:Scan 目标与复制策略的决策说明图,不是数据库客户端或终端截图。

把复制边界放在进入长期结构的位置

生命周期问题不只发生在自定义 Scanner。即使使用了 *[]byte,也应该让“读取数据库”和“进入长期结构”之间的边界可见。下面的循环把每行数据复制成独立切片后再追加,后续排序、缓存或异步处理都不再依赖当前行的临时状态。

// collectBlobs 把每一行的二进制列转换为独立拥有的切片。
func collectBlobs(rows *sql.Rows) ([][]byte, error) {
	defer rows.Close()

	var result [][]byte
	for rows.Next() {
		var raw []byte
		if err := rows.Scan(&raw); err != nil {
			return nil, fmt.Errorf("scan blob: %w", err)
		}
		// *[]byte 已由 database/sql 提供副本;再复制一次是为了让结果集合的所有权更直观。
		result = append(result, append([]byte(nil), raw...))
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate rows: %w", err)
	}
	return result, nil
}

上面的第二次复制不是所有场景都必需;它表达的是“结果集合自己拥有一份数据”。如果确认 raw 已经由 *[]byte 复制,并且后续不会复用或修改它,可以直接追加 raw。关键是要基于接收类型做判断,而不是看到 []byte 就一律认为它是借用内存。

用一张表决定是否需要复制

  • 自定义 Scanner.Scan 收到 []byte:需要跨过下一次 Scan 时,立即复制。
  • Rows.Scan(&value) 的 value 是 []byte:database/sql 已按文档提供调用方拥有的副本。
  • Rows.Scan(&rawBytes) 的 rawBytes 是 sql.RawBytes:只在当前行内同步使用。
  • 数据要进入缓存、消息队列或 goroutine:在交接边界前确认所有权,必要时用 append([]byte(nil), src...) 建立独立副本。

最终可以把规则记成一句话:看见 Scanner 输入的引用类型,先问它是否要跨过下一次 Scan;如果要,就复制;如果直接扫描到 *[]byte,则使用 database/sql 已经提供的副本。

常见问题

为什么同样是 []byte,Scan 到 *[]byte 却可以长期保存?

因为 Rows.Scan 对 *[]byte 的转换会保存一份副本,副本由调用方拥有。自定义 Scanner 收到的 []byte 则需要实现者自己遵守 Scanner 的生命周期约定。

sql.RawBytes 什么时候可以用?

只在当前行的同步处理里使用,并且不会把它返回、缓存或交给异步任务时使用。它的价值是避免复制,代价就是不能把它当成长期数据。

自定义 Scanner 只保存字符串可以吗?

如果在 Scan 内把源字节转换成新的 Go 字符串,结果不再依赖原始切片;但仍要处理 nil、不支持的源类型和复用对象时的旧值清理。

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