85、数据库读写分离:读写分离架构、延迟监控、一致性保证、C语言实现

读写分离,说白了就是让数据库的读操作和写操作走不同的路。写操作交给主库,读操作交给从库。这样做的好处很明显——减轻主库压力,提升系统吞吐量。

我在做嵌入式数据库项目时,遇到过这样一个场景:设备端每秒要写入上千条传感器数据,同时还有十几个查询线程在轮询读取。单库扛不住,读写分离就成了必然选择。

读写分离架构

先看架构。嵌入式环境下的读写分离,跟大型分布式系统不太一样。我们没有那么多机器,通常是一主一从,或者一主多从。主库负责写,从库负责读。

核心思路:主库写入后,通过某种机制同步到从库。应用程序根据操作类型,选择不同的数据库连接。

我习惯用这样的结构:

// 读写分离连接池结构
typedef struct {
    db_conn_t *master;      // 主库连接(写)
    db_conn_t **slaves;     // 从库连接池(读)
    int slave_count;        // 从库数量
    int current_slave;      // 当前轮询到的从库索引
} rw_split_pool_t;

写操作固定走主库,读操作从从库池中选一个。选从库的策略我常用轮询(Round-Robin),简单有效。

// 获取读连接 - 轮询策略
db_conn_t* get_read_conn(rw_split_pool_t *pool) {
    if (pool->slave_count == 0) {
        return pool->master;  // 没有从库,回退到主库
    }
    
    int idx = pool->current_slave;
    pool->current_slave = (idx + 1) % pool->slave_count;
    return pool->slaves[idx];
}

这里有个细节:如果从库挂了怎么办?我一般会加个健康检查,从库不可用时自动切换到主库。这叫「降级策略」。

延迟监控

读写分离最大的坑是什么?延迟。

主库写完数据,从库还没同步过来。这时候去读,读到的是旧数据。我遇到过最夸张的一次,从库延迟了整整3秒——设备都报警了,查询结果还是正常值。

所以,延迟监控必须做。怎么做?

方法一:比较主从库的当前时间戳或事务ID。

// 检查从库延迟(秒)
int check_slave_lag(db_conn_t *master, db_conn_t *slave) {
    uint64_t master_pos = get_master_log_pos(master);
    uint64_t slave_pos = get_slave_read_pos(slave);
    
    // 计算差值,换算成时间
    return (int)(master_pos - slave_pos) / avg_bytes_per_sec;
}

方法二:在应用层记录写入时间戳,读取时判断。

// 写入时记录时间戳
void write_with_timestamp(db_conn_t *master, const char *key, const char *value) {
    char sql[256];
    snprintf(sql, sizeof(sql), 
             "INSERT INTO kv_store VALUES('%s', '%s', %lu)",
             key, value, time(NULL));
    db_execute(master, sql);
}

// 读取时检查延迟
int read_with_lag_check(db_conn_t *slave, const char *key, time_t max_lag) {
    char sql[128];
    snprintf(sql, sizeof(sql), 
             "SELECT value, write_time FROM kv_store WHERE key='%s'", key);
    
    result_t *res = db_query(slave, sql);
    time_t write_time = get_write_time(res);
    
    if (time(NULL) - write_time > max_lag) {
        return -1;  // 延迟超标,需要从主库读
    }
    return 0;
}

我的经验:延迟阈值不要设得太死。网络抖动、磁盘IO波动都可能导致短暂延迟。我一般设2-3秒的容忍窗口,超过这个值才告警或切换。

一致性保证

读写分离后,一致性就成了老大难问题。你想想看,用户刚写完数据,下一秒去读,结果读到的是旧版本——这谁受得了?

我总结了几种保证一致性的策略:

策略 原理 适用场景 代价
强制读主库 关键数据直接读主库 写后立即读的场景 增加主库压力
延迟读取 写入后等待一段时间再读 对实时性要求不高的场景 增加响应时间
版本号校验 读取时带上版本号,不一致则重试 数据冲突概率低的场景 增加网络开销
会话一致性 同一会话内的读操作走主库 用户交互场景 实现稍复杂

我个人最常用的是「会话一致性」。具体做法:在连接池中记录每个会话的写入时间,如果该会话在最近N秒内有写操作,则后续读操作强制走主库。

// 会话一致性实现
typedef struct {
    int session_id;
    time_t last_write_time;
    db_conn_t *assigned_conn;
} session_t;

db_conn_t* get_conn_for_session(session_t *session, rw_split_pool_t *pool) {
    // 如果最近2秒内有写操作,走主库
    if (time(NULL) - session->last_write_time < 2) {
        return pool->master;
    }
    // 否则走从库
    return get_read_conn(pool);
}

C语言实现

好了,理论说完了,咱们动手写代码。下面是一个完整的读写分离实现框架。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>

// 数据库连接(简化结构)
typedef struct {
    char host[64];
    int port;
    char db_name[64];
    void *internal_conn;  // 实际数据库连接句柄
} db_conn_t;

// 读写分离池
typedef struct {
    db_conn_t *master;
    db_conn_t **slaves;
    int slave_count;
    int current_slave;
    pthread_mutex_t lock;
    
    // 延迟监控
    time_t last_check_time;
    int *slave_lags;  // 每个从库的延迟秒数
} rw_split_pool_t;

// 初始化读写分离池
rw_split_pool_t* rw_split_init(const char *master_host, int master_port,
                                const char **slave_hosts, int *slave_ports,
                                int slave_count) {
    rw_split_pool_t *pool = malloc(sizeof(rw_split_pool_t));
    
    // 连接主库
    pool->master = db_connect(master_host, master_port);
    
    // 连接从库
    pool->slave_count = slave_count;
    pool->slaves = malloc(sizeof(db_conn_t*) * slave_count);
    pool->slave_lags = malloc(sizeof(int) * slave_count);
    
    for (int i = 0; i < slave_count; i++) {
        pool->slaves[i] = db_connect(slave_hosts[i], slave_ports[i]);
        pool->slave_lags[i] = 0;
    }
    
    pool->current_slave = 0;
    pthread_mutex_init(&pool->lock, NULL);
    pool->last_check_time = time(NULL);
    
    return pool;
}

// 获取写连接(始终返回主库)
db_conn_t* get_write_conn(rw_split_pool_t *pool) {
    return pool->master;
}

// 获取读连接(带延迟感知)
db_conn_t* get_read_conn(rw_split_pool_t *pool) {
    pthread_mutex_lock(&pool->lock);
    
    // 找延迟最小的从库
    int best_slave = -1;
    int min_lag = 9999;
    
    for (int i = 0; i < pool->slave_count; i++) {
        if (pool->slave_lags[i] < min_lag) {
            min_lag = pool->slave_lags[i];
            best_slave = i;
        }
    }
    
    // 如果所有从库延迟都太大,回退到主库
    if (min_lag > 5) {  // 延迟超过5秒
        pthread_mutex_unlock(&pool->lock);
        return pool->master;
    }
    
    db_conn_t *conn = pool->slaves[best_slave];
    pool->current_slave = (best_slave + 1) % pool->slave_count;
    
    pthread_mutex_unlock(&pool->lock);
    return conn;
}

// 延迟监控线程
void* lag_monitor_thread(void *arg) {
    rw_split_pool_t *pool = (rw_split_pool_t*)arg;
    
    while (1) {
        sleep(1);  // 每秒检查一次
        
        for (int i = 0; i < pool->slave_count; i++) {
            int lag = check_slave_lag(pool->master, pool->slaves[i]);
            pool->slave_lags[i] = lag;
            
            if (lag > 10) {
                printf("[WARNING] Slave %d lag is %d seconds!\n", i, lag);
            }
        }
    }
    
    return NULL;
}

// 使用示例
void demo_rw_split() {
    const char *slave_hosts[] = {"192.168.1.101", "192.168.1.102"};
    int slave_ports[] = {3306, 3306};
    
    rw_split_pool_t *pool = rw_split_init(
        "192.168.1.100", 3306,
        slave_hosts, slave_ports, 2
    );
    
    // 启动延迟监控
    pthread_t monitor_tid;
    pthread_create(&monitor_tid, NULL, lag_monitor_thread, pool);
    
    // 写操作
    db_conn_t *master = get_write_conn(pool);
    db_execute(master, "INSERT INTO sensor_data VALUES(1, 25.6, NOW())");
    
    // 读操作
    db_conn_t *slave = get_read_conn(pool);
    result_t *res = db_query(slave, "SELECT * FROM sensor_data WHERE id=1");
    
    // 处理结果...
}

注意:上面的代码是简化版,实际生产环境还要考虑:连接池复用、断线重连、从库动态增删、事务隔离级别等问题。我曾经在一个项目中忽略了断线重连,结果从库挂了之后,所有读操作都卡死了——血的教训。

架构图

下面这张图展示了读写分离的整体流程:

应用程序 读写分离路由层 写操作 → 主库 | 读操作 → 从库 主库 (Master) 写操作 从库1 (Slave1) 读操作 从库2 (Slave2) 读操作 数据同步 延迟监控模块(独立线程) 每秒检查从库延迟,延迟超标时告警或切换

避坑指南

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

  • 事务内的读操作也要走主库。 我曾经在一个事务里先写后读,结果读操作走了从库,读到的是旧数据。事务内的所有操作,必须走同一个连接。
  • 从库挂了要快速降级。 不要死等从库恢复,该切主库就切。我见过一个系统,从库挂了之后读请求全部超时,整个服务都瘫痪了。
  • 监控要带上告警。 光监控不告警等于没监控。延迟超过阈值,该发邮件发邮件,该发短信发短信。
  • 测试环境也要配读写分离。 别只在生产环境搞,测试环境不配的话,很多延迟相关的问题根本发现不了。

读写分离不是银弹,但它确实是提升数据库性能的有效手段。关键是要把延迟监控和一致性保证做好,否则就是给自己挖坑。


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