一、背景
传统的将数据集中存储至单一数据节点的解决方案,在性能、可用性和运维成本这三方面已经难于满足互联网的海量数据场景。
从性能方面来说,由于关系型数据库大多采用B+树类型的索引,在数据量超过阈值的情况下,索引深度的增加也将使得磁盘访问的IO次数增加,进而导致查询性能的下降;同时,高并发访问请求也使得集中式数据库成为系统的最大瓶颈。
从可用性的方面来讲,服务化的无状态型,能够达到较小成本的随意扩容,这必然导致系统的最终压力都落在数据库之上。而单一的数据节点,或者简单的主从架构,已经越来越难以承担。数据库的可用性,已成为整个系统的关键。
从运维成本方面考虑,当一个数据库实例中的数据达到阈值以上,对于DBA的运维压力就会增大。数据备份和恢复的时间成本都将随着数据量的大小而愈发不可控。一般来讲,单一数据库实例的数据的阈值在1TB之内,是比较合理的范围。
在传统的关系型数据库无法满足互联网场景需要的情况下,将数据存储至原生支持分布式的NoSQL的尝试越来越多。 但NoSQL对SQL的不兼容性以及生态圈的不完善,使得它们在与关系型数据库的博弈中始终无法完成致命一击,而关系型数据库的地位却依然不可撼动。
数据分片指按照某个维度将存放在单一数据库中的数据分散地存放至多个数据库或表中以达到提升性能瓶颈以及可用性的效果。 数据分片的有效手段是对关系型数据库进行分库和分表。分库和分表均可以有效的避免由数据量超过可承受阈值而产生的查询瓶颈。 除此之外,分库还能够用于有效的分散对数据库单点的访问量;分表虽然无法缓解数据库压力,但却能够提供尽量将分布式事务转化为本地事务的可能,一旦涉及到跨库的更新操作,分布式事务往往会使问题变得复杂。 使用多主多从的分片方式,可以有效的避免数据单点,从而提升数据架构的可用性。
通过分库和分表进行数据的拆分来使得各个表的数据量保持在阈值以下,以及对流量进行疏导应对高访问量,是应对高并发和海量数据系统的有效手段。
二、挑战
虽然数据分片解决了性能、可用性以及单点备份恢复等问题,但分布式的架构在获得了收益的同时,也引入了新的问题。
面对如此散乱的分库分表之后的数据,应用开发工程师和数据库管理员对数据库的操作变得异常繁重就是其中的重要挑战之一。他们需要知道数据需要从哪个具体的数据库的分表中获取。另一个挑战则是,能够正确的运行在单节点数据库中的SQL,在分片之后的数据库中并不一定能够正确运行。例如,分表导致表名称的修改,或者分页、排序、聚合分组等操作的不正确处理。跨库事务也是分布式的数据库集群要面对的棘手事情。 合理采用分表,可以在降低单表数据量的情况下,尽量使用本地事务,善于使用同库不同表可有效避免分布式事务带来的麻烦。 在不能避免跨库事务的场景,有些业务仍然需要保持事务的一致性。 而基于XA的分布式事务由于在并发度高的场景中性能无法满足需要,并未被互联网巨头大规模使用,他们大多采用最终一致性的柔性事务代替强一致事务。
三、系统瓶颈
略
四、目标
略
五、分库分表策略
纵向切分
1、对于包含状态的数据分为冷热数据,对于还未完成的流程做为热数据实时更新信息,对于已完成的流程数据做为冷数据留档,用于信息追溯;
横向切分策略
1、 查询切分
记录id与db的关系,根据mapping关系分库
优点:Mapping 关系可以随时修改
缺点:增加了额外的单点
2、 范围切分
按照时间、ID或其它字段切分
优点:单表大小可控、天然支持水平扩展
缺点:无法解决集中写入瓶颈问题,如 按ID范围切分时,ID有序插入会优先进入其中一个DB,而其它DB此时未完全利用
3、 Hash切分(建议)
传统hash切分
通过取模算法 key.hashcode mod databases_num,将不同的数据分布在不同的数据库节点上。
优点:可以较为均匀的将数据切分
缺点:扩容时 需要重新计算切分后的值,涉及到大量的数据迁移
一致性Hash切分
建立一个2^32 hash节点环,计算database的hash值(常用:节点前缀 +(ip+端口).hahcode%2^32),并映射到hash环的具体位置,将待 切分 数据取值key.hashcode mod 2^32,映射到hash 环上,并按照规则从root节点开始顺时针查找,if node_1 < key_hash <= node_2 ;then data_key in node_2 如果循环一遍没有找到则data_key in node_root。
扩容时迁移数据时,如 新增节点在 node_1 ,node_2000 之间 node_1000,则只需要重新计算node_2000节点上的key_hash 重新分配即可(如果key_hash以持久化 则可以省略计算的步骤);
移除或节点异常下线时,如node_1,node_1000,node_2000 node_1000 下线,则node_1000的数据可以直接迁移到node_2000上
优点:扩容时只需要迁移小部分的数据
缺点:如果节点不够分散,会出现hash环的倾斜,导致节点数据不均匀
一致性Hash + 虚拟Hash节点
该思路基于一致性Hash 增加了虚拟节点,这些节点会映射到真实的物理节点,这样就可以调控节点数据分配
优点:减少了Hash环倾斜带来的影响
缺点:增加复杂度
实现方案
注:实现分库后,需要重新设计唯一ID
一致性Hash + 虚拟Hash节点
方案思路
基于sharding-jdbc 自定义分片算法
Sharding-JDBC 简介(详情参考官网:Sharding-JDBC :: ShardingSphere)
Sharding-JDBC是ShardingSphere的第一个产品,也是ShardingSphere的前身。 它定位为轻量级Java框架,在Java的JDBC 层提供的额外服务。它使用客户端直连数据库,以jar包形式提供服务,无需额外部署和依赖,可理解为增强版的JDBC驱动,完全兼容JDBC和各种ORM框架。
适用于任何基于JDBC的ORM框架,如:JPA, Hibernate, Mybatis, Spring JDBC Template或直接使用JDBC。
支持任何第三方的数据库连接池,如:DBCP, C3P0, BoneCP, Druid, HikariCP等。
支持任意实现JDBC规范的数据库。目前支持MySQL,Oracle,SQLServer,PostgreSQL以及任何遵循SQL92标准的数据库。
实现步骤
1、集成sharding-jdbc
pom.xml
<!-- https://mvnrepository.com/artifact/org.apache.shardingsphere/sharding-jdbc-core -->
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>sharding-jdbc-spring-boot-starter</artifactId>
<version>4.1.1</version>
</dependency>
新增sharding-jdbc 配置
spring:
shardingsphere:
#数据源配置,可配置多个data_source_name
datasource:
names: pdmp-0,pdmp-1
pdmp-0:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://XXXXXX/pdmp-0?useUnicode=true&characterEncoding=UTF-8
username: XXXXXX
password: XXXXXX
pdmp-1:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://XXXXXX/pdmp-1?useUnicode=true&characterEncoding=UTF-8
username: XXXXXX
password: XXXXXX
sharding:
#默认数据库分片策略
default-database-strategy:
inline:
#分片算法行表达式,需符合groovy语法
algorithm-expression: pdmp-$->{model_version_id % 2}
sharding-column: model_version_id
#数据表分片策略
tables:
feature_base:
#由数据源名 + 表名组成,以小数点分隔。多个表以逗号分隔,支持inline表达式。缺省表示使用已知数据源与逻辑表名称生成数据节点,用于广播表(即每个库中都需要一个同样的表用于关联查询,多为字典表)或只分库不分表且所有库的表结构完全一致的情况
actual-data-nodes: pdmp-$->{0..1}.feature_base$->{0..9}
table-strategy:
standard:
#分片列名称
sharding-column: image_uuid
#精确分片算法类名称,用于=和IN。该类需实现PreciseShardingAlgorithm接口并提供无参数的构造器
precise-algorithm-class-name: com.hanshow.afms.admin.sharding.ConsistentShardingAlgorithm
props:
sql:
show: true
自定义分片算法
// 实现了两种算法 PreciseShardingAlgorithm(精准分片算法)、RangeShardingAlgorithm(范围分片算法)
public class ConsistentShardingAlgorithm
implements PreciseShardingAlgorithm<Long>, RangeShardingAlgorithm<Long> {
/**
* 精确分片
* 一致性hash算法
*/
@Override
public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> shardingValue) {
//获取已经初始化的分表节点
InitTableNodesToHashLoop initTableNodesToHashLoop =
(InitTableNodesToHashLoop) SpringContextUtils.getBean(InitTableNodesToHashLoop.class);
if (CollectionUtils.isEmpty(availableTargetNames)) {
return shardingValue.getLogicTableName();
}
//这里主要为了兼容当联表查询时,如果两个表非关联表则
//当对副表分表时shardingValue这里传递进来的依然是主表的名称,
//但availableTargetNames中确是副表名称,所有这里要从availableTargetNames中匹配真实表
ArrayList<String> availableTargetNameList = new ArrayList<>(availableTargetNames);
String logicTableName = availableTargetNameList.get(0).replaceAll("[^(a-zA-Z_)]", "");
SortedMap<Long, String> tableHashNode =
initTableNodesToHashLoop.getTableVirtualNodes().get(logicTableName);
ConsistentHashAlgorithm consistentHashAlgorithm = new ConsistentHashAlgorithm(tableHashNode,
availableTargetNames);
return consistentHashAlgorithm.getTableNode(String.valueOf(shardingValue.getValue()));
}
/**
* 范围查询规则
* 可以根据实际场景进行修改
* Sharding.
*
* @param availableTargetNames available data sources or tables's names
* @param shardingValue sharding value
* @return sharding results for data sources or tables's names
*/
@Override
public Collection<String> doSharding(Collection<String> availableTargetNames, RangeShardingValue<Long> shardingValue) {
return availableTargetNames;
}
}
hash环初始化
@Log4j2
public class InitTableNodesToHashLoop {
@Resource
private ShardingDataSource shardingDataSource;
@Getter
private HashMap<String, SortedMap<Long, String>> tableVirtualNodes = new HashMap<>();
@PostConstruct
public void init() {
try {
ShardingRule rule = shardingDataSource.getRuntimeContext().getRule();
Collection<TableRule> tableRules = rule.getTableRules();
ConsistentHashAlgorithm consistentHashAlgorithm = new ConsistentHashAlgorithm();
for (TableRule tableRule : tableRules) {
String logicTable = tableRule.getLogicTable();
tableVirtualNodes.put(logicTable,
consistentHashAlgorithm.initNodesToHashLoop(
tableRule.getActualDataNodes()
.stream()
.map(DataNode::getTableName)
.collect(Collectors.toList()))
);
}
} catch (Exception e) {
log.error("分表节点初始化失败 {}", e);
}
}
一致散列算法
public class ConsistentHashAlgorithm {
//虚拟节点,key表示虚拟节点的hash值,value表示虚拟节点的名称
@Getter
private SortedMap<Long, String> virtualNodes = new TreeMap<>();
//当节点的数目很少时,容易造成数据的分布不均,所以增加虚拟节点来平均数据分布
//虚拟节点的数目;虚拟节点的生成主要是用来让数据尽量均匀分布
//虚拟节点是真实节点的不同映射而已
//比如真实节点user1的hash值为100,那么我们增加3个虚拟节点user1-1、user1-2、user1-3,分别计算出来的hash值可能就为200,345,500;通过这种方式来将节点分布均匀
private static final int VIRTUAL_NODES = 3;
public ConsistentHashAlgorithm() {
}
public ConsistentHashAlgorithm(SortedMap<Long, String> virtualTableNodes, Collection<String> tableNodes) {
if (Objects.isNull(virtualTableNodes)) {
virtualTableNodes = initNodesToHashLoop(tableNodes);
}
this.virtualNodes = virtualTableNodes;
}
public SortedMap<Long, String> initNodesToHashLoop(Collection<String> tableNodes) {
SortedMap<Long, String> virtualTableNodes = new TreeMap<>();
// 每个物理节点绑定多个虚拟节点
for (String node : tableNodes) {
for (int i = 0; i < VIRTUAL_NODES; i++) {
String s = String.valueOf(i);
String virtualNodeName = node + "-VN" + s;
long hash = getHash(virtualNodeName);
virtualTableNodes.put(hash, virtualNodeName);
}
}
return virtualTableNodes;
}
/**
* 通过计算key的hash
* 计算映射的表节点
*
* @param key
* @return
*/
public String getTableNode(String key) {
String virtualNode = getVirtualTableNode(key);
//虚拟节点名称截取后获取真实节点
if (StringUtils.isNotBlank(virtualNode)) {
return virtualNode.substring(0, virtualNode.indexOf("-"));
}
return null;
}
/**
* 获取虚拟节点
* @param key
* @return
*/
public String getVirtualTableNode(String key) {
long hash = getHash(key);
// 得到大于该Hash值的所有Map
SortedMap<Long, String> subMap = virtualNodes.tailMap(hash);
String virtualNode;
if (subMap.isEmpty()) {
//如果没有比该key的hash值大的,则从第一个node开始
Long i = virtualNodes.firstKey();
//返回对应的服务器
virtualNode = virtualNodes.get(i);
} else {
//第一个Key就是顺时针过去离node最近的那个结点
Long i = subMap.firstKey();
//返回对应的服务器
virtualNode = subMap.get(i);
}
return virtualNode;
}
/**
* 使用FNV1_32_HASH算法计算key的Hash值
*
* @param key
* @return
*/
public long getHash(String key) {
final int p = 16777619;
int hash = (int) 2166136261L;
for (int i = 0; i < key.length(); i++)
hash = (hash ^ key.charAt(i)) * p;
hash += hash << 13;
hash ^= hash >> 7;
hash += hash << 3;
hash ^= hash >> 17;
hash += hash << 5;
// 如果算出来的值为负数则取其绝对值
if (hash < 0)
hash = Math.abs(hash);
return hash;
}
}
参考文章:https://blog.csdn.net/free_ant/article/details/111461606