Skip to content

MySQL 读写分离与主从扩展

当数据库压力开始上来时,很多系统的第一反应并不是立刻分库分表,而是先做主从复制和读写分离。

这是一个更符合工程演进顺序的选择,因为它通常不直接改变业务数据模型,也不要求马上处理跨分片事务、全局路由这些更重的问题。放到扩容路径里看,它更像是在单库能力还没有被彻底吃干净之前,把“读压力”从主库身上拆出去。

这篇文章重点展开 4 个方面:

  1. 什么场景下会考虑主从扩展和读写分离
  2. 它到底解决了什么问题
  3. 它会带来哪些新的挑战
  4. 这些挑战通常怎么处理

1. 先说结论:为什么通常先做主从扩展

数据库扩展路径里,一个比较常见的演进顺序是:

  1. 先做索引和 SQL 优化
  2. 再做缓存
  3. 再做主从复制和读写分离
  4. 最后才认真评估分库分表

原因并不复杂:

  1. 读写分离主要解决的是读压力问题
  2. 它保留了单主写入模型,业务改造通常相对更轻
  3. 相比直接分库分表,它的心智负担和改造成本通常更低

所以如果系统是典型的“读多写少”,主从扩展往往会比“直接拆库拆表”更自然。

2. 什么是主从复制和读写分离

2.1 主从复制

主从复制的核心思路是:

  1. 主库负责写
  2. 主库把变更记录下来
  3. 从库复制这些变更并重放
  4. 从库逐步追上主库数据

也就是说,主从复制解决的是:如何把主库的数据变更同步到其他副本。

2.2 读写分离

读写分离通常建立在主从复制之上。

它的核心思路是:

  1. 写请求仍然走主库
  2. 一部分读请求走从库

所以它解决的是 如何把大量读流量从主库分散出去

2.3 两者关系

这两个概念经常一起出现,但不是一回事:

  1. 主从复制是数据同步机制
  2. 读写分离是流量路由策略

如果没有复制,读写分离就没有稳定的数据副本可读;如果只有复制,没有流量路由,也不等于真正用好了从库能力。

3. 哪些场景会考虑读写分离

3.1 读多写少

这是最典型的场景。

例如:

  1. 商品详情页
  2. 内容平台文章页
  3. 订单列表页
  4. 用户资料查询
  5. 后台报表查询

这些场景往往有一个共同点:

  1. 写流量相对可控
  2. 读流量远高于写流量

如果所有读请求都压在主库上,主库会很快成为热点。

3.2 查询类接口远多于事务写接口

有些系统并不是绝对读多写少,但查询接口非常多,且很多查询对“毫秒级绝对最新”并不敏感。

这时读写分离就很适合把:

  1. 列表查询
  2. 统计查询
  3. 非强一致详情查询

迁移到从库承担。

3.3 分布式系统里服务已经横向扩展

在分布式场景里,应用层经常先扩展起来:

  1. 多个应用实例
  2. 多个服务节点
  3. 网关和缓存层都已扩展

但数据库如果还是单主库承担所有读流量,就会成为新的单点瓶颈。

这时主从扩展的价值就很明显:应用层已经分布式了,数据库层至少也要把读能力扩起来。

3.4 报表、搜索、后台查询压力明显

有些业务的主链路并不算特别重,但后台管理系统、数据看板、运营报表查询会持续给主库施压。

这时让这类查询更多走从库,通常是一个很自然的治理动作。

4. 主从扩展和读写分离解决了什么问题

4.1 分担读压力

这是最直接的收益。

原本所有查询都压在主库上,现在可以把一部分查询分发到多个从库,缓解:

  1. 主库 CPU 压力
  2. 主库 IO 压力
  3. 主库连接数和查询并发压力

4.2 提升整体吞吐

数据库整体的读能力,不再只取决于一个主库。

如果有多个从库承担读请求,系统整体吞吐通常会更高。

4.3 提升可用性

从库除了分担读流量,在很多系统里也承担一定的高可用角色。

例如主库故障后,可以通过故障切换把某个从库提升为新的主库。

当然,这不等于“配置完主从就天然高可用”,但它至少提供了更进一步做高可用的基础。

4.4 降低后续分库分表的迫切性

当问题主要集中在读压力时,读写分离往往能把系统稳住,让团队不必过早进入更复杂的分库分表阶段。

5. 它没有解决什么问题

这点非常重要。

读写分离虽然有用,但它并不是万能的。

它没有真正解决下面这些问题:

  1. 主库写入瓶颈
  2. 单表过大
  3. 单库存储容量上限
  4. 跨业务线写热点

也就是说:如果系统真正卡住的是写能力,而不是读能力,读写分离的帮助会非常有限。

6. 它会带来哪些新的挑战

这部分往往比“收益”更值得认真看。

因为读写分离不是“加几个从库就结束”,而是会引入一系列新问题。

6.1 复制延迟

这是最经典的问题。

主库写成功后,从库并不是同一时刻立刻完成同步。

于是就可能出现:

  1. 用户刚提交订单
  2. 接口马上去查询订单详情
  3. 如果这次查询被路由到从库
  4. 从库还没追上
  5. 结果就读不到,或者读到旧数据

这就是典型的主从延迟带来的读一致性问题。

6.2 强一致读变复杂

单库时大家默认“刚写完再读就能读到”,但读写分离后这个默认前提不再天然成立。

于是系统必须先明确:

  1. 哪些读可以走从库
  2. 哪些读必须回主库
  3. 刚写完之后的一段时间要不要强制读主

6.3 故障切换复杂度上升

从库可以提升为主库,但切换不是一句口号,里面会涉及:

  1. 哪个从库追得最完整
  2. 切换时流量怎么转
  3. 应用连接怎么更新
  4. 切换期间会不会丢数据或短暂不可写

6.4 路由规则变复杂

单库时应用几乎不用关心读写去哪。

读写分离后,必须有一层路由逻辑把这些事情明确下来:

  1. 这次是读还是写
  2. 这次读能不能走从库
  3. 要走哪个从库

6.5 从库压力不均衡

并不是加了多个从库,压力就会自动均匀。

如果路由策略、查询模式或热点分布不合理,仍然可能出现:

  1. 某个从库很忙
  2. 某些从库几乎空闲

6.6 排障复杂度上升

以前查问题只盯一个库,现在要区分:

  1. 是主库慢
  2. 还是某个从库慢
  3. 是复制延迟
  4. 还是路由异常

这对监控和排障能力提出了更高要求。

7. 这些挑战通常怎么解决

7.1 复制延迟:区分强一致读和普通读

最常见的做法是把读请求分成两类:

  1. 强一致读
  2. 可接受短暂延迟的普通读

例如:

  1. 提交订单后的立即查询
  2. 刚改完密码后的用户信息读取
  3. 刚扣完库存后的库存校验

这类读更适合直接回主库。

而:

  1. 首页列表
  2. 内容详情
  3. 非关键后台查询

则更适合走从库。

7.2 刚写后读不一致:引入“写后读主”策略

很多系统会设计一段“写后读主”的窗口。

也就是:

  1. 某个用户刚完成写操作
  2. 在短时间窗口内,这个用户后续查询优先走主库

这样可以显著降低“刚写完却查不到”的体验问题。

7.3 路由治理:通过中间层或数据源路由统一控制

应用层不要到处手写“这次查主库、那次查从库”的散乱逻辑。

更常见的做法是:

  1. 在数据库访问层统一做路由
  2. 用中间件、代理或框架能力统一接管
  3. 明确强一致读标签或注解

这样维护成本会低很多。

AbstractRoutingDataSource 统一做“写后读主”

如果把它放进一个 Spring Boot Starter Web + MyBatis 项目里,比较常见的一套落地方式通常是:

  1. 写请求默认走主库
  2. 普通只读查询默认走从库
  3. 某个用户刚写完数据后,短时间内的读取强制回主库

看最小依赖组合:

xml
<dependencies>
    <!-- Web 层入口:Controller、JSON 序列化、内嵌 Tomcat 都会跟着进来 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- MyBatis 和 Spring Boot 的整合 starter -->
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>3.0.3</version>
    </dependency>

    <!-- MySQL JDBC 驱动 -->
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
    
    <!-- 这里显式写出来,是为了让读者知道底层连接池通常就是它 -->
    <dependency>
        <groupId>com.zaxxer</groupId>
        <artifactId>HikariCP</artifactId>
    </dependency>
</dependencies>

再看 application.yml,这里把主库、从库和 MyBatis 一起配好:

yaml
spring:
  datasource:
    master:
      # 主库负责真正写入,也承担强一致读
      url: jdbc:mysql://mysql-master:3306/order_center
      username: root
      password: your_password
      driver-class-name: com.mysql.cj.jdbc.Driver
    slave:
      # 从库主要承担普通查询流量
      url: jdbc:mysql://mysql-replica-1:3306/order_center
      username: root
      password: your_password
      driver-class-name: com.mysql.cj.jdbc.Driver

mybatis:
  mapper-locations: classpath:/mapper/*.xml
  type-aliases-package: com.example.order.domain

启动类保持正常的 Spring Boot + MapperScan 组合即可:

java
@SpringBootApplication
// 批量扫描 MyBatis Mapper,避免每个接口都手写 @Mapper 注册
@MapperScan("com.example.order.mapper")
public class OrderApplication {

    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

接下来是路由上下文和动态数据源配置:

java
/**
 * 保存“当前请求是否需要强制读主库”的线程上下文。
 *
 * <p>它解决的是:用户刚完成写操作后,后续短时间内的查询不要立刻打到可能存在复制延迟的从库。</p>
 */
public final class DbRouteContext {

    // 记录“强制读主”截止时间,按请求线程隔离
    private static final ThreadLocal<Instant> FORCE_MASTER_UNTIL = new ThreadLocal<>();

    /**
     * 工具类不需要创建实例。
     */
    private DbRouteContext() {
    }

    /**
     * 在指定时间窗口内把后续读取强制路由到主库。
     *
     * @param duration 强制读主持续时间,例如 3 秒
     */
    public static void forceMasterFor(Duration duration) {
        // 写成功后的短时间窗口内,后续读取强制走主库
        FORCE_MASTER_UNTIL.set(Instant.now().plus(duration));
    }

    /**
     * 判断当前线程是否仍处于“强制读主”窗口内。
     *
     * @return `true` 表示当前读取应该继续走主库;`false` 表示可以按默认规则走从库
     */
    public static boolean shouldForceMaster() {
        Instant deadline = FORCE_MASTER_UNTIL.get();
        return deadline != null && Instant.now().isBefore(deadline);
    }

    /**
     * 清理当前线程里的路由上下文,避免请求结束后污染下一个请求。
     */
    public static void clear() {
        FORCE_MASTER_UNTIL.remove();
    }
}

/**
 * 读写分离场景下的数据源路由类型。
 */
public enum DbRouteType {
    // 主库,负责写操作和强一致读
    MASTER,
    // 从库,主要承担普通查询流量
    SLAVE
}

/**
 * 根据事务属性和线程上下文动态决定当前应该使用主库还是从库。
 */
public class ReadWriteRoutingDataSource extends AbstractRoutingDataSource {

    /**
     * 计算当前线程本次数据库访问要命中的数据源。
     *
     * @return `MASTER` 表示走主库;`SLAVE` 表示走从库
     */
    @Override
    protected Object determineCurrentLookupKey() {
        // 非只读事务直接走主库,因为这里通常包含写操作
        if (!TransactionSynchronizationManager.isCurrentTransactionReadOnly()) {
            return DbRouteType.MASTER;
        }

        // 只读事务默认走从库,但“刚写后立刻读”的窗口仍然回主库
        return DbRouteContext.shouldForceMaster() ? DbRouteType.MASTER : DbRouteType.SLAVE;
    }
}

/**
 * 注册主库、从库以及最终暴露给 MyBatis 使用的动态数据源。
 */
@Configuration
public class DataSourceConfig {

    /**
     * 绑定主库配置项。
     *
     * @return 主库对应的数据源属性对象
     */
    @Bean
    @ConfigurationProperties("spring.datasource.master")
    public DataSourceProperties masterDataSourceProperties() {
        return new DataSourceProperties();
    }

    /**
     * 绑定从库配置项。
     *
     * @return 从库对应的数据源属性对象
     */
    @Bean
    @ConfigurationProperties("spring.datasource.slave")
    public DataSourceProperties slaveDataSourceProperties() {
        return new DataSourceProperties();
    }

    /**
     * 创建主库连接池。
     *
     * @return 指向主库的 `DataSource`
     */
    @Bean
    public DataSource masterDataSource() {
        // 主库连接池
        return masterDataSourceProperties()
                .initializeDataSourceBuilder()
                .type(HikariDataSource.class)
                .build();
    }

    /**
     * 创建从库连接池。
     *
     * @return 指向从库的 `DataSource`
     */
    @Bean
    public DataSource slaveDataSource() {
        // 从库连接池
        return slaveDataSourceProperties()
                .initializeDataSourceBuilder()
                .type(HikariDataSource.class)
                .build();
    }

    /**
     * 组装读写分离动态数据源,并作为应用默认数据源暴露出去。
     *
     * @param masterDataSource 主库连接池
     * @param slaveDataSource 从库连接池
     * @return 供 MyBatis 和事务管理器共同使用的动态数据源
     */
    @Bean
    @Primary
    public DataSource dataSource(
            @Qualifier("masterDataSource") DataSource masterDataSource,
            @Qualifier("slaveDataSource") DataSource slaveDataSource) {
        // 这个 DataSource 会真正暴露给 MyBatis / 事务管理器使用
        ReadWriteRoutingDataSource routingDataSource = new ReadWriteRoutingDataSource();
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put(DbRouteType.MASTER, masterDataSource);
        targetDataSources.put(DbRouteType.SLAVE, slaveDataSource);
        routingDataSource.setTargetDataSources(targetDataSources);
        // 默认给主库,避免路由上下文缺失时把写请求误打到从库
        routingDataSource.setDefaultTargetDataSource(masterDataSource);
        return routingDataSource;
    }
}

最后看一个完整的 Web -> Service -> MyBatis Mapper 调用链:

java
/**
 * 创建订单接口的请求对象。
 */
@Data
public class CreateOrderRequest {

    // 谁下的单
    private Long userId;
    // 订单金额
    private BigDecimal amount;
}

/**
 * 订单表对应的数据对象。
 */
@Data
@Builder
public class OrderDO {

    // 主键由数据库自增或雪花算法生成
    private Long id;
    private Long userId;
    private BigDecimal amount;
    // 这里简化成字符串,真实项目里通常会收口成枚举
    private String status;
}

/**
 * 订单读写接口入口。
 */
@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    /**
     * 创建订单。
     *
     * @param request 下单请求,包含用户和金额等必要信息
     * @return 新创建订单的主键 ID
     */
    @PostMapping
    public Long create(@RequestBody CreateOrderRequest request) {
        // 写请求会进入主库链路
        return orderService.createOrder(request);
    }

    /**
     * 查询订单详情。
     *
     * @param orderId 订单主键 ID
     * @return 查询到的订单详情
     */
    @GetMapping("/{orderId}")
    public OrderDO detail(@PathVariable Long orderId) {
        // 查询请求默认读从库,但在“刚写后立刻读”的窗口内会回主库
        return orderService.queryOrder(orderId);
    }
}

/**
 * 订单应用服务,负责串联写主库和读从库的路由逻辑。
 */
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderMapper orderMapper;

    /**
     * 创建订单,并在短时间窗口内把后续查询强制路由到主库。
     *
     * @param request 下单请求参数
     * @return 新创建订单的主键 ID
     */
    @Transactional
    public Long createOrder(CreateOrderRequest request) {
        // 这里构造的是准备落库的订单对象
        OrderDO order = OrderDO.builder()
                .userId(request.getUserId())
                .amount(request.getAmount())
                .status("CREATED")
                .build();

        // 插入语句一定走主库
        orderMapper.insert(order);

        // 用户刚写完订单,后续几秒内的查询强制走主库
        DbRouteContext.forceMasterFor(Duration.ofSeconds(3));
        return order.getId();
    }

    /**
     * 查询订单详情。
     *
     * @param orderId 订单主键 ID
     * @return 对应的订单记录;如果不存在则返回 `null`
     */
    @Transactional(readOnly = true)
    public OrderDO queryOrder(Long orderId) {
        // 只读事务默认走从库;如果刚写过,则在窗口期内回主库
        return orderMapper.selectById(orderId);
    }
}

/**
 * 订单数据访问接口。
 */
@Mapper
public interface OrderMapper {

    @Insert("""
            -- 插入订单,这类写操作一定要落到主库
            insert into t_order(user_id, amount, status)
            values(#{userId}, #{amount}, #{status})
            """)
    @Options(useGeneratedKeys = true, keyProperty = "id")
    /**
     * 插入一条订单记录。
     *
     * @param order 待落库的订单对象
     * @return 受影响行数
     */
    int insert(OrderDO order);

    @Select("""
            -- 按主键查订单详情,读写分离后通常属于可路由的读请求
            select id, user_id as userId, amount, status
            from t_order
            where id = #{id}
            """)
    /**
     * 按订单 ID 查询详情。
     *
     * @param id 订单主键 ID
     * @return 查询到的订单对象;如果不存在则返回 `null`
     */
    OrderDO selectById(@Param("id") Long id);
}

/**
 * 在请求结束后清理数据库路由上下文,防止 ThreadLocal 串请求。
 */
@Component
public class DbRouteContextCleanupFilter extends OncePerRequestFilter {

    /**
     * 执行过滤器链,并在请求结束后清理“强制读主”上下文。
     *
     * @param request 当前 HTTP 请求
     * @param response 当前 HTTP 响应
     * @param filterChain 过滤器链
     * @throws ServletException Servlet 处理异常
     * @throws IOException IO 异常
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        } finally {
            // 每个请求结束后清理 ThreadLocal,避免路由上下文串到下一个请求
            DbRouteContext.clear();
        }
    }
}

这套方案体现的不是“读请求永远走从库”,而是:

  1. 写事务和强一致读优先走主库
  2. 普通只读查询尽量分发到从库
  3. “刚写后立刻读”的窗口通过 forceMasterFor 显式兜住

执行效果可以直接看成:

  1. 用户提交订单时,插入语句一定落到主库
  2. 用户立刻刷新订单详情页时,3 秒窗口内查询仍然走主库,所以能读到刚写入的数据
  3. 过了这个窗口,普通详情查询就可以重新走从库,把主库读压力让出去

真正上线时,很多团队还会继续补上:

  1. 多从库权重路由
  2. 延迟阈值超过告警时自动摘除某个从库
  3. 在注解或网关层显式标记“这次请求必须读主”

7.4 故障切换:完善高可用体系

主从不是高可用的全部,还需要配套:

  1. 主从状态监控
  2. 延迟监控
  3. 自动或半自动切换
  4. 切换演练
  5. 回切方案

真正可靠的系统,不是“理论上能切”,而是:团队真的知道故障来了该怎么切,并且切过。

7.5 从库负载均衡:按策略分发读流量

常见思路包括:

  1. 随机分配
  2. 权重分配
  3. 按机房或地域分配
  4. 对特定查询做定向路由

如果某些从库承担的是分析类慢查询,很多时候还会和普通从库做角色隔离。

7.6 监控治理:把复制状态纳入核心监控项

至少要关心:

  1. 主从延迟
  2. 从库复制线程状态
  3. 主从数据一致性校验
  4. 从库负载情况
  5. 切换成功率和恢复时间

否则读写分离上线后,问题往往不是“没有方案”,而是“出了问题却看不见”。

如果要把这件事真正做成可治理的体系,通常不能只停留在“看一眼延迟”。

更常见的做法是把监控拆成 3 层:

第一层:复制链路本身是否健康

这层关注的是“主库变更有没有稳定送到从库”。

典型要看:

  1. 复制延迟是否持续升高
  2. 复制 IO 线程、SQL 线程是否异常中断
  3. 中继日志是否持续堆积
  4. 是否出现复制报错、跳过事务、重放失败
  5. 从库是否意外退出只读模式或被错误写入

这层的目标不是展示漂亮图表,而是第一时间发现:复制链路是不是已经开始不可靠了。

第二层:从库是否还能稳定承担读流量

很多系统的问题不是复制断了,而是从库虽然还在同步,但已经扛不住查询压力。

这时通常要继续看:

  1. 从库 CPU、IO、磁盘空间
  2. 连接数、活跃线程数、QPS、TPS
  3. 慢查询数量和平均响应时间
  4. 热点 SQL 是否集中打到某一个从库
  5. 从库和主库之间的资源差异是否过大

放到治理层面看,复制健康不代表读服务健康。

有些从库“同步是正常的”,但查询早就慢了,这同样会把读写分离做成坏体验。

第三层:路由和切换是否可控

当系统已经引入代理层、中间件或统一数据源路由之后,还要继续关心:

  1. 多少读请求实际命中了从库
  2. 强一致读是否正确回主
  3. 某个从库摘除后流量是否被正确转移
  4. 故障切换耗时多长
  5. 切换后应用连接是否及时恢复

这层本质上是在判断:你的读写分离方案到底有没有被正确执行。

监控之外,还要有治理动作

真正成熟的系统,通常会把“发现问题”继续往下接到“自动或半自动治理动作”:

  1. 延迟超过阈值时,自动把落后从库摘出读流量池
  2. 复制线程异常时,立即告警并触发排障流程
  3. 某个从库慢查询恶化时,动态降低它的读权重
  4. 主库故障时,按预案进行自动或人工确认后的切换
  5. 定期做主从一致性校验,发现漂移后修复

所以监控治理最怕的不是“指标不够多”,而是:看得到异常,却没有后续动作。

7.7 有没有第三方框架或工具可以可视化管理

有,而且在真实工程里通常不是“一个工具包打天下”,而是几类工具配合使用。

更贴近工程现实的理解是:

  1. 监控可视化工具负责看见问题
  2. 拓扑和高可用工具负责处理复制关系与切换
  3. 代理或中间件负责执行读写路由
  4. 一致性校验工具负责发现数据漂移

7.7.1 监控可视化类

这类工具主要解决“看板、告警、趋势分析”。

常见选择包括:

工具更适合做什么特点
Prometheus + Grafana自建统一监控体系灵活度高,能把 MySQL、代理层、应用侧指标放到一张大盘里
Percona Monitoring and Management (PMM)MySQL / ProxySQL / OS 指标可视化对 MySQL 生态比较友好,落地速度通常较快
Zabbix传统运维告警和主机监控老牌方案,适合已有 Zabbix 体系的团队

如果团队已经有完整的可观测平台,最常见的做法往往是:Prometheus/Grafana 统一采集,再按数据库专题做大盘和告警。

7.7.2 复制拓扑与高可用治理类

这类工具主要解决“谁是主、谁跟谁同步、故障时怎么切”的问题。

常见方案包括:

工具更适合做什么特点
Orchestrator管理 MySQL 复制拓扑、可视化查看主从关系、辅助故障切换在主从拓扑治理领域很典型,适合管理多实例复制关系
MHA主库故障后的主从切换老牌方案,很多传统主从架构用过,偏故障切换治理
MySQL InnoDB Cluster + MySQL Router官方体系下做高可用与路由接入更偏官方栈,适合接受官方组件体系的团队

这类工具更像是“数据库拓扑控制面”,它们处理的是:

  1. 当前复制关系长什么样
  2. 某个节点挂了之后谁接班
  3. 切换后其他节点如何重新挂接

7.7.3 读写路由与代理类

这类工具主要解决“请求到底发到主库还是从库”。

常见选择包括:

工具更适合做什么特点
ProxySQL代理层做读写分离、规则路由、权重控制很常见,支持按规则把流量分发到主从节点
Apache ShardingSphere在中间件层统一做读写分离、分库分表和治理适合希望后续继续演进到分片治理的系统
MyCat通过数据库中间件做路由与读写分离在一些历史系统和国内场景里较常见

这类工具的价值在于把“路由规则”从业务代码里抽出来。

这样当某个从库延迟过高、需要摘流量、或者权重要调整时,治理会容易很多。

7.7.4 一致性与延迟校验类

这类工具不是直接做可视化大盘,但在治理中非常重要。

常见工具包括:

工具更适合做什么特点
pt-heartbeat更准确地观测复制延迟比单纯看某些状态值更适合做延迟基准监测
pt-table-checksum检查主从数据是否一致用于发现数据漂移
pt-table-sync修复主从数据不一致常和一致性校验一起使用

也就是说,如果只看监控大盘,不做一致性校验,很多问题其实还没有真正闭环。

7.7.5 怎么理解“可视化管理”

严格来说,主从复制和读写分离的“可视化管理”通常不是一个单点产品,而是一套组合:

  1. PMMGrafana 看状态和趋势
  2. Orchestrator 之类的工具看复制拓扑和切换关系
  3. ProxySQLShardingSphere 这类中间层接管流量路由
  4. Percona Toolkit 做延迟与一致性校验

所以如果问题是:有没有某个第三方框架能把这套架构完全可视化管理起来?

更准确的答案通常是:有不少成熟工具,但大多是分层治理,通常需要组合使用,而不是只装一个工具就全部解决。

8. 分布式场景下特别容易遇到的问题

8.1 订单系统

订单系统里最典型的问题就是:

  1. 刚下单成功
  2. 用户马上刷新订单页
  3. 如果被路由到从库,就可能短时间看不到新订单

这类链路通常需要“写后读主”或更严格的一致性策略。

8.2 账户和余额系统

账户类系统对一致性更敏感。

如果余额刚更新,从库还没同步,就可能出现:

  1. 扣款成功
  2. 查询余额却没变化

所以账户类、支付类、库存核心校验链路,通常会比普通内容系统更保守,不会轻易让关键读走从库。

8.3 多服务协同的分布式系统

当多个服务都依赖同一个数据库集群时,读写分离的问题不再只是数据库本身,而是整个系统的协同问题:

  1. 哪个服务允许读旧数据
  2. 哪个服务必须强一致
  3. 哪些接口要兜底回主

这时候数据库策略就必须和业务语义一起设计,而不能只从 DBA 角度单独决定。

9. 读写分离前最好先问自己的问题

真正上读写分离之前,通常先问下面这些问题更靠谱:

  1. 当前瓶颈到底是不是读压力,而不是写压力?
  2. 有没有明确哪些查询允许短暂旧数据?
  3. 团队能不能治理主从延迟和故障切换?
  4. 访问层有没有统一的读写路由能力?
  5. 监控和告警能不能及时发现复制异常?

如果这些问题还没有答案,读写分离很可能会把“数据库压力问题”变成“一致性和运维问题”。

10. 一张总表:收益、挑战与常见方案

维度读写分离 / 主从扩展的收益带来的挑战常见解决思路
读能力分担读流量,提升整体吞吐从库压力不均负载均衡、按角色分流
主库压力缓解主库查询压力主库仍承担全部写入明确它解决的是读,不是写
可用性为故障切换提供基础切换流程复杂高可用治理、切换演练
一致性普通读可扩展复制延迟导致读旧数据强一致读回主、写后读主
架构演进比分库分表改造轻路由和排障更复杂统一路由层、监控治理

11. 总结

读写分离和主从扩展真正解决的是:

  1. 单主库读压力过大
  2. 系统整体读吞吐不足
  3. 主库成为查询热点

它最典型的代价则是:

  1. 复制延迟
  2. 强一致读变复杂
  3. 路由和故障切换复杂
  4. 排障和监控复杂

所以更稳妥的理解不是:加几个从库,数据库就扩容完成了。

而是:读写分离是把读能力扩出去,同时把一致性治理、路由治理和高可用治理一起带进系统。

如果系统主要卡在读,而且团队能兜住延迟和切换,这通常是一条很自然的演进路线;如果系统真正卡的是写能力,那它就不是最终答案,而只是架构继续演进前的一站。

基于 VitePress 构建的个人技术笔记。