一、使用背景
在这个流量为王的时代,每一次用户操作都可能触发千万级的并发访问,系统的稳定性成为每位开发者的终极战场。无论是电商大促、热门抢票还是社交平台的互动高峰,如何让你的系统在狂风暴雨中稳如泰山?一把强大而可靠的分布式锁,就是破局的利器!
想象一下:
- 双十一秒杀现场,上亿用户同时点击“立即购买”,你的库存还能hold住吗?
- 高并发支付系统,万千订单一键生成,你的数据还安全吗?
- 实时热门榜单,用户疯狂点赞刷新,数据一致性还能保障吗?
这些挑战看似不可战胜,但其实只需一把小小的“锁”就能搞定。而今天要介绍的是一种革命性的实现方式——用 Redisson 自定义注解实现分布式锁,让复杂的分布式锁逻辑一秒化繁为简!
1.1 为什么是分布式锁?
分布式锁的诞生,就是为了解决多线程、多服务实例同时争夺共享资源的问题。没有它,你的系统可能会面临:
- 库存超卖:用户买到不存在的商品,投诉接到爆炸!
- 数据不一致:多个服务同时写入数据,结果一片混乱!
- 资源争夺:高并发场景下,系统直接崩溃,用户流失殆尽!
1.2 为什么选择 Redisson?
Redisson 是 Redis 的超强武器,它提供了简单易用的 API 和强大的分布式工具,分布式锁更是它的杀手锏!Redisson 让你告别繁琐的 Redis 脚本操作,轻松实现:
- 可重入锁:同一线程可以多次加锁,不会死锁!
- 公平锁:让资源争夺变得有序,不再被流量大户压垮!
- 读写锁:读写分离,性能暴增!
- 信号量锁:轻松控制并发量,拒绝服务过载!
1.3 为什么要自定义注解?
用 Redisson 自定义注解,你可以这样玩:
- 再也不用手写复杂的加锁、释放逻辑,只需一句注解就搞定:
@@RedissonLock(key = "order:lock")
public void placeOrder() {
// 秒杀下单逻辑
}
- 清晰优雅,代码整洁如诗;稳定高效,性能拉满!
- 秒杀、抢单、排行榜……任何并发场景一招制敌!
1.4 准备好颠覆认知了吗?
Redisson 自定义分布式锁,将是你系统架构的必备利器。从此,高并发场景不再是噩梦,而是你的舞台!
不想再被“并发地狱”支配?探索分布式锁的秘密吧!
二、赛前准备
2.1 引入依赖
我这里引入了一些依赖,以便更好的实现自定义注解实现分布式锁
- redisson 3.21.0
- spring-boot-starter-aop 2.6.13
- lombok 2.6.13
- hutool 5.7.10
<!-- Redisson -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.21.0</version>
</dependency>
<!-- Spring Boot AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.10</version>
</dependency>
2.2 添加配置
redisson配置类,自定义redis地址和使用的database
/**
* @author yangp
*/
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setDatabase(1);
return Redisson.create(config);
}
}
三、自定义枚举(定义锁类型)
2.1 标准锁介绍
标准锁(也称为非公平锁)是最常见的锁类型,它不保证线程获得锁的顺序。任何等待的线程都可以在锁释放时争夺锁,可能会发生"锁抢占"现象,导致某些线程长时间得不到锁。
- 特性:不保证线程获取锁的顺序,先来先得、适用于大多数需要并发访问资源的场景。
- 使用场景:适用于对性能要求较高且不太关注公平性的场景。
Redisson 中的实现:RLock lock = redisson.getLock("myLock");
2.2 公平锁介绍
公平锁是一种确保锁的请求顺序与请求的顺序相同的锁。也就是说,先请求锁的线程先获得锁,避免了线程饥饿的问题。在高并发的情况下,公平锁能够保证线程按照请求的顺序获得锁,从而避免了资源的抢占和不公平竞争。
- 特性:保证锁的公平性、先请求锁的线程先获得锁。
- 使用场景:当多个线程需要公平地访问共享资源时,使用公平锁可以避免线程饥饿。
Redisson 中的实现:RLock fairLock = redisson.getFairLock("myLock");
2.3 读锁介绍
读锁是属于读写锁(ReadWriteLock
)的一部分,它允许多个线程同时获取读锁。当一个线程持有读锁时,其他线程也可以同时获取读锁,从而实现并发读操作。但是,写锁会阻塞所有的读锁和写锁。
- 特性:多个线程可以并发地获得读锁、只有当没有线程持有写锁时,才能获取读锁。
- 使用场景:适用于读多写少的场景,通过允许多个线程同时读取共享资源,提高性能。
Redisson 中的实现:
RReadWriteLock rwLock = redisson.getReadWriteLock("rwLock");
RLock readLock = rwLock.readLock();
2.4 写锁介绍
写锁是属于读写锁(ReadWriteLock
)的一部分。当一个线程持有写锁时,其他线程(无论是读锁还是写锁)都无法访问该资源,写锁是排它的。
- 特性:只有持有写锁的线程才能修改资源、写锁是排他性的,其他任何线程都不能同时持有写锁或读锁。
- 使用场景:适用于写操作较少但需要保证数据一致性的场景,避免并发修改问题。
Redisson 中的实现:
RReadWriteLock rwLock = redisson.getReadWriteLock("rwLock");
RLock writeLock = rwLock.writeLock();
2.5 枚举实现
这里的枚举使用到策略模式,好好品,每一个枚举实现类对应一种锁,枚举是天然的策略模式。
/**
* 锁类型
* @Author yangp
*/
@Getter
@AllArgsConstructor
public enum LockType implements IDictEnum {
/**
* 标准锁:不保证公平性
*/
STANDARD(1, "标准锁", lockKey -> SpringUtil.getBean(RedissonClient.class).getFairLock(lockKey)),
/**
* 公平锁:保证公平性
*/
FAIR(2, "公平锁", lockKey -> SpringUtil.getBean(RedissonClient.class).getReadWriteLock(lockKey).readLock()),
/**
* 读锁:保证读操作,不保证写操作
*/
READ(3, "读锁", lockKey -> SpringUtil.getBean(RedissonClient.class).getReadWriteLock(lockKey).writeLock()),
/**
* 写锁:保证写操作,不保证读操作
*/
WRITE(4, "写锁", lockKey -> SpringUtil.getBean(RedissonClient.class).getLock(lockKey));
private final Integer type;
private final String desc;
/**
* 锁类型
*/
private final Function<String, RLock> lockTypeFun;
/**
* 获取锁类型
*
* @param lockType 锁类型
* @return 锁类型
*/
public RLock getLockType(String lockType) {
return lockTypeFun.apply(lockType);
}
}
四、自定义回调
4.1 回调接口
在下面的注解中提供一个一个回调接口,当出现方法超时、方法执行异常,提供一个兜底的方案。
/**
* @Author: yangp
* @Date: 2024/12/4 下午3:43
*/
public interface LockCallback {
/**
* 当无法获取锁时调用(方法超时)
*
* @param lockKey 锁的 Key
*/
void onLockFailure(String lockKey);
/**
* 当执行方法抛出异常时调用
*
* @param lockKey 锁的 Key
* @param ex 异常信息
*/
void onException(String lockKey, Throwable ex);
}
4.2 默认回调
目前提供的默认回调,只是打印相关日志信息,可以根据具体的业务,实现默认回调,保证线程即使没有拿到锁,也会有记录,或者记录存入数据库,提供一个兜底的实现。
/**
* @Author: yangp
* @Date: 2024/12/4 下午3:50
*/
public class DefaultLockCallback implements LockCallback {
private static final Logger logger = LoggerFactory.getLogger(DefaultLockCallback.class);
/**
* 当无法获取锁时调用(方法超时)
*
* @param lockKey 锁的 Key
*/
@Override
public void onLockFailure(String lockKey) {
logger.error("[默认分布式锁回调][无法获取锁],获取锁失败,lockKey:{}", lockKey);
}
/**
* 当执行方法抛出异常时调用
*
* @param lockKey 锁的 Key
* @param ex 异常信息
*/
@Override
public void onException(String lockKey, Throwable ex) {
logger.error("[默认分布式锁回调][执行方法抛出异常],执行方法抛出异常,lockKey:{}", lockKey, ex);
}
}
五、自定义注解
注解参数介绍:
- key:锁的 key 支持静态和 SpEL 表达式的 Key,比如:@RedissonLock(key = "#account.userPkId:#account.money"),当然这里money不应该作为key,key应该保证唯一性和不变性。
- leaseTime:锁的持有时间(秒),也就是redis中数据的过期时间。
- waitForLock:是否等待锁,是等待锁则持续等待waitTime秒,不是等待锁,获取不到锁就返回失败。
/**
* Redisson 分布式锁注解
* @Author yangp
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedissonLock {
/**
* 锁的 key 支持静态和 SpEL 表达式的 Key
*
* @return key
*/
String spelKey();
/**
* 锁的持有时间(秒)
*
* @return time
*/
long leaseTime() default 60;
/**
* 是否等待锁
*
* @return boolean
*/
boolean waitForLock() default true;
/**
* 等待锁的时间(秒)
*
* @return time
*/
long waitTime() default 5;
/**
* 锁回调类
*
* @return lockCallback
*/
Class<? extends LockCallback> lockCallback() default DefaultLockCallback.class;
/**
* 锁类型
*
* @return lockType
*/
LockType lockType() default LockType.STANDARD;
}
六、分布式锁切面
分布式锁 AOP 切面类,基于 Redisson 实现。主要用于在方法执行前后处理分布式锁的获取和释放逻辑。
/**
* 分布式锁 AOP 切面类,基于 Redisson 实现。
* 主要用于在方法执行前后处理分布式锁的获取和释放逻辑。
*
* @Author yangp
*/
@Aspect
@Component
@Slf4j
public class RedissonLockAspect {
/**
* Spring Expression Language (SpEL) 表达式解析器,用于解析注解中的动态表达式。
*/
private static final ExpressionParser PARSER = new SpelExpressionParser();
/**
* 用于获取方法参数的名称
*/
private static final DefaultParameterNameDiscoverer NAME_DISCOVERER = new DefaultParameterNameDiscoverer();
/**
* 解析注解中的 SpEL 表达式,生成锁的键值。
*
* @param sp 注解中定义的 SpEL 表达式
* @param method 当前方法对象
* @param args 方法参数
* @return 解析后的锁键
*/
public static String parseSp(String sp, Method method, Object[] args) {
// 创建 SpEL 表达式的上下文
EvaluationContext context = new StandardEvaluationContext();
String[] paramNames = NAME_DISCOVERER.getParameterNames(method);
if (paramNames != null) {
for (int i = 0; i < paramNames.length; i++) {
// 将方法参数绑定到上下文中,参数名作为变量名
context.setVariable(paramNames[i], args[i]);
}
}
// 解析 SpEL 表达式,获取结果
StringBuilder stringBuffer = new StringBuilder();
String key;
String[] split = sp.split(":");
try {
for (String s : split) {
stringBuffer.append(PARSER.parseExpression(s).getValue(context, String.class));
stringBuffer.append(":");
}
stringBuffer.deleteCharAt(stringBuffer.length() - 1);
key = stringBuffer.toString();
} catch (Exception e) {
log.error("解析 SpEL 表达式失败将使用默认值字符作为Key,SpEL: {}, 异常信息: {}", sp, e.getMessage(), e);
key = sp;
}
// 使用方法名作为前缀,并返回最终锁 Key
String lockKey = method.getName() + ":" + key;
// 对锁 Key 进行 MD5 压缩,避免长度过长
return lockKey.length() > 100 ? DigestUtils.md5DigestAsHex(lockKey.getBytes(StandardCharsets.UTF_8)) : lockKey;
}
/**
* AOP 切面方法,处理分布式锁的逻辑。
*
* @param joinPoint 切入点信息
* @param redissonLock 分布式锁注解
* @return 方法执行结果
*/
@Around("@annotation(redissonLock)")
public Object around(ProceedingJoinPoint joinPoint, RedissonLock redissonLock) {
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
// 解析锁键
String lockKey = parseSp(redissonLock.spelKey(), method, joinPoint.getArgs());
// 获取 Redisson 的锁对象
RLock lock = redissonLock.lockType().getLockType(lockKey);
boolean isLocked = false;
try {
// 根据配置尝试获取锁
if (redissonLock.waitForLock()) {
log.info("[分布式锁][等待锁]等待获取分布式锁,key: {}", lockKey);
isLocked = lock.tryLock(redissonLock.waitTime(), redissonLock.leaseTime(), TimeUnit.SECONDS);
} else {
log.info("[分布式锁][非等待锁]尝试获取分布式锁,key: {}", lockKey);
isLocked = lock.tryLock(0, redissonLock.leaseTime(), TimeUnit.SECONDS);
}
// 如果未能获取锁,执行回调并抛出异常
if (!isLocked) {
log.warn("无法获取分布式锁,key: {}", lockKey);
invokeCallback(redissonLock.lockCallback(), lockKey, null);
throw new IllegalStateException("无法获取分布式锁,key: " + lockKey);
}
log.info("成功获取分布式锁,key: {}, 类名: {}, 方法名: {}, 线程: {}", lockKey, method.getDeclaringClass().getName(), method.getName(), Thread.currentThread().getId());
// 执行目标方法
return joinPoint.proceed();
} catch (Throwable ex) {
// 处理目标方法抛出的异常
log.error("方法执行异常,key: {}, 异常信息: {}", lockKey, ex.getMessage(), ex);
invokeCallback(redissonLock.lockCallback(), lockKey, ex);
throw new IllegalStateException("方法执行异常,key: " + lockKey, ex);
} finally {
// 释放锁
if (isLocked) {
try {
// 确保当前线程持有锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
log.info("成功释放分布式锁,key: {}, 线程: {}", lockKey, Thread.currentThread().getId());
}
} catch (Exception e) {
log.error("释放锁时发生异常,key: {}, 线程: {}, 异常信息: {}", lockKey, Thread.currentThread().getId(), e.getMessage(), e);
}
}
}
}
/**
* 执行回调逻辑。
*
* @param callbackClass 回调类
* @param lockKey 锁键
* @param ex 异常信息
*/
private void invokeCallback(Class<? extends LockCallback> callbackClass, String lockKey, Throwable ex) {
if (!ObjectUtils.isEmpty(callbackClass)) {
try {
LockCallback callbackInstance = callbackClass.getDeclaredConstructor().newInstance();
if (ex == null) {
callbackInstance.onLockFailure(lockKey);
} else {
callbackInstance.onException(lockKey, ex);
}
} catch (Exception e) {
log.error("回调方法调用失败,无法实例化回调类: {}, 异常信息: {}", callbackClass.getName(), e.getMessage(), e);
}
}
}
}
七、分布式锁测试
7.1 测试准备
7.1.1 账户实体类
账户实体类,主要有用户主键和金额两个字段
/**
* @author yangp
*/
@Data
public class Account {
/**
* 主键
*/
private String userPkId;
/**
* 金额
*/
private BigDecimal money;
}
7.1.2 账户控制器
接口的入口,这里只是做模拟,应该使用统一的返回值的。
/**
* @Author: yangp
* @Date: 2024/12/4 下午4:57
*/
@RestController
@RequestMapping("/account")
@Slf4j
public class AccountController {
@Resource
private AccountServiceImpl accountService;
@PostMapping("/update")
public String update(@RequestBody Account account) {
return accountService.updateAccount(account);
}
}
7.1.3 账户服务实现类(场景模拟)
假设有两个线程同时尝试更新同一个账户的余额,逻辑是这样的:
- 线程 A 和线程 B 都从数据库中读取账户的余额。
- 线程 A 将余额 +1000 后保存。
- 线程 B 也在几乎相同的时间点,读取账户余额并将余额 +500 保存。
- 如果没有分布式锁,线程 A 和线程 B 会互相覆盖对方的更新,导致账户余额不正确。
public interface IAccountService {
String updateAccount(Account account);
}
@Service
@Slf4j
public class AccountServiceImpl implements IAccountService {
/**
* 模拟数据库,存储账户信息
*/
public static Map<String, BigDecimal> accountDatabase = new HashMap<>();
public AccountServiceImpl() {
// 初始化账户余额 用户ID 111111,初始余额0
accountDatabase.put("111111", new BigDecimal("0"));
}
/**
* 更新账户余额,模拟并发问题
*/
@Override
/* // 使用Redisson分布式锁,保证每次只有一个线程可以更新同一账户
@RedissonLock(spelKey = "'账户' + #account.userPkId", waitTime = 2000) */
public String updateAccount(Account account) {
try {
// 模拟处理时间,假设在这段时间内可能有多个线程同时尝试更新同一个账户
// 模拟延迟,保证模拟并发问题
Thread.sleep(1000);
// 获取账户余额
BigDecimal balance = accountDatabase.get(account.getUserPkId());
if (balance == null) {
throw new RuntimeException("Account not found");
}
// 假设账户余额加1000
BigDecimal newBalance = account.getMoney().add(balance);
// 更新账户余额
accountDatabase.put(account.getUserPkId(), newBalance);
log.info("Updated account: {}", accountDatabase.get(account.getUserPkId()));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return "success";
}
}
7.1.4 集群配置
在微服务开发场景下,服务大多是以集群方式部署的。在本地开发时 有时候会需要以集群的方式启动项目,同时启动多个实例来测试一些相关功能(例如分布式锁),此时不需要打包之后再仍到测试环境去启动多个实例,在IDE中就可以同时启动多个实例。
编辑配置
复制配置
快捷键 alt + e 添加环境变量,自定义端口号
--server.port=8099
7.2 不使用分布式锁
这里模拟了5个线程来跑,演示多个线程互相覆盖对方的更新,导致账户余额不正确:
@SpringBootTest(classes = SpringbootDemoApplication.class)
@Slf4j
class AccountServiceImplTest {
@Autowired
private IAccountService accountService;
@Test
void testUpdateAccountWithoutLock() throws InterruptedException {
// 使用线程池模拟多线程环境
ExecutorService executorService = Executors.newFixedThreadPool(5);
// 用于等待所有线程完成
CountDownLatch latch = new CountDownLatch(5);
// 模拟并发更新
for (int i = 0; i < 5; i++) {
executorService.submit(() -> {
try {
// 创建一个账户对象,模拟更新操作
Account account = new Account();
// 账户ID
account.setUserPkId("111111");
// 每个线程试图加1000
account.setMoney(new BigDecimal("1000"));
// 调用更新账户的方法
accountService.updateAccount(account);
} finally {
latch.countDown(); // 线程完成,减少计数
}
});
}
// 等待所有线程完成
latch.await();
executorService.shutdown();
// 输出最终账户余额
System.out.println("Final account balance: " + AccountServiceImpl.accountDatabase.get("111111"));
// 如果没有加锁,最终账户余额不是5000
System.out.println("Expected account balance: 5000");
}
}
可以看到最终的账户余额(2000) != 预期的账户余额(5000),出现了线程安全问题。
7.3 使用分布式锁
一个执行过程需要1秒,我们默认为等待锁,设置了等待时间为2秒,5个线程对余额分别 + 1000,最终账户余额预期为:5000。
/**
* 更新账户余额,模拟并发问题
*/
@Override
@RedissonLock(spelKey = "'账户' + #account.userPkId", waitTime = 2000)// 使用Redisson分布式锁,保证每次只有一个线程可以更新同一账户
public String updateAccount(Account account) {
try {
// 模拟处理时间,假设在这段时间内可能有多个线程同时尝试更新同一个账户
// 模拟延迟,保证模拟并发问题
Thread.sleep(1000);
// 获取账户余额
BigDecimal balance = accountDatabase.get(account.getUserPkId());
if (balance == null) {
throw new RuntimeException("Account not found");
}
// 假设账户余额加1000
BigDecimal newBalance = account.getMoney().add(balance);
// 更新账户余额
accountDatabase.put(account.getUserPkId(), newBalance);
log.info("Updated account: {}", accountDatabase.get(account.getUserPkId()));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return "success";
}
以看到最终的账户余额(5000) == 预期的账户余额(5000)
7.3 标准锁测试
7.3.1 @RedissonLock(key = "#account.userPkId"):等待锁
- key:包名.类名.方法名.hash(参数)
- leaseTime:锁持有时间60秒
- waitForLock:为等待锁
- waitTime:等待时间为5秒
- 锁类型:标准锁,不保证公平性
服务器:8099 成功获取到锁,并执行中
服务器:8899 没有获取到锁,等待5秒,5秒后执行回调,并抛出异常
7.3.2 @RedissonLock(key = "#account.userPkId",waitForLock = false):非等待锁
- key:包名.类名.方法名.hash(参数)
- leaseTime:锁持有时间60秒
- waitForLock:非等待锁
- 锁类型:标准锁,不保证公平性
8899 成功获取到锁,并执行中
8099没有获取到锁,直接执行回调,并抛出异常
八、4大锁类型使用场景总结
最后给大家献上每种锁类型的使用场景,方便各位大佬食用。
锁类型 | 使用场景 |
---|---|
公平锁 | 金融、银行系统的资金操作,确保用户的请求按顺序处理,防止先到的请求被长时间阻塞。 |
标准锁 | 任务调度系统中,如果任务处理顺序不重要,使用标准锁可以提高吞吐量。 |
读锁 | 读多写少的场景 、 数据读取不频繁改变时 |
写锁 | 写操作独占资源的场景、 数据修改、更新操作 |