目录
- 一、背景
- 二、maven依赖
- 三、单业务并发处理
- 四、多业务并发处理
- 4.1、公共配置
- 4.2、合成业务
- 4.3、转赠业务
- 4.4、控制层
- 4.5、配置文件
- 五、验证
- 5.1、不配置name
- 5.2、配置name
- 六、特别说明
- 6.1、情况一
- 6.2、情况二
- 6.3、情况三
一、背景
我在之前的文章Spring Boot基于KLock实现分布式锁的使用详解讲过关于基于redis的分布式锁的使用,不过我们讲的都是针对单个业务来测试的。今天我们就使用KLock实现对不同的业务进行处理,就已目前NFT中很火的数字藏品为例,数字藏品可以进行合成或者转赠的操作,但是一般一个藏品不能同时进行合成和转赠,只能进行一个操作合成或转赠。我们就以这个简单的事例来来说明。本文中的版本说明如下:
- Spring Boot 的版本是2.6.0
- spring-boot-klock-starter 版本为 1.4-RELEASE
二、maven依赖
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<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.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.alian</groupId>
<artifactId>redis-distribute</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>redis-distribute</name>
<description>redis-distribute</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>cn.keking</groupId>
<artifactId>spring-boot-klock-starter</artifactId>
<version>1.4-RELEASE</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
三、单业务并发处理
假设我们可以通过手机号码进行注册,但是一个手机号码肯定只能注册一次。我们就使用手机号码作为一个redis锁的关键因素。
@Slf4j
@Service
public class RegisterService {
private static final List<String> list = new ArrayList<>();
@Klock(keys = "#phone", lockTimeoutStrategy = LockTimeoutStrategy.FAIL_FAST)
public void register(String phone, String verifyCode) {
String threadName=Thread.currentThread().getName();
log.info("注册账户线程【{}】:注册账户收到的信息:{},{}", threadName, phone, verifyCode);
try {
if (list.contains(phone)){
log.info("注册账户线程【{}】:手机号码已注册", threadName);
return;
}
//模拟业务过程
Thread.sleep(2000);
//模拟数据库保存数据
list.add(phone);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("注册账户线程【{}】:注册账户业务处理完", threadName);
}
}
我们就模拟并发,假设3个线程并发,按照我们的需要,肯定是只能有一个成功。
@Slf4j
@Service
public class TestService {
private final CountDownLatch countDownLatch = new CountDownLatch(1);
@Autowired
private RegisterService registerService;
@PostConstruct
public void register() {
for (int i = 0; i < 3; i++) {
new Thread(() -> {
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
//模拟注册
registerService.register("13816894168", "000000");
}, "Thread" + i).start();
}
countDownLatch.countDown();
}
}
运行结果:
2022-08-25 14:34:41 810 [Thread0] INFO :注册账户线程【Thread0】:注册账户收到的信息:13816894168,000000
2022-08-25 14:34:43 822 [Thread0] INFO :注册账户线程【Thread0】:注册账户业务处理完
2022-08-25 14:34:43 835 [Thread1] INFO :注册账户线程【Thread1】:注册账户收到的信息:13816894168,000000
2022-08-25 14:34:43 835 [Thread1] INFO :注册账户线程【Thread1】:手机号码已注册
2022-08-25 14:34:43 858 [Thread2] INFO :注册账户线程【Thread2】:注册账户收到的信息:13816894168,000000
2022-08-25 14:34:43 858 [Thread2] INFO :注册账户线程【Thread2】:手机号码已注册
从结果来看3个线程中只有一个线程注册成功了,符合我们的预期。
四、多业务并发处理
4.1、公共配置
CommonResult.java
package com.alian.distribute.common;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CommonResult<T> implements Serializable {
private static final long serialVersionUID = 5881578443022652535L;
/**
* 成功
*/
public static String CODE_SUCCESS = "0000";
/**
* 失败
*/
public static String CODE_FAIL = "1000";
/**
* 系统异常
*/
public static String CODE_EXCEPTION = "1001";
/**
* 参数错误
*/
public static String CODE_ERR_PARAM = "1002";
/**
* 业务异常
*/
public static String CODE_BIZ_ERR = "1003";
private String code;
private String message;
private T data;
public CommonResult(String code, String message) {
this(code, message, null);
}
public static <T> CommonResult<T> success(T content) {
return new CommonResult<T>(CODE_SUCCESS, "success", content);
}
public static <T> CommonResult<T> fail(T content) {
return new CommonResult<T>(CODE_FAIL, "fail", content);
}
public static <T> CommonResult<T> errorParam(String message) {
return new CommonResult<T>(CODE_ERR_PARAM, message, null);
}
public static <T> CommonResult<T> exception(String message) {
return new CommonResult<T>(CODE_EXCEPTION, message, null);
}
}
简单封装一个公共返回类
GlobalConstants.java
package com.alian.distribute.constants;
import java.util.ArrayList;
import java.util.List;
public class GlobalConstants {
public static final String COLLECTION_OPTION = "collection.option";
public static List<String> collectionList = new ArrayList<String>() {{
add("10001");
add("10002");
add("10003");
}};
}
这里模拟已有的藏品列表,处理业务时,合成或者转赠则移除一个。
4.2、合成业务
ComposeService.java
package com.alian.distribute.service;
import com.alian.distribute.common.CommonResult;
import com.alian.distribute.constants.GlobalConstants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class ComposeService {
public CommonResult<String> compose(String collectionId) {
try {
if (!GlobalConstants.collectionList.contains(collectionId)) {
log.info("合成藏品:藏品【{}】不存在", collectionId);
return CommonResult.fail("藏品不存在");
}
//模拟业务过程
Thread.sleep(3000);
//模拟数据库保存数据
GlobalConstants.collectionList.remove(collectionId);
log.info("合成藏品:合成藏品业务处理完成");
return CommonResult.fail("合成成功");
} catch (InterruptedException e) {
log.error("合成异常:", e);
return CommonResult.exception("合成异常");
}
}
}
4.3、转赠业务
TransferService.java
package com.alian.distribute.service;
import com.alian.distribute.common.CommonResult;
import com.alian.distribute.constants.GlobalConstants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class TransferService {
public CommonResult<String> transfer(String collectionId) {
try {
if (!GlobalConstants.collectionList.contains(collectionId)) {
log.info("转赠藏品:藏品【{}】不存在", collectionId);
return CommonResult.fail("藏品不存在");
}
//模拟业务过程
Thread.sleep(3000);
//模拟数据库移除数据
GlobalConstants.collectionList.remove(collectionId);
log.info("转赠藏品:转赠藏品业务处理完成");
return CommonResult.success("转赠成功");
} catch (InterruptedException e) {
log.error("转赠异常:", e);
return CommonResult.exception("转赠异常");
}
}
}
4.4、控制层
CollectionController.java
package com.alian.distribute.controller;
import com.alian.distribute.common.CommonResult;
import com.alian.distribute.constants.GlobalConstants;
import com.alian.distribute.service.ComposeService;
import com.alian.distribute.service.TransferService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.klock.annotation.Klock;
import org.springframework.boot.autoconfigure.klock.model.LockTimeoutStrategy;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("/collection")
public class CollectionController {
@Autowired
private ComposeService composeService;
@Autowired
private TransferService transferService;
@Klock(name = GlobalConstants.COLLECTION_OPTION, keys = "#collectionId", lockTimeoutStrategy = LockTimeoutStrategy.FAIL_FAST)
@RequestMapping("/compose")
public CommonResult<String> compose(String collectionId) {
log.info("合成藏品收到的信息:{}", collectionId);
return composeService.compose(collectionId);
}
@Klock(name = GlobalConstants.COLLECTION_OPTION, keys = "#collectionId", lockTimeoutStrategy = LockTimeoutStrategy.FAIL_FAST)
@RequestMapping("/transfer")
public CommonResult<String> transfer(String collectionId) {
log.info("转赠藏品收到的信息:{}", collectionId);
return transferService.transfer(collectionId);
}
}
@Klock 注解上,这里和之前单业务唯一的区别在于,我们多配置了一个name 属性。这里我给大家说几个重点(核心内容,非常重要):
- 在没有配置name 属性时,默认的锁的名称是:lock.全类名.方法名-业务key名,多个key则继续用短横线连接
- 配置了name 属性时,锁的名称是:lock.自定义name的值-业务key名,多个key则继续用短横线连接
从这里我们就可以知道我们只要是让锁名称一致就可以达到控制的效果了。在实际中所有的方法都在一个类,并且每个方法的名称都一样这种情况还是很少见的,所以我们只需要:
- 配置好 name 属性,比如我这里配置的都是:collection.option
- 指定的 keys(包含顺序),比如我们这里的藏品编号:collectionId
4.5、配置文件
application.yml
server:
port: 8081
servlet:
context-path: /distribute
spring:
klock:
#单节点地址
address: 192.168.0.193:6379
#密码
#password:
#获取锁最长阻塞时间(默认:60,单位:秒)
wait-time: 20
#已获取锁后自动释放时间(默认:60,单位:秒)
lease-time: 20
如果是redis集群
spring:
klock:
#获取锁最长阻塞时间(默认:60,单位:秒)
wait-time: 20
#已获取锁后自动释放时间(默认:60,单位:秒)
lease-time: 20
cluster-server:
node-addresses: 192.168.0.111:6379,192.168.0.112:6379,192.168.0.113:6379,192.168.0.101:6379,192.168.0.102:6379,192.168.0.103:6379,192.168.0.114:6379,192.168.0.104:6379
五、验证
为了验证它的正确性,我们就用配置name和不配置name的结果进行一个对比。
5.1、不配置name
我们只需要把
@Klock(name = GlobalConstants.COLLECTION_OPTION, keys = "#collectionId", lockTimeoutStrategy = LockTimeoutStrategy.FAIL_FAST)
改成
@Klock(keys = "#collectionId", lockTimeoutStrategy = LockTimeoutStrategy.FAIL_FAST)
因为业务里休眠2秒了,1秒内先后请求下面两个地址即可,当然也可以使用压力测试工具)
- http://localhost:8081/distribute/collection/compose?collectionId=10001
- http://localhost:8081/distribute/collection/transfer?collectionId=10001
结果如下:
2022-08-25 15:52:50 477 [http-nio-8081-exec-1] INFO :合成藏品收到的信息:10001
2022-08-25 15:52:51 267 [http-nio-8081-exec-2] INFO :转赠藏品收到的信息:10001
2022-08-25 15:52:53 480 [http-nio-8081-exec-1] INFO :合成藏品:合成藏品业务处理完成
2022-08-25 15:52:54 282 [http-nio-8081-exec-2] INFO :转赠藏品:转赠藏品业务处理完成
既合成成功,又转赠成功,明显不符合我们的业务需求。
5.2、配置name
我们还是把注解还原为如下配置,也就是加了name属性,重新启动后我们再依次请求。
@Klock(name = GlobalConstants.COLLECTION_OPTION, keys = "#collectionId", lockTimeoutStrategy = LockTimeoutStrategy.FAIL_FAST)
结果如下:
2022-08-25 15:57:54 737 [http-nio-8081-exec-2] INFO :合成藏品收到的信息:10002
2022-08-25 15:57:57 744 [http-nio-8081-exec-2] INFO :合成藏品:合成藏品业务处理完成
2022-08-25 15:57:57 755 [http-nio-8081-exec-3] INFO :转赠藏品收到的信息:10002
2022-08-25 15:57:57 756 [http-nio-8081-exec-3] INFO :转赠藏品:藏品【10002】不存在
2022-08-25 15:58:31 275 [http-nio-8081-exec-4] INFO :转赠藏品收到的信息:10003
2022-08-25 15:58:34 285 [http-nio-8081-exec-4] INFO :转赠藏品:转赠藏品业务处理完成
2022-08-25 15:58:34 308 [http-nio-8081-exec-5] INFO :合成藏品收到的信息:10003
2022-08-25 15:58:34 309 [http-nio-8081-exec-5] INFO :合成藏品:藏品【10003】不存在
显然,我们这里合成和转赠只能成功一个。现在哪怕是你把合成和转赠功能拆分到两台服务上,分别部署也没有问题的,关键在于这个分布式锁的名称是否一样,上面我已经解释了,不知道大家有没有理解。
六、特别说明
6.1、情况一
@Slf4j
@RestController
@RequestMapping("/lock")
public class LockKeyController {
@Klock(keys = {"#userId"}, lockTimeoutStrategy = LockTimeoutStrategy.FAIL_FAST)
@RequestMapping("/methodA")
public void method(String userId) {
log.info("收到的参数:{}", userId);
}
@Klock(keys = {"#userId"}, lockTimeoutStrategy = LockTimeoutStrategy.FAIL_FAST)
@RequestMapping("/methodB")
public void method(String userId, String collectionId) {
log.info("收到的参数:{},{}", userId, collectionId);
}
}
lock.com.alian.distribute.controller.LockKeyController.method-userId的值,此时是与参数列表是否一样是没有关系的,但是方法名必须一样才可以,因为得到的锁名称会不一样。
6.2、情况二
@Slf4j
@RestController
@RequestMapping("/lock")
public class LockKeyController {
@Klock(name="csdn.alian",keys = {"#userId"}, lockTimeoutStrategy = LockTimeoutStrategy.FAIL_FAST)
@RequestMapping("/methodC")
public void methodC(String userId) {
log.info("收到的参数:{}", userId);
}
@Klock(name="csdn.alian",keys = {"#userId"}, lockTimeoutStrategy = LockTimeoutStrategy.FAIL_FAST)
@RequestMapping("/methodD")
public void methodD(String userId, String collectionId) {
log.info("收到的参数:{},{}", userId, collectionId);
}
}
lock.csdn.alian-userId的值,此时哪怕方法名不一样也没有关系。
6.3、情况三
@Slf4j
@RestController
@RequestMapping("/lock")
public class LockKeyController {
@Klock(name = "keys.sequence", keys = {"#userId", "#collectionId"}, lockTimeoutStrategy = LockTimeoutStrategy.FAIL_FAST)
@RequestMapping("/methodE")
public void methodE(String userId, String collectionId) {
log.info("收到的参数:{},{}", userId, collectionId);
}
@Klock(name = "keys.sequence", keys = {"#collectionId", "#userId"}, lockTimeoutStrategy = LockTimeoutStrategy.FAIL_FAST)
@RequestMapping("/methodF")
public void methodF(String userId, String collectionId, String type) {
log.info("收到的参数:{},{},{}", userId, collectionId, type);
}
}
得到的锁是不一样的。他们得到的锁分别为:
- lock.keys.sequence-userId的值-collectionId的值
- lock.keys.sequence-collectionId的值-userId的值
keys中的顺序哦,当然方法的参数顺序是不影响锁的名称的。最后还有一个点也容易忽视的是锁的名称后半部分都是业务key的值,也需要去注意,比如userId的值和collectionId的值一样了,就会导致两个锁一样,锁了两个业务,假设一个任务执行很长,可能会影响业务了。所以name 属性的配置就不要太随意了,不要都搞成一样了,要根据业务来设置。