单服务器加锁一般使用synchonized关键字或使用ReentrantLock,或者使用数据库中的悲观锁或乐观锁(后面介绍)。
public static void main(String[] args) {
lock1(1);
}
private static void lock1(Object object) {
synchronized (object) {
System.out.println("lock1");
}
}
public static void main(String[] args) {
lock1();
}
private static void lock1() {
ReentrantLock lock = new ReentrantLock();
lock.lock();
System.out.println("lock1");
lock.unlock();
}
当部署多服务器时,synchonized和ReentrantLock就不起作用了,目前常用的分布式锁方案有以下几种
这种方法是在数据库中建立一张锁表,然后通过操作该表中的数据来实现。当我们要锁住某个方法或资源时,就在表中增加一条记录,在释放锁的时候删除该条记录。
这种方法主要有以下缺点
悲观锁,正如其名,它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度(悲观),因此,在整个数据处理过程中,将数据处于锁定状态。 悲观锁的实现,往往依靠数据库提供的锁机制 (也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。
共享锁又称读锁,是读取操作创建的锁。其他用户可以并发读取数据,但任何事务都不能对数据进行修改(获取数据上的排他锁),直到已释放所有共享锁。
如果事务T对数据A加上共享锁后,则其他事务只能对A再加共享锁,不能加排他锁。获准共享锁的事务只能读数据,不能修改数据。
SELECT name from a LOCK IN SHARE MODE;
在查询语句后面增加LOCK IN SHARE MODE,Mysql会对查询结果中的每行都加共享锁,当没有其他线程对查询结果集中的任何一行使用排他锁时,可以成功申请共享锁,否则会被阻塞。其他线程也可以读取使用了共享锁的表,而且这些线程读取的是同一个版本的数据。
排他锁又称写锁,如果事务T对数据A加上排他锁后,则其他事务不能再对A加任任何类型的封锁。获准排他锁的事务既能读数据,又能修改数据。
SELECT name from a where id = 1 FOR UPDATE;
在查询语句后面增加FOR UPDATE,Mysql会对查询结果中的每行都加排他锁,当没有其他线程对查询结果集中的任何一行使用排他锁时,可以成功申请排他锁,否则会被阻塞。
具体流程如下:
在对任意记录进行修改前,先尝试为该记录加上排他锁;
如果加锁失败,说明该记录正在被修改,则当前查询可能要等待或抛出异常,具体相应方案根据业务来;
如果成功加锁,则可以对该记录做修改,事务完成后解锁;
中间若有其他事务对该记录做修改或加排他锁的操作,都会等待解锁或抛出异常。
select * from a where id = 1 for update;
update a set name = 'aa' where id = 1;
注意:
InnoDB默认使用行级锁,行级锁都是基于索引的,如果一条SQL语句用不到索引是不会使用行级锁的,会使用表级锁把整张表锁住。
优点和不足:
悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会。
在关系数据库管理系统里,乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)是一种并发控制的方法。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。乐观事务控制最早是由孔祥重(H.T.Kung)教授提出。
乐观锁( Optimistic Locking ) 相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。
相对于悲观锁,在对数据库进行处理的时候,乐观锁并不会使用数据库提供的锁机制。一般的实现乐观锁的方式就是记录数据版本。
数据版本,为数据增加的一个版本标识。当读取数据时,将版本标识的值一同读出,数据每更新一次,同时对版本标识进行更新。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的版本标识进行比对,如果数据库表当前版本号与第一次取出来的版本标识值相等,则予以更新,否则认为是过期数据。
使用版本号实现乐观锁:
使用版本号时,可以在数据初始化时指定一个版本号,每次对数据的更新操作都对版本号执行+1操作。并判断当前版本号是不是该数据的最新的版本号。
select name, version from a where id = 1;
需要代码判断更改的行数
update a set name = 'bb' where id = 1 and version = 2;
该种方法主要利用redis的setNx(Set if Not eXist)方法,示例如下:
private boolean lock(String key, String value, long timeout, long expireTime) {
long millTime = System.currentTimeMillis();
while (System.currentTimeMillis() - millTime < timeout) {
if (redisUtils.setNX(key, value)) {
System.out.println(System.currentTimeMillis());
// 设置过期时间,过期时间一般小于timeout
redisUtils.expire(key, expireTime);
return true;
}
LOGGER.info(key + "出现锁等待");
// 短暂休眠
try {
Thread.sleep(10, RandomUtils.nextInt(30));
} catch (InterruptedException e) {
e.printStackTrace();
LOGGER.info("locking error");
}
}
return false;
}
// 解锁,避免任务执行时间过长之前的线程可能会将不属于它的锁释放掉的问题。
private void unlock(String key, String value) {
if (StringUtil.isNotBlank(key) && StringUtil.isNotBlank(value)) {
if (Objects.equals(value, redisUtils.get(key))) {
redisUtils.delete(key);
}
}
}
这种方式可能会存在一些问题,由于 set 值和值过期时间不是一个原子操作,就有可能会出现 set 值的过程中,redis 客户端突然出问题了,这样锁就一直不会过期,从而导致后面的请求不能处理。从 2.6.12 版本开始,redis 支持 setNX 和 expire 的原子操作,示例代码如下:
redisTemplate.execute((RedisCallback<Boolean>) connection -> {
Object obj = connection.execute("set",
key.getBytes(),
value.getBytes(),
SafeEncoder.encode("NX"),
SafeEncoder.encode("EX"),
Protocol.toByteArray(expireTime));
return obj != null;
});
加锁操作,与上面一致。
可以使用 memcached 的 add 方法实现分布式锁,如果已经添加过的,add 后不会覆盖。
ZooKeeper是Apache基金会的一个软件项目,为大型分布式计算提供开源的分布式配置服务、同步服务和域名注册。ZooKeeper曾经是Hadoop的一个子项目,现在是一个独立的顶级项目。
ZooKeeper的架构通过冗余服务实现高可用性。如果第一次无应答,客户端就询问另一台ZooKeeper主机。ZooKeeper节点将它们的数据存储于一个分层的命名空间,非常类似于一个文件系统获一个前缀树系统。
大致思想即为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。
使用ZK实现的分布式锁好像完全符合了本文开头我们对一个分布式锁的所有期望。但是,其实并不是,Zookeeper实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同不到所有的Follower机器上。
其实,使用Zookeeper也有可能带来并发问题,只是并不常见而已。考虑这样的情况,由于网络抖动,客户端可ZK集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,Curator客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。(所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡。)
优点:有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。
缺点:性能上不如使用缓存实现分布式锁。 需要对ZK的原理有所了解。
从理解的难易程度角度(从低到高)
数据库 > 缓存 > Zookeeper
从实现的复杂性角度(从低到高)
Zookeeper >= 缓存 > 数据库
从性能角度(从高到低)
缓存 > Zookeeper >= 数据库
从可靠性角度(从高到低)
Zookeeper > 缓存 > 数据库
目前项目中使用的是基于redis的分布式锁方案。