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");
// 处理结果...
}
注意:上面的代码是简化版,实际生产环境还要考虑:连接池复用、断线重连、从库动态增删、事务隔离级别等问题。我曾经在一个项目中忽略了断线重连,结果从库挂了之后,所有读操作都卡死了——血的教训。
架构图
下面这张图展示了读写分离的整体流程:
避坑指南
最后,分享几个我踩过的坑:
- 事务内的读操作也要走主库。 我曾经在一个事务里先写后读,结果读操作走了从库,读到的是旧数据。事务内的所有操作,必须走同一个连接。
- 从库挂了要快速降级。 不要死等从库恢复,该切主库就切。我见过一个系统,从库挂了之后读请求全部超时,整个服务都瘫痪了。
- 监控要带上告警。 光监控不告警等于没监控。延迟超过阈值,该发邮件发邮件,该发短信发短信。
- 测试环境也要配读写分离。 别只在生产环境搞,测试环境不配的话,很多延迟相关的问题根本发现不了。
读写分离不是银弹,但它确实是提升数据库性能的有效手段。关键是要把延迟监控和一致性保证做好,否则就是给自己挖坑。
公众号:蓝海资料掘金营,微信deep3321