Go语言的database/sql包通过占位符(?或$1、$2等)实现参数化查询,这是防止SQL注入的核心手段。但很多开发者在实际使用中踩坑——比如把表名、列名也用占位符替代,或者在拼接动态SQL时误用字符串拼接,导致参数化失效。记住一条铁律:占位符只能用于"值"的位置,不能用于标识符(表名、列名、关键字),这是Go语言database/sql防止SQL注入最关键的注意事项。

SQL注入的本质是用户输入的数据被当作SQL语句的一部分来执行。攻击者通过在输入框中插入恶意SQL片段,比如输入 ' OR '1'='1,就可能绕过登录验证、窃取数据甚至删除整个数据库。Go语言的database/sql包设计了参数化查询机制,从根本上把"代码"和"数据"分开处理,数据库驱动会对参数进行转义和绑定,让注入攻击无从下手。但前提是你必须正确使用占位符。

一、Go语言database/sql占位符的基本用法

Go的database/sql包使用问号(?)作为通用占位符,这是最常见的写法。不同的数据库驱动可能支持不同的占位符风格,比如PostgreSQL的pq驱动支持$1、$2、$3这种命名占位符,而MySQL驱动只支持?。但不管哪种风格,核心原理都一样:把用户输入作为参数传递,而不是拼进SQL字符串。

// 正确用法:使用占位符
db.Query("SELECT * FROM users WHERE id = ? AND status = ?", userID, status)

// 正确用法:PostgreSQL的命名占位符
db.Query("SELECT * FROM users WHERE id = $1 AND status = $2", userID, status)

上面这种写法是安全的。数据库驱动会把userID和status当作纯数据处理,不管里面包含什么特殊字符,都不会被解析成SQL指令。这就是参数化查询的威力所在。

二、最常见的错误:用字符串拼接代替占位符

很多新手或者从其他语言转过来的开发者,习惯用fmt.Sprintf或者字符串加号来拼接SQL。这种做法在Go里是绝对禁止的,因为它直接把用户输入嵌入了SQL语句,等于把大门敞开给攻击者。

// 错误!危险!这就是SQL注入的温床
query := fmt.Sprintf("SELECT * FROM users WHERE id = %d", userID)
db.Query(query)

// 错误!同样危险
query := "SELECT * FROM users WHERE id = " + userID
db.Query(query)

如果userID的值是 1; DROP TABLE users; --,那么拼接出来的SQL就变成了一条毁灭性的指令。数据库会先执行SELECT,然后执行DROP TABLE,你的用户表就没了。所以无论什么场景,都不要用字符串拼接来构造SQL语句中的值部分。

三、表名和列名不能用占位符

这是Go语言database/sql使用中最容易被忽视的坑。占位符只能替代"值",不能替代"标识符"。也就是说,你不能用?来代替表名、列名、排序关键字等。

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

// 错误!列名不能用占位符
db.Query("SELECT ? FROM users WHERE id = ?", columnName, userID)

// 错误!ORDER BY的字段不能用占位符
db.Query("SELECT * FROM users ORDER BY ?", sortColumn)

为什么不行?因为数据库在解析SQL时,需要先知道表名和列名才能确定查询结构,占位符是在值绑定阶段才生效的。如果你把表名放进占位符,数据库驱动会把它当成一个字符串值来处理,而不是一个标识符,最终生成的SQL语法是错误的。

那动态表名、动态列名怎么办?你需要用白名单机制来手动验证。比如维护一个允许的表名列表,在拼接之前先检查用户输入是否在白名单内。

// 正确做法:白名单验证动态表名
var allowedTables = map[string]bool{
    "users": true,
    "orders": true,
    "products": true,
}

if !allowedTables[tableName] {
    return fmt.Errorf("invalid table name")
}

query := "SELECT * FROM " + tableName + " WHERE id = ?"
db.Query(query, userID)

这里虽然用了字符串拼接,但因为tableName已经通过白名单验证,不可能包含恶意内容,所以是安全的。关键在于:白名单验证在前,拼接在后。

四、IN子句的占位符处理

当你需要用IN子句查询多个ID时,很多人会直接写 WHERE id IN (?) 然后传一个切片,这是不行的。database/sql不会自动展开切片,你需要手动构建多个占位符。

// 错误写法
ids := []int{1, 2, 3}
db.Query("SELECT * FROM users WHERE id IN (?)", ids)

// 正确写法:动态构建占位符
func buildInQuery(ids []int) (string, []interface{}) {
    placeholders := make([]string, len(ids))
    args := make([]interface{}, len(ids))
    for i, id := range ids {
        placeholders[i] = "?"
        args[i] = id
    }
    return strings.Join(placeholders, ","), args
}

// 使用
query, args := buildInQuery(ids)
query = "SELECT * FROM users WHERE id IN (" + query + ")"
db.Query(query, args...)

这种写法既安全又灵活。每个ID都是通过占位符绑定的,不存在注入风险。同时要注意,如果ids切片为空,需要特殊处理,否则生成的SQL会是 IN (),语法错误。

五、LIKE模糊查询的占位符陷阱

模糊查询是另一个容易出错的地方。很多人直接把用户输入的搜索词放进占位符,结果发现匹配不了。这是因为LIKE需要通配符%包裹,而%需要在Go代码层面拼接到参数里,不能放在SQL语句中。

// 错误写法:%放在SQL里,但参数没有包含%
db.Query("SELECT * FROM users WHERE name LIKE '%?%'", keyword)

// 正确写法:%在Go层面拼接到参数中
db.Query("SELECT * FROM users WHERE name LIKE ?", "%"+keyword+"%")

这里有个细节需要注意:如果keyword本身包含%或_这些LIKE通配符,可能会导致意外的匹配结果。更安全的做法是先对keyword进行转义,或者使用数据库提供的转义函数。不过在Go的database/sql层面,参数化已经保证了不会发生SQL注入,只是匹配逻辑需要你自己把控。

六、事务中的占位符使用

在事务(Tx)中使用占位符和普通查询完全一样,但有一个额外的注意点:事务中的多条语句如果共享同一个占位符参数,要确保参数顺序一致。事务本身不影响参数化查询的安全性,但如果你在事务中混用了字符串拼接和占位符,就可能引入风险。

tx, err := db.Begin()
if err != nil {
    return err
}
defer tx.Rollback()

// 在事务中使用占位符,和普通查询一样安全
_, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
if err != nil {
    return err
}

_, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID)
if err != nil {
    return err
}

return tx.Commit()

事务中每一条SQL都独立使用占位符,每条语句的参数独立绑定,互不干扰。这是最规范的写法。

七、预处理语句(Prepared Statement)的优势

database/sql包底层会使用预处理语句。当你多次执行同一条SQL但参数不同时,数据库会缓存执行计划,只需替换参数即可。这不仅提升性能,还进一步加固了安全性——因为SQL结构在第一次就已经确定,后续只传参数,攻击者根本没有机会修改SQL结构。

// 第一次执行,数据库编译SQL结构
stmt, err := db.Prepare("SELECT * FROM users WHERE id = ?")
if err != nil {
    return err
}
defer stmt.Close()

// 后续多次执行,只传参数,高效且安全
for _, id := range idList {
    rows, err := stmt.Query(id)
    // 处理结果...
}

使用Prepare的另一个好处是可以提前检查SQL语法错误。如果你的SQL写错了,在Prepare阶段就会报错,而不是等到执行时才发现问题。对于高频查询场景,强烈建议使用预处理语句。

八、不同数据库驱动的占位符差异

Go的database/sql是一个统一接口,但底层驱动各有不同。MySQL的go-sql-driver/mysql只支持?占位符;PostgreSQL的lib/pq支持$1、$2等命名占位符;SQLite3的modernc.org/sqlite也支持?和$1、$2。在写代码时,你需要根据实际使用的驱动来选择占位符风格。

如果你的项目需要同时支持多种数据库,建议封装一个统一的查询层,在内部根据驱动类型做适配。或者直接全部使用?占位符,因为这是最通用的写法,所有主流驱动都支持。

九、除了占位符,还需要注意什么

占位符解决了大部分SQL注入问题,但不是全部。以下几点同样重要:

第一,最小权限原则。数据库连接使用的账号只给必要的权限,不要用root账号跑应用。即使发生注入,攻击者能做的事也有限。

第二,输入验证。在参数绑定之前,对用户输入做类型和长度校验。比如ID应该是正整数,邮箱应该符合格式。这不是防注入的手段,而是纵深防御的一环。

第三,错误信息处理。不要把数据库的原始错误信息直接返回给前端,这可能暴露表结构等敏感信息。统一用自定义错误码或通用提示。

第四,定期审计代码。用静态分析工具扫描项目中是否存在字符串拼接SQL的情况,Go的vet工具和一些第三方linter都能检测这类问题。

十、总结:防SQL注入的核心清单

把所有注意事项浓缩成一份清单,方便你随时对照检查:

1. 所有用户输入的值,必须用占位符绑定,绝不拼接。

2. 表名、列名、排序字段等标识符不能用占位符,用白名单验证。

3. IN子句需要手动构建多个占位符。

4. LIKE查询的通配符在Go层面拼接到参数中。

5. 高频查询使用Prepare预处理语句。

6. 根据数据库驱动选择正确的占位符风格。

7. 配合最小权限、输入验证、错误处理形成纵深防御。

Go语言的database/sql包在设计上已经为防SQL注入做了充分考虑,只要你严格遵循占位符的使用规范,不偷懒用字符串拼接,不把标识符当值来处理,SQL注入在你的项目里就基本不可能发生。安全不是靠一个技巧,而是靠每一行代码的规范执行。