分布式应用进行逻辑处理时经常会遇到并发问题。比如一个操作要修改用户的状态,修改状态需要先读出用户的状态, 在内存里进行修改,改完了再存回去。如果这样的操作同时进行了,就会出现并发问题, 因为读取和保存状态这两个操作不是原子的。(Wiki 解释:所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始, 就一直运行到结束,中间不会有任何 context switch 线程切换。)这个时候就要使用到分布式锁来限制程序并发的执行。本博文主要是的介绍分布式锁的原理和应用场景。
一、分布式锁实现方式锁是一种常用的并发控制机制,用于保证一项资源在任何时候只能被一个线程使用,如果其他线程也要使用同样的资源,必须排队等待上一个线程使用完。
锁指的是程序级别的锁,例如 Java 语言中的 synchronized 和 ReentrantLock 在单应用中使用不会有任何问题, 但如果放到分布式环境下就不适用了,这个时候我们就要使用分布式锁。分布式锁比较好理解就是用于分布式环境下并发控制的一种机制, 用于控制某个资源在同一时刻只能被一个应用所使用。如下所示:
分布式锁比较常见的实现方式有三种:
- 基于Memcached实现的分布式锁:使用add命令,添加成功的情况下,表示创建分布式锁成功。
- 基于数据库分布式锁实现。
- 基于Redis实现的分布式锁。
- 基于ZooKeeper实现的分布式锁:使用ZooKeeper顺序临时节点来实现分布式锁。
场景描述:客户端模拟购买商品过程,在Redis中设定库存总数剩100个,多个客户端同时并发购买。
package com.zhuangxiaoyan.springbootredis.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @description
* 最简单的情况,没有加任何的考虑,
* 即使是单体应用,并发情况下数据一致性都有问题
* @param: null
* @date: 2022/4/9 21:25
* @return:
* @author: xjl
*/
@RestController
public class NoneController {
@Autowired
StringRedisTemplate template;
@RequestMapping("/buy")
public String index() {
// Redis中存有goods:001号商品,数量为100 相当于是的redis中的get("goods")的操作。
String result = template.opsForValue().get("goods");
// 获取到剩余商品数
int total = result == null ? 0 : Integer.parseInt(result);
if (total > 0) {
// 剩余商品数大于0 ,则进行扣减
int realTotal = total - 1;
// 将商品数回写数据库 相当于设置新的值的结果
template.opsForValue().set("goods", String.valueOf(realTotal));
System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
return "购买商品成功,库存还剩:" + realTotal + "件";
} else {
System.out.println("购买商品失败");
}
return "购买商品失败";
}
}
使用Jmeter模拟高并发场景,测试结果如下:
测试结果出现多个用户购买同一商品,发生了数据不一致问题!解决办法:单体应用的情况下,对并发的操作进行加锁操作,保证对数据的操作具有原子性:
package com.zhuangxiaoyan.springbootredis.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @description 单体应用的情况下,对并发的操作进行加锁操作,保证对数据的操作具有原子性
* 1. synchronized
* 2. ReentrantLock
* 这种情况下,不会产生并发问题
* @param: null
* @date: 2022/4/9 21:25
* @return:
* @author: xjl
*/
@RestController
public class ReentrantLockController {
// 引入的ReentrantLock 锁机制
Lock lock = new ReentrantLock();
@Autowired
StringRedisTemplate template;
@RequestMapping("/buy")
public String index() {
// 加锁
lock.lock();
try {
// Redis中存有goods:001号商品,数量为100 相当于是的redis中的get("goods")的操作。
String result = template.opsForValue().get("goods");
// 获取到剩余商品数
int total = result == null ? 0 : Integer.parseInt(result);
if (total > 0) {
int realTotal = total - 1;
// 将商品数回写数据库 相当于设置新的值的结果
template.opsForValue().set("goods", String.valueOf(realTotal));
System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
return "购买商品成功,库存还剩:" + realTotal + "件";
} else {
System.out.println("购买商品失败");
}
} catch (Exception e) {
//解锁
lock.unlock();
} finally {
//解锁
lock.unlock();
}
return "购买商品失败";
}
}
3.2 分布式数据一致性问题
上面解决了单体应用的数据一致性问题,但如果是分布式架构部署呢,架构如下:提供两个服务,端口分别为8001、8002,连接同一个Redis服务,在服务前面有一台Nginx作为负载均衡。两台服务代码相同,只是端口不同。
将8001、8002两个服务启动,每个服务依然用ReentrantLock加锁,用Jmeter做并发测试,发现会出现数据一致性问题!
package com.zhuangxiaoyan.springbootredis.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* @description 面使用redis的set命令来实现加锁
* 1.SET KEY VALUE [EX seconds] [PX milliseconds] [NX|XX]
* EX seconds − 设置指定的到期时间(以秒为单位)。
* PX milliseconds - 设置指定的到期时间(以毫秒为单位)。
* NX - 仅在键不存在时设置键。
* XX - 只有在键已存在时才设置。
* @param: null
* @date: 2022/4/9 21:25
* @return:
* @author: xjl
*/
@RestController
public class RedisLockControllerV1 {
public static final String REDIS_LOCK = "good_lock";
@Autowired
StringRedisTemplate template;
@RequestMapping("/buy")
public String index() {
// 每个人进来先要进行加锁,key值为"good_lock"
String value = UUID.randomUUID().toString().replace("-", "");
try {
Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK, value);
// 加锁失败
if (!flag) {
return "抢锁失败!";
}
System.out.println(value + " 抢锁成功");
String result = template.opsForValue().get("goods");
int total = result == null ? 0 : Integer.parseInt(result);
if (total > 0) {
int realTotal = total - 1;
template.opsForValue().set("goods", String.valueOf(realTotal));
System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
return "购买商品成功,库存还剩:" + realTotal + "件";
} else {
System.out.println("购买商品失败");
}
return "购买商品失败";
} finally {
// 如果在抢到所之后,删除锁之前,发生了异常,锁就无法被释放,所以要在finally处理 template.delete(REDIS_LOCK);
template.delete(REDIS_LOCK);
}
}
}
4.4 Redis设置过期时间
如果程序在运行期间,部署了微服务jar包的机器突然挂了,代码层面根本就没有走到finally代码块,也就是说在宕机前,锁并没有被删除掉,这样的话,就没办法保证解锁,所以,这里需要对这个key加一个过期时间,Redis中设置过期时间有两种方法:
- template.expire(REDIS_LOCK,10, TimeUnit.SECONDS)
- template.opsForValue().setIfAbsent(REDIS_LOCK, value,10L,TimeUnit.SECONDS)
第一种方法需要单独的一行代码,且并没有与加锁放在同一步操作,所以不具备原子性,也会出问题, 第二种方法在加锁的同时就进行了设置过期时间,所有没有问题,这里采用这种方式。
// 为key加一个过期时间,其余代码不变
Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK,value,10L,TimeUnit.SECONDS);
package com.zhuangxiaoyan.springbootredis.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* @description 在第四种情况下,如果在程序运行期间,部署了微服务的jar包的机器突然挂了,代码层面根本就没有走到finally代码块
* 没办法保证解锁,所以这个key就没有被删除
* 这里需要对这个key加一个过期时间,设置过期时间有两种方法
* 1. template.expire(REDIS_LOCK,10, TimeUnit.SECONDS);第一种方法需要单独的一行代码,并没有与加锁放在同一步操作,所以不具备原子性,也会出问题
* 2. template.opsForValue().setIfAbsent(REDIS_LOCK, value,10L,TimeUnit.SECONDS);第二种方法在加锁的同时就进行了设置过期时间,所有没有问题
* @date: 2022/4/9 21:25
* @return:
* @author: xjl
*/
@RestController
public class RedisLockControllerV2 {
public static final String REDIS_LOCK = "good_lock";
@Autowired
StringRedisTemplate template;
@RequestMapping("/buy")
public String index() {
// 每个人进来先要进行加锁,key值为"good_lock"
String value = UUID.randomUUID().toString().replace("-", "");
try {
// 为key加一个过期时间 10s
Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK, value, 10L, TimeUnit.SECONDS);
// 加锁失败
if (!flag) {
return "抢锁失败!";
}
System.out.println(value + " 抢锁成功");
String result = template.opsForValue().get("goods");
int total = result == null ? 0 : Integer.parseInt(result);
if (total > 0) {
int realTotal = total - 1;
template.opsForValue().set("goods", String.valueOf(realTotal));
System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
return "购买商品成功,库存还剩:" + realTotal + "件";
} else {
System.out.println("购买商品失败");
}
return "购买商品失败";
} finally {
// 如果在抢到所之后,删除锁之前,发生了异常,锁就无法被释放,所以要在finally处理 template.delete(REDIS_LOCK);
template.delete(REDIS_LOCK);
}
}
}
4.5 Redis设置锁的删除
设置了key的过期时间,解决了key无法删除的问题,但问题又来了,上面设置了key的过期时间为10秒,如果业务逻辑比较复杂,需要调用其他微服务, 处理时间需要15秒(模拟场景,别较真),而当10秒钟过去之后,这个key就过期了,其他请求就又可以设置这个key, 此时如果耗时15秒的请求处理完了,回来继续执行程序,就会把别人设置的key给删除了,这是个很严重的问题!所以,谁上的锁,谁才能删除。
package com.zhuangxiaoyan.springbootredis.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* @description
* 在第五种情况下,设置了key的过期时间,解决了key无法删除的问题,但问题又来了
* 我们设置了key的过期时间为10秒,如果我们的业务逻辑比较复杂,需要调用其他微服务,需要15秒
* 10秒钟过去之后,这个key就过期了,其他请求就又可以设置这个key了
* 但是如果耗时的请求处理完了,回来继续执行程序,就会把别人设置的key给删除了,这是个很严重的问题
* 所以,谁上的锁,谁才能删除
* @date: 2022/4/9 21:25
* @return:
* @author: xjl
*/
@RestController
public class RedislockControllerV3 {
public static final String REDIS_LOCK = "good_lock";
@Autowired
StringRedisTemplate template;
@RequestMapping("/buy")
public String index() {
// 每个人进来先要进行加锁,key值为"good_lock"
String value = UUID.randomUUID().toString().replace("-", "");
try {
// 为key加一个过期时间10s
Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK, value, 10L, TimeUnit.SECONDS);
// 加锁失败
if (!flag) {
return "抢锁失败!";
}
System.out.println(value + " 抢锁成功");
String result = template.opsForValue().get("goods");
int total = result == null ? 0 : Integer.parseInt(result);
if (total > 0) {
// 如果在此处需要调用其他微服务,处理时间较长。。。
int realTotal = total - 1;
template.opsForValue().set("goods", String.valueOf(realTotal));
System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
return "购买商品成功,库存还剩:" + realTotal + "件";
} else {
System.out.println("购买商品失败");
}
return "购买商品失败";
} finally {
// 谁加的锁,谁才能删除
if (template.opsForValue().get(REDIS_LOCK).equals(value)) {
template.delete(REDIS_LOCK);
}
}
}
}
4.6 Redis中Lua原子操作
规定了谁上的锁,谁才能删除,但finally快的判断和del删除操作不是原子操作,并发的时候也会出问题,并发嘛,就是要保证数据的一致性,保证数据的一致性, 最好要保证对数据的操作具有原子性。在redis中的保证原子操作的是
- 使用Lua脚本,进行锁的删除
- 使用Redis事务来实现原子操作
package com.zhuangxiaoyan.springbootredis.controller;
import com.zhuangxiaoyan.springbootredis.utils.RedisUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import redis.clients.jedis.Jedis;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* @description 在第六种情况下,规定了谁上的锁,谁才能删除
* 但finally快的判断和del删除操作不是原子操作,并发的时候也会出问题
* 并发就是要保证数据的一致性,保证数据的一致性,最好要保证对数据的操作具有原子性
* @param: null
* @date: 2022/4/9 21:25
* @return:
* @author: xjl
*/
@RestController
public class RedisLockControllerV4 {
public static final String REDIS_LOCK = "good_lock";
@Autowired
StringRedisTemplate template;
/**
* @description 使用Lua脚本,进行锁的删除
* @param:
* @date: 2022/4/9 21:56
* @return: java.lang.String
* @author: xjl
*/
@RequestMapping("/buy")
public String index() {
// 每个人进来先要进行加锁,key值为"good_lock"
String value = UUID.randomUUID().toString().replace("-", "");
try {
// 为key加一个过期时间
Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK, value, 10L, TimeUnit.SECONDS);
// 加锁失败
if (!flag) {
return "抢锁失败!";
}
System.out.println(value + " 抢锁成功");
String result = template.opsForValue().get("goods");
int total = result == null ? 0 : Integer.parseInt(result);
if (total > 0) {
// 如果在此处需要调用其他微服务,处理时间较长。。。
int realTotal = total - 1;
template.opsForValue().set("goods", String.valueOf(realTotal));
System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
return "购买商品成功,库存还剩:" + realTotal + "件";
} else {
System.out.println("购买商品失败");
}
return "购买商品失败,服务端口为8001";
} finally {
// 谁加的锁,谁才能删除 使用Lua脚本,进行锁的删除
Jedis jedis = null;
try {
jedis = RedisUtils.getJedis();
String script = "if redis.call('get',KEYS[1]) == ARGV[1] " +
"then " +
"return redis.call('del',KEYS[1]) " +
"else " +
" return 0 " +
"end";
Object eval = jedis.eval(script, Collections.singletonList(REDIS_LOCK), Collections.singletonList(value));
if ("1".equals(eval.toString())) {
System.out.println("-----del redis lock ok....");
} else {
System.out.println("-----del redis lock error ....");
}
} catch (Exception e) {
System.out.println(e.getMessage());
} finally {
if (null != jedis) {
jedis.close();
}
}
}
}
/**
* @description 使用redis事务
* @param:
* @date: 2022/4/9 21:56
* @return: java.lang.String
* @author: xjl
*/
@RequestMapping("/buy2")
public String index2() {
// 每个人进来先要进行加锁,key值为"good_lock"
String value = UUID.randomUUID().toString().replace("-", "");
try {
// 为key加一个过期时间
Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK, value, 10L, TimeUnit.SECONDS);
// 加锁失败
if (!flag) {
return "抢锁失败!";
}
System.out.println(value + " 抢锁成功");
String result = template.opsForValue().get("goods");
int total = result == null ? 0 : Integer.parseInt(result);
if (total > 0) {
// 如果在此处需要调用其他微服务,处理时间较长。。。
int realTotal = total - 1;
template.opsForValue().set("goods", String.valueOf(realTotal));
System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
return "购买商品成功,库存还剩:" + realTotal + "件";
} else {
System.out.println("购买商品失败");
}
return "购买商品失败,服务端口为8001";
} finally {
// 谁加的锁,谁才能删除 ,使用redis事务
while (true) {
template.watch(REDIS_LOCK);
if (template.opsForValue().get(REDIS_LOCK).equalsIgnoreCase(value)) {
template.setEnableTransactionSupport(true);
template.multi();
template.delete(REDIS_LOCK);
List list = template.exec();
if (list == null) {
continue;
}
}
template.unwatch();
break;
}
}
}
}
4.7 Redis集群下的分布式锁
规定了谁上的锁,谁才能删除,并且解决了删除操作没有原子性问题。但还没有考虑缓存续命,以及Redis集群部署下,异步复制造成的锁丢失: 主节点没来得及把刚刚set进来这条数据给从节点,就挂了。所以直接上RedLock的Redisson落地实现。
package com.zhuangxiaoyan.springbootredis.controller;
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* @description
* 在第六种情况下,规定了谁上的锁,谁才能删除
* 1. 缓存续命
* 2. redis异步复制造成的锁丢失:主节点没来得及把刚刚set进来这条数据给从节点,就挂了
* @param: null
* @date: 2022/4/9 21:25
* @return:
* @author: xjl
*/
@RestController
public class RedisLockControllerV5 {
public static final String REDIS_LOCK = "good_lock";
@Autowired
StringRedisTemplate template;
@Autowired
Redisson redisson;
@RequestMapping("/buy")
public String index() {
RLock lock = redisson.getLock(REDIS_LOCK);
lock.lock();
// 每个人进来先要进行加锁,key值为"good_lock"
String value = UUID.randomUUID().toString().replace("-", "");
try {
String result = template.opsForValue().get("goods");
int total = result == null ? 0 : Integer.parseInt(result);
if (total > 0) {
// 如果在此处需要调用其他微服务,处理时间较长。。。
int realTotal = total - 1;
template.opsForValue().set("goods", String.valueOf(realTotal));
System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
return "购买商品成功,库存还剩:" + realTotal + "件";
} else {
System.out.println("购买商品失败");
}
return "购买商品失败";
} finally {
// 如果锁依旧在同时还是在被当前线程持有,那就解锁。 如果是其他的线程持有 那就不能释放锁资源
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
四、Zookeeper分布式锁
五、分布式锁总结
参考博文
系统设计解决方案/1-分布式锁方案/分布式锁解决方案.md · 庄小焱/SeniorArchitect - Gitee.com
如何用Redis实现分布式锁? - 掘金
面试官:怎么用Zk(Zookeeper)实现实现分布式锁呀? - 掘金