Spring Boot 2.2.7+Mybatis Plus+Druid+shardingsphere 整合
没啥好说的,领导叫我水平分表,将业务订单数据水平分表,减少一个表中的数据量,加快数据查询。
除了shardingsphere外,其他3项都是项目中已经整合了的技术。写这篇文章主要是为了记录shardingsphere整合其他3项技术的过程。
shardingsphere定位为轻量级Java框架,在Java的JDBC层提供的额外服务。 它使用客户端直连数据库,以jar包形式提供服务,无需额外部署和依赖,可理解为增强版的JDBC驱动,完全兼容JDBC和各种ORM框架。使用上来说,在你的客户端通过解析你的sql来判断你访问的某个表x是不是已经分成了形如x_1、x_2、x_3这种命名规则的表。
相关网站
任务说明
这里只列出关键信息:
- 需要拆分的表原名为task_info,拆分后的表名为task_info_202001、task_info_202002、task_info_202003……task_info_202012。可见,分开后的表分别保存原表不同月份的数据。
- task_info有对应的订单结果表task_result,同样需要按月拆分。task_result中task_id_关联task_info的id_,但我没在数据库里做外键关联。
- 为了将这几张表在逻辑上认作一张表,那它们之间必然有相同列。可以是单列,比如说主键那一列。也可以是多列,具体看需求。其他列可相同可不同,但是为了一致性,最好定为一样的。
- 分片的那一列我定为主键id,注解有32位。其中前8位是随机的,中间18位取用shardingsphere的SnowflakeShardingKeyGenerator算出来的值,最后6位为年月,如“202005”,可以通过最后这6位来定位是哪张表。
SQL举例
CREATE TABLE `task_info_202001` (
`id_` char(32) NOT NULL COMMENT '无业务意义主键',
`content_` json NOT NULL COMMENT '订单内容',
`create_date_` datetime(6) DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间',
`update_date_` datetime(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (`id_`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='任务表';
CREATE TABLE `task_result_202001` (
`id_` char(32) NOT NULL,
`task_id_` char(32) DEFAULT NULL COMMENT '任务ID',
`error_code_` varchar(8) DEFAULT NULL COMMENT '业务错误码',
`message_` text COMMENT '响应消息',
`result_` json DEFAULT NULL COMMENT '业务处理结果(数据)',
`create_date_` datetime(6) DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间',
PRIMARY KEY (`id_`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='任务处理结果表';
其他月份更改表名即可。
依赖坐标
springboot 选2.2.7的版本,2.3.0还存在一些问题。
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.7.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
其他的一些选择:
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.2</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.shardingsphere/sharding-core-common -->
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>sharding-jdbc-spring-boot-starter</artifactId>
<version>4.1.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.alibaba/druid-spring-boot-starter -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.22</version>
</dependency>
YML配置
spring:
shardingsphere:
datasource:
names: ds0
ds0:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://****
username: *******
password: *******
filters: stat
maxActive: 20
initialSize: 1
maxWait: 1000
minIdle: 1
timeBetweenEvictionRunsMillis: 60000
minEvictableIdleTimeMillis: 300000
validationQuery: select 1 from dual
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
poolPreparedStatements: true
maxOpenPreparedStatements: 20
sharding:
tables:
task_info:
#由数据源名 + 表名组成,以小数点分隔。多个表以逗号分隔,支持inline表达式。缺省表示使用已知数据源与逻辑表名称生成数据节点,用于广播表(即每个库中都需要一个同样的表用于关联查询,多为字典表)或只分库不分表且所有库的表结构完全一致的情况
actual-data-nodes: ds0.task_info_$->{2020}$->{['01','02','03','04','05','06','07','08','09','10','11','12']}
#用于单分片键的标准分片场景
table-strategy:
complex:
sharding-columns: id_,create_date_
#复合分片算法类名称。该类需实现ComplexKeysShardingAlgorithm接口并提供无参数的构造器
algorithmClassName: com.example.privatebatistest.config.MonthTableComplexShardingAlgorithm
task_result:
actual-data-nodes: ds0.task_result_$->{2020}$->{['01','02','03','04','05','06','07','08','09','10','11','12']}
table-strategy:
standard:
sharding-column: task_id_
precise-algorithm-class-name: com.example.mybatistest.config.MonthTableShardingAlgorithm
binding-tables:
- task_info,task_result
default-data-source-name: ds0
defaultTableStrategy: #默认表分片策略,同分库策略
none:
props:
sql:
show: true
executor:
size: 8
server:
port: 10489
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
cache-enabled: true
logging:
level:
root: info
ShardingSphere-SQL: debug
这里注意几点问题:
- names: ds0必须要配,多个数据源用逗号分隔。
- binding-tables的配置是为了同步task_info和task_result的变化,即限制如
task_info_202001 left join task_result_202001
同月份关联查询,而不出现如task_info_202001 left join task_result_202002
这种没有意义的关联查询。- table-strategy只能5选1,不能混合多种配置。否则初始化报错。
关于这些参数都有什么意义,可以参考这里
我将数据分片部分摘抄下来,防止这个网址失效:
dataSources: #数据源配置,可配置多个data_source_name
<data_source_name>: #<!!数据库连接池实现类> `!!`表示实例化该类
driverClassName: #数据库驱动类名
url: #数据库url连接
username: #数据库用户名
password: #数据库密码
# ... 数据库连接池的其它属性
shardingRule:
tables: #数据分片规则配置,可配置多个logic_table_name
<logic_table_name>: #逻辑表名称
actualDataNodes: #由数据源名 + 表名组成,以小数点分隔。多个表以逗号分隔,支持inline表达式。缺省表示使用已知数据源与逻辑表名称生成数据节点,用于广播表(即每个库中都需要一个同样的表用于关联查询,多为字典表)或只分库不分表且所有库的表结构完全一致的情况
databaseStrategy: #分库策略,缺省表示使用默认分库策略,以下的分片策略只能选其一
standard: #用于单分片键的标准分片场景
shardingColumn: #分片列名称
preciseAlgorithmClassName: #精确分片算法类名称,用于=和IN。。该类需实现PreciseShardingAlgorithm接口并提供无参数的构造器
rangeAlgorithmClassName: #范围分片算法类名称,用于BETWEEN,可选。。该类需实现RangeShardingAlgorithm接口并提供无参数的构造器
complex: #用于多分片键的复合分片场景
shardingColumns: #分片列名称,多个列以逗号分隔
algorithmClassName: #复合分片算法类名称。该类需实现ComplexKeysShardingAlgorithm接口并提供无参数的构造器
inline: #行表达式分片策略
shardingColumn: #分片列名称
algorithmInlineExpression: #分片算法行表达式,需符合groovy语法
hint: #Hint分片策略
algorithmClassName: #Hint分片算法类名称。该类需实现HintShardingAlgorithm接口并提供无参数的构造器
none: #不分片
tableStrategy: #分表策略,同分库策略
keyGenerator:
column: #自增列名称,缺省表示不使用自增主键生成器
type: #自增列值生成器类型,缺省表示使用默认自增列值生成器。可使用用户自定义的列值生成器或选择内置类型:SNOWFLAKE/UUID
props: #属性配置, 注意:使用SNOWFLAKE算法,需要配置worker.id与max.tolerate.time.difference.milliseconds属性。若使用此算法生成值作分片值,建议配置max.vibration.offset属性
<property-name>: 属性名称
bindingTables: #绑定表规则列表
- <logic_table_name1, logic_table_name2, ...>
- <logic_table_name3, logic_table_name4, ...>
- <logic_table_name_x, logic_table_name_y, ...>
broadcastTables: #广播表规则列表
- table_name1
- table_name2
- table_name_x
defaultDataSourceName: #未配置分片规则的表将通过默认数据源定位
defaultDatabaseStrategy: #默认数据库分片策略,同分库策略
defaultTableStrategy: #默认表分片策略,同分库策略
defaultKeyGenerator: #默认的主键生成算法 如果没有设置,默认为SNOWFLAKE算法
type: #默认自增列值生成器类型,缺省将使用org.apache.shardingsphere.core.keygen.generator.impl.SnowflakeKeyGenerator。可使用用户自定义的列值生成器或选择内置类型:SNOWFLAKE/UUID
props:
<property-name>: #自增列值生成器属性配置, 比如SNOWFLAKE算法的worker.id与max.tolerate.time.difference.milliseconds
masterSlaveRules: #读写分离规则,详见读写分离部分
<data_source_name>: #数据源名称,需要与真实数据源匹配,可配置多个data_source_name
masterDataSourceName: #详见读写分离部分
slaveDataSourceNames: #详见读写分离部分
loadBalanceAlgorithmType: #详见读写分离部分
props: #读写分离负载算法的属性配置
<property-name>: #属性值
props: #属性配置
sql.show: #是否开启SQL显示,默认值: false
executor.size: #工作线程数量,默认值: CPU核数
max.connections.size.per.query: # 每个查询可以打开的最大连接数量,默认为1
check.table.metadata.enabled: #是否在启动时检查分表元数据一致性,默认值: false
Java关键代码
-
首先在
@SpringBootApplication
存在的主类上配置@SpringBootApplication(exclude = { DruidDataSourceAutoConfigure.class, DataSourceAutoConfiguration.class, }) @MapperScan("com.example.mybatistest.mapper") public class MybatistestApplication { public static void main(String[] args) { SpringApplication.run(MybatistestApplication.class, args); } }
解释一下, 排除DruidDataSourceAutoConfigure.class是为了防止出现如下错误:
*************************** APPLICATION FAILED TO START *************************** Description: Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured. Reason: Failed to determine a suitable driver class Action: Consider the following: If you want an embedded database (H2, HSQL or Derby), please put it on the classpath. If you have database settings to be loaded from a particular profile you may need to activate it (no profiles are currently active).
因为DruidDataSourceAutoConfigure默认用了SpringBoot顶级的配置,如下:
spring: datasource: url:
如果不存在,Druid会启动不成功。我们需要排除它。不过别担心,shardingsphere会帮助我们完成Druid的生成,在前面的配置中可见。
另外一个MapperScan是用来扫描Mybatis的mapper,这是MyBatis的常规玩法。 -
配置中我们用到了一个算法,用来对task_result精确分片:
import org.apache.shardingsphere.api.sharding.standard.PreciseShardingAlgorithm; import org.apache.shardingsphere.api.sharding.standard.PreciseShardingValue; import java.util.Collection; public class MonthTableShardingAlgorithm implements PreciseShardingAlgorithm<String> { @Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<String> shardingValue) { // 根据配置的分表规则生成目标表的后缀 String tableExt = shardingValue.getValue().substring(26); for (String availableTableName : availableTargetNames) { if (availableTableName.endsWith(tableExt)) { // 匹配成功返回正确表名 return availableTableName; } } return null; } }
以上代码利用查询时的shardingColumn的取值,判断正确的表名,返回给上层做决策。
-
另外一个复杂点的复合分片算法,是为了更好地处理 task_info 表的 id_ 和 create_date_ 字段:
import com.google.common.collect.Range; import org.apache.shardingsphere.api.sharding.complex.ComplexKeysShardingAlgorithm; import org.apache.shardingsphere.api.sharding.complex.ComplexKeysShardingValue; import org.springframework.util.ObjectUtils; import java.sql.Timestamp; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Collection; import java.util.HashSet; import java.util.Map; import java.util.Set; public class MonthTableComplexShardingAlgorithm implements ComplexKeysShardingAlgorithm<Comparable<?>> { private final static DateTimeFormatter createDateDf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private final static DateTimeFormatter targetNamesDateDf = DateTimeFormatter.ofPattern("yyyyMMdd"); @Override public Collection<String> doSharding(Collection<String> availableTargetNames, ComplexKeysShardingValue<Comparable<?>> shardingValue) { Set<String> finalTargetNames = new HashSet<>(); //拿到逻辑表名的长度,比如task_info的长度,加上1个下划线的长度,方便后面获取最后的年月 int tablePrefixLength = shardingValue.getLogicTableName().length() + 1; if (shardingValue.getColumnNameAndShardingValuesMap().size() > 0) { /** * 以下是对精确分片的处理,比如= 或者 in */ Map<String, Collection<Comparable<?>>> shardingValuesMap = shardingValue.getColumnNameAndShardingValuesMap(); for (String availableTargetName : availableTargetNames) { LocalDate targetNamesDt = getLocalDateFromTableName(tablePrefixLength, availableTargetName); if (!ObjectUtils.isEmpty(shardingValuesMap.get("create_date_"))) { if (shardingValuesMap.get("create_date_").size() > 0) { Collection<Comparable<?>> createDateStr = shardingValuesMap.get("create_date_"); for (Comparable<?> date : createDateStr) { if (date instanceof Timestamp) { LocalDate timestamp = ((Timestamp) date).toLocalDateTime().toLocalDate(); if (isSameMonth(timestamp, targetNamesDt)) { finalTargetNames.add(availableTargetName); } } } } } else if (!ObjectUtils.isEmpty(shardingValuesMap.get("id_"))) { if (shardingValuesMap.get("id_").size() > 0) { Collection<Comparable<?>> idStr = shardingValuesMap.get("id_"); for (Comparable<?> id : idStr) { if (id instanceof String) { String tableExt = ((String) id).substring(26); if (availableTargetName.endsWith(tableExt)) { finalTargetNames.add(availableTargetName); } } } } } } } else if (shardingValue.getColumnNameAndRangeValuesMap().size() > 0) { /** * 以下是对范围分片的处理,比如>、<等 * 这里主要处理create_date_,通过create_date_来确认是那些表 */ Map<String, Range<Comparable<?>>> rangeValuesMap = shardingValue.getColumnNameAndRangeValuesMap(); Range<Comparable<?>> createDate = rangeValuesMap.get("create_date_"); //2020-05-19 00:00:00 LocalDate lowerEndpointDt = null, upperEndpointDt = null; if (createDate.hasLowerBound()) { lowerEndpointDt = LocalDateTime.parse((String) createDate.lowerEndpoint(), createDateDf).toLocalDate(); } if (createDate.hasUpperBound()) { upperEndpointDt = LocalDateTime.parse((String) createDate.upperEndpoint(), createDateDf).toLocalDate(); } Set<String> lowerSet = new HashSet<>(); Set<String> upperSet = new HashSet<>(); for (String availableTargetName : availableTargetNames) { //例如202002 LocalDate targetNamesDt = getLocalDateFromTableName(tablePrefixLength, availableTargetName); if (lowerEndpointDt != null && (targetNamesDt.isAfter(lowerEndpointDt) || isSameMonth(lowerEndpointDt, targetNamesDt) ) ) { lowerSet.add(availableTargetName); } if (upperEndpointDt != null && (targetNamesDt.isBefore(upperEndpointDt) || (isSameMonth(upperEndpointDt, targetNamesDt)) ) ) { upperSet.add(availableTargetName); } } lowerSet.retainAll(upperSet); finalTargetNames = lowerSet; } return finalTargetNames; } /** * 是否是同一个月 * @param date 待比较日期 * @param targetDate 目标日期 * @return 真为是同一个月 */ private boolean isSameMonth(LocalDate date, LocalDate targetDate) { return targetDate.getYear() == date.getYear() && targetDate.getMonthValue() == date.getMonthValue(); } /** * * @param tablePrefixLength 表名前面相同部分的前缀长度 * @param availableTargetName 可用表名 * @return */ private LocalDate getLocalDateFromTableName(int tablePrefixLength, String availableTargetName) { String targetNamesDateStr = availableTargetName.substring(tablePrefixLength); return LocalDate.parse(targetNamesDateStr + "01", targetNamesDateDf); } }
-
Id生成算法如下,主要为给新增数据添加ID:
import com.baomidou.mybatisplus.core.incrementer.IdentifierGenerator; import org.apache.shardingsphere.core.strategy.keygen.SnowflakeShardingKeyGenerator; import org.apache.shardingsphere.spi.keygen.ShardingKeyGenerator; import java.util.Date; import java.util.Properties; import java.util.Random; public final class ShardingIdGenerator implements ShardingKeyGenerator, IdentifierGenerator { private final static SnowflakeShardingKeyGenerator snowflakeShardingKeyGenerator; static { snowflakeShardingKeyGenerator = new SnowflakeShardingKeyGenerator(); } private static long getRandom(long n) { long min = 1, max = 9; for (int i = 1; i < n; i++) { min *= 10; max *= 10; } return (((long) (new Random().nextDouble() * (max - min)))) + min; } @Override public Comparable<?> generateKey() { Comparable<?> key = snowflakeShardingKeyGenerator.generateKey(); String date = DateFormatUtil.format(new Date(), "yyyyMM"); return String.valueOf(getRandom(8)) + key + date; } @Override public String getType() { return "DATEFLAKE"; } @Override public Properties getProperties() { return snowflakeShardingKeyGenerator.getProperties(); } @Override public void setProperties(Properties properties) { snowflakeShardingKeyGenerator.setProperties(properties); } @Override public Number nextId(Object entity) { return 1; } @Override public String nextUUID(Object entity) { return (String) generateKey(); } }
-
TaskInfo:
import com.baomidou.mybatisplus.annotation.*; import lombok.Data; @Data @TableName("task_info") public class TaskInfo { /** * 表ID */ @TableId(value = "id_", type = IdType.ASSIGN_UUID) private String id; …… }
-
TaskResult:
import com.baomidou.mybatisplus.annotation.*; import lombok.Data; @Data @TableName("task_result") public class TaskResult { @TableId(value = "id_", type = IdType.ASSIGN_UUID) private String id; @TableField("task_id_") private String taskId; …… }
-
其他代码如Mapper、Service、Controller等可以自己实现,这些东西和平常使用上不会有变化。
public interface TaskInfoMapper extends BaseMapper<TaskInfo> { /** * <p> * 查询 : 根据state状态查询用户列表,分页显示 * </p> * * @param page 分页对象,xml中可以从里面进行取值,传递参数 Page 即自动分页,必须放在第一位(你可以继承Page实现自己的分页对象) * @return 分页对象 */ @Select("select i.id_ as id, " + " i.content_ as content " + " from task_info i " + " left join task_result r on i.id_ = r.task_id_ ${ew.customSqlSegment} " + " order by i.create_date_ desc ") Page<Map<String, Object>> findTaskByCondition(@Param(Constants.WRAPPER) Wrapper<Map<String, Object>> wrapper, Page<?> page); @Insert("insert into task_info (content_) values (#{content})") int insertX(TaskInfo taskInfo); }
可以看到 task_info 和 task_result 并不需要我们刻意加上_202005这样的后缀。在
org.apache.shardingsphere.sharding.route.engine.ShardingRouteDecorator#decorate
方法中有详细的从逻辑表到真实表路由规则。可以对源码位置进行调试即可理清一些逻辑。 -
MyBatis需要分页插件,已经自己写的IdentifierGenerator生成数据库ID:
@EnableTransactionManagement @Configuration @MapperScan("com.example.privatebatistest.mapper") public class MybatisPlusConfig { /** * 将shardingsphere的id生成器拿过来用 * 如果不想这样子 * 请使用SPI独立注入到shardingsphere * @return */ @Bean public IdentifierGenerator idGenerator() { return new ShardingIdGenerator(); } @Bean public PaginationInterceptor paginationInterceptor() { PaginationInterceptor paginationInterceptor = new PaginationInterceptor(); // 设置请求的页面大于最大页后操作, true调回到首页,false 继续请求 默认false paginationInterceptor.setOverflow(false); // 设置最大单页限制数量,默认 500 条,-1 不受限制 paginationInterceptor.setLimit(500); // 开启 count 的 join 优化,只针对部分 left join paginationInterceptor.setCountSqlParser(new JsqlParserCountOptimize(true)); return paginationInterceptor; } }
-
如果想让
ShardingIdGenerator
只在shardingsphere中使用,那么需要按官方提供的 Service Provider Interface (SPI)1 去完成配置操作。
操作为:在resources目录下创建META-INF目录,接着在META-INF目录下创建services目录,然后在services目录中创建名为org.apache.shardingsphere.spi.keygen.ShardingKeyGenerator
文件,内容为com.example.privatebatistest.util.ShardingIdGenerator
。这个内容指向ShardingIdGenerator完整类路径。配合配置文件完成注入到shardingsphere中的任务。
如图所示:
以上代码虽然挺多的,但是有几个是MyBatis需要的类,ShardingSphere的主要功能都在配置中完成。
运行
在控制台会有如下输出:
那么配置就完成了。
其他
草草写完,还有待完善。如遇到问题请各位大神锤锤小弟我,不吝赐教,感谢。
https://shardingsphere.apache.org/document/legacy/4.x/document/cn/features/spi/ ↩︎