21、错误处理与日志:sqlite3_errmsg、sqlite3_errcode、自定义错误回调、日志系统集成
说实话,数据库编程里最容易被忽视的就是错误处理。我见过太多项目,上线跑着跑着突然崩了,一查日志——嗯,根本没有日志。或者更惨,日志里全是「操作失败」这种废话,连个错误码都没有。
今天咱们就把这块彻底讲透。SQLite 的错误处理其实很成熟,关键是你得会用。我个人习惯是:每个 API 调用后,必须检查返回值。这不是强迫症,这是保命。
21.1 基础错误获取:sqlite3_errcode 与 sqlite3_errmsg
这两个函数是错误处理的基石。说白了,一个告诉你「出错了」,一个告诉你「错在哪」。
int rc = sqlite3_exec(db, "INSERT INTO users VALUES(1, 'Alice')", NULL, NULL, &zErrMsg);
if (rc != SQLITE_OK) {
// 错误码:比如 SQLITE_CONSTRAINT
int errCode = sqlite3_errcode(db);
// 错误描述:比如 "UNIQUE constraint failed: users.id"
const char *errMsg = sqlite3_errmsg(db);
printf("错误码: %d, 错误信息: %s\n", errCode, errMsg);
sqlite3_free(zErrMsg);
}
我在项目中遇到过一个问题:sqlite3_exec 返回了 SQLITE_BUSY,但错误信息里只写了「database is locked」。当时排查了半天,才发现是另一个线程没提交事务。所以记住:错误码比错误信息更精确,两者要结合看。
核心原则:每个 SQLite API 调用后,立即检查返回值。不要等到程序崩溃了再回头找原因。
21.2 错误码速查表
SQLite 定义了上百个错误码,但常用的其实就十几个。我整理了一张表,你直接收藏就行:
| 错误码 | 宏定义 | 含义 | 常见场景 |
|---|---|---|---|
| 0 | SQLITE_OK | 操作成功 | 一切正常 |
| 1 | SQLITE_ERROR | 通用错误 | SQL 语法错误 |
| 5 | SQLITE_BUSY | 数据库被锁定 | 并发写入冲突 |
| 6 | SQLITE_LOCKED | 表被锁定 | 同一连接内嵌套操作 |
| 7 | SQLITE_NOMEM | 内存不足 | 大数据量操作 |
| 8 | SQLITE_READONLY | 尝试写入只读数据库 | 文件权限问题 |
| 19 | SQLITE_CONSTRAINT | 约束违反 | 主键重复、外键失败 |
| 20 | SQLITE_MISMATCH | 数据类型不匹配 | 插入字符串到整数字段 |
小技巧:可以用
sqlite3_errstr() 把错误码直接转成可读字符串,省得自己查表。
21.3 自定义错误回调
SQLite 允许你注册一个错误回调函数。当内部发生错误时,它会主动调用你。这个功能很多人不知道,但非常实用。
// 自定义错误回调
void myErrorCallback(void *pArg, int iErrCode, const char *zMsg) {
printf("[自定义回调] 错误码: %d, 信息: %s\n", iErrCode, zMsg);
// 可以在这里写日志、发告警、甚至重启连接
}
// 注册回调
sqlite3_config(SQLITE_CONFIG_LOG, myErrorCallback, NULL);
我曾经在一个嵌入式项目里用过这个。设备跑在野外,偶尔会出 SQLITE_IOERR。有了回调,我就能第一时间把错误信息写到 SD 卡日志里,而不是等用户反馈「程序崩了」。
注意:错误回调是在 SQLite 内部调用的,不要在回调里做耗时操作(比如写文件、网络请求)。否则可能引发死锁。
21.4 日志系统集成
光有错误处理还不够,你得把错误记下来。我建议你搞一个简单的日志模块,至少支持三个级别:
- ERROR:数据库操作失败,需要人工介入
- WARN:非致命错误,比如重试成功
- INFO:正常操作记录,比如连接建立、关闭
typedef enum {
LOG_ERROR,
LOG_WARN,
LOG_INFO
} LogLevel;
void db_log(LogLevel level, const char *fmt, ...) {
const char *levels[] = {"ERROR", "WARN", "INFO"};
time_t now = time(NULL);
char timebuf[32];
strftime(timebuf, sizeof(timebuf), "%Y-%m-%d %H:%M:%S", localtime(&now));
va_list args;
va_start(args, fmt);
printf("[%s] [%s] ", timebuf, levels[level]);
vprintf(fmt, args);
printf("\n");
va_end(args);
// 实际项目中,这里应该写入文件
}
然后结合错误处理,写一个统一的封装:
int db_exec_wrapper(sqlite3 *db, const char *sql) {
char *errMsg = NULL;
int rc = sqlite3_exec(db, sql, NULL, NULL, &errMsg);
if (rc != SQLITE_OK) {
db_log(LOG_ERROR, "SQL执行失败 [%d]: %s\nSQL: %s",
sqlite3_errcode(db), sqlite3_errmsg(db), sql);
sqlite3_free(errMsg);
return rc;
}
db_log(LOG_INFO, "SQL执行成功: %s", sql);
return SQLITE_OK;
}
我的经验:日志里一定要带上 SQL 语句本身。否则你看到「约束违反」四个字,根本不知道是哪条 SQL 出的问题。
21.5 知识体系图
下面这张图把整个错误处理与日志的流程串起来了。你照着这个结构写代码,基本不会漏掉什么:
21.6 避坑指南
最后分享几个我踩过的坑:
- 不要忽略 SQLITE_BUSY:我曾经以为重试一次就行,结果在高并发下频繁失败。正确做法是设置忙等待超时:
sqlite3_busy_timeout(db, 5000)。 - 错误信息别直接拼接到 SQL 里:有人喜欢把
sqlite3_errmsg的结果拼到 INSERT 语句里,结果引号没处理好,SQL 注入风险极大。 - 日志文件要轮转:嵌入式设备存储空间有限,日志写满了会导致数据库无法写入。我一般保留最近 7 天的日志,超过就自动删除。
- 错误回调里别调用 sqlite3 API:这会造成递归调用,轻则死锁,重则栈溢出。
推荐做法:写一个
db_safe_exec() 函数,内部自动处理重试、日志、错误回调。这样业务代码里只需要一行调用,清爽又安全。
好了,错误处理这块就这些。记住一句话:没有日志的数据库,就像没有仪表盘的飞机——你永远不知道它什么时候会掉下来。