数据库高可用方案:主从切换、双主架构、负载均衡、故障检测

说实话,高可用这个话题,我做了十几年嵌入式数据库,踩过的坑比吃过的盐还多。你想想看,一个数据库系统要是挂了,整个业务就瘫了。我在一个工业物联网项目里就遇到过——凌晨三点,主库宕机,从库没自动切过去,结果现场数据丢了整整两个小时。从那以后,我对高可用方案就特别较真。

今天咱们就把这块掰开揉碎了讲。核心就四个关键词:主从切换、双主架构、负载均衡、故障检测。它们不是孤立的,而是一套组合拳。

一、主从切换:最基础的保命手段

主从切换,说白了就是让一个从库顶上去当主库。我习惯把它叫做“备胎转正”。

典型的流程是这样的:

  • 主库(Master)负责读写
  • 从库(Slave)只读,实时同步主库数据
  • 一旦主库挂了,从库升级为新主库
  • 客户端自动重连到新主库

这里有个关键点——数据一致性。我曾经在一个项目中,主库刚写完一条记录就崩了,从库还没来得及同步。结果切换后,那条记录丢了。后来我强制要求:必须等从库确认收到日志,主库才算写入成功。这叫“半同步复制”。

核心原则:主从切换不是越快越好,数据不丢才是底线。

二、双主架构:两边都能写,但别乱写

双主架构,就是两个库互为主从。两边都能读写,数据双向同步。

听起来很美好对吧?但我在一个车联网项目里吃过亏——两个主库同时修改同一条记录,结果数据冲突了,时间戳对不上,最后整张表都乱了。

所以双主架构有几个硬性要求:

  • 冲突避免:业务层保证同一时间只有一端写某条数据
  • 自增ID不冲突:比如主库1用奇数ID,主库2用偶数ID
  • 同步延迟容忍:两边数据不是实时一致的,有毫秒级延迟

我的建议:双主架构适合读多写少的场景。如果写操作频繁,还是老老实实用主从吧。

三、负载均衡:别让一个库累死

负载均衡,就是把请求分散到多个数据库节点上。我常用的方式有两种:

  1. 客户端负载均衡:在应用层自己写策略,轮询、随机、权重都行
  2. 中间件负载均衡:用Proxy层(比如MySQL Proxy、HAProxy)统一分发

我个人更推荐中间件方式。为什么?因为客户端方式一旦改了节点列表,所有应用都得重新部署。我在一个智慧城市项目里就吃过这个亏——加了一个从库,结果忘了更新客户端的配置,新库一直空闲着。

负载均衡的策略表:

策略 适用场景 缺点
轮询 各节点性能均衡 无法感知节点负载
最少连接 长连接场景 实现稍复杂
哈希 需要会话保持 节点增减影响大

四、故障检测:别等用户告诉你数据库挂了

故障检测,就是主动发现数据库是否还活着。我见过太多项目,全靠用户反馈才知道数据库挂了。这太被动了。

常用的检测手段:

  • 心跳检测:每隔几秒发一个ping,没回应就标记为可疑
  • 查询检测:执行一条简单的SELECT 1,看能不能返回
  • 日志检测:监控错误日志,发现异常立即告警

我曾经在一个项目中,心跳检测一直正常,但实际查询已经超时了。后来发现是数据库连接池满了,心跳用的独立连接,根本查不出来。所以我现在做故障检测,一定是心跳+查询双保险

注意:故障检测的间隔不要太短。我见过有人设成100毫秒一次,结果数据库没挂,检测程序自己把CPU跑满了。

五、整体架构图

下面这张图,是我自己项目里用的一套高可用方案。你看一眼就明白了:

客户端应用 负载均衡器 主库1(读写) ID: 奇数 主库2(读写) ID: 偶数 从库1(只读) 从库2(只读) 双向同步 故障检测模块

这张图里,客户端先经过负载均衡器,然后请求被分发到两个主库。主库之间双向同步数据,每个主库下面挂一个从库做备份。故障检测模块定时检查所有节点,一旦发现异常,立即触发切换。

六、代码示例:一个简单的故障检测与切换

下面是我写的一个C语言示例,演示了最基本的心跳检测和主从切换逻辑。实际项目中会复杂得多,但核心思路就是这个:

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

// 数据库节点结构
typedef struct {
    char ip[16];
    int port;
    int is_master;   // 1=主, 0=从
    int alive;       // 1=存活, 0=宕机
    int fail_count;  // 连续失败次数
} DBNode;

// 检测一个节点是否存活
int check_alive(DBNode *node) {
    // 实际项目中这里会发一个TCP ping或执行SELECT 1
    // 这里用模拟:随机返回0或1
    int result = rand() % 2;
    if (result == 0) {
        node->fail_count++;
    } else {
        node->fail_count = 0;
    }
    return result;
}

// 主从切换
void switch_master(DBNode *nodes, int count) {
    for (int i = 0; i < count; i++) {
        if (nodes[i].is_master && !nodes[i].alive) {
            printf("[切换] 主库 %s:%d 宕机,开始切换\n", 
                   nodes[i].ip, nodes[i].port);
            nodes[i].is_master = 0;
            
            // 找一个存活的从库升为主库
            for (int j = 0; j < count; j++) {
                if (!nodes[j].is_master && nodes[j].alive) {
                    nodes[j].is_master = 1;
                    printf("[切换] 从库 %s:%d 升为主库\n", 
                           nodes[j].ip, nodes[j].port);
                    break;
                }
            }
        }
    }
}

int main() {
    // 初始化两个节点:一个主,一个从
    DBNode nodes[2] = {
        {"192.168.1.10", 3306, 1, 1, 0},
        {"192.168.1.11", 3306, 0, 1, 0}
    };
    
    printf("开始故障检测循环...\n");
    for (int round = 0; round < 10; round++) {
        printf("\n--- 第 %d 轮检测 ---\n", round + 1);
        
        for (int i = 0; i < 2; i++) {
            nodes[i].alive = check_alive(&nodes[i]);
            printf("节点 %s:%d %s\n", 
                   nodes[i].ip, nodes[i].port,
                   nodes[i].alive ? "存活" : "宕机");
        }
        
        // 如果主库连续失败3次,触发切换
        for (int i = 0; i < 2; i++) {
            if (nodes[i].is_master && nodes[i].fail_count >= 3) {
                switch_master(nodes, 2);
            }
        }
        
        sleep(1);
    }
    
    return 0;
}

经验之谈:连续失败次数不要设成1。网络抖动很常见,一次失败就切换,反而会造成频繁切换。我一般设成3次,间隔1秒,这样既灵敏又稳定。

七、避坑指南

最后,我把自己踩过的坑总结一下,你遇到了直接绕开:

  • 我曾经把故障检测和业务查询混在一起,结果检测查询把数据库连接池占满了。现在都是独立连接做检测。
  • 我曾经在主从切换时忘了更新客户端的连接信息,导致应用还在连旧主库。现在切换后必须广播通知所有客户端。
  • 我曾经在双主架构里没做ID冲突处理,结果两个主库插入了相同ID的数据。现在强制用奇偶ID或者UUID。
  • 我曾经把负载均衡的权重设错了,导致一个库忙死,另一个闲死。现在上线前一定会做压测验证权重。

嗯,高可用这东西,说白了就是“防患于未然”。你平时觉得它多余,等真出事了就知道它有多重要了。希望今天讲的这些,能帮你少走一些弯路。


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