数据库访问的重构:从直接调用到抽象层、连接池实现、ORM模式在C语言中的尝试

说实话,数据库访问这块,是我在遗留系统里见过最"野"的代码。

很多老项目里,SQL语句直接散落在业务逻辑中,mysql_query()sqlite3_exec()满天飞。改一个表结构,得翻遍几十个文件。我接手过一个计费系统,里面光SELECT * FROM users WHERE id = %d这种写法就出现了四十多次。每次加字段,都得祈祷别漏改哪个地方。

这一章,我们就来聊聊怎么把数据库访问从"野蛮生长"变成"井井有条"。

第一步:从直接调用到抽象层

先看一个典型的"反面教材":

// 业务代码里直接写SQL
int get_user_balance(int user_id) {
    MYSQL *conn = mysql_init(NULL);
    mysql_real_connect(conn, "localhost", "root", "pass", "db", 0, NULL, 0);
    
    char sql[256];
    sprintf(sql, "SELECT balance FROM users WHERE id = %d", user_id);
    mysql_query(conn, sql);
    
    MYSQL_RES *res = mysql_store_result(conn);
    MYSQL_ROW row = mysql_fetch_row(res);
    int balance = row ? atoi(row[0]) : 0;
    
    mysql_free_result(res);
    mysql_close(conn);
    return balance;
}

这段代码的问题在哪?

  • 连接管理、SQL拼接、结果解析全混在一起
  • 没法单元测试——你总不能真的连数据库测吧?
  • 换数据库?得把所有mysql_*函数全改一遍

我建议的做法是:先抽象出一个数据访问层(DAL)。

// db_interface.h — 抽象接口
typedef struct db_connection db_connection_t;

typedef struct {
    db_connection_t* (*connect)(const char *host, int port, 
                                 const char *user, const char *pass);
    void (*close)(db_connection_t *conn);
    int (*query)(db_connection_t *conn, const char *sql, 
                 result_set_t *result);
} db_driver_t;

// 业务代码只依赖这个接口
int get_user_balance(db_driver_t *driver, int user_id) {
    char sql[256];
    snprintf(sql, sizeof(sql), 
             "SELECT balance FROM users WHERE id = %d", user_id);
    
    result_set_t result;
    if (driver->query(driver->connect(...), sql, &result) != 0) {
        return -1;  // 错误处理
    }
    return result_get_int(&result, 0, 0);
}

你看,业务代码不再关心底层是MySQL还是PostgreSQL。换数据库?写个新的db_driver_t实现就行。我在一个物联网项目中就用这套方案,从SQLite平滑迁移到了MySQL,只改了一行初始化代码。

核心原则:业务逻辑不应该知道它用的是哪种数据库。它只应该知道"有个地方能存数据、能查数据"。

第二步:连接池——别再每次请求都建连接了

很多C语言新手会犯一个错误:每次数据库操作都新建一个连接,用完就关。

为什么不好?

  • TCP三次握手 + MySQL认证,一次连接耗时几十毫秒
  • 高并发下,数据库服务器会被连接风暴打垮
  • 连接数过多,数据库内存暴涨

我见过一个极端案例:某支付系统压测时,每秒创建300个连接,MySQL直接OOM挂了。

解决方案就是连接池。说白了,就是预先创建一批连接,用的时候借,用完还。

// 连接池核心结构
typedef struct {
    db_connection_t **connections;  // 连接数组
    int *in_use;                    // 使用标记
    int pool_size;                  // 池大小
    pthread_mutex_t mutex;
    pthread_cond_t cond;
} connection_pool_t;

// 从池中借一个连接
db_connection_t* pool_get_connection(connection_pool_t *pool) {
    pthread_mutex_lock(&pool->mutex);
    
    while (1) {
        for (int i = 0; i < pool->pool_size; i++) {
            if (!pool->in_use[i]) {
                pool->in_use[i] = 1;
                pthread_mutex_unlock(&pool->mutex);
                return pool->connections[i];
            }
        }
        // 所有连接都在用,等待
        pthread_cond_wait(&pool->cond, &pool->mutex);
    }
}

// 归还连接
void pool_return_connection(connection_pool_t *pool, 
                            db_connection_t *conn) {
    pthread_mutex_lock(&pool->mutex);
    for (int i = 0; i < pool->pool_size; i++) {
        if (pool->connections[i] == conn) {
            pool->in_use[i] = 0;
            pthread_cond_signal(&pool->cond);
            break;
        }
    }
    pthread_mutex_unlock(&pool->mutex);
}

经验之谈:连接池大小不是越大越好。我一般按"CPU核心数 × 2 + 磁盘数"来估算初始值。然后通过压测微调。曾经有个项目,池子设了200个连接,结果数据库CPU被打满,降到32个反而吞吐量更高。

第三步:ORM模式在C语言中的尝试

ORM(对象关系映射)在Java、Python里很常见。C语言能不能搞?能,但得换个思路。

C没有反射,没有运行时类型信息。所以不能像Hibernate那样自动映射。但我们可以做"半自动"的ORM——用宏和代码生成来减少重复劳动。

// 定义实体
typedef struct {
    int id;
    char name[64];
    double balance;
    time_t created_at;
} User;

// ORM宏:生成CRUD函数
#define DEFINE_ORM_CRUD(TYPE, TABLE, FIELDS) \
    int TYPE##_save(TYPE *entity) { \
        char sql[1024]; \
        snprintf(sql, sizeof(sql), \
            "INSERT INTO " TABLE " VALUES " FIELDS, \
            entity->id, entity->name, entity->balance); \
        return db_execute(sql); \
    } \
    \
    int TYPE##_find_by_id(TYPE *entity, int id) { \
        char sql[256]; \
        snprintf(sql, sizeof(sql), \
            "SELECT * FROM " TABLE " WHERE id = %d", id); \
        result_set_t *rs = db_query(sql); \
        if (!rs || result_set_next(rs) != 0) return -1; \
        entity->id = result_get_int(rs, 0); \
        strcpy(entity->name, result_get_string(rs, 1)); \
        entity->balance = result_get_double(rs, 2); \
        return 0; \
    }

// 使用
DEFINE_ORM_CRUD(User, "users", "%d,'%s',%f")

User u;
u.id = 1001;
strcpy(u.name, "张三");
u.balance = 9999.99;
User_save(&u);  // 自动生成INSERT语句

User found;
User_find_by_id(&found, 1001);  // 自动生成SELECT

嗯,这里要注意:宏展开的SQL有注入风险。我一般会在宏里加一层参数化检查,或者强制使用预处理语句。

避坑指南:我曾经在一个项目里过度依赖ORM宏,结果宏展开后的代码有几千行,编译慢得要命,调试时根本看不清实际执行的SQL。后来我加了一个"调试模式"——定义DEBUG_SQL宏后,所有生成的SQL都会打印到日志里。这个习惯我一直保留到现在。

整体架构图

下面这张图展示了从业务代码到数据库的完整调用链路:

数据库访问重构架构 业务逻辑层 get_user_balance() / save_order() ORM 映射层(宏生成) User_save() / Order_find_by_id() 数据访问接口(DAL) db_driver_t / db_query() / db_execute() 连接池 pool_get_connection() / pool_return_connection() MySQL / PostgreSQL / SQLite 调用方向

从这张图可以看得很清楚:业务代码只跟ORM层打交道,ORM层调用DAL接口,DAL从连接池拿连接,最后才落到具体的数据库。每一层各司其职,改一层不影响其他层。

总结一下

数据库访问重构,说白了就是三件事:

  1. 抽象接口:让业务代码不依赖具体数据库
  2. 连接池:复用连接,别让数据库被连接淹死
  3. 半自动ORM:用宏或代码生成减少重复的CRUD代码

我在好几个项目里实践过这套方案。最明显的变化是:以前改个表结构要改十几个文件,现在只需要改实体定义和ORM宏。测试也好写了——mock掉DAL接口,业务逻辑随便测。

当然,C语言的ORM做不到Java Hibernate那么"智能"。但话说回来,C语言的项目通常对性能要求更高,轻量级的半自动ORM反而更合适。你想想看,一个嵌入式系统里跑Hibernate?那画面太美我不敢看。

一句话记住:数据库访问重构的目标不是"用上高大上的技术",而是"让改数据库的人少掉几根头发"。


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