在Go语言中防止SQL注入,最核心、最有效的方法就是使用database/sql包提供的参数占位符(Prepared Statement),而不是把用户输入直接拼接到SQL字符串里。具体来说,你需要用问号(?)作为占位符,把用户数据通过Exec、Query、QueryRow等方法的可变参数传入,让数据库驱动在底层自动完成转义和参数绑定。这一步做对了,SQL注入的风险基本归零。

很多开发者在写Go代码时,习惯性地用fmt.Sprintf把变量塞进SQL语句,比如fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", userInput),这种写法就是SQL注入的温床。攻击者只要在userInput里输入' OR '1'='1,整个查询逻辑就被篡改了。而用参数占位符的方式,数据库会把传入的值当作纯数据处理,不会当作SQL指令执行,从根本上切断了注入路径。

什么是参数占位符以及为什么它能防注入

参数占位符本质上是预编译语句(Prepared Statement)的语法标记。在database/sql包里,不同的数据库驱动支持不同的占位符符号:MySQL和SQLite用问号(?),PostgreSQL用$1、$2这种编号占位符,SQL Server用@p1、@p2。Go的database/sql包做了一层抽象,你在代码里统一用?就行,底层驱动会自动转换成对应数据库的语法。

防注入的原理在于"数据与指令分离"。当你使用参数占位符时,SQL语句的结构在编译阶段就已经确定了,用户输入的内容只是作为参数绑定进去,数据库引擎不会对这些参数做SQL语法解析。换句话说,不管用户输入什么奇怪的字符,数据库都只会把它当成一个字符串值或者数字值,而不是一段可执行的SQL代码。

Go语言中使用参数占位符的基本写法

下面是一个最典型的使用示例,假设你用的是MySQL数据库:

package main

import (
    "database/sql"
    "fmt"
    "log"

    _ "github.com/go-sql-driver/mysql"
)

func main() {
    db, err := sql.Open("mysql", "user:password@tcp(127.0.0.1:3306)/mydb")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // 正确写法:使用参数占位符
    name := "O'Brien" // 名字里有单引号,如果拼接会出问题
    row := db.QueryRow("SELECT id, age FROM users WHERE name = ?", name)

    var id int
    var age int
    if err := row.Scan(&id, &age); err != nil {
        log.Fatal(err)
    }
    fmt.Printf("id=%d, age=%d\n", id, age)
}

注意看,name变量里有一个单引号,如果你用字符串拼接的方式,SQL就会变成WHERE name = 'O'Brien',直接报语法错误。但用参数占位符,驱动会自动处理这个单引号,查询正常执行。这就是参数化查询的威力。

多参数查询的写法

实际开发中,查询条件往往不止一个。这时候你只需要按顺序在SQL语句里放多个问号,然后在方法调用时按同样的顺序传入参数:

// 查询年龄在某个范围且名字匹配的用户
rows, err := db.Query("SELECT id, name, age FROM users WHERE age >= ? AND age <= ? AND name = ?", minAge, maxAge, userName)
if err != nil {
    log.Fatal(err)
}
defer rows.Close()

for rows.Next() {
    var id int
    var name string
    var age int
    if err := rows.Scan(&id, &name, &age); err != nil {
        log.Fatal(err)
    }
    fmt.Printf("id=%d, name=%s, age=%d\n", id, name, age)
}

这里有三个问号,后面就跟三个参数,顺序必须一一对应。Go的database/sql包不支持命名参数,所以你需要自己记住每个问号对应哪个变量。如果参数特别多,建议用结构体或者切片来管理,避免传参顺序搞混。

插入、更新、删除操作同样适用

参数占位符不只是查询能用,INSERT、UPDATE、DELETE这些写操作同样必须用。很多人觉得写操作不涉及"查询",就放松警惕,其实写操作的SQL注入危害更大,攻击者可以直接篡改数据甚至删除整张表。

// 插入数据
result, err := db.Exec("INSERT INTO users (name, email, age) VALUES (?, ?, ?)", userName, userEmail, userAge)
if err != nil {
    log.Fatal(err)
}
lastID, _ := result.LastInsertId()
fmt.Printf("新增用户ID: %d\n", lastID)

// 更新数据
_, err = db.Exec("UPDATE users SET age = ? WHERE name = ?", newAge, userName)
if err != nil {
    log.Fatal(err)
}

// 删除数据
_, err = db.Exec("DELETE FROM users WHERE id = ?", userID)
if err != nil {
    log.Fatal(err)
}

特别要注意的是,db.Exec返回的是sql.Result,你可以通过它获取影响的行数和最后插入的ID。这些写操作如果不用参数占位符,后果不堪设想。

PostgreSQL的特殊占位符写法

如果你用的是PostgreSQL,占位符的语法和MySQL不一样。PostgreSQL用$1、$2、$3这样的编号形式。Go的pq驱动(github.com/lib/pq)支持这种写法:

import "github.com/lib/pq"

// PostgreSQL使用编号占位符
rows, err := db.Query("SELECT id, name FROM users WHERE age > $1 AND city = $2", minAge, cityName)
if err != nil {
    log.Fatal(err)
}

需要注意的是,PostgreSQL的$1、$2是从1开始计数的,而且不能跳号。如果你的SQL里有重复使用同一个参数的情况,每个位置都要单独写一个占位符编号。这一点和MySQL的?不太一样,迁移代码时要留意。

使用命名参数的替代方案

Go的database/sql标准库不支持命名参数(比如:name这种写法),但社区有一些第三方库可以实现类似功能,比如sqlx。sqlx在database/sql的基础上扩展了命名参数支持:

import "github.com/jmoiron/sqlx"

// 使用sqlx的命名参数
rows, err := db.NamedQuery("SELECT * FROM users WHERE name = :name AND age > :age", map[string]interface{}{
    "name": userName,
    "age":  minAge,
})
if err != nil {
    log.Fatal(err)
}

sqlx的NamedQuery、NamedExec等方法内部还是会把命名参数转换成对应驱动的占位符语法,所以安全性是有保障的。如果你觉得标准库的问号方式在参数多的时候容易搞混顺序,可以考虑用sqlx来提升代码可读性。

常见错误和注意事项

虽然参数占位符能防注入,但实际使用中还是有一些坑需要避开。第一个常见错误是:把表名、列名也用参数占位符。参数占位符只能用于值(value),不能用于标识符(identifier)。比如下面这种写法是错误的:

// 错误!表名不能用参数占位符
db.Query("SELECT * FROM ? WHERE id = ?", tableName, id)

表名和列名需要你自己做白名单校验,确认是合法的标识符后再拼接到SQL里。第二个错误是:在循环里反复Prepare但不复用。每次调用db.Query或db.Exec时,驱动内部会做Prepare和执行,如果同一个SQL在循环里执行很多次,可以先用db.Prepare预编译一次,然后循环调用stmt.Exec或stmt.Query,性能会好很多。

// 循环中复用预编译语句
stmt, err := db.Prepare("INSERT INTO logs (user_id, action) VALUES (?, ?)")
if err != nil {
    log.Fatal(err)
}
defer stmt.Close()

for _, logEntry := range logEntries {
    _, err := stmt.Exec(logEntry.UserID, logEntry.Action)
    if err != nil {
        log.Fatal(err)
    }
}

第三个要注意的是,虽然参数化查询防住了SQL注入,但不代表你可以完全不做输入验证。比如一个年龄字段,你传一个字符串"abc"进去,虽然不会注入,但可能导致类型转换错误或者业务逻辑异常。所以参数化查询和输入验证应该配合使用,一个防安全问题,一个防业务问题。

为什么不推荐用ORM来替代参数化查询

现在很多Go项目用GORM、Ent这样的ORM框架,ORM底层其实也是在用参数化查询,但它封装了一层,有时候开发者会不自觉地用RawSQL或者Where字符串拼接的方式绕过ORM的保护。比如GORM里如果你写db.Where("name = '"+userInput+"'"),那就又回到了拼接SQL的老路上。所以即便用了ORM,也要确保你用的是框架提供的参数绑定方式,而不是自己拼字符串。

另外,ORM的抽象层有时候会掩盖一些性能问题和安全细节。比如批量插入时,ORM可能会在内部生成一条巨大的SQL,如果参数特别多,可能会超出数据库的包大小限制。这时候你需要了解ORM底层的实现,或者直接用database/sql的批量操作来控制。

总结和最佳实践

防止SQL注入在Go语言中其实非常简单,核心就一条:永远不要拼接SQL,永远用参数占位符。具体来说,遵循以下几个原则:第一,所有用户输入、外部数据,一律通过参数传入,不管是查询还是写操作;第二,表名、列名等标识符如果需要动态指定,必须做严格的白名单校验;第三,在高频调用的场景下,使用db.Prepare预编译语句提升性能;第四,参数化查询和输入验证双管齐下,安全和业务逻辑都要兼顾;第五,如果用ORM,确保使用框架的参数绑定API而不是Raw拼接。

Go的database/sql包在设计上就把参数化查询作为默认推荐的使用方式,API设计得很直观,上手成本低。只要你养成习惯,写代码时第一反应就是用问号占位符而不是字符串拼接,SQL注入这个问题在你的项目里就基本不会出现。安全不是靠事后补救,而是靠每一行代码的规范书写。