Skip to main content

05小节:基于注解实现分布式锁重复提交

作者:程序员马丁

在线博客:https://nageoffer.com

note

热门项目实战社群,收获国内众多知名公司面试青睐,近千名同学面试成功!助力你在校招或社招上拿个offer。

基于注解实现分布式锁防重复提交,元数据信息:

©版权所有 - 拿个offer-开源&项目实战星球专属学习项目,依据《中华人民共和国著作权法实施条例》《知识星球产权保护》,严禁未经本项目原作者明确书面授权擅自分享至 GitHub、Gitee 等任何开放平台。违者将面临法律追究。


内容摘要:通过商家创建优惠券引申出防重复提交功能,并通过注解、SpringAOP 以及分布式锁完成该功能,最终封装为组件库的通用功能。

课程目录如下所示:

  • 业务背景
  • Git 分支
  • 通过分布式锁防重复提交
  • 自定义组件库
  • 并发单元测试

业务背景

商家用户在优惠券管理系统中,点击“创建优惠券”按钮来生成一个新的优惠券。这个操作通常是在一个表单提交页面上完成的。商家填写了必要的信息(如优惠券名称、金额、有效期等),然后点击了创建按钮,后端系统能够生成并保存这个优惠券。

在用户点击“创建优惠券”按钮后,可能会出现重复提交的问题。这可能是由于以下原因造成的:

  • 网络延迟:用户的网络连接可能有延迟,或者用户无意中刷新了页面,导致按钮被点击多次,系统接收到多个相同的请求。
  • 按钮未禁用:前端页面中的按钮在用户点击后没有及时禁用,导致用户可以多次点击,从而发起多个创建请求。
  • 系统处理延迟:系统在处理请求时可能出现延迟,用户误以为请求没有成功,从而重复提交相同的请求。

为此,我们需要在后端系统中防重复提交的逻辑解决可能出现的问题。

从交互上来说,可以把前端按钮禁用先完成,然后才是后端工程编码。

Git 分支

20240817_dev_no-duplicate-submit_lock_ding.ma

通过分布式锁防重复提交

1. 能不能用本地锁?

常见的本地锁有两种:synchronizedReentrantLock

synchronized 是 Java 中的一种内置锁机制,用于在代码块或方法上实现线程同步。

public synchronized void synchronizedMethod() {
// 线程安全的代码
}

ReentrantLockjava.util.concurrent.locks 包下的锁实现,它提供了更多的控制和灵活性。

private final ReentrantLock lock = new ReentrantLock();

public void method() {
lock.lock(); // 加锁
try {
// 线程安全的代码
} finally {
lock.unlock(); // 解锁,确保在最终块中释放锁
}
}

为什么不能用本地锁?

  • 范围有限:本地锁仅在应用程序的单个实例中有效。如果你的应用程序在多台服务器上运行(即分布式环境),每个实例的本地锁相互独立,无法在集群中共享锁状态。

  • 竞争条件:不同实例上的本地锁无法相互感知,这意味着多个实例可能同时认为自己获得了锁,从而导致并发冲突。

而且,上面锁定的话,单个实例里的逻辑就变成串行了。如果想让不同的用户、不同的参数并行执行,还需要额外代码控制。

2. 什么是分布式锁?

分布式锁是一种用于在分布式系统中协调多个节点对共享资源的访问的机制。它确保在多个节点并发访问时,只有一个节点可以在某个时刻拥有特定资源的访问权,从而避免数据不一致、竞争条件或资源冲突的问题。

目前市场主要是以 Redis 实现的分布式锁为主,其中 Redisson 这个工具包中的分布式锁功能用的较多。

分布式锁比较关键的一个概念就是分布式锁 Key,这个应该如何定义?我们由以下几部分组成:

  • 分布式锁前缀。
  • 请求路径。
  • 当前访问用户。
  • 参数 MD5。

代码如下所示:

private final RedissonClient redissonClient;

@Override
public void createCouponTemplate(CouponTemplateSaveReqDTO requestParam) {
// 获取分布式锁标识
String lockKey = String.format("no-duplicate-submit:path:%s:currentUserId:%s:md5:%s", getServletPath(), getCurrentUserId(), calcArgsMD5(requestParam));
RLock lock = redissonClient.getLock(lockKey);

// 尝试获取锁,获取锁失败就意味着已经重复提交,直接抛出异常
if (!lock.tryLock()) {
throw new ClientException("请勿短时间内重复提交优惠券模板");
}

try {
// 执行常规业务代码
// ......
} finally {
lock.unlock();
}
}

/**
* @return 获取当前线程上下文 ServletPath
*/
private String getServletPath() {
ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return sra.getRequest().getServletPath();
}

/**
* @return 当前操作用户 ID
*/
private String getCurrentUserId() {
// 用户属于非核心功能,这里先通过模拟的形式代替。后续如果需要后管展示,会重构该代码
return "1810518709471555585";
}

/**
* @return joinPoint md5
*/
private String calcArgsMD5(CouponTemplateSaveReqDTO requestParam) {
return DigestUtil.md5Hex(JSON.toJSONBytes(requestParam));
}

3. Jmeter 压力测试

我们可以尝试使用 Jmeter 压测下试试看能不能防止重复提交呢?

注意不要用 Chrome 开多个页面访问,Chrome 访问是单线程的,上一个请求不返回,下一个不执行。

因为执行逻辑太快了,可能压力测试没办法顺时触发,我们可以添加睡眠操作。

@Override
public void createCouponTemplate(CouponTemplateSaveReqDTO requestParam) {
// 获取分布式锁标识
String lockKey = String.format("no-duplicate-submit:path:%s:currentUserId:%s:md5:%s", getServletPath(), getCurrentUserId(), calcArgsMD5(requestParam));
RLock lock = redissonClient.getLock(lockKey);

// 尝试获取锁,获取锁失败就意味着已经重复提交,直接抛出异常
if (!lock.tryLock()) {
throw new ClientException("请勿短时间内重复提交优惠券模板");
}

try {
// 睡眠 2 秒
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}

try {
// 执行常规业务代码
// ......
} finally {
lock.unlock();
}
}

同时启动 20 个线程执行创建优惠券模板接口。

通过 Jmeter 压测得知:

为什么错误的请求前面图标还是绿的?因为 HTTP 返回码是 200,只是在返回体里做了异常标识。

通过这个代码可以解决我们的优惠券防重复提交问题。虽然业务问题解决了,但是遇到了和上一节操作日志记录一样的问题。

  • 当业务变得复杂后,记录操作日志放在业务代码中会导致业务的逻辑比较繁琐。
  • 对于代码的可读性和可维护性来说是一个灾难,因为不止这一处使用,存在大量的冗余代码。

上一节使用 SpringAOP、SpEL 以及现成框架解决的优雅记录操作日志,这次我们自己通过 SpringAOP、注解和分布式锁实现一个通用组件。

自定义组件库

如果常规来说,我们会按照 12306 的组件定义格式,每个功能实现一个组件库,为了避免大家从零到一实现的编码困难度,选择写入 framework 模块中。

12306 学习地址:https://gitee.com/nageoffer/12306

解锁付费内容,👉 戳