为什么使用锁
两个原因:
-
效率
避免多个客户端重复相同的工作,此时对共享资源的操作需要满足幂等性,操作多次的结果是一样的。这种情况下加锁就是为了避免重复相同的工作。
-
正确性
避免多个客户端操作同一份共享资源时由于环境原因导致数据不一致,此时对共享资源的操作不满足幂等性,这种情况下加锁就是为了业务逻辑的正确性。
锁的作用的就是确保同一时间只有一个线程可以进入临界区执行代码
什么是锁
Linux 自旋锁
1 | 单处理器自旋锁的工作流程是: |
Linux 互斥锁
1 | 互斥锁是基于自旋锁、等待队列、整型变量的引用计数器的结构体封装, |
Java语言层面的锁都是基于Linux锁实现的
本地锁
1 | //本地锁没有锁超时的机制,一个线程获取到锁后,其他线程只能阻塞等待 |
读写锁
读写锁应用在读多写少的场合,比如缓存
读写锁是两个锁的组合,主要建立了以下关系:
- 读锁与写锁互斥(比如:更新缓存时加写锁,读取缓存时加读锁。则更新缓存时,读取缓存的线程要阻塞等待)
- 读锁与读锁共享(多个线程可以并发同时读取缓存)
分布式锁-Spring-Integration-Redis
Redis一次只运行一个命令,Lua脚本的运行与其他的Redis命令相同,都是原子操作
获取锁(lua脚本执行命令)
lua脚本逻辑:
先判断key是否存在?
key存在,则判断key对应value是否和本次要设置的值一致,一致则设置超时时间
key不存在或者key对应value不一致,则新设置key-value,并设置超时时间(set的值要保持唯一,可以使用uuid,以防误删其他客户端获取到的锁。场景:【客户端A获取锁成功后,由于操作阻塞较长时间,key过期删除了。然后客户端B获取锁成功,在B操作期间,客户端A又尝试去释放锁,如果没有value判断,就会造成客户端A释放了客户端B获取到的锁】)
1 | local lockClientId = redis.call('GET', KEYS[1]) |
释放锁
删除key
1 | // 都是删除key |
Spring-Integration-Redis实现分布式锁:(ReentrantLock + Redis)
在本地使用ReentrantLock来防止:线程1获取redis锁,操作阻塞,redis锁超时被删除,然后线程2来获取锁时会阻塞在获取同一把ReentrantLock上,只有线程1释放redis锁(由于redis锁超时,会throw IllegalStateException: Lock was released in the store due to expiration. The integrity of data protected by this lock may have been compromised)后,ReentrantLock锁才会释放,然后线程2才可以获取到同一把redis锁+ReentrantLock
但是如果线程1、2不是在同一个JVM里,这种本地锁的方式无法解决:锁超时引起的两个线程同时进入临界区的问题
Spring-Integration-Redis 对锁超时没有进行更好的处理,只是抛出异常
所以,使用Spring-Integration-Redis时,需要慎重设置Key过期时间(默认60s),因为过期时间设置太短会有锁超时问题,过期时间设置太长,如果一个服务在获取锁后崩溃, 其他服务要阻塞很久才可以重新获取锁。
问题:
- 每个锁对应的key都设置了超时时间,锁超时删除会引发两个线程同时进入临界区的问题
- 这种方式是基于单机Redis的实现,还具有单点失败的风险
- 即使将Reids架设成主从结构,在主从切换的时候也会出现两个线程同时进入临界区的问题
- 使用RedLock应对Redis高可用环境下分布式锁的获取,也会存在集群节点崩溃和时间跳跃导致两个线程同时进入临界区的问题,另外RedLock也没解决锁超时删除会引发两个线程同时进入临界区的问题
解决:
- 解决锁超时删除:锁续约机制
- 解决单点失败:部署Redis集群使用RedLock
- 解决RedLock集群节点崩溃问题:延迟重启崩溃节点(类似TCP退避重传机制,延迟时间大于锁的有效时间)
- 解决RedLock时间跳跃问题:禁止人为修改系统时间,使用一个不会进行“跳跃”式调整系统时钟的ntpd程序。
分布式锁-Reddison
获取锁(lua脚本执行命令)
lua脚本逻辑:
判断HASH key是否存在,
不存在,则新建HASH Key,设置Field和自增Field,并设置Key过期时间
判断HASH Key Field是否存在,
存在,则自增Field,并设置Key过期时间(默认30,000ms=30s)
返回Key过期时间
1 | if (redis.call('exists', KEYS[1]) == 0) then |
为防止锁超时引起的安全问题,Redisson引入锁续约机制:获取锁成功后开启看门狗定时器默认每30,000/3ms=10s刷新一次Key过期时间
1 | if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then |
释放锁
判断HASH Key-Field是否存在,
不存在,直接返回空,Throw IllegalMonitorStateException:attempt to unlock lock, not locked by current thread
存在,递减 HASH Key-Field,并判断递减后返回count是否为0
为0,则可以释放锁:执行DEL 删除Key,并发布 UNLOCK_MESSAGE 消息(在获取锁时,未马上获取锁成功时会订阅UNLOCK_MESSAGE并同步等待用户指定的tryLock时间)
不为0,则续约锁
1 | if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then |
优点:解决锁超时问题,支持Redis Cluster环境下的RedLock
缺点:Redis Cluster的缺点:集群节点崩溃问题、时间跳跃问题
分布式锁-ZooKeeper
zookeeper和客户端建立连接后有心跳检测机制,当zookeeper长时间检测不到客户端心跳时,会主动释放客户端创建的临时节点,因此不需要设置锁的过期时间,不依赖于锁过期时间,所以不存在锁超时问题。
【ZooKeeper检测不到客户端心跳主动释放客户端创建的临时节点】这种机制也可能是由客户端长时间GC所导致的,所以也存在安全问题,但是相比redis的锁超时要可靠多了
另外ZooKeeper集群的强一致性保证了客户端要么获取不到锁,要么获取到锁后,ZooKeeper的集群节点数据一定是一致的。即不存在Redis集群的节点崩溃和时间跳跃的问题
优点:强一致性,不需要关心锁超时问题
缺点:性能不高
分布式锁-Mysql
创建一张资源表,获取锁则插入一条记录,释放锁则删除相应的记录。
有两种方式:
悲观锁:select for update + insert (一定要加事务)【事务是保证多个SQL语句要么都执行成功提交,要么任意一个失败都全部回滚,for updte是对要更新的行加锁保证更新的逻辑正确性】
乐观锁:select + update where version=xx + while循环直到成功
优点:不引入第三方组件,减少单点失败概率
缺点:数据库性能较低,支持的并发不高
另外Mysql提供了get_lock & release_lock函数也可以用于分布式锁,加锁释放锁操作都必须是同一个SqlSession,连接断开Mysql还会自动释放锁。这种锁机制是基于单机数据库的,所以存在单点失败问题。
基本用法:
1 | #加锁 |