读写分离实现:中间件与客户端路由
读写分离实现:从原理到生产落地的全面指南
在现代高并发互联网应用中,数据库往往是系统瓶颈所在。读写分离是一种经典且行之有效的数据库扩展方案,通过将读操作与写操作分流到不同的数据库实例,显著提升系统吞吐量和可用性。本教程将从原理出发,手把手教你实现读写分离的两种主流模式:中间件代理与客户端路由,并深入探讨生产环境中的关键细节。
为什么需要读写分离
绝大多数业务场景呈现“读多写少”的特征(如 80% 读、20% 写)。单一数据库实例在处理大量并发读时,连接数、CPU、内存、IO 都会成为瓶颈,同时写操作引发的锁竞争会进一步拖慢读请求。
读写分离通过主从复制架构,使以下目标成为可能:
- 水平扩展读能力:增加多个只读从库分担读请求。
- 提升写入性能:主库专注处理写入,减少读干扰。
- 数据冗余与高可用:从库可作为主库故障时的切换候选。
- 分析查询隔离:将复杂的报表查询导向专用从库,避免影响在线业务。
读写分离的核心前提:主从复制
在实施读写分离前,必须保证主库与从库数据一致(或最终一致)。这是通过 MySQL 主从复制(或 PostgreSQL 流复制等)来实现的。
┌─────────────┐ Binlog/Stream ┌─────────────┐
│ Master │ ──────────────────── │ Slave 1 │
│ (写入) │ 异步/半同步 │ (只读) │
└─────────────┘ └─────────────┘
│ ┌─────────────┐
└──────────────────────────── │ Slave 2 │
复制延迟 │ (只读) │
└─────────────┘
关键点:
- 复制存在延迟(通常为毫秒级),意味着刚写入的数据可能无法立即在从库读取。
- 读写分离方案必须处理“读后写”一致性(Read-Your-Writes)问题。
实现模式一:中间件代理模式
中间件代理位于应用与数据库之间,对应用完全透明。应用只需连接代理的单个入口,由代理根据 SQL 类型(读/写)或用户配置规则,自动转发到主库或从库。
常见代理
- MySQL Router:MySQL 官方轻量级中间件,适用于 InnoDB Cluster 或 InnoDB ReplicaSet。
- ProxySQL:功能强大的 SQL 感知代理,支持高可用、查询规则、缓存、连接池等。
- ShardingSphere-Proxy:Apache 顶级项目,可独立部署,支持读写分离、数据分片等。
- MaxScale:MariaDB 出品,协议感知智能代理。
架构示意
App ──► [ 中间件代理 : 3306 ] ──┬──► 主库 (写)
└──► 从库1 (读)
└──► 从库2 (读)
用户只需将数据库地址配置为代理的 IP 和端口,无需修改代码(或仅需极少量修改)。
ProxySQL 实现详解
步骤 1:安装与启动 ProxySQL
# Debian/Ubuntu 环境示例
wget https://github.com/sysown/proxysql/releases/download/v2.5.5/proxysql_2.5.5-debian11_amd64.deb
sudo dpkg -i proxysql_2.5.5-debian11_amd64.deb
sudo systemctl start proxysql
默认监听端口:6033(管理端口)、6032(SQL 流量端口)。
步骤 2:配置后端服务器
登录管理控制台:
mysql -u admin -padmin -h 127.0.0.1 -P6032
添加主库与从库服务器定义:
INSERT INTO mysql_servers(hostgroup_id, hostname, port) VALUES (10, '192.168.1.10', 3306); -- 写组
INSERT INTO mysql_servers(hostgroup_id, hostname, port) VALUES (20, '192.168.1.11', 3306); -- 读组
INSERT INTO mysql_servers(hostgroup_id, hostname, port) VALUES (20, '192.168.1.12', 3306);
步骤 3:配置监控用户与 MySQL 连接凭据
在主库上创建监控用户(用于 ProxySQL 检查状态):
CREATE USER 'monitor'@'%' IDENTIFIED BY 'monitor_pass';
GRANT REPLICATION CLIENT ON *.* TO 'monitor'@'%';
在 ProxySQL 中设置监控及业务用户:
-- 监控用户
UPDATE global_variables SET variable_value='monitor' WHERE variable_name='mysql-monitor_username';
UPDATE global_variables SET variable_value='monitor_pass' WHERE variable_name='mysql-monitor_password';
-- 业务用户 (应用使用)
INSERT INTO mysql_users(username, password, default_hostgroup) VALUES ('app_user', 'app_pass', 10);
-- 加载至运行时并持久化
LOAD MYSQL SERVERS TO RUNTIME; SAVE MYSQL SERVERS TO DISK;
LOAD MYSQL USERS TO RUNTIME; SAVE MYSQL USERS TO DISK;
LOAD MYSQL VARIABLES TO RUNTIME; SAVE MYSQL VARIABLES TO DISK;
步骤 4:定义读写分离规则
路由规则基于正则表达式匹配 SQL。^SELECT 开头的语句被发送到读组(hostgroup 20),其他发送到写组(10)。
INSERT INTO mysql_query_rules (rule_id, active, match_pattern, destination_hostgroup, apply)
VALUES (1, 1, '^SELECT.*', 20, 1);
INSERT INTO mysql_query_rules (rule_id, active, match_digest, destination_hostgroup, apply)
VALUES (2, 1, '.', 10, 1); -- 最后一条兜底路由到写组
加载规则:
LOAD MYSQL QUERY RULES TO RUNTIME; SAVE MYSQL QUERY RULES TO DISK;
现在应用连接 127.0.0.1:6033 使用用户 app_user,读写即可自动分离。
步骤 5:处理复制延迟与强一致性读
对于刚写入后需要立刻读取最新数据的场景,可以在业务 SQL 中强制走主库,ProxySQL 支持通过注释 /* hostgroup=10 */ 或设置查询规则对特定表 SELECT ... FOR UPDATE 路由到写组。
-- 代码中可这样写
INSERT INTO orders (user_id, amount) VALUES (1001, 99);
-- 立即使用 hint 读主
SELECT /* hostgroup=10 */ * FROM orders WHERE user_id = 1001;
或通过规则将 SELECT ... FOR UPDATE 固定到主机组 10:
INSERT INTO mysql_query_rules (rule_id, active, match_pattern, destination_hostgroup, apply)
VALUES (3, 1, '.*FOR UPDATE', 10, 1);
中间件模式的优缺点
优点:
- 对应用无侵入,支持异构语言栈。
- 集中管理负载均衡、连接池、查询缓存。
- 支持透明故障切换。
缺点:
- 引入额外网络跳转,增加延迟。
- 单点风险(需部署多实例 + Keepalived 等)。
- 复杂查询解析对 CPU 有一定消耗。
- 环境部署与运维复杂度上升。
实现模式二:客户端路由模式
客户端路由通过在应用层引入 Smart Driver 或轻量级路由模块,由应用自身感知多个数据源,依据规则将读操作分发到从库,写操作定点发送到主库。
常见框架方案
- Java:Spring 路由数据源 + AbstractRoutingDataSource
- Java:ShardingSphere-JDBC
- Go:gorm 的多源/或自定义连接池切换
- Python:SQLAlchemy 的 bind 绑定或路由
Spring Boot 抽象路由数据源实现
原理:继承 AbstractRoutingDataSource,覆写 determineCurrentLookupKey() 方法,根据当前线程事务上下文返回数据源 key(如 “master” 或 “slave”)。
步骤 1:定义多数据源配置
spring:
datasource:
master:
jdbc-url: jdbc:mysql://192.168.1.10:3306/mydb
username: root
password: masterpass
slave1:
jdbc-url: jdbc:mysql://192.168.1.11:3306/mydb
username: root
password: slavepass
slave2:
jdbc-url: jdbc:mysql://192.168.1.12:3306/mydb
username: root
password: slavepass
步骤 2:创建 DynamicDataSource 及路由 Key 持有者
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDbType();
}
}
public class DataSourceContextHolder {
private static final ThreadLocal<String> contextHolder = new ThreadLocal<>();
public static void setDbType(String dbType) { contextHolder.set(dbType); }
public static String getDbType() { return contextHolder.get(); }
public static void clearDbType() { contextHolder.remove(); }
}
步骤 3:装配数据源
@Configuration
public class DataSourceConfig {
@Bean
@Primary
public DataSource routingDataSource() {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("master", masterDataSource());
targetDataSources.put("slave1", slave1DataSource());
// 可添加多个从库并放到负载均衡列表
RoutingDataSource routing = new RoutingDataSource();
routing.setDefaultTargetDataSource(masterDataSource());
routing.setTargetDataSources(targetDataSources);
return routing;
}
// 定义 masterDataSource, slave1DataSource... 略
}
步骤 4:AOP 拦截实现读写切换
定义自定义注解 @ReadOnly,在 service 方法上标记,利用 AOP 环绕通知设置数据源类型。
@Aspect
@Component
public class DataSourceAspect {
@Around("@annotation(ReadOnly)")
public Object around(ProceedingJoinPoint point) throws Throwable {
try {
// 若为 Slave 负载,可实现简单的随机从库选择
String slaveKey = loadBalanceSlave(); // “slave1” 或 “slave2”
DataSourceContextHolder.setDbType(slaveKey);
return point.proceed();
} finally {
DataSourceContextHolder.clearDbType();
}
}
}
使用示例:
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@ReadOnly
public Order getOrderById(Long id) {
return orderMapper.selectById(id); // 走从库
}
public void createOrder(Order order) {
orderMapper.insert(order); // 默认走主库
}
}
ShardingSphere-JDBC 实现(Java 生态推荐)
ShardingSphere-JDBC 在客户端层以 JDBC 驱动形式提供读写分离、数据分片等强大功能,无需 AOP 即可自动分离。
配置读写分离规则 (application.yml):
spring:
shardingsphere:
datasource:
names: master, slave0, slave1
master:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.1.10:3306/mydb
username: root
password: pass
slave0:
# ... similar
slave1:
# ... similar
rules:
readwrite-splitting:
data-sources:
myds:
type: Static
props:
write-data-source-name: master
read-data-source-names: slave0, slave1
load-balancer-name: round_robin
load-balancers:
round_robin:
type: ROUND_ROBIN
配置完成后,所有通过 ShardingSphere 数据源执行的 SQL 将自动路由:写走 master,读在 slave0、slave1 轮询。
强制读主可通过 HintManager:
try (HintManager hintManager = HintManager.getInstance()) {
hintManager.setWriteRouteOnly(); // 强制主库
orderMapper.selectById(id);
}
客户端路由模式的优缺点
优点:
- 性能高,无代理层网络开销。
- 天然无单点(随应用实例扩展)。
- 编程方式灵活,可根据业务定义细粒度路由。
缺点:
- 应用与数据库路由耦合,语言依赖性强。
- 多语言栈需各自实现,维护成本高。
- 连接数与从库管理分散,监控需额外采集。
- 依赖应用优雅处理故障切换。
生产实践关键与陷阱
1. 复制延迟与一致性
解决方案:
- 强一致性场景:写操作后的即时读必须走主库(使用 hint 或代码强制)。
- 会话一致性:缓存写入主库的 Key,在指定时间窗口内(如 3s)该 Key 的读都走主库。
- 半同步复制:降低主从延迟极端情况,但会轻微影响写入性能。
- 业务容忍:对于非实时场景(如商品列表、历史订单),允许短暂延迟。
2. 从库健康检查与自动剔除
中间件(如 ProxySQL)通过定期执行监控查询(如 select @@read_only)判断从库是否在线、复制是否正常。配置 mysql_replication_hostgroups 表可实现自动化:当从库延迟过大或被设置 read_only=OFF 时自动重新分类。
客户端方案需自定义连接池健康检查,或使用数据库驱动自带的负载均衡(如 MySQL Connector/J 的 loadbalance URL 配合故障转移配置)。
示例(连接字符串负载):
jdbc:mysql:loadbalance://host1:3306,host2:3306/db?loadBalanceBlacklistTimeout=5000
3. 负载均衡策略
- 轮询 (Round Robin):最简单,适用于配置相近的从库。
- 权重分配:根据机器配置分配不同权重。
- 响应时间 (Shortest Response Time):ProxySQL 等支持,更智能。
4. 主库切换
读写分离环境中,主库故障需要将从库提升为新主库,并同步调整路由配置。可结合 Orchestrator、MHA、InnoDB Cluster 等工具实现自动故障转移,中间件层(如 ProxySQL)可通过脚本监听并重新配置写主机组。
5. 监控指标
必须监控:
- 主从复制延迟秒数(
Seconds_Behind_Master)。 - 每个从库的查询 QPS、连接数、错误率。
- 代理或应用端读/写路由比例是否符合预期。
中间件模式 vs 客户端路由:选型指南
| 维度 | 代理中间件 | 客户端路由 |
|---|---|---|
| 开发语言 | 任意语言 | 依赖特定语言生态 (Java、Go 等) |
| 性能开销 | 额外一跳网络延迟,集中处理瓶颈 | 无额外网络开销,延迟低 |
| 运维复杂度 | 需维护高可用代理集群 | 随应用实例扩展,运维轻 |
| 功能灵活性 | 统一管理连接池、缓存、查询改写 | 业务定制更灵活,可实现复杂路由 |
| 容灾切换 | 在代理层统一处理,应用无感 | 需在客户端实现重试/切换逻辑 |
| 典型产品 | ProxySQL、ShardingSphere-Proxy | ShardingSphere-JDBC、Spring Routing |
建议:
- 中小规模、语言单一(如全 Java 栈)团队,客户端路由(尤其 ShardingSphere-JDBC)性价比高。
- 多语言混合、期望零代码改造、需集中控管的异构环境,代理中间件更合适。
总结
读写分离是数据库扩展的第一步,实现模式虽有不同,但核心原则一致:透明分流、延迟处理、故障感知。选择中间件还是客户端路由,需结合团队技术栈、性能要求与运维能力。无论哪种方案,在生产环境中都需密切关注主从延迟与切换策略,方能在提升并发能力的同时保障数据一致性。
下一步可继续学习:高可用架构下的读写分离、分库分表联合使用与NewSQL 数据库的替代方案。