在分布式系统中,系统中存在不同的应用,并且不同的应用部署在不同的服务器中,分布式锁简单来说就是控制分布式系统中不同应用(进程)之间访问共享资源的一种锁的实现。
分布式锁在电商下单,秒杀、多人抢购、大促活动等场景下应用广泛。
多个服务间 + 保证同一时刻内 + 同一用户只能有一个请求(防止关键业务出现数据冲突和并发错误)
实现分布式锁有三种方式,如下列表所示:
本文对Redis分布式锁演进进行简单介绍。
Redis除了拿来做缓存,你还见过基于Redis的什么用法?
Redis做分布式锁的时候有需要注意的问题?
如果是Redis是单点部署的,会带来什么问题?那你准备怎么解决单点问题呢?
集群模式下,比如主从模式,有没有什么问题呢?
那你简单的介绍一下Redlock吧?你简历上写redisson,你谈谈。
Redis分布式锁如何续期?看门狗知道吗?
使用IDEA搭建连个SpringBoot项目,spring-boot-redis-lock01、spring-boot-redis-lock02,先建Module模块lock01,配置完成后复制为Module模块lock02即可。具体过程忽略,可按如下步骤创建:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0modelVersion>
<parent>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-parentartifactId>
<version>2.3.3.RELEASEversion>
<relativePath/>
parent>
<groupId>com.redis.lockgroupId>
<artifactId>spring-boot-redis-lock01artifactId>
<version>0.0.1-SNAPSHOTversion>
<name>spring-boot-redis-lock01name>
<description>spring-boot-redis-lock01description>
<properties>
<java.version>1.8java.version>
properties>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-actuatorartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.apache.commonsgroupId>
<artifactId>commons-pool2artifactId>
dependency>
<dependency>
<groupId>redis.clientsgroupId>
<artifactId>jedisartifactId>
<version>3.1.0version>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-aopartifactId>
dependency>
<dependency>
<groupId>org.redissongroupId>
<artifactId>redissonartifactId>
<version>3.13.4version>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-devtoolsartifactId>
<scope>runtimescope>
<optional>trueoptional>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<optional>trueoptional>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId><scope>testscope>
<exclusions>
<exclusion>
<groupId>org.junit.vintagegroupId>
<artifactId>junit-vintage-engineartifactId>
exclusion>
exclusions>
dependency>
dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-maven-pluginartifactId>
plugin>
plugins>
build>
project>
server.port=1111
#Module02端口为2222
#=========================redis相关配置========================
#Redis数据库索引(默认方0)
spring.redis.database=0
#Redis服务器地址
spring.redis.host=127.0.0.1
#Redis服务器连接端口
spring.redis.port=6379
#Redis服务器连接密码(默认为空)
spring.redis.password=123admin
#连接池最大连接数(使用负值表示没有限制)默认8
spring.redis.lettuce.pool.max-active=8
#连接池最大阻塞等待时间(使用负值表示没有限制)默认-1
spring.redis.lettuce.pool.max-wait=-1
#连接池中的最大空闲连接默认8
spring.redis.lettuce.pool.max-idle=8
#连接池中的最小空闲连接默犬认0
spring.redis.lettuce.pool.min-idle=0
package com.redis.lock;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SpringBootRedisLock01Application {
public static void main(String[] args) {
SpringApplication.run(SpringBootRedisLock01Application.class, args);
}
}
这里配置Redis的相关配置类
package com.redis.lock.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.io.Serializable;
import java.net.UnknownHostException;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Serializable> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory) throws UnknownHostException {
RedisTemplate<String, Serializable> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(lettuceConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return redisTemplate;
}
}
因为是演示Demo,为了简单起见,直接在Controller层写业务代码。
@RestController
public class GoodController{
private final Lock lock = new ReentrantLock();
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@GetMapping("/buy_goods")
public String buyGoods(){
// get key 看看库存的数量够不够
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0){
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001", String.valueOf(realNumber));
System.out.println("成功买到商品,库存还剩下: "+ realNumber + " 件" + "\t服务提供端口" + serverPort);
return "成功买到商品,库存还剩下:" + realNumber + " 件" + "\t服务提供端口" + serverPort;
}else{
System.out.println("商品已经售完/活动结束/调用超时,欢迎下次光临" + "\t服务提供端口" + serverPort);
}
return "商品已经售完/活动结束/调用超时,欢迎下次光临" + "\t服务提供端口" + serverPort;
}
}
首先在本地Redis中设置商品库存数量:
127.0.0.1:6379> set goods:001 100
OK
127.0.0.1:6379>
然后启动项目,在浏览器中访问如下地址:
http://localhost:1111/buy_goods
返回数据:成功买到商品,库存还剩下:99 件 服务提供端口1111
测试成功后,将spring-boot-redis-lock01 复制为spring-boot-redis-lock02,把端口改为2222即可。至此,准备工作完成。
/**
* 版本1,不加锁,在单机、非高平发下可以运行正常。但是在多线程、高并发下就会出现问题。
* @return
*/
private String version01(){
// get key ====看看库存的数量够不够
//获取值和对值修改都是非原子性操作
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0){
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001", String.valueOf(realNumber));
System.out.println("成功买到商品,库存还剩下: "+ realNumber + " 件" + "\t服务提供端口" + serverPort);
return "成功买到商品,库存还剩下:" + realNumber + " 件" + "\t服务提供端口" + serverPort;
}else{
System.out.println("商品已经售完/活动结束/调用超时,欢迎下次光临" + "\t服务提供端口" + serverPort);
}
return "商品已经售完/活动结束/调用超时,欢迎下次光临" + "\t服务提供端口" + serverPort;
}
版本1在单机、串行访问下可以运行正常,但是在多线程、高并发下就会出现库存超卖的问题。
对版本1 进行修改,加锁。
在版本1的基础上增加JVM层面的锁,如synchronized、ReentrantLock,版本2如下:
/**
* 版本2,单机版synchronized锁
* @return
*/
private String synchronizedVersion02() {
synchronized (this){
/**
* 方法体,均为版本1代码
*/
}
}
/**
* 版本2,单机版Lock
* 单机锁没办法解决分布式部署下超卖问题。
* @return
*/
private String lockVersion02() {
try {
//lock定义请查看“业务类”模块
//lock.lock();block until condition holds 不见不散
lock.tryLock(2, TimeUnit.SECONDS);//过时不候,超过等待时间就不再等待
/**
*
* 方法体,均为版本1代码
*/
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
return "商品已经售完/活动结束/调用超时,欢迎下次光临" + "\t服务提供端口" + serverPort;
}
版本2加完单机锁后,在单机环境、多线程情况下,也可以正常访问。
但是在分布式环境部署情况下,还是会出现超卖情况。
对Demo进行分布式改造。架构如下所示:
web01代表serverLock01,web02代表serverLock02
分布式部署以后,单机锁还是出现超卖现象,需要分布式锁。
演示需要使用Nginx,Nginx安装可以参考本人的这篇文章Nginx简单介绍和详细安装。此处不在赘述。
修改Nginx的配置文件,进行负均衡配置
#业务服务地址
upstream redisLock{
server 127.0.0.1:1111;
server 127.0.0.1:2222;
}
server {
listen 80;
server_name localhost;
location / {
# 用到的负载均衡配置
proxy_pass http://redisLock;
root html;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
配置完成后重新启动Nginx使配置生效:
nginx -s reload
启动1111、2222两个后端服务,在浏览器中访问http://localhost/buy_goods 进行手动刷新,可以看到服务在端口1111,2222两者之间轮流出现(Nginx默认的负载均衡策略为轮训)。
// 服务端口1111返回数据
成功买到商品,库存还剩下: 95 件 服务提供端口1111
成功买到商品,库存还剩下: 93 件 服务提供端口1111
成功买到商品,库存还剩下: 91 件 服务提供端口1111
成功买到商品,库存还剩下: 89 件 服务提供端口1111
//服务端口2222返回数据
成功买到商品,库存还剩下: 96 件 服务提供端口2222
成功买到商品,库存还剩下: 94 件 服务提供端口2222
成功买到商品,库存还剩下: 92 件 服务提供端口2222
成功买到商品,库存还剩下: 90 件 服务提供端口2222
上边通过手动刷新浏览器,下面开始使用Jmeter模拟高并发请求,观察数据输出,看是否会出现问题!
恢复Redis库存数据:
127.0.0.1:6379> get goods:001
"75"
127.0.0.1:6379> set goods:001 100
OK
127.0.0.1:6379>
打开Jmeter,配置100个线程在1秒内执行,访问http://localhost/buy_goods
启动测试,查看打印的日志信息:
结论:JVM锁(单机锁)在分布式架构下已不再适用,必须改用分布式锁。
Redis具有极高的性能,且其命令对分布式锁支持友好,借助SET命令即可实现加锁处理。
SET命令
中文解释:
版本3代码如下:
/**
* 定义RedisLock锁常量
*/
private static final String REDIS_LOCK = "redisLock";
/**
* 版本3,使用Redis的setnx命令作为分布式锁
* @return
*/
private String redisSetNxCommand03() {
String value = UUID.randomUUID().toString() + Thread.currentThread().getName();
//setnx
boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, value);
if (!flag){
return "抢锁失败";
}
/**
* 方法体,均为版本1代码
*/
//解锁操作
stringRedisTemplate.delete(REDIS_LOCK);
}
版本3中存在问题,加锁后,程序在执行过程中,如果出现异常退出,将会导致锁没有被释放。对三版本进行改造,加finally块,在finally块中进行解锁。如下:
private String redisSetNxCommandFinally04() {
String value = UUID.randomUUID().toString() + Thread.currentThread().getName();
try {
//setnx
boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, value);
if (!flag){
return "抢锁失败";
}
/**
* 方法体,均为版本1 代码
*/
}finally {
//解锁
stringRedisTemplate.delete(REDIS_LOCK);
}
}
版本4中的程序同样存在一定问题,在加了finally块后,虽然可以保证程序在正常、异常情况下都能解锁,但是考虑一些极端情况,比如服务器宕机(部署了微服务jar包的机器挂了),代码层面是无法运行到finally块的,这样就不能保证锁被释放,key就无法被删除。
解决:在加锁时设置过期时间。
版本5代码如下:
private String redisSetNxCommandExpire05() {
String value = UUID.randomUUID().toString() + Thread.currentThread().getName();
try {
//setnx
boolean flag = Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, value));
//设置过期时间(非原子操作)
stringRedisTemplate.expire(REDIS_LOCK,10,TimeUnit.SECONDS);
if (!flag){
return "抢锁失败";
}
/**
* 方法体,均为版本1 代码
*/
}finally {
//解锁
stringRedisTemplate.delete(REDIS_LOCK);
}
}
版本5中依然存在问题,那就是加锁操作和设置锁过期时间的操作是两步,并非原子操作。将其改为原子操作,版本6代码如下:
private String redisSetNxCommandExpire06() {
String value = UUID.randomUUID().toString() + Thread.currentThread().getName();
try {
//原子操作,设置锁的同时,设置过期时间
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, value, 10, TimeUnit.SECONDS);
if (!flag){
return "抢锁失败";
}
/**
* 方法体,均为版本1 代码
*/
}finally {
//解锁
stringRedisTemplate.delete(REDIS_LOCK);
}
版本6中还是存在问题,如果业务执行时间超时,锁自动过期,当业务执行完进行解锁操作时,有可能会把其他线程的锁删除掉。
因此,在解锁前必须进行判断,如果是自己的锁,就删除,不是自己的锁就不能删除。
版本7代码如下:
private String redisSetNxCommandExpire07() {
String value = UUID.randomUUID().toString() + Thread.currentThread().getName();
try {
//原子操作,设置锁的同时,设置过期时间
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, value, 10, TimeUnit.SECONDS);
if (!flag){
return "抢锁失败";
}
/**
* 方法体,均为版本1 代码
*/
}finally {
//解锁需进行判断:判断加锁与解锁有可能不是同一客户端。(非原子操作)
if (stringRedisTemplate.opsForValue().get(REDIS_LOCK).equals(value)){
//若在此时,这把锁突然不是这个客户端的,则会误解锁。
stringRedisTemplate.delete(REDIS_LOCK);
}
}
}
版本7看起来已经很不错了,但是还是存在问题,那就是解锁判断和解锁操作不是原子操作,类似于加锁操作和设置过期时间不是原子操作一样,判断加锁与解锁有可能不是同一客户端,可能会出现误解锁。
解锁操作同样也必须是原子操作,使用Redis+lua脚本进行解锁。
增加RedisUtils工具类
package com.redis.lock.utils;
/**
* @ClassName RedisUtils
* @Description TODO
* @Version 1.0
**/
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
public class RedisUtils {
private static JedisPool jedisPool;
static {
JedisPoolConfig jpc = new JedisPoolConfig();
jpc.setMaxTotal(20);
jpc.setMaxIdle(10);
jedisPool = new JedisPool(jpc);
}
public static Jedis getJedis() throws Exception{
if(jedisPool == null) {
throw new NullPointerException("JedisPool is not OK.");
}
return jedisPool.getResource();
}
}
版本8 lua脚本代码示例如下:
private String redisSetNxCommandLua081() throws Exception {
String value = UUID.randomUUID().toString() + Thread.currentThread().getName();
try {
//原子加锁并设置过期时间
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, value, 10, TimeUnit.SECONDS);
if (!flag){
return "抢锁失败";
}
/**
* 方法体,均为版本1 代码
*/
}finally {
//lua脚本方法1 (官网方式)
String luaScript = "if redis.call(\"get\",KEYS[1]) == ARGV[1]" +
"then" +
" return redis.call(\"del\",KEYS[1])\n" +
"else" +
" return 0" +
"end";
//使用lua脚删除锁
Long lock1 = stringRedisTemplate.execute(new DefaultRedisScript<Long>(luaScript, Long.class),
Arrays.asList(REDIS_LOCK), value);
//lua脚本方法2
//使用jedis
Jedis jedis = RedisUtils.getJedis();
String script = "if redis.call('get', KEYS[1]) == ARGV[1] "
+ "then "
+ " return redis.call('del', KEYS[1]) "
+ "else "
+ " return 0 "
+ "end";
try {
Object o = jedis.eval(script, Collections.singletonList(REDIS_LOCK),
Collections.singletonList(value));
if("1".equals(o.toString())) {
System.out.println("---del redis lock ok.");
}else {
System.out.println("---del redis lock error.");
}
}finally {
if(jedis != null){
jedis.close();
}
}
}
那有人就问了,如果不使用lua脚本,是否还有其他方法???
必须有,可以使用Redis自身的事务进行解决!
Redis事务
版本8 使用Redis事务进行解锁操作如下:
private String redisSetNxCommandTran081() {
String value = UUID.randomUUID().toString() + Thread.currentThread().getName();
try {
//原子加锁并设置过期时间
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, value, 10, TimeUnit.SECONDS);
if (!flag){
return "抢锁失败";
}
/**
* 方法体,均为版本1 代码
*/
}finally {
/**
* 问:如果此处不使用lua脚本,是否还有其他方法???
* 用Redis自身的事务来处理。
*/
while (true){
stringRedisTemplate.watch(REDIS_LOCK);
if (stringRedisTemplate.opsForValue().get(REDIS_LOCK).equals(value)){
stringRedisTemplate.setEnableTransactionSupport(true);
stringRedisTemplate.multi();
stringRedisTemplate.delete(REDIS_LOCK);
List<Object> list = stringRedisTemplate.exec();
if (list.isEmpty()){
continue;
}
}
stringRedisTemplate.unwatch();
break;
}
}
总结:分布式锁必须满足原子加锁和原子解锁。
在版本8两种情况下,需要确保redisLock过期时间大于业务执行时间。(考虑Redis分布式锁如何实现缓存续期?看门狗模式)
我们自己看门狗缓存续期,会很复杂。在集群模式下我们自己实现Redis锁也不是很OK,直接使用RedLock的落地实现Redisson来实现Redis的分布式锁。
Redis分布式锁&RedLock算法
使用Redisson作为分布式锁:
在POM中加入Redisson的依赖
<dependency>
<groupId>org.redissongroupId>
<artifactId>redissonartifactId>
<version>3.13.4version>
dependency>
在RedisConfig配置类中增加Redisson配置
/**
* 单机Redisson
* @return
*/
@Bean
public Redisson redisson() {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(0);
return (Redisson) Redisson.create(config);
}
代码层面直接注入Redisson即可
@Autowired
private Redisson redisson;
private String redissonVersion9() {
//获取分布式锁
RLock redissonLock = redisson.getLock(REDIS_LOCK);
redissonLock.lock();
try {
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0){
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001", String.valueOf(realNumber));
System.out.println("成功买到商品,库存还剩下: "+ realNumber + " 件" + "\t服务提供端口" + serverPort);
return "成功买到商品,库存还剩下:" + realNumber + " 件" + "\t服务提供端口" + serverPort;
}else{
System.out.println("商品已经售完/活动结束/调用超时,欢迎下次光临" + "\t服务提供端口" + serverPort);
}
return "商品已经售完/活动结束/调用超时,欢迎下次光临" + "\t服务提供端口" + serverPort;
}finally {
//解锁
redissonLock.unlock();
}
}
该情况下可以说已经接近完美了,但是在极高的并发下,在finally块中直接使用 redissonLock.unlock()解锁, 还是会有几率产生如下异常:
IllegalMonitorStateException: attempt to unlock lock,not loked by current thread by node id:da6385f-81a5-4e6c-b8c0
对版本9中的解锁代码进行优化。
对解锁逻辑进行优化,最终完整版如下:
package com.redis.lock.controller;
import com.redis.lock.utils.RedisUtils;
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import redis.clients.jedis.Jedis;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @ClassName GoodsController
* @Version 1.0
**/
@RestController
public class GoodsController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
/**
* 定义RedisLock锁常量
*/
private static final String REDIS_LOCK = "redisLock";
@Autowired
private Redisson redisson;
@GetMapping("/buy_goods")
public String buy_Goods(){
RLock redissonLock = redisson.getLock(REDIS_LOCK);
redissonLock.lock();
try {
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if (goodsNumber > 0) {
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001", String.valueOf(realNumber));
System.out.println("成功买到商品,库存还剩下: " + realNumber + " 件" + "\t服务提供端口" + serverPort);
return "成功买到商品,库存还剩下:" + realNumber + " 件" + "\t服务提供端口" + serverPort;
} else {
System.out.println("商品已经售完/活动结束/调用超时,欢迎下次光临" + "\t服务提供端口" + serverPort);
}
return "商品已经售完/活动结束/调用超时,欢迎下次光临" + "\t服务提供端口" + serverPort;
} finally {
//解锁推荐使用此种写法
if (redissonLock.isLocked()) {
if (redissonLock.isHeldByCurrentThread()) {
redissonLock.unlock();
}
}
}
}
}
ReentrantLock、synchronized Jvm锁在单机版上是可用的。
nginx分布式微服务单机锁不行。
取消单机锁,上Redis分布式锁,使用setnx命令
只加了锁,没有释放锁,出异常的话,可能无法释放锁,必须要在代码层面finally释放锁。
宕机了,部署了微服务代码层面根本没有走到finally代码块,没办法保证解锁,这个key没有被删除,需要有lockKey的过期时间设定。
为redis的分布式锁key,增加过期时间,此外,还必须要setnx+过期时间必须同一行,即保证原子操作。
必须规定只能自己删除自己的锁,不能把别人的锁删除了,防止张冠李戴,1删2,2删3。
Redis集群环境下,我们自己写的也不oK直接上RedLock之Redisson落地实现。