完整代码已开源,可直接到Gitee仓库复制使用。
数据库扛不住的时候,出路无非两条。查询压力大,加从库,把读分出去;单表数据量压下来,拆表。这两件事手工做起来都不轻松,路由查询该走哪个库、写入该落到哪张表,这些判断写散在业务代码里,后面谁来接手都头疼。
ShardingSphere-JDBC 把这些活儿收到中间件这一层。它不是一个独立部署的代理服务,而是一个"加强版 JDBC 驱动",直接塞进应用的 JVM 里,SQL 出去之前先在本地完成解析、路由、改写、归并。业务侧的感受基本就是:换个依赖,加个配置文件,SQL 照写。
我拿一个订单业务做了个最小的可跑示例,把读写分离、按 user_id 分表、分布式主键、跨分片分页这几件事串成了一条线。
一、示例解决什么问题
先说说背景。一张订单表涨到几千万行以后,会同时遇到两种疼:一种是查询慢,尤其是列表和分页,扫描的数据量太大;另一种是写入和存储压力集中在单个实例上,扩不动。
示例用一套配置同时应对这两件事:
- 读写分离:写操作(INSERT、UPDATE、DELETE)全部打向主库,查询(SELECT)分给从库。读多写少的业务,加一个从库通常就能明显缓解。
- 分库分表:这里演示的是分表。按用户 ID 取模,把订单数据分散到两张物理表里,单表数据量直接减半,后续加表也只需要改一行配置。
- 分布式主键:分表之后自增主键会撞车,两张表各自从 1 开始,合起来必然重复。示例改用雪花算法生成全局唯一的 Long 型 ID。
- 跨分片分页:不带分片键的分页查询会被路由到所有分片,ShardingSphere 负责把各分片的结果归并、排序、裁剪。这块能跑通,但代价不小,后面专门讲。
二、技术栈与整体结构
版本这块先把底交清楚,ShardingSphere 的配置项在不同大版本之间改过好几轮,用错版本的写法会直接启动报错。
| 技术 | 版本 | 作用 |
|---|---|---|
| Java | 17 | 运行时 |
| Spring Boot | 3.5.16 | 应用框架 |
| ShardingSphere-JDBC | 5.5.2 | 分片与读写分离中间件 |
| MyBatis-Plus | 3.5.15 | ORM 与分页插件 |
| MySQL | 8.x | 主库、从库 |
| HikariCP | 随 Spring Boot | 连接池 |
| Lombok | - | 少写样板代码 |
项目结构不复杂,一个启动类,加配置、实体、Mapper、Service、Controller:
sharding-sphere-master-slave/
├── src/main/java/com/song/sharding/sphere/
│ ├── MSApplication.java # 启动类
│ ├── config/MybatisPlusConfig.java # 分页插件配置
│ ├── entity/Order.java # 订单实体
│ ├── mapper/OrderMapper.java # Mapper 接口
│ ├── service/OrderService.java # 业务逻辑
│ └── controller/OrderController.java # REST 接口
├── src/main/resources/
│ ├── application.yml # Spring Boot 配置
│ ├── sharding-ms.yaml # 读写分离 + 分片规则
└── pom.xml
请求进来之后的流向大致是这样:
客户端请求
↓
OrderController(REST 接口)
↓
OrderService(业务逻辑)
↓
OrderMapper(MyBatis-Plus)
↓
ShardingSphere-JDBC(解析 → 路由 → 改写 → 执行 → 归并)
↓
写操作 → 主库 write_ds 读操作 → 从库 read_ds_0
↓ ↓
t_order_0 | t_order_1(物理分片表)
三、先把环境铺好
需要准备的东西不多,但有几个地方容易忽略(来自Shardingsphere官网):
- Shardingsphere不处理主库和从库的数据同步
- Shardingsphere不处理主库和从库的数据同步延迟导致的数据不一致
- Shardingsphere不支持主库多写
- Shardingsphere不处理主从库间的事务一致性。主从模型中,事务中的数据读写均用主库。
这些要自己处理。
示例里的约定:
- 主库(写):
localhost:3308/order_db_0 - 从库(读):
localhost:3306/order_db_0
两个库里都要有一样的表结构:
CREATE DATABASE IF NOT EXISTS order_db_0;
USE order_db_0;
CREATE TABLE t_order_0 (
id BIGINT PRIMARY KEY COMMENT '订单ID(分布式ID)',
order_no VARCHAR(64) NOT NULL COMMENT '订单编号',
user_id BIGINT NOT NULL COMMENT '用户ID(分片键)',
goods_name VARCHAR(128) COMMENT '商品名称',
order_amount DECIMAL(10,2) COMMENT '订单金额',
create_time DATETIME COMMENT '创建时间',
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表0';
CREATE TABLE t_order_1 (
id BIGINT PRIMARY KEY COMMENT '订单ID(分布式ID)',
order_no VARCHAR(64) NOT NULL COMMENT '订单编号',
user_id BIGINT NOT NULL COMMENT '用户ID(分片键)',
goods_name VARCHAR(128) COMMENT '商品名称',
order_amount DECIMAL(10,2) COMMENT '订单金额',
create_time DATETIME COMMENT '创建时间',
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表1';
从库的表结构要和主库保持一致,生产上靠 MySQL 主从复制自动同步,本地演示也可以手动建一份。
四、配置逐项拆解
真正决定行为的是 sharding-ms.yaml。我拆得细一点,因为读写分离和分片混在一个文件里,缩进和层级写错是最常见的问题,5.x 的配置比 4.x 严格不少,错一个缩进直接启动失败。
4.1 数据源:谁写谁读
dataSources:
write_ds: # 写数据源(主库)
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://localhost:3308/order_db_0?serverTimezone=UTC&useSSL=false&useUnicode=true&characterEncoding=UTF-8
username: root
password: 你的密码
read_ds_0: # 读数据源(从库)
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://localhost:3306/order_db_0?serverTimezone=UTC&useSSL=false&useUnicode=true&characterEncoding=UTF-8
username: root
password: 你的密码
这里定义了物理上真实存在的两个连接池。write_ds 指向 3308 的主库,read_ds_0 指向 3306 的从库。名字是自己起的,但后面的规则里要引用,所以起名字时保持语义清楚。
4.2 运行模式
mode:
type: Standalone # 单机模式
repository:
type: JDBC # 用 JDBC 持久化元数据
单机模式表示配置只存在本地,没有接配置中心。多人协作或需要动态改规则的场景,可以换成 Cluster 模式接 ZooKeeper 或 Nacos。repository 决定元数据存在哪,JDBC 表示存在本地库表里。
4.3 读写分离规则
rules:
- !READWRITE_SPLITTING
dataSourceGroups:
readwrite_ds:
writeDataSourceName: write_ds
readDataSourceNames:
- read_ds_0
transactionalReadQueryStrategy: PRIMARY # 事务中强制读主库
loadBalancerName: random
loadBalancers:
random:
type: RANDOM # 随机负载均衡
它是读写分离的核心,逐项说:
dataSourceGroups是一个逻辑数据源分组,组名readwrite_ds可以随便起。注意 5.5.x 用dataSourceGroups包一层,早期 5.3 之前的写法是直接把dataSources放规则下面,两者不通用。writeDataSourceName指定写走到哪个物理数据源,这里就是write_ds。readDataSourceNames是个列表,可以放多个从库。只写一个就是单从库,加两个 ShardingSphere 会自动在它们之间做负载均衡。transactionalReadQueryStrategy: PRIMARY是个很关键的开关。它规定:只要当前处于事务中,事务内的 SELECT 也强制走主库。不设这一段,就会出现"刚写入马上查不到"的情况——写进了主库,查询却从从库读,而从库还没同步过来。loadBalancerName: random引用下面定义的负载均衡算法。
loadBalancers 里 type: RANDOM 表示随机挑一个从库。可选还有 ROUND_ROBIN(轮询)等策略。单从库时这一步其实没有实际影响。
4.4 分片规则
- !SHARDING
tables:
t_order:
actualDataNodes: readwrite_ds.t_order_${0..1} # 逻辑表映射到2张物理表
tableStrategy:
standard:
shardingColumn: user_id # 分片键
shardingAlgorithmName: t_order_inline
bindingTables:
- t_order
shardingAlgorithms:
t_order_inline:
type: INLINE
props:
algorithm-expression: t_order_${user_id % 2}
几个概念别混了:
- 逻辑表
t_order:业务代码里就写这个名字,像一张普通表一样用。物理上它并不存在。 actualDataNodes:逻辑表到底对应哪些物理表。readwrite_ds.t_order_${0..1}里的${0..1}是行表达式,展开后就是readwrite_ds.t_order_0和readwrite_ds.t_order_1。前面的readwrite_ds是数据源组的名字,不是物理数据源名——这一步容易写错,写成write_ds就找不到了。tableStrategy.standard:标准分片策略,针对单个分片键。shardingColumn: user_id指明分片键是谁。shardingAlgorithmName:指向下面具体的算法。bindingTables:绑定表配置。把有关联关系的表放在一起(比如订单表和订单明细表都按同一个键分片),避免关联查询时出现笛卡尔积。这里只有一张表,单列出来是留位置。shardingAlgorithms:算法本体。type: INLINE是行表达式分片算法,algorithm-expression: t_order_${user_id % 2}表示用user_id对 2 取模决定去哪个表。
路由规则:
user_id % 2 == 0,数据落到t_order_0user_id % 2 == 1,数据落到t_order_1
比如 user_id = 1000 进 t_order_0,user_id = 1001 进 t_order_1。行表达式底层是 Groovy 语法,除了取模,还能写范围 ${0..3}、枚举 ${['a','b']} 这类写法。
4.5 Spring Boot 侧的配置
application.yml 里两件事,一个是让应用走 ShardingSphere 驱动,一个是 MyBatis-Plus 的常规配置:
spring:
datasource:
driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
url: jdbc:shardingsphere:classpath:sharding-ms.yaml
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.song.sharding.sphere.mapper
configuration:
map-underscore-to-camel-case: true # 下划线转驼峰
数据源交给 ShardingSphere 驱动接管,URL 用 jdbc:shardingsphere: 前缀指向上面的规则文件。这样 Spring Boot 里就不会再单独配 MySQL 的 URL 和账号了,连接的控制权全部交给 ShardingSphere。
map-underscore-to-camel-case: true 负责把数据库的 user_id、create_time 这类下划线字段自动映射到 Java 的驼峰属性,省掉一堆 @Results 注解。
调试的时候可以把 SQL 日志打开,在sharding-ms.yaml里配置:
props:
sql-show: true
打开后控制台会打印实际执行的 SQL 和真实表名,验证路由是不是按预期走。
五、核心代码怎么写
配置负责"怎么路由",代码负责"用起来像普通单库一样"。我喜欢用 ShardingSphere-JDBC的地方:业务代码对分片基本无感知。
5.1 pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.song</groupId>
<artifactId>sharding-sphere-master-slave</artifactId>
<version>1.0-SNAPSHOT</version>
<name>sharding-sphere-master-slave</name>
<description>SpringBoot3+ShardingSphere-JDBC5读写分离实战</description>
<properties>
<java.version>17</java.version>
<shardingsphere.version>5.5.2</shardingsphere.version>
<mybatis-plus.version>3.5.15</mybatis-plus.version>
</properties>
<dependencies>
<!-- SpringBoot Web 基础依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- shardingsphere-jdbc -->
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc</artifactId>
<scope>compile</scope>
<version>${shardingsphere.version}</version>
</dependency>
<!-- MySQL8 官方驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- MyBatis-Plus 简化CRUD操作 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- 分页插件 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>${mybatis-plus.version}</version>
<scope>compile</scope>
</dependency>
<!-- Lombok 简化实体类代码 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
5.2 实体类和主键策略
@Data
@TableName("t_order")
public class Order {
@TableId(type = IdType.ASSIGN_ID) // 雪花算法生成分布式ID
private Long id;
private String orderNo;
private Long userId; // 分片键
private String goodsName;
private BigDecimal orderAmount;
private LocalDateTime createTime;
}
两个点值得说。
第一,@TableName("t_order") 写的是逻辑表名,不是 t_order_0 或 t_order_1。物理表名由 ShardingSphere 在改写阶段替换,业务代码看不见。
第二,@TableId(type = IdType.ASSIGN_ID) 指定用雪花算法生成主键。MyBatis-Plus 3.3.0 以后默认就是雪花算法,显式写出来是为了让意图清楚,也避免全局配置被改后行为跑偏。雪花算法生成的是 19 位的 Long 型 ID,趋势递增,全局唯一,正好补上分表之后自增主键失效这个洞。
5.3 Mapper、Service 和 Controller
Mapper , Service 简单到没什么可写的:
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
}
@Service
public class OrderService extends ServiceImpl<OrderMapper, Order> {
}
Controller 就是一层转发,把 URL 参数接进来:
@RestController
@RequestMapping("/order")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@GetMapping("/add") // 新增订单
public String add(@RequestParam Long userId) { /* ... */ }
@GetMapping("/listByUserId") // 携带分片键 → 精准路由
public Object listByUserId(@RequestParam("userId") Long userId) { /* ... */ }
@GetMapping("/list") // 不带分片键 → 广播查询
public Object list() { /* ... */ }
@GetMapping("/page")
public Object pageOrders(
@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
@RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize,
@RequestParam(value = "userId", required = false) Long userId) { /* ... */ }
@GetMapping("/batchAdd")
public String batchAddOrders() { /* ... */ }
}
5.4 分页插件别忘了配
MyBatis-Plus 的分页靠拦截器实现,不配它,selectPage 出来的还是全量数据:
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
六、一条 SQL 进来之后发生了什么
配置和代码都摆好了,但真正理解这套东西,最好跟着一条 SQL 走一遍。看日志记录:
2026-10-30T20:58:42.985+08:00 INFO 2856 --- [nio-8080-exec-3] ShardingSphere-SQL : Logic SQL: SELECT id,order_no,user_id,goods_name,order_amount,create_time FROM t_order WHERE (user_id = ?)
2026-10-30T20:58:42.985+08:00 INFO 2856 --- [nio-8080-exec-3] ShardingSphere-SQL : Actual SQL: read_ds_0 ::: SELECT id,order_no,user_id,goods_name,order_amount,create_time FROM t_order_1 WHERE (user_id = ?) ::: [1001]
它进到 ShardingSphere 之后,会依次经过五步:
解析。 SQL 先被解析成抽象语法树,提取出表名、操作类型、WHERE 条件里的字段和值。比如看到 WHERE user_id = 1001,就能拿到分片键 user_id 和它的值 1001。
路由。 这一步决定 SQL 去哪个(些)库、哪张表。判断逻辑是两条线并行看的:先看操作类型定读写,INSERT / UPDATE / DELETE 和带 FOR UPDATE、SELECT ... INTO 的强一致读,直接绑主库;普通 SELECT 按负载均衡算法挑一个从库。再看有没有分片键,带上分片键的查询能精确定位到某一张表,没带就会路由到所有分片。
改写。 逻辑表名在这里被替换成物理表名。t_order 加上 user_id = 1001 这个条件,改写后就变成 t_order_1。分页查询在这一步还会做特殊处理,去各个分片捞够数据再合并。
执行。 改写后的 SQL 交给 HikariCP 执行。多分片的查询是并发发出去的,不会一个分片接一个分片地等。
归并。 各分片返回结果后,框架在内存里做二次处理:排序、去重、分页裁剪、聚合计算。比如 ORDER BY create_time 跨了两个分片,就要把两边结果合起来重新排序,再取前 N 条。
把这几步和示例对应起来看会更清楚。调用 /order/listByUserId?userId=1001,带了分片键,路由能一步定位到 t_order_1,只在从库上查这一张表,效率最高。改成 /order/list,没有分片键,SQL 会被广播到 t_order_0 和 t_order_1,两边各查一半再合并——功能没问题,但代价成倍上升。
关于事务这块,有个容易忽略的细节。ShardingSphere 会监听 Spring 的事务上下文,只要当前线程被 @Transactional 包住,路由上下文就会锁死在主库。换句话说,同一个事务里哪怕写的是 SELECT,走的也是主库。这正是前面配置里 transactionalReadQueryStrategy: PRIMARY 在实际运行中发挥的作用,它从机制上堵住了"写完立刻读不到"这类脏读问题。这个设计对读写分离场景相当重要,没有它,凡是"下单后立刻查订单"这种流程都得额外加保险。
七、跑起来该看到什么
先把项目运行起来:
mvn clean package -DskipTests
java -jar target/sharding-sphere-master-slave-1.0-SNAPSHOT.jar
或者直接在 IDEA 里跑 MSApplication.java。服务起来后监听 8080,看日志:
........
........
. ____ _ __ _ _
/\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )___ | '_ | '_| | '_ / _` | \ \ \ \
\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |___, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.5.16)
.......
.......
2026-10-30T20:59:53.440+08:00 INFO 5044 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port 8080 (http)
2026-10-30T20:59:53.452+08:00 INFO 5044 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2026-10-30T20:59:53.452+08:00 INFO 5044 --- [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/10.1.55]
2026-10-30T20:59:53.498+08:00 INFO 5044 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2026-10-30T20:59:53.498+08:00 INFO 5044 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 800 ms
_ _ |_ _ _|_. ___ _ | _
| | |/|_)(_| | |_\ |_)||_|_\
/ |
3.5.15
2026-10-30T20:59:54.085+08:00 INFO 5044 --- [ main] m.e.s.MybatisPlusApplicationContextAware : Register ApplicationContext instances org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext@74cadd41
2026-10-30T20:59:54.164+08:00 INFO 5044 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
2026-10-30T20:59:54.171+08:00 INFO 5044 --- [ main] com.song.sharding.sphere.MSApplication : Started MSApplication in 2.131 seconds (process running for 2.862)
7.1 验证分片是不是真的生效
连加两笔订单,让分片键分别是偶数和奇数:
# user_id=1000,1000 % 2 = 0,数据落到 t_order_0
curl "http://localhost:8080/order/add?userId=1000"
日志:
2026-10-30T21:01:27.987+08:00 INFO 5044 --- [nio-8080-exec-1] ShardingSphere-SQL : Logic SQL: INSERT INTO t_order ( id, order_no, user_id, goods_name, order_amount, create_time ) VALUES ( ?, ?, ?, ?, ?, ? )
2026-10-30T21:01:27.987+08:00 INFO 5044 --- [nio-8080-exec-1] ShardingSphere-SQL : Actual SQL: write_ds ::: INSERT INTO t_order_0 ( id, order_no, user_id, goods_name, order_amount, create_time ) VALUES (?, ?, ?, ?, ?, ?) ::: [2116153499200319490, b4ca58b8-3b29-4b34-858c-2929d958d756, 1000, 实战测试商品, 99.99, 2026-10-30T21:01:25.049747]
# user_id=1001,1001 % 2 = 1,数据落到 t_order_1
curl "http://localhost:8080/order/add?userId=1001"
日志:
2026-10-30T21:02:31.629+08:00 INFO 5044 --- [nio-8080-exec-2] ShardingSphere-SQL : Logic SQL: INSERT INTO t_order ( id, order_no, user_id, goods_name, order_amount, create_time ) VALUES ( ?, ?, ?, ?, ?, ? )
2026-10-30T21:02:31.629+08:00 INFO 5044 --- [nio-8080-exec-2] ShardingSphere-SQL : Actual SQL: write_ds ::: INSERT INTO t_order_1 ( id, order_no, user_id, goods_name, order_amount, create_time ) VALUES (?, ?, ?, ?, ?, ?) ::: [2116153778213810178, 6eb2ec21-1431-468c-981a-b93e973d1a86, 1001, 实战测试商品, 99.99, 2026-10-30T21:02:31.624313700]
然后到数据库里对一下账:
SELECT * FROM t_order_0; -- 应该只有 user_id 为偶数的记录
SELECT * FROM t_order_1; -- 应该只有 user_id 为奇数的记录
两张表各有各的数据,说明取模分片按预期生效了。
7.2 验证读写分离
把 SQL 日志打开(就是前面那行 props: sql-show: true 配置),连续调几次查询和写入接口,盯控制台输出的 SQL。写操作的日志打在主库连接上,查询的日志打在从库连接上,两者分开出现,说明读写已经分到了不同的数据源。
2026-10-30T21:03:40.386+08:00 INFO 5044 --- [nio-8080-exec-3] ShardingSphere-SQL : Logic SQL: SELECT COUNT(*) AS total FROM t_order WHERE (user_id = ?)
2026-10-30T21:03:40.386+08:00 INFO 5044 --- [nio-8080-exec-3] ShardingSphere-SQL : Actual SQL: read_ds_0 ::: SELECT COUNT(*) AS total FROM t_order_0 WHERE (user_id = ?) ::: [1000]
2026-10-30T21:03:40.445+08:00 INFO 5044 --- [nio-8080-exec-3] ShardingSphere-SQL : Logic SQL: SELECT id,order_no,user_id,goods_name,order_amount,create_time FROM t_order WHERE (user_id = ?) LIMIT ?
2026-10-30T21:03:40.445+08:00 INFO 5044 --- [nio-8080-exec-3] ShardingSphere-SQL : Actual SQL: read_ds_0 ::: SELECT id,order_no,user_id,goods_name,order_amount,create_time FROM t_order_0 WHERE (user_id = ?) LIMIT ? ::: [1000, 10]
要观察得更直观,可以在从库上故意停掉主从同步,再调一次写入后立刻查询——如果查不到刚写的数据,说明读确实走了从库;而如果在事务里做同样的操作又能查到,就印证了事务内强制读主库这条规则在起作用。
7.3 批量数据
curl "http://localhost:8080/order/batchAdd"
这个接口一次生成 1000 条订单,user_id 从 1 递增。因为分片键连续取值,取模之后数据会均匀分到两张表里,每张大约 500 条。跑完再去 SELECT COUNT(*) 两张表,能直接看出数据分布是不是均衡——这也是验证分片键选得好不好的一个土办法,如果业务里分片键分布严重倾斜,就该考虑换个算法了。
7.4 分页
curl "http://localhost:8080/order/page?pageNum=1&pageSize=10&userId=1001"
带上 userId 时,查询能定位到单个分片,分页由该分片自己完成,速度快。把 userId 去掉再调一次,就会变成跨分片分页:两个分片各取一部分,再在内存里归并裁剪。数据量小的时候感觉不出来,量一大就能明显看到两者之间的差距。
八、几个真会踩的坑
都是这套配置里最容易出问题的地方。
物理表必须手工建。 ShardingSphere 只负责路由,不会帮你 CREATE TABLE。actualDataNodes 里写了 t_order_0 和 t_order_1,这两张表就得先在库里建好,缺一张,跑到那个分片就报错。
主从同步是前提。 读写分离生效的前提是从库的数据和主库一致,这个一致性靠 MySQL 主从复制来保证。示例本身不管同步,同步没配好,读出来的就是旧数据。刚写完就查不到的现象,八成是同步延迟。
分片键写错的代价很大。 不写 user_id 的查询会路由到所有分片。列表、统计、后台导出这类查询最容易犯这个毛病。带分片键的查询只在单表上跑,不带分片键的查询要在全部分片上跑一遍再合并,性能差距是数量级的。
跨分片分页要小心。 分页查询在没有分片键时特别费劲,ShardingSphere 得从每个分片都捞数据,再在内存里排序裁剪。数据量大的时候,页码越往后翻越慢。这类查询要么想办法带上分片键,要么干脆另建一张专门用于展示的宽表。
分布式 ID 不能省。 分表之后自增主键必然重复,实体上那个 IdType.ASSIGN_ID 不是装饰,去掉就是主键冲突。
跨分片事务不在本示例范围内。 单分片内的事务 ShardingSphere 能正常处理,一旦一次写操作涉及多个分片,就需要 XA 这类分布式事务的支持,示例没有涉及,实际业务里得单独设计。
九、常见问题
问:怎么确认读写分离到底有没有生效? 打开 SQL 日志,观察控制台打印的 SQL。读和写会打在不同的数据源连接上,看日志。
2026-10-30T21:01:27.987+08:00 INFO 5044 --- [nio-8080-exec-1] ShardingSphere-SQL : Logic SQL: INSERT INTO t_order ( id, order_no, user_id, goods_name, order_amount, create_time ) VALUES ( ?, ?, ?, ?, ?, ? )
2026-10-30T21:01:27.987+08:00 INFO 5044 --- [nio-8080-exec-1] ShardingSphere-SQL : Actual SQL: write_ds ::: INSERT INTO t_order_0 ( id, order_no, user_id, goods_name, order_amount, create_time ) VALUES (?, ?, ?, ?, ?, ?) ::: [2116153499200319490, b4ca58b8-3b29-4b34-858c-2929d958d756, 1000, 实战测试商品, 99.99, 2026-10-30T21:01:25.049747]
.......
2026-10-30T21:03:40.386+08:00 INFO 5044 --- [nio-8080-exec-3] ShardingSphere-SQL : Logic SQL: SELECT COUNT(*) AS total FROM t_order WHERE (user_id = ?)
2026-10-30T21:03:40.386+08:00 INFO 5044 --- [nio-8080-exec-3] ShardingSphere-SQL : Actual SQL: read_ds_0 ::: SELECT COUNT(*) AS total FROM t_order_0 WHERE (user_id = ?) ::: [1000]
2026-10-30T21:03:40.445+08:00 INFO 5044 --- [nio-8080-exec-3] ShardingSphere-SQL : Logic SQL: SELECT id,order_no,user_id,goods_name,order_amount,create_time FROM t_order WHERE (user_id = ?) LIMIT ?
2026-10-30T21:03:40.445+08:00 INFO 5044 --- [nio-8080-exec-3] ShardingSphere-SQL : Actual SQL: read_ds_0 ::: SELECT id,order_no,user_id,goods_name,order_amount,create_time FROM t_order_0 WHERE (user_id = ?) LIMIT ? ::: [1000, 10]
问:为什么插入之后马上查不到? 大概率是主从同步延迟。事务内查询会强制走主库;或者开启并行复制(MTS)、排查从库负载和大事务。
问:想加更多分片怎么办? 三步。先在两个库里把新表建出来,比如 t_order_2、t_order_3;再把配置里的 actualDataNodes 改成 readwrite_ds.t_order_${0..3};最后把分片算法改成 t_order_${user_id % 4}。必须同时做存量数据迁移/再均衡。
问:多个从库怎么加? 在 readDataSourceNames 下面继续往下排就行:
readDataSourceNames:
- read_ds_0
- read_ds_1
- read_ds_2
加完之后,loadBalancers 里配的随机策略就会在三个从库之间分发请求。
十、源代码
完整代码已开源:sharding-sphere-master-slave(Gitee)
转载请注明作者与原文链接