目前几乎所有的大型web应用全都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式应用中的CAP理论告诉我们:
任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)、分区容错性(Partation tolerance)。最多只能同时满足其中两项。
所以在设计之初,就需要对三者做出取舍。一般在互联网场景中,都会选择牺牲强一致性,来换取高可用性,系统只需要保证最终一致性
。
那么问题就是,如何保证最终一致性?通常的技术方案有分布式事务,分布式锁。我们需要保证,在同一时刻,只有一个线程执行某一个特定方法。我们知道,在单机环境中,或者是单JVM中,java的并发api可以保证并发安全。但是在集群情况下,java的并发api就没办法了。我们需要分布式锁。
通常分布式锁的实现方案有三种:
1.基于数据库实现
2.基于缓存(redis、memcached、tair)
3.基于zookeeper
在思考方案前,首先要考虑目标,我们需要什么样的分布式锁?
1、安全,要保证加锁的方案同一时刻只能被一台机器上的一个线程访问。
2、可重入锁,避免死锁
3、最好是阻塞锁
4、高可用的获取锁和释放锁功能,尤其要关注释放锁的可靠性。
1.基于数据库的实现
基于数据库表
数据库实现分布式锁####
利用DB来实现分布式锁,有两种方案。两种方案各有好坏,但是总体效果都不是很好。但是实现还是比较简单的。
- 利用主键唯一规则:
我们知道数据库是有唯一主键规则的,主键不能重复,对于重复的主键会抛出主键冲突异常。
了解JDK reentrantlock的人都知道,reentrantlock是利用了OS的CAS特性实现的锁。主要是维护一个全局的状态,每次竞争锁都会CAS修改锁的状态,修改成功之后就占用了锁,失败的加入到同步队列中,等待唤醒。
其实这和分布式锁实现方案基本是一致的,首先我们利用主键唯一规则,在争抢锁的时候向DB中写一条记录,这条记录主要包含锁的id、当前占用锁的线程名、重入的次数和创建时间等,如果插入成功表示当前线程获取到了锁,如果插入失败那么证明锁被其他人占用,等待一会儿继续争抢,直到争抢到或者超时为止。
这里我主要写了一个简单的实现:
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* 利用mysql实现可重入分布式锁
*/
public class MysqlprimaryLock {
private static Connection connection;
static {
try {
Class.forName("com.mysql.jdbc.Driver");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
String url = "jdbc:mysql://10.0.0.212:3308/dbwww_lock?user=lock_admin&password=lock123";
try {
connection = DriverManager.getConnection(url);
} catch (SQLException e) {
e.printStackTrace();
}
}
/**
* 加锁
* @param lockID
*/
public void lock(String lockID) {
acquire(lockID);
}
/**
* 获取锁
* @param lockID
* @return
*/
public boolean acquire(String lockID) {
String sql = "insert into test_lock('id','count','thName','addtime') VALUES (?,?,?,?)";
while (true) {
try {
PreparedStatement statement = connection.prepareStatement(sql);
statement.setString(1, lockID);
statement.setInt(2, 1);
statement.setLong(1, System.currentTimeMillis());
boolean ifsucess = statement.execute();//如果成功,那么就是获取到了锁
if (ifsucess)
return true;
} catch (SQLException e) {
e.printStackTrace();
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
continue;
}
}
/**
* 超时获取锁
* @param lockID
* @param timeOuts
* @return
* @throws InterruptedException
*/
public boolean acquire(String lockID, long timeOuts) throws InterruptedException {
String sql = "insert into test_lock('id','count','thName','addtime') VALUES (?,?,?,?)";
long futureTime = System.currentTimeMillis() + timeOuts;
long ranmain = timeOuts;
long timerange = 500;
while (true) {
CountDownLatch latch = new CountDownLatch(1);
try {
PreparedStatement statement = connection.prepareStatement(sql);
statement.setString(1, lockID);
statement.setInt(2, 1);
statement.setLong(1, System.currentTimeMillis());
boolean ifsucess = statement.execute();//如果成功,那么就是获取到了锁
if (ifsucess)
return true;
} catch (SQLException e) {
e.printStackTrace();
}
latch.await(timerange, TimeUnit.MILLISECONDS);
ranmain = futureTime - System.currentTimeMillis();
if (ranmain <= 0)
break;
if (ranmain < timerange) {
timerange = ranmain;
}
continue;
}
return false;
}
/**
* 释放锁
* @param lockID
* @return
* @throws SQLException
*/
public boolean unlock(String lockID) throws SQLException {
String sql = "DELETE from test_lock where id = ?";
PreparedStatement statement = connection.prepareStatement(sql);
statement.setString(1, lockID);
boolean ifsucess = statement.execute();
if (ifsucess)
return true;
return false;
}
}
这里是利用主键冲突规则,加入了id','count','thName','addtime',count主要是为了重入计数,thName为了判断占用锁的线程,addtime是记录占用时间。上面代码没有实现重入的逻辑。
重入主要实现思路是,在每次获取锁之前去取当前锁的信息,如果锁的线程是当前线程,那么更新锁的count+1,并且执行锁之后的逻辑。如果不是当前锁,那么进行重试。释放的时候也要进行count-1,最后减到0时,删除锁标识释放锁。
优点:实现简单
缺点:没有超时保护机制,mysql存在单点,并发量大的时候请求量太大、没有线程唤醒机制,用异常去控制逻辑多少优点恶心。
对于超时保护:如果可能,可以采用定时任务去扫描超过一定阈值的锁,并删除。但是也会存在,锁住的任务执行时间很长,删除锁会导致并发问题。所以需要对超时时间有一个很好的预估。
对于单点问题:有条件可以搞一个主从,但是为了一个锁来搞一个主从是不是优点浪费?同时主从切换的时候系统不可用,这也是一个问题。
并发量大的时候请求量太大:因为这种实现方式是没有锁的唤醒机制的,不像reentrantlock在同步队列中的节点,可以通过唤醒来避免多次的循环请求。但是分布式环境数据库这种锁的实现是不能做到唤醒的。所以只能将获取锁的时间间隔调高,避免死循环给系统和DB带来的巨大压力。这样也牺牲了系统的吞吐量,因为总会有一定的间隔锁是空闲的。
用异常去控制逻辑多少优点恶心:就不说了,每次失败都抛异常.....
2. 利用Mysql行锁的特性
Mysql是有表锁、页锁和行锁的机制的,可以利用这个机制来实现锁。这里尽量使用行锁,它的吞吐量是最高的。
/**
* 超时获取锁
* @param lockID
* @param timeOuts
* @return
* @throws InterruptedException
*/
public boolean acquireByUpdate(String lockID, long timeOuts) throws InterruptedException, SQLException {
String sql = "SELECT id from test_lock where id = ? for UPDATE ";
long futureTime = System.currentTimeMillis() + timeOuts;
long ranmain = timeOuts;
long timerange = 500;
connection.setAutoCommit(false);
while (true) {
CountDownLatch latch = new CountDownLatch(1);
try {
PreparedStatement statement = connection.prepareStatement(sql);
statement.setString(1, lockID);
statement.setInt(2, 1);
statement.setLong(1, System.currentTimeMillis());
boolean ifsucess = statement.execute();//如果成功,那么就是获取到了锁
if (ifsucess)
return true;
} catch (SQLException e) {
e.printStackTrace();
}
latch.await(timerange, TimeUnit.MILLISECONDS);
ranmain = futureTime - System.currentTimeMillis();
if (ranmain <= 0)
break;
if (ranmain < timerange) {
timerange = ranmain;
}
continue;
}
return false;
}
/**
* 释放锁
* @param lockID
* @return
* @throws SQLException
*/
public void unlockforUpdtate(String lockID) throws SQLException {
connection.commit();
}
利用for update加显式的行锁,这样就能利用这个行级的排他锁来实现分布式锁了,同时unlock的时候只要释放commit这个事务,就能达到释放锁的目的。
优点:实现简单
缺点:连接池爆满和事务超时的问题单点的问题,单点问题,行锁升级为表锁的问题,并发量大的时候请求量太大、没有线程唤醒机制。
连接池爆满和事务超时的问题单点的问题:利用事务进行加锁的时候,query需要占用数据库连接,在行锁的时候连接不释放,这就会导致连接池爆满。同时由于事务是有超时时间的,过了超时时间自动回滚,会导致锁的释放,这个超时时间要把控好。
对于单点问题:同上。
并发量大的时候请求量太大:同上。
行锁升级为表锁的问题:Mysql行锁默认需要走索引,如果不走索引会导致锁表,如果可以,在sql中可以强制指定索引。
总结:
数据库这种方式看起来简单,实际不会采用的。因为要埋这些坑,要加入更多的代码。使方案变得越来越复杂。
2.基于缓存实现
基于缓存实现,在性能上会比较好,而且缓存一般都是集群部署的,解决了单点问题。
可以参考本博的另外一篇博文关于基于Redis的分布式锁的实现:
缓存的分布式锁主要通过Redis实现,当然其他的缓存也是可以的。关于缓存有两种实现吧:
- 基于SetNX实现:
setNX是Redis提供的一个原子操作,如果指定key存在,那么setNX失败,如果不存在会进行Set操作并返回成功。我们可以利用这个来实现一个分布式的锁,主要思路就是,set成功表示获取锁,set失败表示获取失败,失败后需要重试。
具体看下代码:
import redis.clients.jedis.Jedis;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Redis分布式锁
*/
public class RedisLockTest {
private Jedis jedisCli = new Jedis("localhost",6381);
private int expireTime = 1;
/**
* 获取锁
* @param lockID
* @return
*/
public boolean lock(String lockID){
while(true){
long returnFlag = jedisCli.setnx(lockID,"1");
if (returnFlag == 1){
System.out.println(Thread.currentThread().getName() + " get lock....");
return true;
}
System.out.println(Thread.currentThread().getName() + " is trying lock....");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
return false;
}
}
}
/**
* 超时获取锁
* @param lockID
* @param timeOuts
* @return
*/
public boolean lock(String lockID,long timeOuts){
long current = System.currentTimeMillis();
long future = current + timeOuts;
long timeStep = 500;
CountDownLatch latch = new CountDownLatch(1);
while(future > current){
long returnFlag = jedisCli.setnx(lockID,"1");
if (returnFlag == 1){
System.out.println(Thread.currentThread().getName() + " get lock....");
jedisCli.expire(lockID,expireTime);
return true;
}
System.out.println(Thread.currentThread().getName() + " is trying lock....");
try {
latch.await(timeStep, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
current = current + timeStep;
}
return false;
}
public void unlock(String lockId){
long flag = jedisCli.del(lockId);
if (flag>0){
System.out.println(Thread.currentThread().getName() + " release lock....");
}else {
System.out.println(Thread.currentThread().getName() + " release lock fail....");
}
}
/**
* 线程工厂,命名线程
*/
public static class MyThreadFactory implements ThreadFactory{
public static AtomicInteger count = new AtomicInteger();
@Override
public Thread newThread(Runnable r) {
count.getAndIncrement();
Thread thread = new Thread(r);
thread.setName("Thread-lock-test "+count);
return thread;
}
}
public static void main(String args[]){
final String lockID = "test1";
Runnable task = () ->{
RedisLockTest testCli = new RedisLockTest();
testCli.lock(lockID);
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
testCli.unlock(lockID);
};
MyThreadFactory factory = new MyThreadFactory();
ExecutorService services = Executors.newFixedThreadPool(10);
for (int i = 0;i<3;i++)
services.execute(factory.newThread(task));
}
}
看看结果:
pool-1-thread-3 is trying lock....
pool-1-thread-2 get lock....
pool-1-thread-1 is trying lock....
pool-1-thread-3 is trying lock....
pool-1-thread-2 release lock....
pool-1-thread-1 get lock....
pool-1-thread-3 is trying lock....
pool-1-thread-1 release lock....
pool-1-thread-3 get lock....
pool-1-thread-3 release lock....
可以看到,几个线程很好的进行了同步。
这种方式也是有优点和缺点:
优点:实现简单,吞吐量十分客观,对于高并发情况应付自如,自带超时保护,对于网络抖动的情况也可以利用超时删除策略保证不会阻塞所有流程。
缺点:单点问题、没有线程唤醒机制、网络抖动可能会引起锁删除失败。
对单点问题:因为redis一般都是单实例使用,那么对于单点问题,可以做一个主从。当然主从切换的时候也是不可用的,因为主从同步是异步的,可能会并发问题。如果对于主从还是不能保证可靠性的话,可以上Redis集群,对于Redis集群,因为使用了类一致性Hash算法,虽然不能避免节点下线的并发问题(当前的任务没有执行完,其他任务就开始执行),但是能保证Redis是可用的。可用性的问题是出了问题之后的备选方案,如果我们系统天天都出问题还玩毛啊,对于突发情况牺牲一两个请求还是没问题的。
对于线程唤醒机制:分布式锁大多都是这样轮训获取锁的,所以控制住你的重试频率,也不会导致负载特别高的。可能就是吞吐量低点而已。
对于锁删除失败:分布式锁基本都有这个问题,可以对key设置失效时间。这个超时时间需要把控好,过大那么系统吞吐量低,很容易导致超时。如果过小那么会有并发问题,部分耗时时间比较长的任务就要遭殃了。
3.基于Zookeeper实现
当某个客户端获取锁后,突然挂掉。没关系,对应的znode会被自动删除,不会出现锁释放不了的问题。
是阻塞锁
可重入
集群部署
1、实现原理:
基于zookeeper瞬时有序节点实现的分布式锁,其主要逻辑如下(该图来自于IBM网站)。大致思想即为:每个客户端对某个功能加锁时,在zookeeper上的与该功能对应的指定节点的目录下,生成一个唯一的瞬时有序节点。判断是否获取锁的方式很简单,
只需要判断有序节点中序号最小的一个。当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。
2、优点
锁安全性高,zk可持久化
3、缺点
性能开销比较高。因为其需要动态产生、销毁瞬时节点来实现锁功能。
4、实现
可以直接采用zookeeper第三方库curator即可方便地实现分布式锁。以下为基于curator实现的zk分布式锁核心代码:
Java代码
-
@Override public boolean tryLock(LockInfo info) { InterProcessMutex mutex = getMutex(info); int tryTimes = info.getTryTimes(); long tryInterval = info.getTryInterval(); boolean flag = true;// 代表是否需要重试 while (flag && --tryTimes >= 0) { try { if (mutex.acquire(info.getWaitLockTime(), TimeUnit.MILLISECONDS)) { LOGGER.info(LogConstant.DST_LOCK + "acquire lock successfully!"); flag = false; break; } } catch (Exception e) { LOGGER.error(LogConstant.DST_LOCK + "acquire lock error!", e); } finally { checkAndRetry(flag, tryInterval, tryTimes); } } return !flag;// 最后还需要重试,说明没拿到锁 }
Java代码
-
@Override public boolean releaseLock(LockInfo info) { InterProcessMutex mutex = getMutex(info); int tryTimes = info.getTryTimes(); long tryInterval = info.getTryInterval(); boolean flag = true;// 代表是否需要重试 while (flag && --tryTimes >= 0) { try { mutex.release(); LOGGER.info(LogConstant.DST_LOCK + "release lock successfully!"); flag = false; break; } catch (Exception e) { LOGGER.error(LogConstant.DST_LOCK + "release lock error!", e); } finally { checkAndRetry(flag, tryInterval, tryTimes); } } return !flag;// 最后还需要重试,说明没拿到锁 }
Java代码
-
/** * 获取锁。此处需要加同步,concurrentHashmap无法避免此处的同步问题 * @param info 锁信息 * @return 锁实例 */ private synchronized InterProcessMutex getMutex(LockInfo info) { InterProcessReadWriteLock lock = null; if (locksCache.get(info.getLock()) != null) { lock = locksCache.get(info.getLock()); } else { lock = new InterProcessReadWriteLock(client, BASE_DIR + info.getLock()); locksCache.put(info.getLock(), lock); } InterProcessMutex mutex = null; switch (info.getIsolate()) { case READ: mutex = lock.readLock(); break; case WRITE: mutex = lock.writeLock(); break; default: throw new IllegalArgumentException(); } return mutex; }
Java代码
-
/** * 判断是否需要重试 * @param flag 是否需要重试标志 * @param tryInterval 重试间隔 * @param tryTimes 重试次数 */ private void checkAndRetry(boolean flag, long tryInterval, int tryTimes) { try { if (flag) { Thread.sleep(tryInterval); LOGGER.info(LogConstant.DST_LOCK + "retry getting lock! now retry time left: " + tryTimes); } } catch (InterruptedException e) { LOGGER.error(LogConstant.DST_LOCK + "retry interval thread interruptted!", e); } }
但是生产环境上还是推荐zookeeper。
关于ZK实现的分布式锁的性能,可以参考这方篇文章:
一个基于zookeeper实现的分布式锁的性能测试