很多 Go 项目以为用了 database/sql 或 ORM 就天然安全,结果真正出问题的地方,往往是手写 SQL、拼接排序字段,或者在 ORM 里偷用了 Raw()。要把 SQL 注入风险压下去,不能只记住“别拼字符串”这一句,还得分清楚哪些内容能交给占位符,哪些内容只能靠白名单控制。
这篇文章按实际开发里的几个高频场景展开:先看 database/sql 的参数绑定规则,再看动态表名和 ORDER BY 这类标识符的处理方式,最后补上 GORM 与 Scan 的易错点。读完后,你可以快速判断一段 Go 数据库代码到底是安全绑定,还是只是看起来安全。
为什么只靠参数化还不够
在 Go 里,防 SQL 注入至少有两套机制需要同时落地:
- 参数绑定:保护查询条件里的数据值,比如
id、name、status - 白名单校验:保护表名、字段名、
ORDER BY、GROUP BY这类 SQL 标识符
两者解决的不是同一类问题。占位符只负责把“值”安全传给驱动,不负责决定 SQL 结构;白名单只负责限制 SQL 结构里的可选项,也不能替代参数绑定。漏掉任何一个,都可能留下后门。
database/sql 中占位符必须与驱动匹配
Go 标准库 database/sql 本身并不会自动帮你防注入,它只负责把参数传给驱动。真正决定安不安全的,还是你写出来的 SQL 字符串,以及占位符是否符合当前驱动的规则。
不同驱动的占位符规则
这里最容易踩的坑,是把不同数据库驱动的占位符混用:
- MySQL 和 SQLite 驱动使用
? - PostgreSQL 驱动使用
$1、$2
如果写错,驱动通常不会“帮你猜”,而是直接报错。常见表现包括:
sql: expected 0 arguments, got 1:说明占位符没有被识别,传入参数没有真正绑定上ERROR: syntax error at or near "123":常见于 PostgreSQL 中误写?,驱动把它当普通字符处理
安全写法与常见反例
查询和执行语句时,应始终把数据值作为单独参数传入,而不是先拼成完整 SQL:
db.Query("SELECT * FROM users WHERE id = ? AND status = ?", id, "active")不要写成下面这种形式:
fmt.Sprintf("... id = %d", id)实操上建议先确认你当前使用的驱动,比如 github.com/go-sql-driver/mysql 或 github.com/lib/pq,然后统一按该驱动的占位符规则编写 SQL。对于团队项目,最好把这条规则直接写进代码评审清单。
参数类型也要匹配
参数绑定并不代表类型问题会自动消失。比如传 int64 给 ? 没问题,但如果把 nil 传给不接受 NULL 的字段,运行时仍可能 panic。遇到可空字段时,要明确使用 sql.NullInt64 这类类型,而不是依赖隐式行为。
表名、字段名和 ORDER BY 不能参数化
很多注入问题并不发生在 WHERE id = ? 这种地方,而是出在“动态 SQL 结构”上。这里有一条必须记住的规则:SQL 标准不允许对标识符(identifier)做参数化,因此表名、字段名、ORDER BY、GROUP BY 等内容,都不能指望用占位符解决。

为什么 ORDER BY ? 不成立
像 ORDER BY ?、SELECT ? FROM users 这样的写法,所有主流驱动都会拒绝,运行时直接报错。此时有些项目会退一步改成字符串拼接:
fmt.Sprintf("ORDER BY %s", input)这一步通常正是风险开始的地方,因为用户输入会直接进入 SQL 结构本身。
白名单校验该怎么落地
正确做法是把所有可变标识符限制在固定集合里:
- 排序字段用白名单映射,例如:
validSortFields := map[string]bool{"created_at": true, "name": true, "score": true} - 查询前先判断
validSortFields[input]是否成立 - 表名使用常量、配置项或
switch映射,而不是拼接前缀后缀
例如表名选择可以写成:
switch input {
case "prod":
tableName = "users_prod"
case "test":
tableName = "users_test"
}不要写成:
"users_" + input同样的规则也适用于 LIMIT、OFFSET、GROUP BY:
- 数值类参数先转成
int,并做范围检查,例如limit <= 100 - 字符串类选项必须来自白名单
GORM 里最容易失守的是 Raw() 和 Table()
用了 ORM 并不等于可以忽略注入风险。GORM 默认开启 PrepareStmt,像下面这种写法通常是安全的:

db.Where("name = ?", name).Find(&u)问题通常出在你绕开 ORM 的安全路径,自己拼 SQL 或手动传入不可信表名。
Raw() 为什么要格外小心
一旦调用 Raw(),或者在语句中自行拼接动态片段,原本由 ORM 帮你兜底的绑定机制就可能失效。建议是:
- 除非绝对必要,否则不要用
Raw() - 必须用时,也只允许拼接白名单中的字段名或表名
- 数据值仍然必须通过
?或$1传参
Table() 的前提是表名来源可控
如果确实需要动态表名,可以使用:
db.Table(tableName).Where("id = ?", id).Find(&u)但前提非常明确:tableName 必须来自配置、枚举或固定映射,不能直接来自用户输入。
可以用日志反查参数是否真的绑定
GORM 日志也是排查问题的重要入口。如果日志里出现了类似:
SELECT * FROM users WHERE name = 'admin'' OR ''1''=''1'这通常意味着参数没有被正确绑定,而是被直接拼进 SQL 里了。看到这种输出,应该立刻回溯代码,查找 fmt.Sprintf、字符串拼接或不受控的 Raw() 调用。
Scan 接收 NULL 时要显式声明类型
Scan 本身不负责防注入,但它处理不好时,依然可能把问题放大成 panic、结构泄露或异常业务逻辑。尤其在字段允许 NULL 的表上,接收类型写错是很常见的线上隐患。
可空字段不要直接用基础类型
如果数据库字段可能为 NULL,就不要直接用 string、int 这类基础类型接收。更稳妥的方式是使用:
sql.NullStringsql.NullInt64sql.NullBool
例如扫描后应先检查 .Valid:
if name.Valid {
fmt.Println(name.String)
}不要默认直接使用 name.String。
结构体字段与 NULL 覆盖问题
如果使用结构体承接数据库结果,还要留意 sql tag 与空值覆盖问题。没有 omitempty 的 NULL 字段,可能在后续更新或序列化流程中覆盖已有值,形成不易察觉的静默数据污染。
一份可直接落地的检查清单
如果你在审查 Go 1.22 项目里的数据库代码,可以按下面几项快速检查:
- 所有查询条件值是否都通过
Query、Exec、Where等接口传参,而不是字符串拼接 - 占位符是否和当前驱动一致:MySQL/SQLite 用
?,PostgreSQL 用$1、$2 - 表名、字段名、
ORDER BY、GROUP BY是否全部经过白名单或固定映射 - GORM 中是否存在不受控的
Raw()、字符串拼表名、拼排序字段 - 可空字段是否使用了
sql.NullString、sql.NullInt64、sql.NullBool等显式类型
最关键的一点还是:白名单校验和参数绑定不是替代关系,而是并行关系。 占位符只能保护数据值,保护不了 SQL 结构;白名单只能约束 SQL 结构,也替代不了参数绑定。两者必须同时落实,Go 项目的 SQL 注入防护才算真正闭环。









