好了切入正题,一直在工作中会聊到很多锁的问题,今天跟大家一起闲聊一下,究竟什么是锁,为什么需要锁,以及分布式的情况下,怎么设计和实现锁。
什么是锁?
明·魏禧《大铁椎传》上是这样解释的:
锁:置于可启闭的器物上,以钥匙或暗码(如字码机构、时间机构、自动释放开关、磁性螺线管等)打开的扣件,例如:柄铁折叠环复,如锁上练,引之长丈许。
锁,就是要对一个可启闭的东西上,拥有者拥有着钥匙或者某些 Code , 用于打开的扣件。那么锁为什么要产生?为什么要用锁来将那个东西给加上锁,以便达到只有拥有者可以操作的效果呢?
历史上来看,锁几乎与私有制同时诞生。早在公元前3000年的中国仰韶文化遗址中,就留存有装在木结构框架建筑上的木锁。东汉时,中国铁制三簧锁的技术已具有相当高的水平。三簧锁前后沿用了1000多年。
那么在互联网,在软件中的锁是什么定义呢?在我看来,锁就是保证多线程在竞态条件下对共享资源操作的一致性。
怎么理解?
如果没有共享资源,那么锁并没有任何作用,每个业务每个线程都拥有自己的独占的资源,那么锁也就没有用武之地了。这些资源,任何其他业务其他线程都访问不了,那么这些资源对于本业务来说就是私有的,也就不需要加锁了。
那什么叫竞态条件呢?百科里是这样解释的:
竞态条件(race condition),从多进程间通信的角度来讲,是指两个或多个进程对共享的数据进行读或写的操作时,最终的结果取决于这些进程的执行顺序。
我们可以抓住三个关键字,多进程、共享数据、执行顺序。如果并没有多进程多线程,那么并不需要锁,因为不可能会出现竞态。如果所有的操作都是有序的,那么也不需要锁,因为顺序操作只要每个操作都是原子性的,那么基本不可能会出现竞态。
所以,锁的出现,是为了保证多线程在竞态条件下对共享资源操作的一致性。
经典传统应用环境下锁的使用机制是怎么样的?我们都知道数据库有很多种锁。乐观锁,悲观锁,排他锁,行锁,表锁… 诸如此类的定义。我们这里只稍微看乐观锁和悲观锁。
乐观锁:很乐观。认为大家的数据操作都是很守规矩的不会乱来,所以只在修改操作的时候会加锁。
悲观锁:很悲观。认为大家的数据操作都是不可估计而且可能带来严重影响的,所以在整个操作过程都会进行加锁。
当然,各种设计可能在这个层次之上会加上意向锁,意思就是你要获得乐观锁之前,要先获得意见乐观锁,意向排他锁与此类似。
也可能很变态,带上意向的意向锁。就好像,预约一下去预约去预约买车牌的预约。
传统的应用因为都是单机的,所以可以单起一个线程单独控制所有的操作即可,对数据进行锁定。很多的数据库都实现了相应的锁机制。
例如 Oracle ,根据保护的对象不同,Oracle实现的数据库锁可以分为以下几大类。
1、DML锁(data locks,数据锁),用于保护数据的完整性。
2、DDL锁(dictionary locks,字典锁),用于保护数据库对象的结构,如表、索引等的结构定义。
3、内部锁和闩(internal locks and latches),保护数据库的内部结构。
例如 MySQL ,不同的引擎支持的锁类型是不一样的,下面的表格可以一探究竟。至于乐观、悲观、意向乐观、意向悲观这些的设计跟 Oracle 如出一辙。
但是慢慢的,很多软件都运行在分布式的环境下,具体的套路可以看看我之前的文章。分布式架构的套路No.74
那为什么需要在分布式环境下使用锁呢?传统的应用在单机的情况下直接用一个统一的线程进行管控就可以了,但是在分布式环境下情况又不一样了。如果每个人都只持有自己的锁,对于其他人不可见,并不是全局唯一的锁,这样的锁是没有意义的。所以也就会有了分布式架构下的锁。
分布式锁有两层含义。第一层是在分布式的系统中用锁来保证业务的正确性。另外一层是用分布式的服务来保证锁的高可用性。
在分布式的环境中,分布式锁的实现方式大概有下面这么几种。
- 数据库。
- 缓存。
- 分布式一致性系统。
下面我们一一来聊他们的设计和实现。
数据库
现在 MySQL 和很多数据库都实现了分布式,但是也可以使用 MySQL 自己来实现分布式锁,实现方式是这样的。
1、在分布式操作之前,对数据库的定义了唯一键的表中插入一条数据。
2、操作之后,将这条数据删除掉。
3、启动一个定时 Job ,对已经过时的锁进行删除。
这样就能实现一个基于数据库的排他锁了。
缓存系统
缓存系统在实现的时候跟数据库的模式差不多,但是因为数据都是在缓存中,所以加锁和解锁都会比数据库快很多。
下面举例看看基于 Redis 的分布式锁实现。Redis 的分布式锁都是基于一个命令 — SETNX,也就是 SET IF NOT EXIST,如果不存在就写入。从 Redis 2.6.12 版本开始,Redis 的 SET 命令直接直接设置 NX 和 EX 属性,NX 即附带了 SETNX 数据,key 存在就无法插入,EX 是过期属性,可以设置过期时间。这样一个命令就能原子的完成加锁和设置过期时间。
第一次放广告,试试了,别打我哈:
代码在这,自己看看吧。
pom文件是这样。
实现的代码是这样的。我就不讲解了,Code will talk 。
package lock; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import redis.clients.jedis.JedisPoolConfig; import utils.Printer; import java.util.Collections; import java.util.ResourceBundle; /** * @Author 大蕉 * @Since 2018.02.13 * @desc 基于redis的分布式锁实现 */ public class RedisManager { public static JedisPool jedisPool; private static final String LOCK_SUCCESS = "OK"; private static final String SET_IF_NOT_EXIST = "NX"; private static final String SET_WITH_EXPIRE_TIME = "PX"; private static final Long RELEASE_SUCCESS = 1L; /** * * 过期时间设置 * EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 。 * PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 。 * * 执行条件设置 * NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 。 * XX :只在键已经存在时,才对键进行设置操作。 */ static { //读取相关的配置 ResourceBundle resourceBundle = ResourceBundle.getBundle("redis"); int maxActive = Integer.parseInt(resourceBundle.getString("redis.pool.maxActive")); int maxIdle = Integer.parseInt(resourceBundle.getString("redis.pool.maxIdle")); int maxWait = Integer.parseInt(resourceBundle.getString("redis.pool.maxWait")); String ip = resourceBundle.getString("redis.ip"); int port = Integer.parseInt(resourceBundle.getString("redis.port")); JedisPoolConfig config = new JedisPoolConfig(); //设置最大连接数 config.setMaxTotal(maxActive); //设置最大空闲数 config.setMaxIdle(maxIdle); //设置超时时间 config.setMaxWaitMillis(maxWait); //初始化连接池 jedisPool = new JedisPool(config, ip, port); } public static boolean tryLock(String key,String value,int expireSecond){ Jedis jedis = jedisPool.getResource(); if(jedis == null){ return false; } String result = jedis.set(key, value, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireSecond); if (LOCK_SUCCESS.equals(result)) { return true; } return false; } public static boolean releaseDistributedLock(String key,String value) { Jedis jedis = jedisPool.getResource(); if(jedis == null){ return false; } String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Object result = jedis.eval(script, Collections.singletonList(key), Collections.singletonList(value)); if (RELEASE_SUCCESS.equals(result)) { return true; } return false; } public static void main(String[] args){ Printer.println(tryLock("A","B",100)); Printer.println(releaseDistributedLock("A","B")); } }
除此之外,Redis 的作者还实现了一个分布式锁算法,叫Redlock,有兴趣的朋友自己 Google 。感兴趣的朋友多的话,我后面再聊聊这个算法的由来和因缘。
那么基于 Tair 的分布式锁是怎么实现的呢?
Tair 多了一个版本的概念,所以另外一种实现思路是用版本来控制锁。加锁的时候写一个默认的版本号,那么如果两次写入都指定了同一个版本的话,服务端会直接报错导致加锁失败。
分布式一致性系统
分布式一致性的系统,现在最流行的应该局势 Zookeeper 了。Zookeeper 实现了类 Paxos 的设计。用 Zookeeper 是使用新增子节点的模式来进行加锁。
比如 B 要对数据 A 进行加锁,可以这样操作。 create /locks/A "B"
其他节点在对 Zookeeper 进行加锁的时候,因为目录已经存在,会直接报错。解锁的时候直接 delete /locks/A ,这样就好了。
Zookeeper 是怎么实现分布式一致性的呢?最最主要的设计就是 Zookeeper 实现了 leader 选举制以及 follower 转发制。follower 在接收到请求的时候,会直接转发给 leader,由 leader 进行数据的统一处理。