10、预处理语句与参数绑定:sqlite3_prepare_v2详解、sqlite3_bind_*系列函数、sqlite3_column_*系列函数、防止SQL注入

说实话,很多刚接触SQLite的朋友,上来就是拼字符串构造SQL。我当年也这么干过,直到有一次线上数据被意外清空……嗯,那滋味不好受。今天咱们聊聊预处理语句,这玩意儿能让你写代码更安全、更高效。

为什么不用拼字符串?

你想想看,拼字符串最大的问题是什么?

  • SQL注入风险:用户输入里带个 ' OR 1=1 --,你的表就裸奔了
  • 性能差:每次执行都要重新解析SQL,数据库引擎累得慌
  • 类型处理麻烦:数字要转字符串,字符串要加引号,还得处理转义

我在项目中遇到过一位同事,他写了个用户登录模块,直接拼字符串。测试环境跑得好好的,上线第一天就被注入了。从那以后,我们团队立了规矩:任何拼接SQL的代码,代码审查直接打回

sqlite3_prepare_v2:一切从这里开始

预处理语句的核心,就是先把SQL语句"编译"成一个内部对象。以后执行的时候,直接拿这个对象跑,不用再解析了。

sqlite3_stmt *stmt;
const char *sql = "INSERT INTO users (name, age) VALUES (?, ?);";

int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
    fprintf(stderr, "准备失败: %s\n", sqlite3_errmsg(db));
    return rc;
}

这里有几个细节要注意:

  • 第二个参数:SQL语句,用 ? 做占位符
  • 第三个参数:传 -1 表示自动计算长度,也可以传正数指定字节数
  • 第四个参数:输出参数,拿到预处理语句句柄
  • 第五个参数:一般传 NULL,表示不需要知道SQL末尾位置
我的习惯:我一般用 sqlite3_prepare_v2 而不是旧版的 sqlite3_prepare。v2版本会缓存schema信息,性能更好,而且能检测到表结构变化后自动重新编译。

sqlite3_bind_*:给占位符填值

占位符 ? 就像填空题的空格,sqlite3_bind_* 系列函数就是往空格里填答案。

函数 用途 示例
sqlite3_bind_int 绑定整型 sqlite3_bind_int(stmt, 1, 25);
sqlite3_bind_double 绑定浮点型 sqlite3_bind_double(stmt, 1, 3.14);
sqlite3_bind_text 绑定字符串 sqlite3_bind_text(stmt, 1, "张三", -1, SQLITE_TRANSIENT);
sqlite3_bind_blob 绑定二进制数据 sqlite3_bind_blob(stmt, 1, data, len, SQLITE_STATIC);
sqlite3_bind_null 绑定NULL sqlite3_bind_null(stmt, 1);

第二个参数是占位符的索引,从1开始。第三个参数就是要绑定的值。

我曾经踩过的坑sqlite3_bind_text 的最后一个参数,传 SQLITE_TRANSIENT 还是 SQLITE_STATIC
SQLITE_TRANSIENT 表示SQLite会自己拷贝一份数据,你传完字符串后可以马上释放。
SQLITE_STATIC 表示你保证字符串指针在SQLite使用期间一直有效。
我建议:除非你非常确定内存生命周期,否则一律用 SQLITE_TRANSIENT。安全第一。

完整示例:插入一条用户记录

sqlite3 *db;
sqlite3_stmt *stmt;

// 打开数据库
sqlite3_open("test.db", &db);

// 准备语句
const char *sql = "INSERT INTO users (name, age, email) VALUES (?, ?, ?);";
sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);

// 绑定参数
sqlite3_bind_text(stmt, 1, "李四", -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 2, 30);
sqlite3_bind_text(stmt, 3, "lisi@example.com", -1, SQLITE_TRANSIENT);

// 执行
int rc = sqlite3_step(stmt);
if (rc == SQLITE_DONE) {
    printf("插入成功!\n");
} else {
    printf("插入失败: %s\n", sqlite3_errmsg(db));
}

// 重置,准备下一次使用
sqlite3_reset(stmt);

// 用完释放
sqlite3_finalize(stmt);
sqlite3_close(db);

注意看,sqlite3_step 执行完后,用 sqlite3_reset 重置,这样同一个 stmt 可以反复使用。批量插入时,这个技巧能省不少时间。

sqlite3_column_*:读取查询结果

查询数据时,用 sqlite3_column_* 系列函数从结果集中取值。

const char *sql = "SELECT id, name, age FROM users WHERE age > ?;";
sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);

sqlite3_bind_int(stmt, 1, 18);

while (sqlite3_step(stmt) == SQLITE_ROW) {
    int id = sqlite3_column_int(stmt, 0);
    const unsigned char *name = sqlite3_column_text(stmt, 1);
    int age = sqlite3_column_int(stmt, 2);
    
    printf("ID: %d, 姓名: %s, 年龄: %d\n", id, name, age);
}

sqlite3_finalize(stmt);

列索引也是从0开始。常用的函数:

  • sqlite3_column_int — 取整型
  • sqlite3_column_double — 取浮点型
  • sqlite3_column_text — 取字符串(返回 const unsigned char*
  • sqlite3_column_blob — 取二进制数据
  • sqlite3_column_bytes — 取数据的字节数
  • sqlite3_column_type — 取列的数据类型
小技巧sqlite3_column_type 返回 SQLITE_INTEGERSQLITE_FLOATSQLITE_TEXTSQLITE_BLOBSQLITE_NULL 之一。如果你不确定数据库里存的是什么类型,先调这个函数判断一下,再取对应的值。我在处理用户自定义字段时经常这么干。

防止SQL注入:预处理语句就是最好的盾牌

说白了,SQL注入的本质就是:用户输入被当成了SQL代码执行

预处理语句为什么能防注入?因为 ? 占位符绑定的值,永远被当作数据,而不是SQL代码。就算用户输入 ' OR 1=1 --,SQLite也会把它当成一个普通的字符串值,不会去解析它。

看个对比:

// ❌ 危险写法
char sql[256];
sprintf(sql, "SELECT * FROM users WHERE name = '%s'", user_input);
// 如果 user_input = "' OR 1=1 --",就变成了:
// SELECT * FROM users WHERE name = '' OR 1=1 --'
// 所有用户数据全暴露!

// ✅ 安全写法
const char *sql = "SELECT * FROM users WHERE name = ?;";
sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
sqlite3_bind_text(stmt, 1, user_input, -1, SQLITE_TRANSIENT);
// 不管 user_input 是什么,都只是 name 字段的值
注意:预处理语句只对 ? 占位符绑定的值有效。如果你还是用 sprintf 拼表名、列名,那照样有注入风险。表名和列名不能用占位符,需要你自己做白名单校验。

知识体系总览

下面这张图,把整个流程串起来了:

预处理语句与参数绑定 核心流程 1. 准备阶段 sqlite3_prepare_v2 2. 绑定参数 sqlite3_bind_* 3. 执行 sqlite3_step INSERT/UPDATE/DELETE SELECT SQLITE_DONE 执行完成,无结果集 SQLITE_ROW 有数据行,读取结果 读取列值 sqlite3_column_* 循环直到SQLITE_DONE 释放资源 sqlite3_finalize 核心原则:SQL语句与数据分离,占位符 ? 只代表数据,不参与SQL解析 防止SQL注入的关键:永远不要拼接用户输入到SQL字符串中

几个实用建议

  • 批量操作时重用stmt:绑定新值前调 sqlite3_reset,避免反复 prepare,性能能提升好几倍
  • 事务包裹批量写入:先 BEGIN TRANSACTION,批量执行完再 COMMIT,速度飞起
  • 检查返回值:每个SQLite API调用都要检查返回值,别偷懒。我见过太多因为没检查返回值,导致数据没写进去还浑然不知的案例
  • 命名占位符:除了 ?,还可以用 ?NNN(如 ?1?2)、:AAA@AAA$AAA。我个人喜欢用 ?,简单直观

一句话总结:预处理语句 + 参数绑定 = 安全 + 高效。这是C语言操作SQLite的标配,别再用拼字符串那种原始方式了。


公众号:蓝海资料掘金营,微信deep3321