@@ -0,0 +1,240 @@ | |||||
## spring-boot-demo-distributed-lock-redis | |||||
> 此 demo 主要演示了 Spring Boot 如何基于 RedisTemplate 实现一个分布式锁 | |||||
## 1.开发步骤 | |||||
在 `demo-distributed-lock-api` 模块中,已经实现了基于 AOP 的分布式锁注解拦截、简单的扣减库存案例,因此本模块只需要实现以下两个接口即可。 | |||||
- `com.xkcoding.distributed.lock.api.DistributedLock` | |||||
- `com.xkcoding.distributed.lock.api.DistributedLockClient` | |||||
### 1.1.添加依赖 | |||||
```xml | |||||
<dependencies> | |||||
<dependency> | |||||
<groupId>com.xkcoding</groupId> | |||||
<artifactId>demo-distributed-lock-api</artifactId> | |||||
<version>1.0.0-SNAPSHOT</version> | |||||
</dependency> | |||||
<dependency> | |||||
<groupId>org.springframework.boot</groupId> | |||||
<artifactId>spring-boot-starter-data-redis</artifactId> | |||||
</dependency> | |||||
<dependency> | |||||
<groupId>org.projectlombok</groupId> | |||||
<artifactId>lombok</artifactId> | |||||
<optional>true</optional> | |||||
</dependency> | |||||
</dependencies> | |||||
``` | |||||
### 1.2.代码实现 | |||||
#### 1.2.1.RedisDistributedLock | |||||
> 基于 RedisTemplate 实现分布式锁,主要是通过 Lua 脚本保证原子性操作、通过 hash 数据结构实现可重入性 | |||||
```java | |||||
public class RedisDistributedLock extends DistributedLock { | |||||
private final StringRedisTemplate redisTemplate; | |||||
/** | |||||
* 锁的唯一标识,格式:主机标识(UUID)+线程编号 | |||||
* 防误删 | |||||
*/ | |||||
private final String uniqueId; | |||||
protected RedisDistributedLock(StringRedisTemplate redisTemplate, String uniqueIdPrefix, String lockKey, long lockTime, | |||||
TimeUnit timeUnit) { | |||||
super(lockKey, lockTime, timeUnit); | |||||
this.redisTemplate = redisTemplate; | |||||
this.uniqueId = uniqueIdPrefix + ":" + Thread.currentThread().getId(); | |||||
} | |||||
@Override | |||||
public void lock() { | |||||
// 加锁失败,自旋阻塞 | |||||
while (!tryLock()) { | |||||
try { | |||||
TimeUnit.MILLISECONDS.sleep(50); | |||||
} catch (InterruptedException e) { | |||||
throw new RuntimeException(e); | |||||
} | |||||
} | |||||
} | |||||
@Override | |||||
public boolean tryLock() { | |||||
try { | |||||
return tryLock(lockTime, timeUnit); | |||||
} catch (InterruptedException e) { | |||||
return false; | |||||
} | |||||
} | |||||
@Override | |||||
public boolean tryLock(long time, @NotNull TimeUnit unit) throws InterruptedException { | |||||
String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 " + | |||||
"then " + | |||||
" redis.call('hincrby', KEYS[1], ARGV[1], 1) " + | |||||
" redis.call('expire', KEYS[1], ARGV[2]) " + | |||||
" return 1 " + | |||||
"else " + | |||||
" return 0 " + | |||||
"end"; | |||||
long expire = unit.toSeconds(time); | |||||
Boolean getLock = Optional.ofNullable( | |||||
redisTemplate.execute( | |||||
new DefaultRedisScript<>(script, Boolean.class), | |||||
Collections.singletonList(lockKey), | |||||
uniqueId, String.valueOf(expire))) | |||||
.orElse(Boolean.FALSE); | |||||
// 如果获得锁,开启自动续期 | |||||
if (getLock) { | |||||
renewLockTime(time, unit); | |||||
} | |||||
return getLock; | |||||
} | |||||
private void renewLockTime(long time, TimeUnit unit) { | |||||
String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 1 " + | |||||
"then " + | |||||
" return redis.call('expire', KEYS[1], ARGV[2]) " + | |||||
"else " + | |||||
" return 0 " + | |||||
"end"; | |||||
long expire = unit.toSeconds(time); | |||||
new Timer().schedule(new TimerTask() { | |||||
@Override | |||||
public void run() { | |||||
Boolean renewed = Optional.ofNullable( | |||||
redisTemplate.execute( | |||||
new DefaultRedisScript<>(script, Boolean.class), | |||||
Collections.singletonList(lockKey), | |||||
uniqueId, String.valueOf(expire)) | |||||
).orElse(Boolean.FALSE); | |||||
// 续期成功,代表未被解锁,则需要进行下一次续期 | |||||
if (renewed) { | |||||
renewLockTime(time, unit); | |||||
} | |||||
} | |||||
}, expire * 1000 / 3); | |||||
} | |||||
@Override | |||||
public void unlock() { | |||||
String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 " + | |||||
"then " + | |||||
" return nil " + | |||||
"elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 " + | |||||
"then " + | |||||
" return redis.call('del', KEYS[1]) " + | |||||
"else " + | |||||
" redis.call('expire', KEYS[1], ARGV[2]) " + | |||||
" return 0 " + | |||||
"end"; | |||||
// 如果解锁,发现是重入的,需要重新续期 | |||||
long expire = timeUnit.toSeconds(lockTime); | |||||
Long flag = this.redisTemplate.execute( | |||||
new DefaultRedisScript<>(script, Long.class), | |||||
Collections.singletonList(lockKey), | |||||
uniqueId, String.valueOf(expire) | |||||
); | |||||
if (flag == null) { | |||||
throw new IllegalMonitorStateException("this lock doesn't belong to you!"); | |||||
} | |||||
} | |||||
} | |||||
``` | |||||
#### 1.2.2.RedisDistributedLockClient | |||||
> 获取一把 RedisTemplate 分布式锁,这里需要注意集群部署下,通过 uuid 标识当前应用唯一 id | |||||
```java | |||||
public class RedisDistributedLockClient implements DistributedLockClient { | |||||
private final StringRedisTemplate redisTemplate; | |||||
/** | |||||
* 唯一标识前缀,用于集群环境下的主机标识,会在 Bean 初始化到 Spring 容器的时候设置 | |||||
*/ | |||||
private final String uniqueIdPrefix; | |||||
public RedisDistributedLockClient(StringRedisTemplate redisTemplate) { | |||||
this.redisTemplate = redisTemplate; | |||||
uniqueIdPrefix = UUID.randomUUID().toString(); | |||||
} | |||||
/** | |||||
* 获取一把锁 | |||||
* | |||||
* @param lockKey 锁的标识 | |||||
* @param lockTime 锁的时间 | |||||
* @param timeUnit 锁的时间单位 | |||||
* @return 锁 | |||||
*/ | |||||
@Override | |||||
public DistributedLock getLock(String lockKey, long lockTime, TimeUnit timeUnit) { | |||||
return new RedisDistributedLock(redisTemplate, uniqueIdPrefix, lockKey, lockTime, timeUnit); | |||||
} | |||||
} | |||||
``` | |||||
#### 1.2.3.自动装配 | |||||
> 替换 `demo-distributed-lock-api` 中的默认实现 | |||||
```java | |||||
@Configuration(proxyBeanMethods = false) | |||||
public class RedisDistributedLockAutoConfiguration { | |||||
@Bean | |||||
public RedisDistributedLockClient distributedLockClient(StringRedisTemplate redisTemplate) { | |||||
return new RedisDistributedLockClient(redisTemplate); | |||||
} | |||||
} | |||||
``` | |||||
## 2.测试 | |||||
### 2.1.环境搭建 | |||||
主要是 mysql 及 redis 环境的搭建,这里我提供了 docker-compose 文件,方便同学们一键启动测试环境 | |||||
```bash | |||||
$ cd demo-distributed-lock/demo-distributed-lock-redis | |||||
$ docker compose -f docker-compose.env.yml up | |||||
``` | |||||
### 2.2.测试流程 | |||||
这里我通过 Apache Bench 进行模拟并发场景,我也构建了一个压测镜像 `xkcoding/ab:alpine-3.16.2` ,方便同学们进行快速测试 | |||||
> 注意:每次启动项目,都会在重置库存,你也可以手动调用 `/demo/stock/reset` 接口重置 | |||||
#### 2.2.1.测试无分布式锁下高并发请求是否会发生超卖 | |||||
1. 把 `RedisDistributedLockAutoConfiguration` 类全部注释掉,这将不会装配 Redisson 分布式锁 | |||||
2. 启动 `RedisDistributedLockApplication` | |||||
3. 模拟 5000 请求数 100 并发的压测环境 `docker run -it --rm xkcoding/ab:alpine-3.16.2 ab -n 5000 -c 100 http://${替换成你电脑的内网IP}:8080/demo/stock/reduce` | |||||
4. 等待压测结束,前往数据库查看库存是否从 5000 减为 0 | |||||
#### 2.2.2.测试 Redisson 分布式锁 | |||||
1. 把 `RedisDistributedLockAutoConfiguration` 类的注释解开,此时 Spring Boot 会自动装配我们的 Redisson 分布式锁 | |||||
2. 再次启动 `RedisDistributedLockApplication` | |||||
3. 再次模拟 5000 请求数 100 并发的压测环境 `docker run -it --rm xkcoding/ab:alpine-3.16.2 ab -n 5000 -c 100 http://${替换成你电脑的内网IP}:8080/demo/stock/reduce` | |||||
4. 等待压测结束,前往数据库查看库存是否从 5000 减为 0 | |||||
## 3.参考 | |||||
- [Spring Data Redis 官方文档之调用 lua 脚本](https://docs.spring.io/spring-data/redis/docs/3.0.0-M5/reference/html/#scripting) | |||||
- [lua 脚本语法](https://www.runoob.com/lua/lua-tutorial.html) |
@@ -0,0 +1,14 @@ | |||||
version: "3.8" | |||||
services: | |||||
mysql: | |||||
image: mysql:8.0.30 | |||||
ports: | |||||
- "3306:3306" | |||||
environment: | |||||
- MYSQL_ROOT_PASSWORD=root | |||||
- MYSQL_DATABASE=spring-boot-demo | |||||
redis: | |||||
image: redis:7.0.4-alpine | |||||
ports: | |||||
- "6379:6379" |
@@ -16,4 +16,23 @@ | |||||
<java.version>17</java.version> | <java.version>17</java.version> | ||||
</properties> | </properties> | ||||
<dependencies> | |||||
<dependency> | |||||
<groupId>com.xkcoding</groupId> | |||||
<artifactId>demo-distributed-lock-api</artifactId> | |||||
<version>1.0.0-SNAPSHOT</version> | |||||
</dependency> | |||||
<dependency> | |||||
<groupId>org.springframework.boot</groupId> | |||||
<artifactId>spring-boot-starter-data-redis</artifactId> | |||||
</dependency> | |||||
<dependency> | |||||
<groupId>org.projectlombok</groupId> | |||||
<artifactId>lombok</artifactId> | |||||
<optional>true</optional> | |||||
</dependency> | |||||
</dependencies> | |||||
</project> | </project> |
@@ -1,4 +1,4 @@ | |||||
package com.xkcoding.zookeeper; | |||||
package com.xkcoding.distributed.lock; | |||||
import org.springframework.boot.SpringApplication; | import org.springframework.boot.SpringApplication; | ||||
import org.springframework.boot.autoconfigure.SpringBootApplication; | import org.springframework.boot.autoconfigure.SpringBootApplication; | ||||
@@ -9,14 +9,13 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; | |||||
* </p> | * </p> | ||||
* | * | ||||
* @author yangkai.shen | * @author yangkai.shen | ||||
* @date Created in 2018-12-27 14:51 | |||||
* @date 2022-09-03 12:23 | |||||
*/ | */ | ||||
@SpringBootApplication | @SpringBootApplication | ||||
public class SpringBootDemoZookeeperApplication { | |||||
public class RedisDistributedLockApplication { | |||||
public static void main(String[] args) { | public static void main(String[] args) { | ||||
SpringApplication.run(SpringBootDemoZookeeperApplication.class, args); | |||||
SpringApplication.run(RedisDistributedLockApplication.class, args); | |||||
} | } | ||||
} | } | ||||
@@ -0,0 +1,134 @@ | |||||
package com.xkcoding.distributed.lock.autoconfigure; | |||||
import com.xkcoding.distributed.lock.api.DistributedLock; | |||||
import org.jetbrains.annotations.NotNull; | |||||
import org.springframework.data.redis.core.StringRedisTemplate; | |||||
import org.springframework.data.redis.core.script.DefaultRedisScript; | |||||
import java.util.Collections; | |||||
import java.util.Optional; | |||||
import java.util.Timer; | |||||
import java.util.TimerTask; | |||||
import java.util.concurrent.TimeUnit; | |||||
/** | |||||
* <p> | |||||
* 基于 RedisTemplate 实现的分布式锁 | |||||
* </p> | |||||
* | |||||
* @author yangkai.shen | |||||
* @date 2022-09-03 12:33 | |||||
*/ | |||||
public class RedisDistributedLock extends DistributedLock { | |||||
private final StringRedisTemplate redisTemplate; | |||||
/** | |||||
* 锁的唯一标识,格式:主机标识(UUID)+线程编号 | |||||
* 防误删 | |||||
*/ | |||||
private final String uniqueId; | |||||
protected RedisDistributedLock(StringRedisTemplate redisTemplate, String uniqueIdPrefix, String lockKey, long lockTime, | |||||
TimeUnit timeUnit) { | |||||
super(lockKey, lockTime, timeUnit); | |||||
this.redisTemplate = redisTemplate; | |||||
this.uniqueId = uniqueIdPrefix + ":" + Thread.currentThread().getId(); | |||||
} | |||||
@Override | |||||
public void lock() { | |||||
// 加锁失败,自旋阻塞 | |||||
while (!tryLock()) { | |||||
try { | |||||
TimeUnit.MILLISECONDS.sleep(50); | |||||
} catch (InterruptedException e) { | |||||
throw new RuntimeException(e); | |||||
} | |||||
} | |||||
} | |||||
@Override | |||||
public boolean tryLock() { | |||||
try { | |||||
return tryLock(lockTime, timeUnit); | |||||
} catch (InterruptedException e) { | |||||
return false; | |||||
} | |||||
} | |||||
@Override | |||||
public boolean tryLock(long time, @NotNull TimeUnit unit) throws InterruptedException { | |||||
String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 " + | |||||
"then " + | |||||
" redis.call('hincrby', KEYS[1], ARGV[1], 1) " + | |||||
" redis.call('expire', KEYS[1], ARGV[2]) " + | |||||
" return 1 " + | |||||
"else " + | |||||
" return 0 " + | |||||
"end"; | |||||
long expire = unit.toSeconds(time); | |||||
Boolean getLock = Optional.ofNullable( | |||||
redisTemplate.execute( | |||||
new DefaultRedisScript<>(script, Boolean.class), | |||||
Collections.singletonList(lockKey), | |||||
uniqueId, String.valueOf(expire))) | |||||
.orElse(Boolean.FALSE); | |||||
// 如果获得锁,开启自动续期 | |||||
if (getLock) { | |||||
renewLockTime(time, unit); | |||||
} | |||||
return getLock; | |||||
} | |||||
private void renewLockTime(long time, TimeUnit unit) { | |||||
String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 1 " + | |||||
"then " + | |||||
" return redis.call('expire', KEYS[1], ARGV[2]) " + | |||||
"else " + | |||||
" return 0 " + | |||||
"end"; | |||||
long expire = unit.toSeconds(time); | |||||
new Timer().schedule(new TimerTask() { | |||||
@Override | |||||
public void run() { | |||||
Boolean renewed = Optional.ofNullable( | |||||
redisTemplate.execute( | |||||
new DefaultRedisScript<>(script, Boolean.class), | |||||
Collections.singletonList(lockKey), | |||||
uniqueId, String.valueOf(expire)) | |||||
).orElse(Boolean.FALSE); | |||||
// 续期成功,代表未被解锁,则需要进行下一次续期 | |||||
if (renewed) { | |||||
renewLockTime(time, unit); | |||||
} | |||||
} | |||||
}, expire * 1000 / 3); | |||||
} | |||||
@Override | |||||
public void unlock() { | |||||
String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 " + | |||||
"then " + | |||||
" return nil " + | |||||
"elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 " + | |||||
"then " + | |||||
" return redis.call('del', KEYS[1]) " + | |||||
"else " + | |||||
" redis.call('expire', KEYS[1], ARGV[2]) " + | |||||
" return 0 " + | |||||
"end"; | |||||
// 如果解锁,发现是重入的,需要重新续期 | |||||
long expire = timeUnit.toSeconds(lockTime); | |||||
Long flag = this.redisTemplate.execute( | |||||
new DefaultRedisScript<>(script, Long.class), | |||||
Collections.singletonList(lockKey), | |||||
uniqueId, String.valueOf(expire) | |||||
); | |||||
if (flag == null) { | |||||
throw new IllegalMonitorStateException("this lock doesn't belong to you!"); | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,23 @@ | |||||
package com.xkcoding.distributed.lock.autoconfigure; | |||||
import org.springframework.context.annotation.Bean; | |||||
import org.springframework.context.annotation.Configuration; | |||||
import org.springframework.data.redis.core.StringRedisTemplate; | |||||
/** | |||||
* <p> | |||||
* 基于 RedisTemplate 分布式锁自动装配类 | |||||
* </p> | |||||
* | |||||
* @author yangkai.shen | |||||
* @date 2022-09-03 15:43 | |||||
*/ | |||||
@Configuration(proxyBeanMethods = false) | |||||
public class RedisDistributedLockAutoConfiguration { | |||||
@Bean | |||||
public RedisDistributedLockClient distributedLockClient(StringRedisTemplate redisTemplate) { | |||||
return new RedisDistributedLockClient(redisTemplate); | |||||
} | |||||
} |
@@ -0,0 +1,44 @@ | |||||
package com.xkcoding.distributed.lock.autoconfigure; | |||||
import com.xkcoding.distributed.lock.api.DistributedLock; | |||||
import com.xkcoding.distributed.lock.api.DistributedLockClient; | |||||
import org.springframework.data.redis.core.StringRedisTemplate; | |||||
import java.util.UUID; | |||||
import java.util.concurrent.TimeUnit; | |||||
/** | |||||
* <p> | |||||
* 获取一把 RedisTemplate 实现的分布式锁 | |||||
* </p> | |||||
* | |||||
* @author yangkai.shen | |||||
* @date 2022-09-03 12:35 | |||||
*/ | |||||
public class RedisDistributedLockClient implements DistributedLockClient { | |||||
private final StringRedisTemplate redisTemplate; | |||||
/** | |||||
* 唯一标识前缀,用于集群环境下的主机标识,会在 Bean 初始化到 Spring 容器的时候设置 | |||||
*/ | |||||
private final String uniqueIdPrefix; | |||||
public RedisDistributedLockClient(StringRedisTemplate redisTemplate) { | |||||
this.redisTemplate = redisTemplate; | |||||
uniqueIdPrefix = UUID.randomUUID().toString(); | |||||
} | |||||
/** | |||||
* 获取一把锁 | |||||
* | |||||
* @param lockKey 锁的标识 | |||||
* @param lockTime 锁的时间 | |||||
* @param timeUnit 锁的时间单位 | |||||
* @return 锁 | |||||
*/ | |||||
@Override | |||||
public DistributedLock getLock(String lockKey, long lockTime, TimeUnit timeUnit) { | |||||
return new RedisDistributedLock(redisTemplate, uniqueIdPrefix, lockKey, lockTime, timeUnit); | |||||
} | |||||
} |
@@ -0,0 +1,19 @@ | |||||
server: | |||||
port: 8080 | |||||
servlet: | |||||
context-path: /demo | |||||
spring: | |||||
sql: | |||||
init: | |||||
continue-on-error: true | |||||
mode: always | |||||
schema-locations: | |||||
- "classpath:db/schema.sql" | |||||
datasource: | |||||
driver-class-name: com.mysql.cj.jdbc.Driver | |||||
url: jdbc:mysql://localhost:3306/spring-boot-demo | |||||
username: root | |||||
password: root | |||||
redis: | |||||
host: 127.0.0.1 | |||||
port: 6379 |