前面我们已经完成了SpringCloudAlibaba的学习,我们对一个微服务项目的架构体系已经有了一定的了解,那么本章我们将在应用层面继续探讨微服务。
Redis,它是一个基于内存的高性能数据库,我们当时已经学习了包括基本操作、常用数据类型、持久化、事务和锁机制以及使用Java与Redis进行交互等,利用它的高性能,我们还使用它来做Mybatis的二级缓存、以及Token的持久化存储。而这一部分,我们将继续深入,探讨Redis在分布式开发场景下的应用。
注意:redis新版本命令slave统一改为了replica!!!
在分布式场景下,我们可以考虑让Redis实现主从模式:
主从复制,是指将一台Redis服务器的数据,复制到其他的Redis服务器。前者称为主节点(Master),后者称为从节点(Slave),数据的复制是单向的,只能由主节点到从节点。Master以写为主,Slave 以读为主。
这样的好处肯定是显而易见的:
实现了读写分离,提高了性能。
在写少读多的场景下,我们甚至可以安排很多个从节点,这样就能够大幅度的分担压力,并且就算挂掉一个,其他的也能使用。
那么我们现在就来尝试实现一下,这里在Windows下进行测试,打开Redis文件夹,我们要开启两个Redis服务器,修改配置文件redis.windows.conf
:
# Accept connections on the specified port, default is 6379 (IANA #815344).
# If port 0 is specified Redis will not listen on a TCP socket.
port 6001
一个服务器的端口设定为6001,复制一份,另一个的端口为6002,接着我们指定配置文件进行启动,打开cmd,输入redis-server.exe redis.windows.conf
:
现在我们的两个服务器就启动成功了,接着我们可以使用命令查看当前服务器的主从状态,我们打开客户端:
输入info replication
命令来查看当前的主从状态,可以看到默认的角色为:master,也就是说所有的服务器在启动之后都是主节点的状态。那么现在我们希望让6002作为从节点,通过一个命令即可:
可以看到,连接6002客户端在输入slaveof 127.0.0.1 6001
命令后,就会将6001服务器作为主节点,而当前节点作为6001的从节点,并且角色也会变成:slave,接着我们来看看6001的情况:
可以看到从节点信息中已经出现了6002服务器,也就是说现在我们的6001和6002就形成了主从关系(还包含一个偏移量,这个偏移量反应的是从节点的同步情况)
主服务器和从服务器都会维护一个复制偏移量,主服务器每次向从服务器中传递 N 个字节的时候,会将自己的复制偏移量加上 N。从服务器中收到主服务器的 N 个字节的数据,就会将自己额复制偏移量加上 N,通过主从服务器的偏移量对比可以很清楚的知道主从服务器的数据是否处于一致,如果不一致就需要进行增量同步了。
那么我们现在可以来测试一下,在主节点新增数据,看看是否会同步到从节点:
可以看到,我们在6001服务器插入的a
,可以在从节点6002读取到,那么,从节点新增的数据在主节点能得到吗?我们来测试一下:
可以看到从节点只能进行读操作,不能进行写操作,节点的模式为只读模式。那么如果我们现在不想让6002作为6001的从节点了呢?只需输入slaveof no one
命令即可。
可以看到,通过输入slaveof no one
,即可变回Master角色。接着我们再来启动一台6003服务器,流程是一样的:
可以看到,在连接之后,也会直接同步主节点的数据,因此无论是已经处于从节点状态还是刚刚启动完成的服务器,都会从主节点同步数据,实际上整个同步流程为:
当我们的主节点关闭后,从节点依然可以读取数据:
但是从节点会疯狂报错
当然每次都去敲个命令配置主从太麻烦了,我们可以直接在配置文件中配置,添加这样行即可:
slaveof 127.0.0.1 6001
这里我们给6002和6003服务器都配置一下,现在我们重启三个服务器。
可以看到现在是一主两从模式。当然,除了作为Master节点的从节点外,我们还可以将其作为从节点的从节点,比如现在我们让6003作为6002的从节点:
也就是说,现在差不多是这样的的一个情况:
采用这种方式,优点肯定是显而易见的,但是缺点也很明显,整个传播链路一旦中途出现问题,那么就会导致后面的从节点无法及时同步。
前面我们讲解了Redis实现主从复制的一些基本操作,那么我们接着来看哨兵模式。
经过之前的学习,我们发现,实际上最关键的还是主节点,因为一旦主节点出现问题,那么整个主从系统将无法写入,因此,我们得想一个办法,处理一下主节点故障的情况。实际上我们可以参考之前的服务治理模式,比如Nacos和Eureka,所有的服务都会被实时监控,那么只要出现问题,肯定是可以及时发现的,并且能够采取响应的补救措施,这就是我们即将介绍的哨兵:
注意这里的哨兵不是我们之前学习SpringCloud Alibaba的那个,是专用于Redis的。哨兵会对所有的节点进行监控,如果发现主节点出现问题,那么会立即让从节点进行投票,选举一个新的主节点出来,这样就不会由于主节点的故障导致整个系统不可写(注意要实现这样的功能最小的系统必须是一主一从,再小的话就没有意义了)
那么怎么启动一个哨兵呢?我们只需要稍微修改一下配置文件即可,这里直接删除配置文件全部内容,后续启动redis会自动生成其他配置内容,添加:
sentinel monitor lbwnb 127.0.0.1 6001 1
其中第一个和第二个是固定,第三个是为监控对象名称,随意,后面就是主节点的相关信息,包括IP地址和端口,最后一个1我们暂时先不说,然后我们使用此配置文件启动服务器,正常启动命令新增 --sentinel
,可以看到启动后:
可以看到以哨兵模式启动后,会自动监控主节点,然后还会显示那些节点是作为从节点存在的。
现在我们直接把主节点关闭,看看会发生什么事情:
可以看到从节点还是正常的在报错,一开始的时候不会直接重新进行选举而是继续尝试重连(因为有可能只是网络小卡一下,没必要这么敏感),但是我们发现,经过一段时间之后,将6003选举为主节点,6002连接主节点6003,哨兵输出了以下内容:
可以看到哨兵发现主节点已经有一段时间不可用了,那么就会开始进行重新选举,6003节点被选为了新的主节点,并且之前的主节点6001变成了新的主节点的从节点:
当我们再次启动6001时,会发现,它自动变成了6003的从节点,并且会将数据同步过来:
那么,这个选举规则是怎样的呢?是在所有的从节点中随机选取还是遵循某种规则呢?
slave-priority(新版本也对应replica-priority)
配置项(默认是100),越小表示优先级越高。注意:redis新版本命令slave统一改为了replica!!!(我也不知道我电脑怎么装了个3.x版本的,正常下个5.x版本的就可以,上述slave统一改为replica就能正常操作)
要是哨兵也挂了咋办?没事,咱们可以多安排几个哨兵,只需要把哨兵的配置复制一下,然后修改端口,这样就可以同时启动多个哨兵了,我们启动3个哨兵(一主二从三哨兵),这里我们吧最后一个值改为2
,哨兵默认端口为26379,启动多个哨兵记得修改不同端口号,以免启动报错(另两个哨兵端口咱设为26389、26399):
sentinel monitor lbwnb 127.0.0.1 6001 2
2
这个值实际上代表的是当有几个哨兵认为主节点挂掉时,就判断主节点真的挂掉了
现在我们把6001节点挂掉,看看这三个哨兵会怎么样:
可以看到都显示将master切换为6003节点了。
那么,在哨兵重新选举新的主节点之后,我们Java中的Redis的客户端怎么感知到呢?我们来看看,首先还是导入依赖:
<dependencies>
<dependency>
<groupId>redis.clientsgroupId>
<artifactId>jedisartifactId>
<version>4.2.1version>
dependency>
dependencies>
public class Main {
public static void main(String[] args) {
//这里我们直接使用JedisSentinelPool来获取Master节点
//需要把三个哨兵的地址都填入
try (JedisSentinelPool pool = new JedisSentinelPool("lbwnb",
new HashSet<>(Arrays.asList("127.0.0.1:26379", "127.0.0.1:26389", "127.0.0.1:26399")))) {
Jedis jedis = pool.getResource(); //直接询问并得到Jedis对象,这就是连接的Master节点
jedis.set("test", "114514"); //直接写入即可,实际上就是向Master节点写入
Jedis jedis2 = pool.getResource(); //再次获取
System.out.println(jedis2.get("test")); //读取操作
} catch (Exception e) {
e.printStackTrace();
}
}
}
这样,Jedis对象就可以通过哨兵来获取Master节点,当Master节点更新后,也能得到最新的。
如果我们服务器的内存不够用了,但是现在我们的Redis又需要继续存储内容,那么这个时候就可以利用集群来实现扩容。
因为单机的内存容量最大就那么多,已经没办法再继续扩展了,但是现在又需要存储更多的内容,这时我们就可以让N台机器上的Redis来分别存储各个部分的数据(每个Redis可以存储1/N的数据量),这样就实现了容量的横向扩展。同时每台Redis还可以配一个从节点,这样就可以更好地保证数据的安全性。
那么问题来,现在用户来了一个写入的请求,数据该写到哪个节点上呢?我们来研究一下集群的机制:
首先,一个Redis集群包含16384个插槽,集群中的每个Redis 实例负责维护一部分插槽以及插槽所映射的键值数据,那么这个插槽是什么意思呢?
实际上,插槽就是键的Hash计算后的一个结果,注意这里出现了计算机网络
中的CRC循环冗余校验,这里采用CRC16,能得到16个bit位的数据,也就是说算出来之后结果是0-65535之间,再进行取模,得到最终结果:
Redis key的路由计算公式:slot = CRC16(key) % 16384
结果的值是多少,就应该存放到对应维护的Redis下,比如Redis节点1负责0-5565的插槽,而这时客户端插入了一个新的数据a=10
,a在Hash计算后结果为666,那么a就应该存放到1号Redis节点中。简而言之,本质上就是通过哈希算法将插入的数据分摊到各个节点的,所以说哈希算法真的是处处都有用啊。
那么现在我们就来搭建一个简单的Redis集群,这里创建6个配置,注意开启集群模式:
# Normal Redis instances can't be part of a Redis Cluster; only nodes that are
# started as cluster nodes can. In order to start a Redis instance as a
# cluster node enable the cluster support uncommenting the following:
#
cluster-enabled yes
接着记得把所有的持久化文件全部删除,所有的节点内容必须是空的。
然后输入redis-cli.exe --cluster create --cluster-replicas 1 127.0.0.1:6001 127.0.0.1:6002 127.0.0.1:6003 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003
,这里的--cluster-replicas 1
指的是每个节点配一个从节点:
输入之后,会为你展示客户端默认分配的方案,并且会询问你当前的方案是否合理。可以看到6001/6002/6003都被选为主节点,其他的为从节点,我们直接输入yes即可:
最后分配成功,可以看到插槽的分配情况:
现在我们随便连接一个节点,尝试插入一个值:
在插入时,出现了一个错误,实际上这就是因为a计算出来的哈希值(插槽),不归当前节点管,我们得去管这个插槽的节点执行,通过上面的分配情况,我们可以得到15495属于节点6003管理:
在6003节点插入成功,当然我们也可以使用集群方式连接,这样我们无论在哪个节点都可以插入,只需要添加-c
表示以集群模式访问:
可以看到,在6001节点成功对a的值进行了更新,只不过还是被重定向到了6003节点进行插入。
我们可以输入cluster nodes
命令来查看当前所有节点的信息:
那么现在如果我们让某一个主节点挂掉会怎么样?现在我们把6001挂掉:
可以看到原本的6001从节点7003,晋升为了新的主节点,而之前的6001已经挂了,现在我们将6001重启试试看:
可以看到6001变成了7003的从节点,那么要是6001和7003都挂了呢?
这时我们尝试插入新的数据:
可以看到,当存在节点不可用时,会无法插入新的数据,现在我们将6001和7003恢复:
可以看到恢复之后又可以继续正常使用了。
最后我们来看一下如何使用Java连接到集群模式下的Redis,我们需要用到JedisCluster对象:
public class Main1 {
public static void main(String[] args) {
//和客户端一样,随便连一个就行,也可以多写几个,构造方法有很多种可以选择
try (JedisCluster cluster = new JedisCluster(new HostAndPort("127.0.0.1", 6003))) {
System.out.println("集群实例数量:" + cluster.getClusterNodes().size());
cluster.set("a", "yyds");
System.out.println(cluster.get("a"));
}
}
}
操作基本和Jedis对象一样,这里就不多做赘述了。
在我们的传统单体应用中,经常会用到锁机制,目的是为了防止多线程竞争导致的并发问题,但是现在我们在分布式环境下,又该如何实现锁机制呢?可能一条链路上有很多的应用,它们都是独立运行的,这时我们就可以借助Redis来实现分布式锁。
实际上在高并发下,我们看似正常的借阅流程,会出现问题,比如现在同时来了10个同学要借同一本书,但是现在只有3本,而我们的判断规则是,首先看书够不够,如果此时这10个请求都已经走到这里,并且都判定为可以进行借阅,那么问题就出现了,接下来这10个请求都开始进行借阅操作,导致库存直接爆表,形成超借问题(在电商系统中也存在同样的超卖问题)
@Override
public boolean doBorrow(int uid, int bid) {
//1. 判断图书和用户是否都支持借阅,如果此时来了10个线程,都进来了,那么都能够判断为可以借阅
if(bookClient.bookRemain(bid) < 1)
throw new RuntimeException("图书数量不足");
if(userClient.userRemain(uid) < 1)
throw new RuntimeException("用户借阅量不足");
//2. 首先将图书的数量-1,由于上面10个线程同时进来,同时判断可以借阅,那么这个10个线程就同时将图书数量-1,那库存岂不是直接变成负数了???
if(!bookClient.bookBorrow(bid))
throw new RuntimeException("在借阅图书时出现错误!");
...
}
因此,为了解决这种问题,我们就可以利用分布式锁来实现。那么Redis如何去实现分布式锁呢?
在Redis存在这样一个命令:
setnx key value
这个命令看起来和set
命令差不多,但是它有一个机制,就是只有当指定的key不存在的时候,才能进行插入,实际上就是set if not exists
的缩写。
可以看到,当客户端1设定a之后,客户端2使用setnx
会直接失败。
当客户端1将a删除之后,客户端2就可以使用setnx
成功插入了。
利用这种特性,我们就可以在不同的服务中实现分布式锁,那么问题来了,要是某个服务加了锁但是卡顿了呢,或是直接崩溃了,那这把锁岂不是永远无法释放了?因此我们还可以考虑加个过期时间:
set a 666 EX 5 NX
这里使用set
命令,最后加一个NX表示是使用setnx
的模式,和上面是一样的,但是可以通过EX设定过期时间,这里设置为5秒,也就是说如果5秒还没释放,那么就自动删除。
可以看到5s内setnx是失败的,5s过后setnx成功!
当然,添加了过期时间,带了的好处是显而易见的,但是同时也带来了很多的麻烦,我们来设想一下这种情况:
因此,单纯只是添加过期时间,会出现这种把别人加的锁谁卸了的情况,要解决这种问题也很简单,我们现在的目标就是保证任务只能删除自己加的锁,如果是别人加的锁是没有资格删的,所以我们可以吧a的值指定为我们任务专属的值,比如可以使用UUID之类的,如果在主动删除锁的时候发现值不是我们当前任务指定的,那么说明可能是因为超时,其他任务已经加锁了。
如果你在学习本篇之前完成了JUC并发编程篇的学习,那么一定会有一个疑惑,如果在超时之前那一刹那进入到释放锁的阶段,获取到值肯定还是自己,但是在即将执行删除之前,由于超时机制导致被删除并且其他任务也加锁了,那么这时再进行删除,仍然会导致删除其他任务加的锁。
实际上本质还是因为锁的超时时间不太好衡量,如果超时时间能够设定地比较恰当,那么就可以避免这种问题了。
要解决这个问题,我们可以借助一下Redisson框架,它是Redis官方推荐的Java版的Redis客户端。它提供的功能非常多,也非常强大,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期,它为我们提供了很多种分布式锁的实现,使用起来也类似我们在JUC中学习的锁,这里我们尝试使用一下它的分布式锁功能。
添加依赖:
<dependency>
<groupId>org.redissongroupId>
<artifactId>redissonartifactId>
<version>3.17.0version>
dependency>
<dependency>
<groupId>io.nettygroupId>
<artifactId>netty-allartifactId>
<version>4.1.75.Finalversion>
dependency>
首先看不加锁的情况:
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try(Jedis jedis = new Jedis("127.0.0.1", 6379)){
for (int j = 0; j < 100; j++) { //每个客户端获取a然后增加a的值再写回去,如果不加锁那么肯定会出问题
int a = Integer.parseInt(jedis.get("a")) + 1;
jedis.set("a", a+"");
}
}
}).start();
}
}
这里没有直接用incr
而是我们自己进行计算,方便模拟。
以上demo是开启十个线程,每个线循环100次,每次都使的a的值+1;我们期望的是最后a的结果为1000,来看看运行之后的结果。
首先设置a初始值为0
发现运行完程序之后,a的结果并不是我们预期的1000
现在我们来给它加一把锁,注意这个锁是基于Redis的,不仅仅只可以用于当前应用,是能够垮系统的:
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379"); //配置连接的Redis服务器,也可以指定集群
RedissonClient client = Redisson.create(config); //创建RedissonClient客户端
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try (Jedis jedis = new Jedis("127.0.0.1", 6379)) {
RLock lock = client.getLock("testLock"); //指定锁的名称,拿到锁对象
for (int j = 0; j < 100; j++) {
lock.lock(); //加锁
int a = Integer.parseInt(jedis.get("a")) + 1;
jedis.set("a", a + "");
lock.unlock(); //解锁
}
}
System.out.println("结束!");
}).start();
}
}
实际上是对++操作进行加锁,锁只能被一个线程拿到,保证多个线程的串行化,此时a的结果就是我们预期的1000。
注意,如果用于存放锁的Redis服务器挂了,那么肯定是会出问题的,这个时候我们就可以使用RedLock,它的思路是,在多个Redis服务器上保存锁,只需要超过半数的Redis服务器获取到锁,那么就真的获取到锁了,这样就算挂掉一部分节点,也能保证正常运行,这里就不做演示了。
前面我讲解了Redis在分布式场景的下的相关应用,接着我们来看看MySQL数据库在分布式场景下的应用。
当我们使用MySQL的时候,也可以采取主从复制的策略,它的实现思路基本和Redis相似,也是采用增量复制的方式,MySQL会在运行的过程中,会记录二进制日志,所有的DML和DDL操作都会被记录进日志中,主库只需要将记录的操作复制给从库,让从库也运行一次,那么就可以实现主从复制。但是注意它不会在一开始进行全量复制,所以最好在开始主从之前将数据库的内容保持一致。
和之前一样,一旦我们实现了主从复制,那么就算主库出现故障,从库也能正常提供服务,并且还可以实现读写分离等操作。这里我们就使用两台主机来搭建一主一从的环境,首先确保两台服务器都安装了MySQL数据库并且都已经正常运行了:
Linux使用yum安装MySQL
接着我们需要创建对应的账号,一会方便从库进行访问的用户:
CREATE USER test identified with mysql_native_password by '123456';
接着我们开启一下外网访问:
vim /etc/my.cnf
找到 bind - address 这一行。 默认情况下, bind - address 的值为 127.0.0.1 ,所以只能监听本地连接。我们需要将这个值改为远程连接 ip 可访问,可使用通配符 ip 地址 *, ::, 0.0.0.0 ,当然也可以是单独的固定 ip,这样就仅允许指定 ip 连接,更加安全。
在某些 MySQL 版本的配置文件中,没有 bind - address 这一行,这种情况下,在合适的位置加上就可以了。
# 添加/修改如下配置
bind-address = *
然后重启MySQL,使刚刚编辑的 my.cnf 文件生效
systemctl restart mysqld
现在我们首先来配置主库,主库只需要为我们刚刚创建好的用户分配一个主从复制的权限即可:
grant replication slave on *.* to test;
FLUSH PRIVILEGES;
然后我们可以输入命令来查看主库的相关情况:
File
的值是当前使用的二进制日志的文件名,Position
是该日志里面的位置信息(不需要纠结这个究竟代表什么),记住这两个值,会在下面配置从服务器时用到。
这样主库就搭建完成了,接着我们需要将从库进行配置,首先是配置文件:
# 这里需要将server-id配置为其他的值(默认是1)所有Mysql主从实例的id必须唯一,不能打架,不然一会开启会失败
server-id = 2
重启MySQL服务,然后进入数据库,输入:
change replication source to SOURCE_HOST='47.96.156.51',SOURCE_PORT=13306,SOURCE_USER='test',SOURCE_PASSWORD='123456',SOURCE_LOG_FILE='binlog.000003',SOURCE_LOG_POS=157;
注意后面的logfile和pos就是我们上面从主库中显示的信息。
执行完成后,显示OK表示没有问题,接着输入:
start replica;
现在我们的从机就正式启动了,现在我们输入:
show replica status\G; -- \G:换行显示
来查看当前从机状态,可以看到:
最关键的是下面的Replica_IO_Running和Replica_SQL_Running必须同时为Yes才可以,实际上从库会创建两个线程,一个线程负责与主库进行通信,获取二进制日志,暂时存放到一个中间日志(Relay_Log)中,而另一个线程则是将中间表保存的二进制日志的信息进行执行,然后插入到从库中。
主从同步机制
最后配置完成,我们来看看在主库进行操作会不会同步到从库:
主库
从库
可以看到在主库中创建的数据库,被同步到从库中了,我们再来试试看创建表和插入数据:
use yyds;
create table test (
`id` int primary key,
`name` varchar(255) NULL,
`passwd` varchar(255) NULL
);
主库
从库
现在我们随便插入一点数据:
这样,我们的MySQL主从就搭建完成了,那么如果主机此时挂了会怎么样?
可以看到IO线程是处于重连状态,会等待主库重新恢复运行。
在大型的互联网系统中,可能单台MySQL的存储容量无法满足业务的需求,这时候就需要进行扩容了。
和之前的问题一样,单台主机的硬件资源是存在瓶颈的,不可能无限制地纵向扩展,这时我们就得通过多台实例来进行容量的横向扩容,我们可以将数据分散存储,让多台主机共同来保存数据。
那么问题来了,怎么个分散法?
**垂直拆分:**我们的表和数据库都可以进行垂直拆分,所谓垂直拆分,就是将数据库中所有的表,按照业务功能拆分到各个数据库中(是不是感觉跟前面两章的学习的架构对应起来了)而对于一张表,也可以通过外键之类的机制,将其拆分为多个表。
**水平拆分:**水平拆分针对的不是表,而是数据,我们可以让很多个具有相同表的数据库存放一部分数据,相当于是将数据分散存储在各个节点上。
那么要实现这样的拆分操作,我们自行去编写代码工作量肯定是比较大的,因此目前实际上已经有一些解决方案了,比如我们可以使用MyCat(也是一个数据库中间件,相当于挂了一层代理,再通过MyCat进行分库分表操作数据库,只需要连接就能使用,类似的还有ShardingSphere-Proxy)或是Sharding JDBC(应用程序中直接对SQL语句进行分析,然后转换成分库分表操作,需要我们自己编写一些逻辑代码),这里我们就讲解一下Sharding JDBC。
官方文档(中文): https://shardingsphere.apache.org/document/5.2.0/cn/overview/#shardingsphere-jdbc
定位为轻量级 Java 框架,在 Java 的 JDBC 层提供的额外服务,它使用客户端直连数据库,以 jar 包形式提供服务,无需额外部署和依赖,可理解为增强版的 JDBC 驱动,完全兼容 JDBC 和各种 ORM 框架。
适用于任何基于 JDBC 的 ORM 框架,如:JPA, Hibernate, Mybatis, Spring JDBC Template 或直接使用 JDBC;
支持任何第三方的数据库连接池,如:DBCP, C3P0, BoneCP, HikariCP 等;
支持任意实现 JDBC 规范的数据库,目前支持 MySQL,PostgreSQL,Oracle,SQLServer 以及任何可使用 JDBC 访问的数据库。
这里我们主要演示一下水平分表方式,我们直接创建一个新的SpringBoot项目即可,依赖如下:
<dependencies>
<dependency>
<groupId>org.apache.shardingspheregroupId>
<artifactId>shardingsphere-jdbc-core-spring-boot-starterartifactId>
<version>5.2.0version>
dependency>
<dependency>
<groupId>org.mybatis.spring.bootgroupId>
<artifactId>mybatis-spring-boot-starterartifactId>
<version>2.2.2version>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<optional>trueoptional>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
<scope>testscope>
dependency>
dependencies>
数据库我们这里直接用上面演示的即可,因为只需要两个表结构一样的数据库即可,正好上面演示的进行了同步,所以我们直接把从库变回正常状态就可以了:
stop replica;
接着我们把两个表的root用户密码改一下、允许远程连接,一会用这个用户远程连接数据库:
use mysql;
update user set host = '%' where user = 'root';
alter user root identified with mysql_native_password by '你的密码';
FLUSH PRIVILEGES;
接下来配置文件添加配置,现在我们是一个分库分表的状态,需要配置两个数据源:
spring:
shardingsphere:
datasource:
# 有几个数据就配几个,这里是名称,按照下面的格式,名称+数字的形式
names: db0,db1
# 为每个数据源单独进行配置
db0:
# 数据源实现类,这里使用默认的HikariDataSource
type: com.zaxxer.hikari.HikariDataSource
# 数据库驱动
driver-class-name: com.mysql.cj.jdbc.Driver
# 不用我多说了吧
jdbc-url: jdbc:mysql://47.96.156.51:13306/yyds
username: root
password: 247907Lkl!
db1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://43.143.150.157:3306/yyds
username: root
password: 247907Lkl!
如果启动没有问题,那么就是配置成功了:
接着我们需要对项目进行一些编写,添加我们的用户实体类和Mapper:
@Data
@AllArgsConstructor
public class User {
int id;
String name;
String passwd;
}
@Mapper
public interface UserMapper {
@Select("select * from test where id = #{id}")
User getUserById(int id);
@Insert("insert into test(id, name, passwd) values(#{id}, #{name}, #{passwd})")
int addUser(User user);
}
实际上这些操作都是常规操作,在编写代码时关注点依然放在业务本身上,现在我们就来编写配置文件,我们需要告诉ShardingJDBC要如何进行分片,首先明确:现在是两个数据库都有test表存放用户数据,我们目标是将用户信息分别存放到这两个数据库的表中。
不废话了,直接上配置:
spring:
shardingsphere:
rules:
sharding:
tables:
#这里填写表名称,程序中对这张表的所有操作,都会采用下面的路由方案
#比如我们上面Mybatis就是对test表进行操作,所以会走下面的路由方案
test:
#这里填写实际的路由节点,比如现在我们要分两个库,那么就可以把两个库都写上,以及对应的表
#也可以使用表达式,比如下面的可以简写为 db$->{0..1}.test
actual-data-nodes: db0.test,db1.test
#这里是分库策略配置
database-strategy:
#这里选择标准策略,也可以配置复杂策略,基于多个键进行分片
standard:
#参与分片运算的字段,下面的算法会根据这里提供的字段进行运算
sharding-column: id
#这里填写我们下面自定义的算法名称
sharding-algorithm-name: my-alg
sharding-algorithms:
#自定义一个新的算法,名称随意
my-alg:
#算法类型,官方内置了很多种,这里演示最简单的一种
#对2取模,单数放在db1,双数放在db0
type: MOD
props:
sharding-count: 2
props:
#开启日志,一会方便我们观察
sql-show: true
其中,分片算法有很多内置的,可以在这里查询:https://shardingsphere.apache.org/document/current/cn/user-manual/common-config/builtin-algorithm/sharding/,这里我们使用的是MOD,也就是取模分片算法,它会根据主键的值进行取模运算,比如我们这里填写的是2,那么就表示对主键进行模2运算,根据数据源的名称,比如db0就是取模后为0,db1就是取模后为1(官方文档描述的并不是很清楚),也就是说,最终实现的效果就是单数放在db1
,双数放在db0
,当然它还支持一些其他的算法,这里就不多介绍了,可以去看官方文档。
那么现在我们编写一个测试用例来看看,是否能够按照我们上面的规则进行路由:
@SpringBootTest
class ShardingJdbcApplicationTests {
@Resource
UserMapper mapper;
@Test
void contextLoads() {
for (int i = 0; i < 10; i++) {
//这里ID自动生成0-9,然后插入数据库
mapper.addUser(new User(i, "xxx", "ccc"));
}
}
}
现在我们可以开始运行了:
测试通过,我们来看看数据库里面是不是按照我们的规则进行数据插入的:
可以看到这两张表,都成功按照我们指定的路由规则进行插入了,我们来看看详细的路由情况,通过控制台输出的SQL就可以看到:
可以看到所有的SQL语句都有一个Logic SQL(这个就是我们在Mybatis里面写的,是什么就是什么)紧接着下面就是Actual SQL,也就是说每个逻辑SQL最终会根据我们的策略转换为实际SQL,比如第一条数据,它的id是0,那么实际转换出来的SQL会在db0这个数据源进行插入。
这样我们就很轻松地实现了分库策略。
分库完成之后,接着我们来看分表,比如现在我们的数据库中有test_0
和test_1
两张表,表结构一样,但是我们也是希望能够根据id取模运算的结果分别放到这两个不同的表中,实现思路其实是差不多的,这里首先需要介绍一下两种表概念:
**逻辑表:**相同结构的水平拆分数据库(表)的逻辑名称,是 SQL 中表的逻辑标识。 例:订单数据根据主键尾数拆分为 10 张表,分别是 t_order_0
到 t_order_9
,他们的逻辑表名为 t_order
**真实表:**在水平拆分的数据库中真实存在的物理表。 即上个示例中的 t_order_0
到 t_order_9
现在我们就以一号数据库为例,那么我们在里面创建上面提到的两张表,之前的那个test
表删不删都可以,就当做不存在就行了:
create table test_0 (
`id` int primary key,
`name` varchar(255) NULL,
`passwd` varchar(255) NULL
);
create table test_1 (
`id` int primary key,
`name` varchar(255) NULL,
`passwd` varchar(255) NULL
);
接着我们不要去修改任何的业务代码,Mybatis里面写的是什么依然保持原样,即使我们的表名已经变了,我们需要做的是通过路由来修改原有的SQL,配置如下:
spring:
shardingsphere:
rules:
sharding:
tables:
test:
actual-data-nodes: db0.test_$->{0..1}
#现在我们来配置一下分表策略,注意这里是table-strategy上面是database-strategy
table-strategy:
#基本都跟之前是一样的
standard:
sharding-column: id
sharding-algorithm-name: my-alg
sharding-algorithms:
my-alg:
#这里我们演示一下INLINE方式,我们可以自行编写表达式来决定
type: INLINE
props:
#比如我们还是希望进行模2计算得到数据该去的表
#只需要给一个最终的表名称就行了test_,后面的数字是表达式取模算出的
#实际上这样写和MOD模式一模一样
algorithm-expression: test_$->{id % 2}
#没错,查询也会根据分片策略来进行,但是如果我们使用的是范围查询,那么依然会进行全量查询
#这个我们后面紧接着会讲,这里先写上吧
allow-range-query-with-inline-sharding: false
现在我们来测试一下,看看会不会按照我们的策略进行分表插入:
可以看到,根据我们的算法,原本的逻辑表被修改为了最终进行分表计算后的结果,我们来查看一下数据库:
插入我们了解完毕了,我们来看看查询:
@SpringBootTest
class ShardingJdbcApplicationTests {
@Resource
UserMapper mapper;
@Test
void contextLoads() {
System.out.println(mapper.getUserById(0));
System.out.println(mapper.getUserById(1));
}
}
可以看到,根据我们配置的策略,查询也会自动选择对应的表进行,是不是感觉有内味了。
那么如果是范围查询呢?
@Select("select * from test where id between #{start} and #{end}")
List<User> getUsersByIdRange(int start, int end);
@SpringBootTest
class ShardingJdbcApplicationTests {
@Resource
UserMapper mapper;
@Test
void contextLoads() {
System.out.println(mapper.getUsersByIdRange(3, 5));
}
}
我们来看看执行结果会怎么样:
可以看到INLINE算法默认是不支持进行全量查询的,我们得将上面的配置项改成true:
allow-range-query-with-inline-sharding: true
再次进行测试:
可以看到,最终出来的SQL语句是直接对两个表都进行查询,然后求出一个并集出来作为最后的结果。
当然除了分片之外,还有广播表和绑定表机制,用于多种业务场景下,这里就不多做介绍了,详细请查阅官方文档。
前面我们讲解了如何进行分库分表,接着我们来看看分布式序列算法。
在复杂分布式系统中,特别是微服构架中,往往需要对大量的数据和消息进行唯一标识。随着系统的复杂,数据的增多,分库分表成为了常见的方案,对数据分库分表后需要有一个唯一ID来标识一条数据或消息(如订单号、交易流水、事件编号等),此时一个能够生成全局唯一ID的系统是非常必要的。
比如我们之前创建过学生信息表、图书借阅表、图书管理表,所有的信息都会有一个ID作为主键,并且这个ID有以下要求:
为了区别于其他的数据,这个ID必须是全局唯一的。
主键应该尽可能的保持有序,这样会大大提升索引的查询效率。
那么我们在分布式系统下,如何保证ID的生成满足上面的需求呢?
1、**使用UUID:**UUID是由一组32位数的16进制数字随机构成的,我们可以直接使用JDK为我们提供的UUID类来创建:
public static void main(String[] args) {
String uuid = UUID.randomUUID().toString();
System.out.println(uuid);
}
结果为73d5219b-dc0f-4282-ac6e-8df17bcd5860
,生成速度非常快,可以看到确实是能够保证唯一性,因为每次都不一样,而且这么长一串那重复的概率真的是小的可怜。
但是它并不满足我们上面的第二个要求,也就是说我们需要尽可能的保证有序,而这里我们得到的都是一些无序的ID。
2、雪花算法(Snowflake):
我们来看雪花算法,它会生成一个一个64bit大小的整型的ID,int肯定是装不下了。
可以看到它主要是三个部分组成,时间+工作机器ID+序列号,时间以毫秒为单位,41个bit位能表示约70年的时间,时间纪元从2016年11月1日零点开始,可以使用到2086年,工作机器ID其实就是节点ID,每个节点的ID都不相同,那么就可以区分出来,10个bit位可以表示最多1024个节点,最后12位就是每个节点下的序列号,因此每台机器每毫秒就可以有4096个系列号。
这样,它就兼具了上面所说的唯一性和有序性了,但是依然是有缺点的,第一个是时间问题,如果机器时间出现倒退,那么就会导致生成重复的ID,并且节点容量只有1024个,如果是超大规模集群,也是存在隐患的。
ShardingJDBC支持以上两种算法为我们自动生成ID,文档:https://shardingsphere.apache.org/document/current/cn/user-manual/common-config/builtin-algorithm/keygen/
这里,我们就是要ShardingJDBC来让我们的主键ID以雪花算法进行生成,首先是配置数据库,因为我们默认的id是int类型,装不下64位的,改一下:
ALTER TABLE `yyds`.`test` MODIFY COLUMN `id` bigint NOT NULL FIRST;
接着我们需要修改一下Mybatis的插入语句,因为现在id是由ShardingJDBC自动生成,我们就不需要自己加了:
@Insert("insert into test(name, passwd) values(#{name}, #{passwd})")
int addUser(User user);
接着我们在配置文件中将我们的算法写上:
spring:
shardingsphere:
rules:
sharding:
tables:
test:
actual-data-nodes: db0.test,db1.test
#这里还是使用分库策略
database-strategy:
standard:
sharding-column: id
sharding-algorithm-name: my-alg
#这里使用自定义的主键生成策略
key-generate-strategy:
column: id
key-generator-name: my-gen
key-generators:
#这里写我们自定义的主键生成算法
my-gen:
#使用雪花算法
type: SNOWFLAKE
props:
#工作机器ID,保证唯一就行
worker-id: 666
sharding-algorithms:
my-alg:
type: MOD
props:
sharding-count: 2
接着我们来编写一下测试用例:
@SpringBootTest
class ShardingJdbcApplicationTests {
@Resource
UserMapper mapper;
@Test
void contextLoads() {
for (int i = 0; i < 20; i++) {
mapper.addUser(new User("aaa", "bbb"));
}
}
}
可以看到日志:
在插入的时候,将我们的SQL语句自行添加了一个id字段,并且使用的是雪花算法生成的值,并且也是根据我们的分库策略在进行插入操作。
最后我们来看看读写分离,我们前面实现了MySQL的主从,那么我们就可以将主库作为读,从库作为写:
这里我们还是将数据库变回主从状态,直接删除当前的表,我们重新来过:
drop table test;
我们需要将从库开启只读模式,在MySQL配置中进行修改:
read-only = 1
这样从库就只能读数据了(但是root账号还是可以写数据),接着我们重启服务器:
sudo systemctl restart mysqld
然后进入主库,看看状态:
现在我们配置一下从库:
change replication source to SOURCE_HOST='47.96.156.51',SOURCE_PORT=13306,SOURCE_USER='test',SOURCE_PASSWORD='123456',SOURCE_LOG_FILE='binlog.000004',SOURCE_LOG_POS=11305;
---
start replica;
然后查看从库状态:
现在我们在主库创建表:
create table test (
`id` bigint primary key,
`name` varchar(255) NULL,
`passwd` varchar(255) NULL
);
然后我们就可以配置ShardingJDBC了,打开配置文件(有些配置项可能随着版本变更可以也会随着改变,运行过程中如发现有配置异常的情况,直接去查官网即可):
spring:
shardingsphere:
rules:
#配置读写分离
readwrite-splitting:
data-sources:
#名称随便写
readwrite_ds:
#使用静态策略
static-strategy:
#配置写库(只能一个)
write-data-source-name: db0
#配置从库(多个,逗号隔开)
read-data-source-names: db1
#负载均衡策略,可以自定义
load-balancer-name: my-load
load-balancers:
#自定义的负载均衡策略
my-load:
type: ROUND_ROBIN
注意把之前改的用户实体类和Mapper改回去,这里我们就不用自动生成ID的了。所有的负载均衡算法地址:
https://shardingsphere.apache.org/document/current/cn/user-manual/common-config/builtin-algorithm/load-balance/
现在我们就来测试一下吧:
@SpringBootTest
class ShardingJdbcApplicationTests {
@Resource
UserMapper mapper;
@Test
void contextLoads() {
mapper.addUser(new User(10, "aaa", "bbb"));
System.out.println(mapper.getUserById(10));
}
}
运行查看sql日志:
可以看到,当我们执行插入操作时,会直接向db0进行操作,而读取操作是会根据我们的配置,选择db1进行操作。
至此,微服务应用章节到此结束。