从单体锁到分布式锁
单体锁
1. synchronized
- 同步代码块
Object obj = new Object();
synchronized(obj){
//需要被同步的代码块
}
synchronized(this){}
上面两种锁的都是对象
synchronize(TheClass.class){
}
这种锁住的是一个类
- 同步方法
public synchronized void testThread()
{
//需要被同步的代码块
}
2. 同步锁 ReentrantLock
JDK 1.7之后引入的JUC包中的重要工具类,让线程同步变得如此丝滑
class A
{
private final ReentrantLock lock=new ReentrantLock();
public void method()
{
lock.lock();
try{
//需要被同步的代码块
}catch(Exception e){
e.printStackTrace();
}finally{
lock.unlock();
}
}
}
这是一种功能更为强大的线程同步机制,通过显式定义同步锁对象来实现同步,这里的同步锁由Lock对象充当。使用Lock与使用同步代码块有点类似,只是使用Lock时可以显示使用Lock对象作为同步锁,而使用同步方法时系统隐式使用当前对象作为同步监视器。
其中,为了确保能够在必要的时候释放锁,代码中使用finally来确保锁的释放,来防止死锁!
分布式锁
上面两种都是单体锁,跨JVM的情况下不好使,如果需要解决分布式服务下的资源竞争问题,需要引入分布式锁。
3. 基于数据库排他锁实现分布式锁
基于排他锁(for update
)实现
实现方式
- 获取锁可以通过,在select语句后增加
for update
,数据库会在查询过程中给数据库表增加排他锁。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁,我们可以认为获得排它锁的线程即可获得分布式锁; - 其余实现与使用唯一索引相同;
- 释放锁通过
connection.commit();
操作,提交事务来实现。
for update
具体可参考数据库-MySQL中for update的作用和用法一文。
select … for update
select @@autocommit;
给一行数据加锁 需要把自动提交关闭
set @@autocommit = 0;
在执行 select * from xxx where 。。。for update之后
另外一个会话是没办法通过 select * from xxx where 。。。for update 查询出数据的
但是可以通过select * from xxx where 。。。插出数据,
也就是说 可以读数据,不能写数据。
优点:
简单方便,易于理解,易于操作
缺点:
- 排他锁会占用连接,产生连接爆满的问题;
- 如果表不大,可能并不会使用行锁;
- 同样存在单点问题、并发量问题。
实现
伪代码
CREATE TABLE `methodLock` (
`id` INT ( 11 ) NOT NULL AUTO_INCREMENT COMMENT '主键',
`method_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_method_name` ( `method_name ` ) USING BTREE
) ENGINE = INNODB DEFAULT CHARSET = utf8 COMMENT = '锁定中的方法';
/**
* 加锁
*/
public boolean lock() {
// 开启事务
connection.setAutoCommit(false);
// 循环阻塞,等待获取锁
while (true) {
// 执行获取锁的sql
result = select * from methodLock where method_name = xxx for update;
// 结果非空,加锁成功
if (result != null) {
return true;
}
}
// 加锁失败
return false;
}
/**
* 解锁
*/
public void unlock() {
// 提交事务,解锁
connection.commit();
}
SpringBoot +JPA案例
https://www.cnblogs.com/dalaoyang/p/11214159.html
4. 基于Redis的Setnx实现分布式锁
加锁
set resource_name my_random_value NX PX 30000
释放锁
delete
释放锁需要lua脚本
if redis.call("get",KEYS[1])==ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
4.1、加锁
加锁实际上就是在redis中,给Key键设置一个值,为避免死锁,并给定一个过期时间。
SET lock_key random_value NX PX 5000
值得注意的是:
random_value
是客户端生成的唯一的字符串。
NX
代表只在键不存在时,才对键进行设置操作。
PX 5000
设置键的过期时间为5000毫秒。
这样,如果上面的命令执行成功,则证明客户端获取到了锁。
4.2、解锁
解锁的过程就是将Key键删除。但也不能乱删,不能说客户端1的请求将客户端2的锁给删除掉。这时候random_value
的作用就体现出来。
为了保证解锁操作的原子性,我们用LUA脚本完成这一操作。先判断当前锁的字符串是否与传入的值相等,是的话就删除Key,解锁成功。
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end
4.3、在SpringBoot中的具体实现
首先,我们在pom文件中,引入Jedis。在这里,笔者用的是最新版本,注意由于版本的不同,API可能有所差异。
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.0.1</version>
</dependency>
加锁的过程很简单,就是通过SET指令来设置值,成功则返回;否则就循环等待,在timeout时间内仍未获取到锁,则获取失败。
@Service
public class RedisLock {
Logger logger = LoggerFactory.getLogger(this.getClass());
private String lock_key = "redis_lock"; //锁键
protected long internalLockLeaseTime = 30000;//锁过期时间
private long timeout = 999999; //获取锁的超时时间
//SET命令的参数
SetParams params = SetParams.setParams().nx().px(internalLockLeaseTime);
@Autowired
JedisPool jedisPool;
/**
* 加锁
* @param id
* @return
*/
public boolean lock(String id){
Jedis jedis = jedisPool.getResource();
Long start = System.currentTimeMillis();
try{
for(;;){
//SET命令返回OK ,则证明获取锁成功
String lock = jedis.set(lock_key, id, params);
if("OK".equals(lock)){
return true;
}
//否则循环等待,在timeout时间内仍未获取到锁,则获取失败
long l = System.currentTimeMillis() - start;
if (l>=timeout) {
return false;
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}finally {
jedis.close();
}
}
}
解锁我们通过jedis.eval
来执行一段LUA就可以。将锁的Key键和生成的字符串当做参数传进来。
/**
* 解锁
* @param id
* @return
*/
public boolean unlock(String id){
Jedis jedis = jedisPool.getResource();
String script =
"if redis.call('get',KEYS[1]) == ARGV[1] then" +
" return redis.call('del',KEYS[1]) " +
"else" +
" return 0 " +
"end";
try {
Object result = jedis.eval(script, Collections.singletonList(lock_key),
Collections.singletonList(id));
if("1".equals(result.toString())){
return true;
}
return false;
}finally {
jedis.close();
}
}
最后,我们可以在多线程环境下测试一下。我们开启1000个线程,对count进行累加。调用的时候,关键是唯一字符串的生成。这里,笔者使用的是Snowflake
算法。
@Controller
public class IndexController {
@Autowired
RedisLock redisLock;
int count = 0;
@RequestMapping("/index")
@ResponseBody
public String index() throws InterruptedException {
int clientcount =1000;
CountDownLatch countDownLatch = new CountDownLatch(clientcount);
ExecutorService executorService = Executors.newFixedThreadPool(clientcount);
long start = System.currentTimeMillis();
for (int i = 0;i<clientcount;i++){
executorService.execute(() -> {
//通过Snowflake算法获取唯一的ID字符串
String id = IdUtil.getId();
try {
redisLock.lock(id);
count++;
}finally {
redisLock.unlock(id);
}
countDownLatch.countDown();
});
}
countDownLatch.await();
long end = System.currentTimeMillis();
logger.info("执行线程数:{},总耗时:{},count数为:{}",clientcount,end-start,count);
return "Hello";
}
}
优点: 简单
缺点:不具备可重入性。
5. 基于Zookeeper的原生语法实现分布式锁
原理
大概就是借助zk的瞬时节点的有序性和Watcher
https://juejin.cn/post/6844903729406148622#comment
6. 基于Zookeeper工具类curator实现分布式锁【推荐】
zookeeper的四种节点类型
1、持久化节点 :所谓持久节点,是指在节点创建后,就一直存在,直到有删除操作来主动清除这个节点——不会因为创建该节点的客户端会话失效而消失。
2、****持久化顺序节点****:这类节点的基本特性和上面的节点类型是一致的。额外的特性是,在ZK中,每个父节点会为他的第一级子节点维护一份时序,会记录每个子节点创建的先后顺序。基于这个特性,在创建子节点的时候,可以设置这个属性,那么在创建节点过程中,ZK会自动为给定节点名加上一个数字后缀,作为新的节点名。这个数字后缀的范围是整型的最大值。****基于持久顺序节点原理的经典应用-分布式唯一ID生成器****。
3、临时节点:和持久节点不同的是,临时节点的生命周期和客户端会话绑定。也就是说,如果客户端会话失效,那么这个节点就会自动被清除掉。注意,这里提到的是会话失效,而非连接断开。另外,在临时节点下面不能创建子节点,集群zk环境下,****同一个路径的临时节点只能成功创建一个,利用这个特性可以用来实现master-slave选举****。
4、****临时顺序节点****:相对于临时节点而言,临时顺序节点比临时节点多了个有序,也就是说每创建一个节点都会加上节点对应的序号,先创建成功,序号越小。其经典应用场景为实现分布式锁。
监视器(watcher)
当zookeeper创建一个节点时,会注册一个该节点的监视器,当节点状态发生改变时,watch会被触发,zooKeeper将会向客户端发送一条通知(就一条,因为watch只能被触发一次)。
原理
Curator内部是通过InterProcessMutex(可重入锁)来在zookeeper中创建临时有序节点实现的,之前说过,如果通过临时节点及watch机制实现锁的话,这种方式存在一个比较大的问题:所有取锁失败的进程都在等待、监听创建的节点释放,很容易发生"羊群效应",zookeeper的压力是比较大的,而临时有序节点就很好的避免了这个问题,Curator内部就是创建的临时有序节点。
基本原理:
创建临时有序节点,每个线程均能创建节点成功,但是其序号不同,只有序号最小的可以拥有锁,其它线程只需要监听比自己序号小的节点状态即可
基本思路如下:
1、在你指定的节点下创建一个锁目录lock;
2、线程X进来获取锁在lock目录下,并创建临时有序节点;
3、线程X获取lock目录下所有子节点,并获取比自己小的兄弟节点,如果不存在比自己小的节点,说明当前线程序号最小,顺利获取锁;
4、此时线程Y进来创建临时节点并获取兄弟节点 ,判断自己是否为最小序号节点,发现不是,于是设置监听(watch)比自己小的节点(这里是为了发生上面说的羊群效应);
5、线程X执行完逻辑,删除自己的节点,线程Y监听到节点有变化,进一步判断自己是已经是最小节点,顺利获取锁。
代码实现
package cn.stylefeng.guns.modular.es.entity;
/**
* @author: allen
* @date: 2020/11/24 23:11
* @version: 1.0
*/
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.TimeUnit;
/**
* classname:DistributedLock
* desc:基于zookeeper的开源客户端Cruator实现分布式锁
* author:simonsfan
*/
public class DistributedLock {
public static Logger log = LoggerFactory.getLogger(DistributedLock.class);
private InterProcessMutex interProcessMutex; //可重入排它锁
private String lockName; //竞争资源标志
private String root = "/distributed/lock/";//根节点
private static CuratorFramework curatorFramework;
private static String ZK_URL = "zookeeper1.tq.master.cn:2181,zookeeper3.tq.master.cn:2181,zookeeper2.tq.master.cn:2181,zookeeper4.tq.master.cn:2181,zookeeper5.tq.master.cn:2181";
static{
curatorFramework= CuratorFrameworkFactory.newClient(ZK_URL,new ExponentialBackoffRetry(1000,3));
curatorFramework.start();
}
/**
* 实例化
* @param lockName
*/
public DistributedLock(String lockName){
try {
this.lockName = lockName;
interProcessMutex = new InterProcessMutex(curatorFramework, root + lockName);
}catch (Exception e){
log.error("initial InterProcessMutex exception="+e);
}
}
/**
* 获取锁
*/
public void acquireLock(){
int flag = 0;
try {
//重试2次,每次最大等待2s,也就是最大等待4s
while (!interProcessMutex.acquire(2, TimeUnit.SECONDS)){
flag++;
if(flag>1){ //重试两次
break;
}
}
} catch (Exception e) {
log.error("distributed lock acquire exception="+e);
}
if(flag>1){
log.info("Thread:"+Thread.currentThread().getId()+" acquire distributed lock busy");
}else{
log.info("Thread:"+Thread.currentThread().getId()+" acquire distributed lock success");
}
}
/**
* 释放锁
*/
public void releaseLock(){
try {
if(interProcessMutex != null && interProcessMutex.isAcquiredInThisProcess()){
interProcessMutex.release();
curatorFramework.delete().inBackground().forPath(root+lockName);
log.info("Thread:"+Thread.currentThread().getId()+" release distributed lock success");
}
}catch (Exception e){
log.info("Thread:"+Thread.currentThread().getId()+" release distributed lock exception="+e);
}
}
}
业务层使用时要记得释放锁。要特别注意的是 interProcessMutex.acquire(2, TimeUnit.SECONDS)方法,可以设定等待时候,加上重试的次数,即排队等待时间= acquire × 次数,这两个值一定要设置好,因为使用了分布式锁之后,接口的TPS就下降了,没获取到锁的接口都在等待/重试,如果这里设置的最大等待时间4s,这时并发进来1000个请求,4秒内处理不完1000个请求怎么办呢?所以一定要设置好这个重试次数及单次等待时间,根据自己的压测接口设置合理的阈值,避免业务流转发生问题!
7. 基于Redission分布式锁【推荐】
文档: https://github.com/redisson/redisson/wiki/8.-distributed-locks-and-synchronizers
使用javaAPI
RLock lock = redisson.getLock("myLock");
// traditional lock method
lock.lock();
// or acquire lock and automatically unlock it after 10 seconds
lock.lock(10, TimeUnit.SECONDS);
// or wait for lock aquisition up to 100 seconds
// and automatically unlock it after 10 seconds
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
try {
...
} finally {
lock.unlock();
}
}
使用SpringBootStarter
1. Add redisson-spring-boot-starter
dependency into your project:
Maven
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.14.0</version>
</dependency>
Gradle
compile 'org.redisson:redisson-spring-boot-starter:3.14.0'
Downgrade redisson-spring-data
module if necessary to support required Spring Boot version:
redisson-spring-data module name | Spring Boot version |
---|---|
redisson-spring-data-16 | 1.3.x |
redisson-spring-data-17 | 1.4.x |
redisson-spring-data-18 | 1.5.x |
redisson-spring-data-20 | 2.0.x |
redisson-spring-data-21 | 2.1.x |
redisson-spring-data-22 | 2.2.x |
redisson-spring-data-23 | 2.3.x |
redisson-spring-data-24 | 2.4.x |
2. Add settings into application.settings
file
Common spring boot settings or Redisson settings could be used.
# common spring boot settings
spring:
redis:
database:
host:
port:
password:
ssl:
timeout:
cluster:
nodes:
sentinel:
master:
nodes:
# Redisson settings
#path to config - redisson.yaml
redisson:
file: classpath:redisson.yaml
config: |
clusterServersConfig:
idleConnectionTimeout: 10000
connectTimeout: 10000
timeout: 3000
retryAttempts: 3
retryInterval: 1500
failedSlaveReconnectionInterval: 3000
failedSlaveCheckInterval: 60000
password: null
subscriptionsPerConnection: 5
clientName: null
loadBalancer: !<org.redisson.connection.balancer.RoundRobinLoadBalancer> {}
subscriptionConnectionMinimumIdleSize: 1
subscriptionConnectionPoolSize: 50
slaveConnectionMinimumIdleSize: 24
slaveConnectionPoolSize: 64
masterConnectionMinimumIdleSize: 24
masterConnectionPoolSize: 64
readMode: "SLAVE"
subscriptionMode: "SLAVE"
nodeAddresses:
- "redis://127.0.0.1:7004"
- "redis://127.0.0.1:7001"
- "redis://127.0.0.1:7000"
scanInterval: 1000
pingConnectionInterval: 0
keepAlive: false
tcpNoDelay: false
threads: 16
nettyThreads: 32
codec: !<org.redisson.codec.FstCodec> {}
transportMode: "NIO"
3. Use Redisson through spring bean with RedissonClient
interface or RedisTemplate
/ReactiveRedisTemplate
objects
8.各种锁的对比
方式 | 优点 | 缺点 |
---|---|---|
数据库MYSQL的InnoDb | 实现简单,易于实现 | 对数据库压力大 |
Redis | 易于理解 | 自己实现、不支持阻塞 |
Zookeeper | 支持阻塞 | 需理解Zookeeper、程序复杂 |
Curator【推荐】 | 提供锁的方法 | 依赖Zookeeper,强一致 |
Redission【推荐】 | 提供锁的高级API,可阻塞 |
实际上,公司也是用Curator和Redisson的多,因为Redis集群和zk集群都是现成的,可以就地取材。