diff --git a/demo-distributed-lock/demo-distributed-lock-redis/README.md b/demo-distributed-lock/demo-distributed-lock-redis/README.md new file mode 100644 index 0000000..f522819 --- /dev/null +++ b/demo-distributed-lock/demo-distributed-lock-redis/README.md @@ -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 + + + com.xkcoding + demo-distributed-lock-api + 1.0.0-SNAPSHOT + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.projectlombok + lombok + true + + +``` + +### 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) diff --git a/demo-distributed-lock/demo-distributed-lock-redis/docker-compose.env.yml b/demo-distributed-lock/demo-distributed-lock-redis/docker-compose.env.yml new file mode 100644 index 0000000..cac8254 --- /dev/null +++ b/demo-distributed-lock/demo-distributed-lock-redis/docker-compose.env.yml @@ -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" diff --git a/demo-distributed-lock/demo-distributed-lock-redis/pom.xml b/demo-distributed-lock/demo-distributed-lock-redis/pom.xml index 35f7e5c..8952b03 100644 --- a/demo-distributed-lock/demo-distributed-lock-redis/pom.xml +++ b/demo-distributed-lock/demo-distributed-lock-redis/pom.xml @@ -16,4 +16,23 @@ 17 + + + com.xkcoding + demo-distributed-lock-api + 1.0.0-SNAPSHOT + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.projectlombok + lombok + true + + + diff --git a/demo-zookeeper/src/main/java/com/xkcoding/zookeeper/SpringBootDemoZookeeperApplication.java b/demo-distributed-lock/demo-distributed-lock-redis/src/main/java/com/xkcoding/distributed/lock/RedisDistributedLockApplication.java similarity index 56% rename from demo-zookeeper/src/main/java/com/xkcoding/zookeeper/SpringBootDemoZookeeperApplication.java rename to demo-distributed-lock/demo-distributed-lock-redis/src/main/java/com/xkcoding/distributed/lock/RedisDistributedLockApplication.java index 766fbcf..af177ec 100644 --- a/demo-zookeeper/src/main/java/com/xkcoding/zookeeper/SpringBootDemoZookeeperApplication.java +++ b/demo-distributed-lock/demo-distributed-lock-redis/src/main/java/com/xkcoding/distributed/lock/RedisDistributedLockApplication.java @@ -1,4 +1,4 @@ -package com.xkcoding.zookeeper; +package com.xkcoding.distributed.lock; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -9,14 +9,13 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; *

* * @author yangkai.shen - * @date Created in 2018-12-27 14:51 + * @date 2022-09-03 12:23 */ @SpringBootApplication -public class SpringBootDemoZookeeperApplication { +public class RedisDistributedLockApplication { public static void main(String[] args) { - SpringApplication.run(SpringBootDemoZookeeperApplication.class, args); + SpringApplication.run(RedisDistributedLockApplication.class, args); } } - diff --git a/demo-distributed-lock/demo-distributed-lock-redis/src/main/java/com/xkcoding/distributed/lock/autoconfigure/RedisDistributedLock.java b/demo-distributed-lock/demo-distributed-lock-redis/src/main/java/com/xkcoding/distributed/lock/autoconfigure/RedisDistributedLock.java new file mode 100644 index 0000000..d6d2c34 --- /dev/null +++ b/demo-distributed-lock/demo-distributed-lock-redis/src/main/java/com/xkcoding/distributed/lock/autoconfigure/RedisDistributedLock.java @@ -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; + +/** + *

+ * 基于 RedisTemplate 实现的分布式锁 + *

+ * + * @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!"); + } + } + +} diff --git a/demo-distributed-lock/demo-distributed-lock-redis/src/main/java/com/xkcoding/distributed/lock/autoconfigure/RedisDistributedLockAutoConfiguration.java b/demo-distributed-lock/demo-distributed-lock-redis/src/main/java/com/xkcoding/distributed/lock/autoconfigure/RedisDistributedLockAutoConfiguration.java new file mode 100644 index 0000000..48d4f57 --- /dev/null +++ b/demo-distributed-lock/demo-distributed-lock-redis/src/main/java/com/xkcoding/distributed/lock/autoconfigure/RedisDistributedLockAutoConfiguration.java @@ -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; + +/** + *

+ * 基于 RedisTemplate 分布式锁自动装配类 + *

+ * + * @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); + } + +} diff --git a/demo-distributed-lock/demo-distributed-lock-redis/src/main/java/com/xkcoding/distributed/lock/autoconfigure/RedisDistributedLockClient.java b/demo-distributed-lock/demo-distributed-lock-redis/src/main/java/com/xkcoding/distributed/lock/autoconfigure/RedisDistributedLockClient.java new file mode 100644 index 0000000..b6373f8 --- /dev/null +++ b/demo-distributed-lock/demo-distributed-lock-redis/src/main/java/com/xkcoding/distributed/lock/autoconfigure/RedisDistributedLockClient.java @@ -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; + +/** + *

+ * 获取一把 RedisTemplate 实现的分布式锁 + *

+ * + * @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); + } +} diff --git a/demo-distributed-lock/demo-distributed-lock-redis/src/main/resources/application.yml b/demo-distributed-lock/demo-distributed-lock-redis/src/main/resources/application.yml new file mode 100644 index 0000000..6d472b7 --- /dev/null +++ b/demo-distributed-lock/demo-distributed-lock-redis/src/main/resources/application.yml @@ -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