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 知识体系图

下面这张图把整个错误处理与日志的流程串起来了。你照着这个结构写代码,基本不会漏掉什么:

SQLite 错误处理与日志系统 SQLite API 调用 检查 rc 记录 INFO 日志 获取错误码/信息 记录 ERROR 日志 触发自定义错误回调

21.6 避坑指南

最后分享几个我踩过的坑:

  • 不要忽略 SQLITE_BUSY:我曾经以为重试一次就行,结果在高并发下频繁失败。正确做法是设置忙等待超时:sqlite3_busy_timeout(db, 5000)
  • 错误信息别直接拼接到 SQL 里:有人喜欢把 sqlite3_errmsg 的结果拼到 INSERT 语句里,结果引号没处理好,SQL 注入风险极大。
  • 日志文件要轮转:嵌入式设备存储空间有限,日志写满了会导致数据库无法写入。我一般保留最近 7 天的日志,超过就自动删除。
  • 错误回调里别调用 sqlite3 API:这会造成递归调用,轻则死锁,重则栈溢出。
推荐做法:写一个 db_safe_exec() 函数,内部自动处理重试、日志、错误回调。这样业务代码里只需要一行调用,清爽又安全。

好了,错误处理这块就这些。记住一句话:没有日志的数据库,就像没有仪表盘的飞机——你永远不知道它什么时候会掉下来。


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