21、MySQL预处理与事务:mysql_stmt_prepare、绑定参数、事务控制、错误码处理

说实话,很多C语言开发者写MySQL程序时,都是直接拼SQL字符串往里怼。这种做法在小项目里跑跑还行,一旦上了生产环境,问题就来了——SQL注入、性能低下、事务混乱。今天我就把预处理和事务这块掰开揉碎讲清楚。

21.1 预处理语句:为什么不用拼字符串?

先说说我个人的习惯。每次写数据库操作,我第一反应就是用预处理语句。为什么?因为安全。

你想想看,如果用户输入一个用户名叫 admin' OR '1'='1,你直接拼到SQL里会怎样?整张表的数据都暴露了。我在项目中遇到过这种惨案,还好只是测试环境,不然就等着被开除吧。

预处理语句的核心思想很简单:SQL结构固定,参数单独传递。MySQL服务器先解析SQL模板,再绑定参数执行。这样用户输入再脏,也影响不了SQL结构。

21.1.1 基本流程

预处理语句的使用分三步走:

  1. 准备阶段:调用 mysql_stmt_prepare() 解析SQL模板
  2. 绑定参数:用 mysql_stmt_bind_param() 把变量和占位符关联起来
  3. 执行:调用 mysql_stmt_execute() 跑起来

来看代码:

MYSQL_STMT *stmt;
MYSQL_BIND bind[2];
char name[50];
int age;

// 1. 准备SQL模板
stmt = mysql_stmt_init(mysql);
const char *sql = "INSERT INTO users(name, age) VALUES(?, ?)";
mysql_stmt_prepare(stmt, sql, strlen(sql));

// 2. 绑定参数
memset(bind, 0, sizeof(bind));
bind[0].buffer_type = MYSQL_TYPE_STRING;
bind[0].buffer = name;
bind[0].buffer_length = sizeof(name);
bind[1].buffer_type = MYSQL_TYPE_LONG;
bind[1].buffer = &age;

mysql_stmt_bind_param(stmt, bind);

// 3. 设置值并执行
strcpy(name, "张三");
age = 25;
mysql_stmt_execute(stmt);

// 重复使用
strcpy(name, "李四");
age = 30;
mysql_stmt_execute(stmt);

mysql_stmt_close(stmt);
小提示:同一个预处理语句可以反复执行,只需要改绑定的变量值就行。这在批量插入时性能提升非常明显——少了SQL解析的开销。

21.2 绑定参数的细节

绑定参数这块,坑不少。我刚开始用的时候也栽过跟头。

21.2.1 MYSQL_BIND结构体

每个参数都要填一个 MYSQL_BIND 结构体。关键字段就这几个:

字段 说明 常见取值
buffer_type 数据类型 MYSQL_TYPE_LONG, MYSQL_TYPE_STRING, MYSQL_TYPE_DOUBLE
buffer 指向数据的指针 &int_var, char_array, &double_var
buffer_length 缓冲区大小(字符串必填) sizeof(buffer)
is_null 是否为NULL my_bool is_null = 0; &is_null
length 实际数据长度(变长数据用) unsigned long len; &len

嗯,这里要注意:字符串类型必须设置 buffer_length,否则MySQL不知道从哪读到哪。我曾经忘了设这个,结果插进去的数据全是乱码,排查了半天才发现。

21.2.2 处理NULL值

数据库里经常有NULL字段。怎么传?用 is_null 字段:

my_bool is_null[2];
MYSQL_BIND bind[2];

memset(bind, 0, sizeof(bind));

// 第一个参数正常传值
bind[0].buffer_type = MYSQL_TYPE_LONG;
bind[0].buffer = &id;
bind[0].is_null = &is_null[0];
is_null[0] = 0;  // 非NULL

// 第二个参数传NULL
bind[1].buffer_type = MYSQL_TYPE_STRING;
bind[1].is_null = &is_null[1];
is_null[1] = 1;  // 就是NULL
警告:如果字段是NOT NULL,你却传了NULL,执行时会报错。错误码通常是 1048(Column cannot be null)。记得在绑定前检查业务逻辑。

21.3 事务控制:要么全做,要么全不做

事务这东西,说白了就是一组操作要么全部成功,要么全部回滚。我在做银行转账功能时深有体会——扣钱成功但加钱失败,这谁受得了?

21.3.1 基本操作

MySQL默认是自动提交模式,每条SQL都立即生效。要用手动事务,得先关掉自动提交:

// 关闭自动提交
mysql_autocommit(mysql, 0);

// 执行一组操作
mysql_query(mysql, "UPDATE accounts SET balance = balance - 100 WHERE id = 1");
mysql_query(mysql, "UPDATE accounts SET balance = balance + 100 WHERE id = 2");

// 检查结果,决定提交还是回滚
if (/* 全部成功 */) {
    mysql_commit(mysql);
} else {
    mysql_rollback(mysql);
}

// 恢复自动提交
mysql_autocommit(mysql, 1);

21.3.2 事务+预处理组合拳

实际项目中,事务和预处理经常一起用。比如批量转账:

MYSQL_STMT *stmt;
MYSQL_BIND bind[2];
int from_id, to_id;
double amount;

// 准备更新语句
stmt = mysql_stmt_init(mysql);
const char *sql = "UPDATE accounts SET balance = balance + ? WHERE id = ?";
mysql_stmt_prepare(stmt, sql, strlen(sql));

// 绑定参数
memset(bind, 0, sizeof(bind));
bind[0].buffer_type = MYSQL_TYPE_DOUBLE;
bind[0].buffer = &amount;
bind[1].buffer_type = MYSQL_TYPE_LONG;
bind[1].buffer = &to_id;
mysql_stmt_bind_param(stmt, bind);

// 开启事务
mysql_autocommit(mysql, 0);

// 扣钱
amount = -100.0;
to_id = 1;
mysql_stmt_execute(stmt);

// 加钱
amount = 100.0;
to_id = 2;
mysql_stmt_execute(stmt);

// 检查错误
if (mysql_errno(mysql) == 0) {
    mysql_commit(mysql);
    printf("转账成功\n");
} else {
    mysql_rollback(mysql);
    printf("转账失败: %s\n", mysql_error(mysql));
}

mysql_autocommit(mysql, 1);
mysql_stmt_close(stmt);
核心原则:事务内的所有操作,必须检查每一步的返回值。只要有一个失败,立即回滚。别想着「应该没问题吧」——我曾经就因为少检查一个返回值,导致数据对不上账,加班到凌晨三点。

21.4 错误码处理:别让程序静默死掉

错误处理是数据库编程里最容易被忽视的部分。很多新手写代码,调用完 mysql_stmt_execute 就不管了,程序跑飞了都不知道。

21.4.1 常用错误码

错误码 含义 常见原因
1062 主键/唯一键重复 插入重复数据
1048 列不能为NULL NOT NULL字段传了NULL
1146 表不存在 SQL写错表名
1213 死锁 并发事务互相等待
2006 MySQL服务器已断开 连接超时或网络问题

21.4.2 错误处理模板

我个人习惯写一个统一的错误处理函数:

void check_stmt_error(MYSQL_STMT *stmt, const char *action) {
    if (mysql_stmt_errno(stmt) != 0) {
        fprintf(stderr, "[错误] %s 失败: %s (错误码: %d)\n",
                action,
                mysql_stmt_error(stmt),
                mysql_stmt_errno(stmt));
        
        // 特殊处理死锁
        if (mysql_stmt_errno(stmt) == 1213) {
            printf("检测到死锁,建议重试事务\n");
        }
        
        // 特殊处理连接断开
        if (mysql_stmt_errno(stmt) == 2006) {
            printf("连接已断开,需要重新连接\n");
        }
    }
}

// 使用示例
mysql_stmt_execute(stmt);
check_stmt_error(stmt, "执行转账扣款");
经验之谈:死锁错误(1213)在并发高的系统里很常见。MySQL会自动回滚其中一个事务,你的程序只需要捕获这个错误,然后重试整个事务即可。我一般重试3次,每次间隔100ms。

21.5 知识体系总览

下面这张图把本章的核心逻辑串起来了:

MySQL预处理与事务核心流程 预处理语句 mysql_stmt_prepare() 解析SQL模板 mysql_stmt_bind_param() 绑定参数 mysql_stmt_execute() 执行 事务控制 mysql_autocommit(0) 关闭自动提交 执行一组SQL操作 检查错误码 → 提交或回滚 错误码处理:mysql_errno / mysql_stmt_errno 预处理保证SQL安全,事务保证数据一致性,错误码处理保证程序健壮性

说白了,这三者缺一不可。预处理防注入,事务保一致,错误码处理让程序不崩。我在实际项目中,这三样东西几乎是每段数据库代码的标配。

最后提醒一句:别偷懒。每次调用MySQL API后都检查返回值,每次事务结束都检查错误码。养成这个习惯,能帮你省下无数个通宵排查的夜晚。


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