当前位置:首页 > 文章列表 > Golang > Go教程 > Go database/sql Scan 得到的 []byte 为什么不能直接长期保存

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

来源:17golang原创 2026-10-13 01:26:20 0浏览 收藏

如果自定义 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删除
Go database/sql 怎样依次读取多个 SQL 结果集Go database/sql 怎样依次读取多个 SQL 结果集
上一篇
Go database/sql 怎样依次读取多个 SQL 结果集
兽音译者公开网页怎么选?功能区、域名与免责声明核对说明
下一篇
兽音译者公开网页怎么选?功能区、域名与免责声明核对说明
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    446次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    517次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    532次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    478次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    303次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议 和 隐私政策
返回登录
  • 重置密码