分布式锁是控制分布式系统之间同步访问共享资源的一种方式。在分布式系统中,常常需要协调他们的动作。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,在这种情况下,便需要使用到分布式锁。
在分布式系统中,常常需要协调他们的动作。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,这个时候,便需要使用到分布式锁。(幂等 是重复提交 不是对资源的争抢)
为了防止分布式系统中的多个进程之间相互干扰,我们需要一种分布式协调技术来对这些进程进行调度。而这个分布式协调技术的核心就是来实现这个分布式锁。
为什么要使用分布式锁
成员变量 A 存在 JVM1、JVM2、JVM3 三个 JVM 内存中
成员变量 A 同时都会在 JVM 分配一块内存,三个请求发过来同时对这个变量操作,显然结果是不对的
不是同时发过来,三个请求分别操作三个不同 JVM 内存区域的数据,变量 A 之间不存在共享,也不具有可见性,处理的结果也是不对的
注:该成员变量 A 是一个有状态的对象
如果我们业务中确实存在这个场景的话,我们就需要一种方法解决这个问题,这就是分布式锁要解决的问题
场景二:转账
场景三:分布式任务
某服务提供一组任务,A请求随机从任务组中获取一个任务;B请求随机从任务组中获取一个任务。
在理想的情况下,A从任务组中挑选一个任务,任务组删除该任务,B从剩下的的任务中再挑一个,任务组删除该任务。
同样的,在真实情况下,如果不做任何处理,可能会出现A和B挑中了同一个任务的情况。
扣减库存
多个线程对同一个商品扣减库存
分布式锁应该具备哪些条件
在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行
高可用的获取锁与释放锁
高性能的获取锁与释放锁
具备可重入特性(可理解为重新进入,由多于一个任务并发使用,而不必担心数据错误)
具备锁失效机制,防止死锁
具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败
这把锁要是一把可重入锁(避免死锁)
这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)
分布式锁的实现有哪些
Mysql(DBMS):利用数据库的唯一性索引实现。
Memcached:利用 Memcached 的 add 命令。此命令是原子性操作,只有在 key 不存在的情况下,才能 add 成功,也就意味着线程得到了锁。
Redis:和 Memcached 的方式类似,利用 Redis 的 setnx 命令。此命令同样是原子性操作,只有在 key 不存在的情况下,才能 set 成功。
Zookeeper:利用 Zookeeper 的顺序临时节点,来实现分布式锁和等待队列。Zookeeper 设计的初衷,就是为了实现分布式锁服务的。
Chubby:Google 公司实现的粗粒度分布式锁服务,底层利用了 Paxos 一致性算法。
Mysql(DBMS)实现分布式锁
实现方式一:利用mysql的隔离性:唯一索引
use test;
CREATE TABLE `DistributedLock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁名',
`desc` varchar(1024) NOT NULL DEFAULT '备注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';
//数据库中的每一条记录就是一把锁,利用的mysql唯一索引的排他性
lock(name,desc){
insert into DistributedLock(`name`,`desc`) values (#{name},#{desc});
}
unlock(name){
delete from DistributedLock where name = #{name}
}
锁重入:可增加可重入功能(避免再次获取锁导致死锁)
增加字段进程识别信息(ip、服务名称、线程id) 与 重入计数count,如果是同一个进程同一个线程则允许重入。
获取:再次获取锁的同时更新count(+1).
释放:更新count-1,当count==0删除记录。
可靠性
主从mysql:mysql宕机,立刻切换。
锁的持有者挂掉:定时任务清楚持有一定时间的锁。
性能
db操作都有一定性能损耗
阻塞锁
有此需求的业务线需要使用自旋多次尝试获取锁的实现。
实现方式二:利用select … where … for update 排他锁
boolean lock(){
connection.setAutoCommit(false)
while(true){
try{
result = select ... from DistributedLock where name=lock for update;
if(result==null){
return true;
}
}catch(Exception e){
connection.commit();
}
sleep(*);
}
return false;
}
void unlock(){
connection.commit();
}
其他附加功能与实现一基本一致,这里需要注意的是“where name=lock ”,name字段必须要走索引,否则会锁表。有些情况下,比如表不大,mysql优化器会不走这个索引,导致锁表问题。
实现方式三:version 乐观锁
所谓乐观锁与前边最大区别在于基于CAS思想,是不具有互斥性,不会产生锁等待而消耗资源,操作过程中认为不存在并发冲突,只有update version失败后才能觉察到。我们的抢购、秒杀就是用了这种实现以防止超卖。
通过增加递增的版本号字段实现乐观锁
select ...,version
update table set version+1 where version=xx
当然有人说可以在更新的时候这样写,通过比较拿到的account是否发生了变化来处理。如果还是除次拿到的值则允许成功更新。
update personal_bank set account=200
where id="xxx" and account=oldAccount
但是实现会有什么问题吗?留给大家思考
通过 Redis 分布式锁的实现理解基本概念
分布式锁实现的三个核心要素:
加锁
最简单的方法是使用 setnx 命令。key 是锁的唯一标识,按业务来决定命名。比如想要给一种商品的秒杀活动加锁,可以给 key 命名为 “lock_sale_商品ID” 。而 value 设置成什么呢?我们可以姑且设置成 1。加锁的伪代码如下:
setnx(lock_sale_商品ID,1)
当一个线程执行 setnx 返回 1,说明 key 原本不存在,该线程成功得到了锁;当一个线程执行 setnx 返回 0,说明 key 已经存在,该线程抢锁失败。
解锁
有加锁就得有解锁。当得到锁的线程执行完任务,需要释放锁,以便其他线程可以进入。释放锁的最简单方式是执行 del 指令,伪代码如下:
del(lock_sale_商品ID)
释放锁之后,其他线程就可以继续执行 setnx 命令来获得锁。
锁超时
锁超时是什么意思呢?如果一个得到锁的线程在执行任务的过程中挂掉,来不及显式地释放锁,这块资源将会永远被锁住(死锁),别的线程再也别想进来。所以,setnx 的 key 必须设置一个超时时间,以保证即使没有被显式释放,这把锁也要在一定时间后自动释放。setnx 不支持超时参数,所以需要额外的指令,伪代码如下:
expire(lock_sale_商品ID, 30)
综合伪代码如下:
if(setnx(lock_sale_商品ID,1) == 1){
expire(lock_sale_商品ID,30)
try {
do something ......
} finally {
del(lock_sale_商品ID)
}
}
存在什么问题
以上伪代码中存在三个致命问题
setnx 和 expire 的非原子性
设想一个极端场景,当某线程执行 setnx,成功得到了锁:
setnx 刚执行成功,还未来得及执行 expire 指令,节点 1 挂掉了。
这样一来,这把锁就没有设置过期时间,变成死锁,别的线程再也无法获得锁了。
怎么解决呢?setnx 指令本身是不支持传入超时时间的,set 指令增加了可选参数,伪代码如下:
set(lock_sale_商品ID,1,30,NX)
这样就可以取代 setnx 指令。
del 导致误删
又是一个极端场景,假如某线程成功得到了锁,并且设置的超时时间是 30 秒。
如果某些原因导致线程 A 执行的很慢很慢,过了 30 秒都没执行完,这时候锁过期自动释放,线程 B 得到了锁。
随后,线程 A 执行完了任务,线程 A 接着执行 del 指令来释放锁。但这时候线程 B 还没执行完,线程A实际上 删除的是线程 B 加的锁。
怎么避免这种情况呢?可以在 del 释放锁之前做一个判断,验证当前的锁是不是自己加的锁。至于具体的实现,可以在加锁的时候把当前的线程 ID 当做 value,并在删除之前验证 key 对应的 value 是不是自己线程的 ID。
加锁:
String threadId = Thread.currentThread().getId()
set(key,threadId ,30,NX)
解锁:
if(threadId .equals(redisClient.get(key))){
del(key)
}
但是,这样做又隐含了一个新的问题,判断和释放锁是两个独立操作,不是原子性。
出现并发的可能性
还是刚才第二点所描述的场景,虽然我们避免了线程 A 误删掉 key 的情况,但是同一时间有 A,B 两个线程在访问代码块,仍然是不完美的。怎么办呢?我们可以让获得锁的线程开启一个守护线程,用来给快要过期的锁“续航”。
当过去了 29 秒,线程 A 还没执行完,这时候守护线程会执行 expire 指令,为这把锁“续命”20 秒。守护线程从第 29 秒开始执行,每 20 秒执行一次。
当线程 A 执行完任务,会显式关掉守护线程。
另一种情况,如果节点 1 忽然断电,由于线程 A 和守护线程在同一个进程,守护线程也会停下。这把锁到了超时的时候,没人给它续命,也就自动释放了。
Redis实现分布式锁
Redis为单进程单线程模式,采用队列模式将并发访问变成串行访问,且多客户端对redis的连接并不存在竞争关系。其次Redis提供一些命令SETNX,GETSET,可以方便实现分布式锁机制
要保证的高可用(1一个业务节点宕机,不产生死锁;2不会被其他线程释放,谁家的锁只能由谁释放;3.保证redis加锁的原子性 4.可重入性等等),目前来看网上大部分的redis锁实现都非常不严谨,漏洞很常见,谨慎使用也许业务量小并不容易发现bug!!!
Redis命令
SETNX命令(推荐使用set(arg1,arg2,arg3,arg4,arg5))
语法:ETNX key value
功能:当且仅当 key 不存在,将 key 的值设为 value ,并返回1;若给定的 key 已经存在,则 SETNX 不做任何动作,并返回0。
GETSET命令
语法:GETSET key value
功能:将给定 key 的值设为 value ,并返回 key 的旧值 (old value),当 key 存在但不是字符串类型时,返回一个错误,当key不存在时,返回nil。
GET命令
语法:GET key
功能:返回 key 所关联的字符串值,如果 key 不存在那么返回特殊值 nil 。
DEL命令语法:
DEL key [KEY …]
功能:删除给定的一个或多个 key ,不存在的 key 会被忽略。
EVAL命令语法:
EVAL script numkeys key [key …] arg [arg …]
从 Redis 2.6.0 版本开始,通过内置的 Lua 解释器,可以使用 EVAL 命令对 Lua 脚本进行求值。
Redis 悲观锁代码实现
以下为代码摘抄,后期会作为一个功能点开源。(以下代码并不严谨,只做说明示例!!)
/**
* 加锁代码摘录
*/
@Override
public boolean tryLock(String lockName, long timeout, TimeUnit unit) throws InterruptedException {
LockInfo lockInfo = new LockInfo(Thread.currentThread(), lockName);
// 先判断重入锁
if (reentrantIfNeed(lockInfo)) {
return true;
} else {
return tryAcquire(lockInfo, timeout, unit);
}
}
/**
* @param lockInfo
* @param timeout
* @param unit
* @return
* @throws InterruptedException
*/
private boolean tryAcquire(LockInfo lockInfo, long timeout, TimeUnit unit) throws InterruptedException {
// lock first time
Long loopTimeout = (unit != null) ? unit.toMillis(timeout) : timeout;
long startMillis = System.currentTimeMillis();
boolean isAcqired = false;
do {
String result = getCache().set(lockInfo.getName(), lockInfo.getValue(), SET_IF_NOT_EXIST,
SET_EXPIRE_MILLISECONDS, LOCK_EXPIRE_MILLSECOND);
if ("OK".equals(result)) {
isAcqired = true;
break;
}
TimeUnit.NANOSECONDS.sleep(RETRY_INTERVAL);
} while (System.currentTimeMillis() - startMillis > loopTimeout);
if (isAcqired) {
allLockInfo.add(lockInfo);
}
return isAcqired;
}
/**
* 解锁代码摘录
*/
protected static final String UNLOCK_LUA_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return -1 end";
@Override
public void unlock(String lockName) {
Thread currentThread = Thread.currentThread();
LockInfo lockInfo = new LockInfo(currentThread, lockName);
LockInfo locked = getExistedLock(lockInfo);
if (null == locked) {
throw new IllegalMonitorStateException("currentThread : [ " + currentThread + " ] unlock : [ " + lockName
+ " ] fail because of not owner.");
}
int reentrantTimes = locked.reentrantMinusOne();
if (reentrantTimes > 0) {
return;
} else if (reentrantTimes < 0) {
throw new IllegalMonitorStateException("currentThread : [ " + currentThread + " ] unlock : [ " + lockName
+ " ] fail because of reentrant can't be negative.");
} else {
Object result = getCache().eval(UNLOCK_LUA_SCRIPT, Collections.singletonList(lockName),
Collections.singletonList(locked.getValue()));
if (!UNLOCK_FAIL.equals(result)) {
allLockInfo.remove(locked);
}
}
}
Redis 乐观锁代码实现
/**
* @author zhangsh
*/
public class RedisWatchLock {
private static final String redisHost = "127.0.0.1";
private static final int port = 6379;
private static JedisPoolConfig config;
private static JedisPool pool;
private static ExecutorService service;
private static int count = 10;
private static CountDownLatch latch;
private static AtomicInteger Countor = new AtomicInteger(0);
static {
config = new JedisPoolConfig();
config.setMaxIdle(10);
config.setMaxWaitMillis(1000);
config.setMaxTotal(30);
pool = new JedisPool(config, redisHost, port);
service = Executors.newFixedThreadPool(10);
latch = new CountDownLatch(count);
}
public static void main(String args[]) {
int count = 10;
String ThreadNamePrefix = "thread-";
Jedis cli = pool.getResource();
cli.del("redis_inc_key");// 先删除既定的key
cli.set("redis_inc_key", String.valueOf(1));// 设定默认值
for (int i = 0; i < count; i++) {
Thread th = new Thread(new TestThread(pool));
th.setName(ThreadNamePrefix + i);
System.out.println(th.getName() + "inited...");
service.submit(th);
}
service.shutdown();
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("all sub thread sucess");
System.out.println("countor is " + Countor.get());
String countStr = cli.get("redis_inc_key");
System.out.println(countStr);
}
public static class TestThread implements Runnable {
private String incKeyStr = "redis_inc_key";
private Jedis cli;
private JedisPool pool;
public TestThread(JedisPool pool) {
cli = pool.getResource();
this.pool = pool;
}
public void run() {
try {
for (int i = 0; i < 100; i++) {
actomicAdd();//生产环境中批量操作尽量使用redisPipeLine!!
}
} catch (Exception e) {
pool.returnBrokenResource(cli);
} finally {
pool.returnResource(cli);
latch.countDown();
}
}
/**
* 0 watch key
* 1 multi
* 2 set key value(queued)
* 3 exec
*
* return null:fail
* reurn "ok": succeed
*
* watch每次都需要执行(注册)
*/
public void actomicAdd() {
cli.watch(incKeyStr);// 0.watch key
boolean flag = true;
while (flag) {
String countStr = cli.get("redis_inc_key");
int countInt = Integer.parseInt(countStr);
int expect = countInt + 1;
Transaction tx = cli.multi(); // 1.multi
tx.set(incKeyStr, String.valueOf(expect));// 2.set key value
// (queued)
List<Object> list = tx.exec();// 3.exec
if (list == null) {
System.out.println("fail");
continue;
} else {
flag = false;
System.out.println("succeed");
}
System.out.println("my expect num is " + expect);
System.out.println("seting....");
}
Countor.incrementAndGet();
}
}
}
ZooKeeper分布式锁实现
ZooKeeper典型应用——分布式锁
Zookeeper 如何实现分布式锁
对比
数据库分布式锁实现
缺点:1.db操作性能较差,并且有锁表的风险
2.非阻塞操作失败后,需要轮询,占用cpu资源;
3.长时间不commit或者长时间轮询,可能会占用较多连接资源
Redis(缓存)分布式锁实现
缺点:1.锁删除失败 过期时间不好控制
2.非阻塞,操作失败后,需要轮询,占用cpu资源;
ZK分布式锁实现
缺点:性能不如redis实现,主要原因是写操作(获取锁释放锁)都需要在Leader上执行,然后同步到follower。
总之:ZooKeeper有较好的性能和可靠性。
从理解的难易程度角度(从低到高)数据库 > 缓存 > Zookeeper
从实现的复杂性角度(从低到高)Zookeeper >= 缓存 > 数据库
从性能角度(从高到低)缓存 > Zookeeper >= 数据库
从可靠性角度(从高到低)Zookeeper > 缓存 > 数据库