假设现在要秒杀一个商品,这个商品只有10件,在高并发分布式场景下如何保证数据一致性,即一波人中只有10个人能够买到。在本文中介绍如何利用redis实现分布式锁来解决这个问题。
nginx做负载均衡,postman进行高并发测试,不会使用的自行百度,文中没有提及如何使用及测试,请自行将一个项目改端口同时跑几个,模拟分布式场景。
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
server:
port: 8888
spring:
redis:
host: localhost
port: 6379
#连接超时时间(毫秒)
timeout: 5000
#密码默认空
password:
#Redis数据库索引(默认为0)
database: 0
jedis:
pool:
#连接池最大连接数(使用负值表示没有限制)
max-active: 50
#连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: 3000
#连接池中的最大空闲连接
max-idle: 20
#连接池中的最小空闲连接
min-idle: 2
package com.au.springcloud.mydistributelock.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* @author huzhijin
* @date 2019/12/21 7:26 下午
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){
RedisTemplate redisTemplate = new RedisTemplate();
redisTemplate.setConnectionFactory(redisConnectionFactory);
// 解决编码问题,redisTemplate默认使用JDK二进制序列化方式
RedisSerializer stringSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringSerializer);
redisTemplate.setValueSerializer(stringSerializer);
redisTemplate.setHashKeySerializer(stringSerializer);
redisTemplate.setHashValueSerializer(stringSerializer);
return redisTemplate;
}
}
启动redis服务端: redis-serer
MacBook-Pro:~ huzhijin$ redis-server
14103:C 21 Dec 2019 22:33:53.996 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
14103:C 21 Dec 2019 22:33:53.996 # Redis version=5.0.7, bits=64, commit=00000000, modified=0, pid=14103, just started
14103:C 21 Dec 2019 22:33:53.996 # Warning: no config file specified, using the default config. In order to specify a config file use redis-server /path/to/redis.conf
14103:M 21 Dec 2019 22:33:53.998 * Increased maximum number of open files to 10032 (it was originally set to 256).
_._
_.-``__ ''-._
_.-`` `. `_. ''-._ Redis 5.0.7 (00000000/0) 64 bit
.-`` .-```. ```\/ _.,_ ''-._
( ' , .-` | `, ) Running in standalone mode
|`-._`-...-` __...-.``-._|'` _.-'| Port: 6379
| `-._ `._ / _.-' | PID: 14103
`-._ `-._ `-./ _.-' _.-'
|`-._`-._ `-.__.-' _.-'_.-'|
| `-._`-._ _.-'_.-' | http://redis.io
`-._ `-._`-.__.-'_.-' _.-'
|`-._`-._ `-.__.-' _.-'_.-'|
| `-._`-._ _.-'_.-' |
`-._ `-._`-.__.-'_.-' _.-'
`-._ `-.__.-' _.-'
`-._ _.-'
`-.__.-'
14103:M 21 Dec 2019 22:33:53.999 # Server initialized
14103:M 21 Dec 2019 22:33:53.999 * DB loaded from disk: 0.000 seconds
14103:M 21 Dec 2019 22:33:53.999 * Ready to accept connections
新开一个命令行界面,启动客户端:redis-cli
,设置商品数量=10:set product-count 10
MacBook-Pro:~ huzhijin$ redis-cli
127.0.0.1:6379> set product-count 10
OK
127.0.0.1:6379> get product-count
"10"
package com.au.springcloud.mydistributelock.Controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author huzhijin
* @date 2019/12/21 7:24 下午
*/
@RestController
public class TestController {
@Autowired
private RedisTemplate redisTemplate;
@GetMapping("/test1")
public Object test1() {
return redisTemplate.opsForValue().get("product-count");
}
}
启动项目在浏览器输入http://localhost:8888/test1
看结果是否是10。如果报错说明没连上,如果是null可能是编码问题。
package com.au.springcloud.mydistributelock.Controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author huzhijin
* @date 2019/12/21 7:24 下午
*/
@RestController
public class TestController {
@Autowired
private RedisTemplate redisTemplate;
@GetMapping("/test2")
public String test2() {
synchronized (TestController.class) {
Integer productCount = Integer.parseInt(redisTemplate.opsForValue().get("product-count").toString());
if (productCount <= 0) {
System.out.println("库存不足");
return "fail";
}
System.out.println("成功购买,库存:" + productCount);
redisTemplate.opsForValue().set("product-count", String.valueOf(productCount - 1));
}
return "success";
}
}
在这个代码中使用了synchronized关键字来实现锁,这在单机模式下的高并发是没有问题的,但是在分布式情况下是会出问题的。我们知道,synchronized (TestController.class)只能保证在当前JVM进程中只有一个线程能够执行synchronized里面的代码块,分布式模式下是多个JVM在运行,即多进程,这时候该如何处理呢,见下。
在redis中,有一个setnx命令,注意这是一个原子命令,即要么全部成功,要么全部失败:
格式:setnx key value
返回值:设置成功返回1,失败返回0。
含义:只在键key不存在时,将key的值设置成value。若key存在,则不进行任何操作。
setnx是set if not exists的简写。
利用这个命令,我们可以用来实现分布式锁,我们固定一个key(可以为商品id),value随意,然后每个线程执行业务代码之前都去执行一下setnx命令,如果返回结果为1,说明不存在这个key,那么就可以获取该锁,如果返回结果为0,说明已经存在了这个key,就不执行业务代码。SpringBoot中对应setnx命令的是setIfAbsent()方法。
Java代码实现:
package com.au.springcloud.mydistributelock.Controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author huzhijin
* @date 2019/12/21 7:24 下午
*/
@RestController
public class TestController {
@Autowired
private RedisTemplate redisTemplate;
@GetMapping("/test3")
public String test3() {
String concurrentKey = "product";
// 获取锁
Boolean flag = redisTemplate.opsForValue().setIfAbsent(concurrentKey, "test"); // 等价于setnx key value
if (!flag) {
// flag=false说明concurrentKey已经存在了,获取锁失败,也就是说只有一个线程能够执行下面的代码
return "系统繁忙,请稍后重试!";
}
try {
// 业务代码
Integer productCount = Integer.parseInt(redisTemplate.opsForValue().get("product-count").toString());
if (productCount <= 0) {
System.out.println("库存不足");
return "fail";
}
productCount = productCount - 1;
System.out.println("成功购买,库存:" + productCount);
redisTemplate.opsForValue().set("product-count", String.valueOf(productCount));
} finally {
// 释放锁,放在finally中是为了防止业务代码抛异常,从而导致这行代码不被执行,即锁永远不被释放,之后的线程都不能获取锁。
redisTemplate.delete(concurrentKey);
}
return "success";
}
}
这种实现方式看上去很完美,但是会有一种bug,即如果程序还没执行到finally里面的代码的时候这个web程序所在服务器宕机了(再比如运维人员重新发布程序),那么将导致这把锁永远都不能释放。所以我们应该给这把锁的key加上一个有效时间。
package com.au.springcloud.mydistributelock.Controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
/**
* @author huzhijin
* @date 2019/12/21 7:24 下午
*/
@RestController
public class TestController {
@Autowired
private RedisTemplate redisTemplate;
@GetMapping("/test4")
public String test4() {
String concurrentKey = "product";
// 获取锁
Boolean flag = redisTemplate.opsForValue().setIfAbsent(concurrentKey, "test", 5, TimeUnit.SECONDS); // 此处设置超时时间,setIfAbsent是原子操作
if (!flag) {
// flag=false说明concurrentKey已经存在了,获取锁失败
return "fail";
}
try {
// 业务代码
Integer productCount = Integer.parseInt(redisTemplate.opsForValue().get("product-count").toString());
if (productCount <= 0) {
System.out.println("库存不足");
return "fail";
}
productCount = productCount - 1;
System.out.println("成功购买,库存:" + productCount);
redisTemplate.opsForValue().set("product-count", String.valueOf(productCount));
} finally {
// 释放锁,放在finally中是为了防止业务代码抛异常,从而导致这行代码不被执行,即锁永远不被释放,之后的线程都不能获取锁。
redisTemplate.delete(concurrentKey);
}
return "success";
}
}
这种实现方式加上了过期时间,看上去好像没什么问题了,但是,如果业务处理时间大于锁过期时间,那么执行redisTemplate.delete(concurrentKey)的时候,可能释放的不是自己的锁,而是下一个线程的锁,最坏的情况是每个线程都是释放的下一个线程的锁,会导致这把锁永久失效。
这就衍生出两个问题:
对于第一个问题,我们可以在获取锁的时候,将key对应的value设置为随机字符串,然后释放锁的时候判断一下该value是否是自己设置的value,如果是则释放,否则不释放。
package com.au.springcloud.mydistributelock.Controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* @author huzhijin
* @date 2019/12/21 7:24 下午
*/
@RestController
public class TestController {
@Autowired
private RedisTemplate redisTemplate;
@GetMapping("/test5")
public String test5() {
String concurrentKey = "product";
String concurrentValue = UUID.randomUUID().toString();
// 获取锁
Boolean flag = redisTemplate.opsForValue().setIfAbsent(concurrentKey, concurrentValue, 10, TimeUnit.SECONDS); // 等价于setnx key value
if (!flag) {
// flag=false说明concurrentKey已经存在了,获取锁失败
return "fail";
}
try {
// 业务代码
Integer productCount = Integer.parseInt(redisTemplate.opsForValue().get("product-count").toString());
if (productCount <= 0) {
System.out.println("库存不足");
return "fail";
}
productCount = productCount - 1;
System.out.println("成功购买,库存:" + productCount);
redisTemplate.opsForValue().set("product-count", String.valueOf(productCount));
} finally {
// 判断是否是自己加的锁,如果是可以释放
if(concurrentValue.equals(redisTemplate.opsForValue().get(concurrentKey))) {
// 释放锁,放在finally中是为了防止业务代码抛异常,从而导致这行代码不被执行,即锁永远不被释放,之后的线程都不能获取锁。
redisTemplate.delete(concurrentKey);
}
}
return "success";
}
}
上面这个方案还是没有解决如何保证业务代码执行时间大于锁失效时间,对于这个问题,我们可以在线程获取锁成功之后,再新开一个子线程,这个线程的任务就是定时给父线程加的锁续命。比如父线程获取锁成功(假设锁失效时间为5s),新开了子线程,子线程每2s判断一下父线程是否还持有那把锁,如果持有就给那把锁重置失效时间。
Redisson github:https://github.com/redisson/redisson
redisson框架为我们提供了简单又好用的分布式锁,它就解决了如何保证业务代码执行时间大于锁失效时间的这个问题,即子线程监听父线程是否还持有该锁,当然,这个框架不只是实现了这个功能。
使用:
导入maven依赖:
<dependency>
<groupId>org.redissongroupId>
<artifactId>redissonartifactId>
<version>3.11.6version>
dependency>
在redisConfig.java中添加bean:RedissonClient:
package com.au.springcloud.mydistributelock.config;
import org.redisson.Redisson;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* @author huzhijin
* @date 2019/12/21 7:26 下午
*/
@Configuration
public class RedisConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private Integer port;
@Value("${spring.redis.password}")
private String password;
@Bean
public Redisson redissonClient() {
// 单机模式
Config config = new Config();
config.useSingleServer().setAddress("redis://" + host + ":" + port).setPassword(password).setDatabase(0);
return (Redisson)Redisson.create(config);
}
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate redisTemplate = new RedisTemplate();
redisTemplate.setConnectionFactory(redisConnectionFactory);
RedisSerializer stringSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringSerializer);
redisTemplate.setValueSerializer(stringSerializer);
redisTemplate.setHashKeySerializer(stringSerializer);
redisTemplate.setHashValueSerializer(stringSerializer);
return redisTemplate;
}
}
使用:
package com.au.springcloud.mydistributelock.Controller;
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
/**
* @author huzhijin
* @date 2019/12/21 7:24 下午
*/
@RestController
public class TestController {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private Redisson redisson;
@GetMapping("/test6")
public String test6(){
String concurrentKey = "product";
RLock redissonLock = redisson.getLock(concurrentKey);
// 获取锁
redissonLock.lock(30, TimeUnit.SECONDS);
try {
// 业务代码
Integer productCount = Integer.parseInt(redisTemplate.opsForValue().get("product-count").toString());
if (productCount <= 0) {
System.out.println("库存不足");
return "fail";
}
productCount = productCount - 1;
System.out.println("成功购买,库存:" + productCount);
redisTemplate.opsForValue().set("product-count", String.valueOf(productCount));
} finally {
// 释放锁
redissonLock.unlock();
}
return "success";
}
}
写到这里已经算是完善了,但是还是会有问题的,如果redis是主从架构的,如果master节点宕机了,数据还没同步到slave节点,那么会导致多个客户端拿到锁。