分布式系统的CAP原则
在一个分布式系统中,一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)是不可能完全兼顾的
要么AP,要么CP,要么AC,但是不存在CAP
- 一致性(C):在分布式系统中的所有数据备份,在同一时刻是否同样的值,即写操作之后的读操作,必须返回该值。(分为弱一致性、强一致性和最终一致性)
- 可用性(A):在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据更新具备高可用性)
- 分区容忍性(P):以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择。
分布式锁是保证分布式系统最终一致性的一种手段。
java分布式锁的常见实现方式
1、线程锁、进程锁、分布式锁
java开发中,一个进程中,当多线程的去竞争某一资源的时候,
我们通常会用一把锁来保证,某个时间只有一个线程获取资源的使用权,
如加上synchronize关键字或ReentrantLock锁等。
多个进程相互竞争一个资源,如何保证资源只会被一个操作者持有呢?
通常由操作系统控制共享资源该由哪个进程访问
而在微服务的架构下,多个应用服务同时对同一条数据做修改,那么要确保数据的正确性,就只能有一个应用修改成功
比如:
server1、server2、server3 这三个服务都要修改amount这个数据,
每个服务更新的值不同,为了保证数据的正确性,三个服务都向lock server服务申请修改权限,
最终server2拿到了修改权限,即server2将amount更新为2,其他服务由于没有获取到修改权限则返回更新失败。
就需要实现分布式锁保证同一时间只有一个服务、一个实例访问共享资源
2、分布式锁的实现方式
使用redis实现
原理
因为redis是一个单独的非业务服务,不会受到其他业务服务的限制。
所有的业务服务都可以向redis发送写入命令,且只有一个业务服务可以写入命令成功,那么这个写入命令成功的服务即获得了锁,可以进行后续对资源的操作,
其他未写入成功的服务,则进行其他处理。
redis的String类型就可以实现。
setnx命令: 表示SET if Not eXists,即如果 key 不存在,才会设置它的值,否则什么也不做。
setnx具有原子性,保证了只会有一个线程操作成功。
当业务处理完后需要释放锁,给其他实例、线程使用
假如锁被某个服务获取后,这个服务宕机了,那么这个try_lock会一直存在redis中,
锁无法释放,会导致死锁
为了避免这种情况,往往设置锁的过期时间, 设置键过期时间,超过这个时间即给try_lock删除掉
如下:给锁添加10s的过期时间
参考代码
try{
lock = redisTemplate.opsForValue().setIfAbsent(lockKey, LOCK);
logger.info("cancelCouponCode是否获取到锁:"+lock);
if (lock) {
// TODO
redisTemplate.expire(lockKey,1, TimeUnit.MINUTES); //成功设置过期时间
return res;
}else {
logger.info("cancelCouponCode没有获取到锁,不执行任务!");
}
}finally{
if(lock){
redisTemplate.delete(lockKey);
logger.info("cancelCouponCode任务结束,释放锁!");
}else{
logger.info("cancelCouponCode没有获取到锁,无需释放锁!");
}
}
但现实是业务复杂,且无法保证10s的过期时间够业务处理完成
锁过期时间设置过短会有并发问题,设置过长就会陷入长时间等待锁的过长,造成资源浪费,
假如可以在快要过期的时候续上过期时间不就能解决这个问题了吗?
在Redisson(Redis SDK客户端)中,就已经帮我们实现了这个功能,这个自动续时的我们称其为”看门狗”。
详细实现参考以下链接:
blog.51cto.com/u_15193673/…
redis单实例的情况下,假如redis挂掉了,那么业务就不能正常使用了,
这时候可以通过引入redis集群的方式保证业务的正常
redis集群
但是涉及到redis集群,就会有新的问题出现,
假设是主从集群,且主从数据并不是强一致性。
当主节点宕机后,主节点的数据还未来得及同步到从节点,进行主从切换后,新的主节点并没有老的主节点的全部数据,这就会导致刚写入到老的主节点的锁在新的主节点并没有,其他服务来获取锁时还是会加锁成功。
此时则会有2个服务都可以操作公共资源,此时的分布式锁则是不安全的
redis的作者也想到这个问题,于是他发明了RedLock
关于RedLock
要实现RedLock,需要至少5个实例(官方推荐),且每个实例都是master,不需要从库和哨兵。
实现流程
-
1、客户端先获取当前时间戳T1
-
2、客户端依次向5个master实例发起加锁命令,且每个请求都会设置超时时间(毫秒级,注意:不是锁的超时时间),如果某一个master实例由于网络等原因导致加锁失败,则立即想下一个master实例申请加锁。
-
3、当客户端加锁成功的请求大于等于3个时,且再次获取当前时间戳T2,
当时间戳T2 - 时间戳T1 < 锁的过期时间。则客户端加锁成功,否则失败。
-
4、加锁成功,开始操作公共资源,进行后续业务操作
-
5、加锁失败,向所有redis节点发送锁释放命令
即当客户端在大多数redis实例上申请加锁成功后,且加锁总耗时小于锁过期时间,则认为加锁成功。
释放锁需要向全部节点发送锁释放命令。
第3步为啥要计算申请锁前后的总耗时与锁释放时间进行对比呢?
因为如果申请锁的总耗时已经超过了锁释放时间,那么可能前面申请redis的锁已经被释放掉了,保证不了大于等于3个实例都有锁存在了,锁也就没有意义了
这样的话分布式锁就真的没问题了嘛?
-
1、得5个redis实例,成本大大增加
-
2、可以通过上面的流程感受到,这个RedLock锁太重了
-
3、主从切换这种场景绝大多数的时候不会碰到,偶尔碰到的话,保证最终的兜底操作我觉得也没啥问题。
基于数据库实现排他锁
方案一
CREATE TABLE `method_lock`
(
`id` int(11) NOT NULL AUTO_INCREMENT,
`method_name` varchar(50) NOT NULL,
`desc` varchar(50) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `method_name` (`method_name`) USING BTREE
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;
获取锁
INSERT INTO `method_lock` (method_name, desc)
VALUES ('methodName', 'methodName desc');
对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功。
方案二(乐观锁)
DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock`
(
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`method_name` varchar(64) NOT NULL COMMENT '锁定的方法名',
`state` tinyint NOT NULL COMMENT '1:未分配;2:已分配',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`version` int NOT NULL COMMENT '版本号',
PRIMARY KEY (`id`),
UNIQUE KEY `method_name` (`method_name`) USING BTREE
) ENGINE = InnoDB
AUTO_INCREMENT = 3
DEFAULT CHARSET = utf8
COMMENT ='锁定中的方法';
先获取锁的信息
select id, method_name, state, version
from `method_lock`
where state = 1
and method_name = 'methodName';
占有锁
update t_resoure
set state=2,
version=2,
update_time=now()
where method_name = 'methodName'
and state = 1
and version = 2;
如果没有更新影响到一行数据,则说明这个资源已经被别人占位了
缺点:
- 1、这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
- 2、这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
- 3、这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
- 4、这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
解决方案:
- 1、数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。
- 2、没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
- 3、非阻塞的?搞一个while循环,直到insert成功再返回成功。
- 4、非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
使用zooKeeper实现
什么是zookeeper
zk是一个分布式协调服务,功能包括:配置维护、域名服务、分布式同步、组服务等。
zk的数据结构跟Unix文件系统类似。是一棵树形结构,这里不做详细介绍。
zookeeper节点介绍
zk的节点称之为znode节点,znode节点分两种类型:
- 1、临时节点(Ephemeral):当客户端与服务器断开连接后,临时znode节点就会被自动删除
- 2、持久节点(Persistent):当客户端与服务器断开连接后,持久znode节点不会被自动删除
znode节点还有一些特性:
- 1、节点有序:在一个父节点下创建子节点,zk提供了一个可选的有序性,创建子节点时会根据当前子节点数量给节点名添加序号。例:/root下创建/java,生成的节点名称则为java0001,/root/java0001。
- 2、临时节点:当会话结束或超时,自动删除节点
- 3、事件监听:当节点有创建,删除,数据修改,子节点变更的时候,zk会通知客户端的。
实现原理
zookeeper就是通过临时节点和节点有序来实现分布式锁的。
- 1、每个获取锁的线程会在zk的某一个目录下创建一个临时有序的节点。
- 2、节点创建成功后,判断当前线程创建的节点的序号是否是最小的。
- 3、如果序号是最小的,那么获取锁成功。
- 4、如果序号不是最小的,则对他序号的前一个节点添加事件监听。如果前一个节点被删了(锁被释放了),那么就会唤醒当前节点,则成功获取到锁。
redis、zookeeper、数据库实现方式的比较
redis
优点:性能上比较好,天然的支持高并发
缺点:
- 1、获取锁失败后,得轮询的去获取锁
- 2、依赖设置过期时间
- 3、依赖集群提高容灾
- 4、大多数情况下redis无法保证数据强一致性(保证了最终一致性)
zookeeper
优点:
- 1、不用设置过期时间
- 2、事件监听机制,加锁失败后,可以等待锁释放
缺点:
- 1、性能不如redis
- 2、当网络不稳定时,可能会有多个节点同时获取锁问题。例:node1由于网络波动,导致zk将其删除,刚好node2获取到锁,那么此时node1和node2两者都会获取到锁。
数据库的
类似redis,但性能上不如redis