7.21 SpringBoot项目实战【图书借阅】并发最佳实践:细粒度Key锁、数据库乐观锁、synchronized、ReentrantLock

news2024/12/26 17:18:52

CSDN成就一亿技术人

文章目录

  • 前言
  • 一、编写服务层
  • 二、编写控制器
  • 三、并发实战
    • 1. synchronized关键字
    • 2. Lock 接口
    • 3. Atomic类
    • 4. 细粒度Key锁
    • 5. 数据库乐观锁
    • 6. 最终service完整代码
  • 最后


前言

上文的产品设计流程:查看图书列表 7.3 实现-》查看图书详情上文7.20 -》图书借阅(本文)。
就好比:一帮人 抢借一本书,这和秒杀1本书 如出一辙,大家都懂 这就存在 并发问题
本文会先写【业务实现】,再来谈【如何解决】并发问题!重点在第三段的并发实战:代码演示使用 synchronized、ReentrantLock、AtomicBoolean、细粒度Key锁、数据库乐观锁,以版本迭代的方式,逐个分析遇到的问题,以及解决的方案,助你理解这种场景的最佳实践!


一、编写服务层

在这里插入图片描述

BookBorrowService新增borrowBook方法定义(其它方法省略):

public interface BookBorrowService {
    /**
     * 图书借阅: 哪个学生(userid)借了哪本书(bookId)
     **/
    void borrowBook(Integer bookId, Integer userId);
}

BookBorrowServiceImpl增加实现方法borrowBook

📢 内部逻辑大家都能想到,简单列一下,主要是4步,前2步是校验,后2步是insert和update SQL:

  • 1.校验当前学生 是否有 借阅资格
  • 2.校验图书状态 是否为 0-闲置
  • 3.向book_borrowing表插入一条 待审核 借阅记录
  • 4.修改图书状态1-借阅中

先实现业务代码(并发问题后面考虑):

@Transactional(rollbackFor = Exception.class)
@Override
public void borrowBook(Integer bookId, Integer userId) {
    // 1. 校验当前学生是否有有借阅资格
    Student student = studentMapperExt.selectByUserId(userId);
    Assert.ifFalse(student != null && ExamineEnum.APPROVED.getCode().equals(student.getIsApproved()), "请先申请借阅资格");
    // 2. 校验图书状态是否为0-闲置
    Book book = bookMapper.selectByPrimaryKey(bookId);
    Assert.ifNull(book, "bookId不合法");
    Assert.ifFalse(BookStatusEnum.FREE.getCode().equals(book.getStatus()), "手慢了, 请稍后再试吧");

    // 3. 向book_borrowing表插入一条【待审核】借阅记录
    BookBorrowing bookBorrowing = new BookBorrowing();
    // 照着数据表设置数据即可, 能设置的设置, 不能设置的空着
    bookBorrowing.setStudentId(student.getId());
    bookBorrowing.setBookId(bookId);
    bookBorrowing.setBorrowTime(new Date());
    bookBorrowing.setStatus(BookBorrowStatusEnum.TO_BE_EXAMINE.getCode());
    bookBorrowing.setGmtCreate(new Date());
    bookBorrowing.setGmtModified(new Date());
    bookBorrowingMapper.insertSelective(bookBorrowing);
    // 4. 修改book表的图书状态为1-借阅中
    Book updateBook = new Book();
    updateBook.setId(bookId);
    updateBook.setStatus(BookStatusEnum.BORROWING.getCode());
    bookMapper.updateByPrimaryKeySelective(updateBook);
}

📢 前面都讲过,这里简单解读一下:

  1. 因为有1个insert和1个update SQL语句,所以支持事务:@Transactional
  2. 前两步是通过Mybatis Mapper查询,然后通过断言工具类Assert做校验;
  3. 第三步是执行insert,按照book_borrowing表结构设计来设置数据;
  4. 第四步是执行update,大家都看的懂!

二、编写控制器

在这里插入图片描述

BookAdminController类新增方法:

@PostMapping("/book/borrow")
public TgResult<String> borrowBook(@Min(value = 1, message = "id必须大于0") @RequestParam("bookId") Integer bookId) {
    Integer userId = AuthContextInfo.getAuthInfo().loginUserId();
    bookBorrowService.borrowBook(bookId, userId);
    return TgResult.ok();
}

这里就不啰嗦了,看不懂的话,请复习前面讲过的内容。


三、并发实战

1. synchronized关键字

synchronized 是 JVM 提供的关键字,同步阻塞,是解决并发问题常用解决方案,用起来嘎嘎简单,是悲观锁的一种。“悲观”的意思是不管有没有竞争,反正我都认为会和其他线程产生竞争,所以每次使用都会上锁。

  • synchronized 用法一

    锁住整个方法,例如加在方法声明上:

public synchronized void borrowBook(Integer bookId, Integer userId) {
    略。。。
}
  • synchronized 用法二

    锁住代码块,例如只锁住第2+3+4块代码:

    public void borrowBook(Integer bookId, Integer userId) {
        // 1. 校验当前学生是否有有借阅资格
        synchronized (this) {
            // 2. 校验图书状态是否为0-闲置
            // 3. 向book_borrowing表插入一条【待审核】借阅记录
            // 4. 修改book表的图书状态为1-借阅中
        }
    }
    

    这里的this 可能会与其它锁 共用this,所以建议定义一个单独的Object仅用于借阅场景,例如:

    private static final Object LOCK_BORROW = new Object();
    public void borrowBook(Integer bookId, Integer userId) {
        // 1. 校验当前学生是否有有借阅资格
        synchronized (LOCK_BORROW) {
            // 2. 校验图书状态是否为0-闲置
            // 3. 向book_borrowing表插入一条【待审核】借阅记录
            // 4. 修改book表的图书状态为1-借阅中
        }
    }
    

    📢 即便如此,这段代码仍然有2个痛点

    1. 所有线程都会一直等待 执行 2+3+4 代码,试想一下,1个线程执行200ms,10个是2秒,100个就是20秒,1000个就是200秒,显然不符合我们的期望:当有人借到书了,其它人就可以散了,不必再执行2+3+4的代码!
    2. 借不同的书,也会相互阻塞!这就更说不过去了,我们更期望的是:你锁你的,我锁我的!

2. Lock 接口

同样是悲观锁,但Lock接口提供了tryLock方法,这就解决了上面说到的 使用synchronized 的第1个痛点👏,抢不到锁的直接回家,不用一直等待了!

常用的Lock接口实现是ReentrantLock,用它实现代码如下:

private static final Lock lockBorrow = new ReentrantLock();
public void borrowBook(Integer bookId, Integer userId) {
  	// 1. 校验当前学生是否有有借阅资格
     if (lockBorrow.tryLock()) {
         try {
             // 2. 校验图书状态是否为0-闲置
             // 3. 向book_borrowing表插入一条【待审核】借阅记录
             // 4. 修改book表的图书状态为1-借阅中
         } finally {
             lockBorrow.unlock();
         }
     } else {
         throw new BizException("手慢了, 请稍后再试吧");
     }
}

记住,Lock接口使用的标准格式:try finally,避免死锁

📢 但使用Lock 依然没有解决第2个痛点

3. Atomic类

Atomic类是指java.util.concurrent.atomic包下的原子类,属于乐观锁,底层使用CAS实现。

乐观锁,不用提前加锁,更新前检查是不是和期望值相同,相同才更新,达到无锁并发更新的效果。

例如,使用AtomicBoolean 实现代码如下:

// 初始false
private static final AtomicBoolean atomicLock = new AtomicBoolean(false);
public void borrowBook(Integer bookId, Integer userId) {
  	// 1. 校验当前学生是否有有借阅资格
    // 加锁:使用CAS将false改为true, 如果成功则返回true
    if (atomicLock.compareAndSet(false, true)) {
         try {
             // 2. 校验图书状态是否为0-闲置
             // 3. 向book_borrowing表插入一条【待审核】借阅记录
             // 4. 修改book表的图书状态为1-借阅中
         } finally {
             // 使用CAS将true改为false
             atomicLock.set(false);
         }
     } else {
         throw new BizException("手慢了, 请稍后再试吧");
     }
}

同样,和Lock接口使用非常类似:try finally,避免死锁

📢 使用CAS加锁:将false改为true,因为是原子操作,所以只有1个线程能操作成功, 如果成功则返回true

解锁,直接设为false即可,因为不涉及线程竞争!

但依然也没有解决第2个痛点

4. 细粒度Key锁

那么,有没有像分布式锁那样只锁定某个Key的本地锁

答案肯定是有的:

  • 使用synchronized可以实现 只锁定某个Key的锁,因为本身synchronized就支持锁定具体对象,所以只要是同一个Key就可以!只不过当前场景不太适合,原因还是痛点1 的一直等待问题,这是synchronized 不能解决的!

  • 使用ReentrantLock的话,也可以实现 只锁定某个Key的锁,方式之一是对每个Key 都生成一个ReentrantLock,然后调用lock()tryLock(),感觉差点意思!

  • 本文要分享的是使用ConcurrentHashMap的方式,借助的是ConcurrentHashMap线程安全,只要将Key put 成功则加锁成功,解锁也只是remove Key,代码如下:

private static final ConcurrentHashMap<Integer, Integer> map = new ConcurrentHashMap<>();
public void borrowBook(Integer bookId, Integer userId) {
  	// 1. 校验当前学生是否有有借阅资格
    // 加锁:put返回null,说明刚刚加入,则加锁成功
    if (map.putIfAbsent(bookId, bookId) == null) {
         try {
             // 2. 校验图书状态是否为0-闲置
             // 3. 向book_borrowing表插入一条【待审核】借阅记录
             // 4. 修改book表的图书状态为1-借阅中
         } finally {
             // 解锁移除key
             map.remove(bookId);
         }
     } else {
         throw new BizException("手慢了, 请稍后再试吧");
     }
}

📢 通过ConcurrentHashMap的方式,我们就同时解决了两个痛点!👏

当然,细粒度的锁,第三方框架也有相关实现,这里不做扩展,后面找机会再分享~

5. 数据库乐观锁

上面实现的都是JVM级别的,针对当前场景,如果我们部署多个JVM 实例,在不引入分布式锁的场景下,依然有可能造成 超卖 问题!那么此时,我们还有一个兜底利器是:数据库乐观锁

实现方式:将第4步:修改book表的图书状态为1-借阅中,使用数据库乐观锁方式实现!将 图书状态=0-闲置 作为期望值,实现SQL代码如下:

update book set status=1
where id=#{id} and status = 0

📢 通过id主键进行更新,也就是采用 行锁更新,这是我们推荐的! 重点是带了 and status = 0,确保一行记录的status一旦被更新过了,就不再被更新!即使有多个JVM同时执行,最终也只会有1个JVM返回受影响行数=1

BookMapperExt 增加 updateBorrowStatus方法:

public interface BookMapperExt {
    int updateBorrowStatus(Integer id);
}

BookMapperExt.xml 对应的SQL如下:

<update id="updateBorrowStatus">
    update book set status=1
    where id=#{id} and status = 0
</update>

再修改一下第4步的调用代码:

// 4. 修改book表的图书状态为1-借阅中(数据库乐观锁方式)
int effectRows = bookMapperExt.updateBorrowStatus(bookId);
Assert.ifFalse(effectRows > 0, "手慢了, 请稍后再试吧");

当 effectRows =0 受影响行数为0时,代表没更新到,也就是没抢到, 使用Assert抛出异常 来回滚事务!

6. 最终service完整代码

private static final ConcurrentHashMap<Integer, Integer> map = new ConcurrentHashMap<>();

@Transactional(rollbackFor = Exception.class)
@Override
public void borrowBook(Integer bookId, Integer userId) {
    // 1. 校验当前学生是否有有借阅资格
    Student student = studentMapperExt.selectByUserId(userId);
    Assert.ifFalse(student != null && ExamineEnum.APPROVED.getCode().equals(student.getIsApproved()), "请先申请借阅资格");
	// 加锁:put返回null,说明刚刚加入,则加锁成功
    if (map.putIfAbsent(bookId, bookId) == null) {
        try {
            // 2. 校验图书状态是否为0-闲置
            Book book = bookMapper.selectByPrimaryKey(bookId);
            Assert.ifNull(book, "bookId不合法");
            Assert.ifFalse(BookStatusEnum.FREE.getCode().equals(book.getStatus()), "手慢了, 请稍后再试吧");
            // 3. 向book_borrowing表插入一条【待审核】借阅记录
            BookBorrowing bookBorrowing = new BookBorrowing();
            // 照着数据表设置数据即可, 能设置的设置, 不能设置的空着
            bookBorrowing.setStudentId(student.getId());
            bookBorrowing.setBookId(bookId);
            bookBorrowing.setBorrowTime(new Date());
            bookBorrowing.setStatus(BookBorrowStatusEnum.TO_BE_EXAMINE.getCode());
            bookBorrowing.setGmtCreate(new Date());
            bookBorrowing.setGmtModified(new Date());
            bookBorrowingMapper.insertSelective(bookBorrowing);
            // 4. 修改book表的图书状态为1-借阅中(数据库乐观锁方式)
            int effectRows = bookMapperExt.updateBorrowStatus(bookId);
            Assert.ifFalse(effectRows > 0, "手慢了, 请稍后再试吧");
        } finally {
            // 解锁移除key
            map.remove(bookId);
        }
    } else {
        throw new BizException("手慢了, 请稍后再试吧");
    }
}

最后

看到这,觉得有帮助的,刷波666,感谢大家的支持~

想要看更多实战好文章,还是给大家推荐我的实战专栏–>《基于SpringBoot+SpringCloud+Vue前后端分离项目实战》,由我和 前端狗哥 合力打造的一款专栏,可以让你从0到1快速拥有企业级规范的项目实战经验!

具体的优势、规划、技术选型都可以在《开篇》试读!

订阅专栏后可以添加我的微信,我会为每一位用户进行针对性指导!

另外,别忘了关注我:天罡gg ,怕你找不到我,发布新文不容易错过: https://blog.csdn.net/scm_2008

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1125542.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【微服务】Feign 整合 Sentinel,深入探索 Sentinel 的隔离和熔断降级规则,以及授权规则和自定义异常返回结果

文章目录 前言一、Feign 整合 Sentinel1.1 实现步骤1.2 FallbackFactory 示例 二、Sentinel 实现隔离2.1 隔离的实现方法2.2 Sentinel 实现线程隔离示例 三、熔断降级规则3.1 熔断降级原理及其流程3.2 熔断策略 —— 慢调用3.3 熔断策略 —— 异常比例和异常数 四、授权规则4.1…

今年这行情,不会自动化的要做好心理准备了

李强是一名软件测试工程师&#xff0c;入行之后在一家小型公司工作了五年。这段时间里&#xff0c;他主要负责手工测试和一些简单的自动化测试工作。由于公司项目也相对简单&#xff0c;他逐渐陷入了工作的舒适区&#xff0c;没有积极追求新的知识和技能。 然而随着身边朋友发展…

MD5生成和校验

MD5生成和校验 2021年8月19日席锦 任何类型的一个文件&#xff0c;它都只有一个MD5值&#xff0c;并且如果这个文件被修改过或者篡改过&#xff0c;它的MD5值也将改变。因此&#xff0c;我们会对比文件的MD5值&#xff0c;来校验文件是否是有被恶意篡改过。 什么是MD5&#xff…

Docker Swarm 集群搭建

Docker Swarm Mode Docker Swarm 集群搭建 Docker Swarm 节点维护 Docker Service 创建 1.准备主机 搭建一个 docker swarm 集群&#xff0c;包含 5 个 swarm 节点。这 5 个 swarm 节点的 IP 与暂 时的角色分配如下&#xff08;注意&#xff0c;搭建完成后会切换角色&#xff…

winscp连接虚拟机过程

1、winscp安装 安装winscp&#xff1a;winscp安装 2、winscp连接虚拟机 参考链接&#xff1a;WinSCP怎么连接虚拟机 执行ifconfig查看主机ip 可见192.168.187.129即为虚拟机地址。执行 netstat -ntpl 启动网络连接后&#xff0c;即可进行winscp连接。 过程中可能遇到以下问…

vue v-for

目录 前言&#xff1a;Vue.js 中的 v-for 指令 详解&#xff1a;v-for 指令的基本概念 用法&#xff1a;v-for 指令的实际应用 1. 列表渲染 2. 动态组件 3. 表单选项 4. 嵌套循环 5. 键值对遍历 解析&#xff1a;v-for 指令的优势和局限性 优势&#xff1a; 局限性&a…

通义大模型使用指南之通义千问

一、注册 我们可以打开以下网站&#xff0c;用手机号注册一个账号即可。 通义大模型 (aliyun.com) 二、使用介绍 如图&#xff0c;我们可以看到有三个大项功能&#xff0c;通义千问、通义万相、通义听悟。下来我们体验一下通义千问的功能。 1、通义千问 通义千问主要有两个功能…

C++之函数重载【详解】

C之函数重载【详解】 1. 函数重载的概念2. C支持函数重载的原理(名字修饰)2.1 前言2.2 函数名修饰规则2.3 VS下的命名修饰规则 重载函数是函数的一种特殊情况&#xff0c;为方便使用&#xff0c;C允许在同一中声明几个功能类似的同名函数&#xff0c;但是这些同名函数的形式参数…

DOS攻击-ftp_fuzz.py

搭建FTP 使用AlphaFuzzer的FTPFUSS进行攻击 挖掘漏洞&#xff0c;自动用特殊字符看能不能把服务器崩掉 这些都是测试的目录 不能随意使用&#xff0c;可能会把C盘内容清掉 也可以自己写脚本测试下

考试成绩一键私发

哈喽&#xff0c;老师们&#xff01;这里有一个超级实用的教学小助手&#xff0c;让你的成绩发布工作变得更轻松&#xff01;一起来看看这个成绩查询系统吧&#xff01; 什么是成绩查询系统&#xff1f; 成绩查询系统&#xff0c;就像一个自动化的成绩发布平台。它可以帮助老师…

【Leetcode】【中等】1726.同积元组

力扣&#xff08;LeetCode&#xff09;官网 - 全球极客挚爱的技术成长平台备战技术面试&#xff1f;力扣提供海量技术面试资源&#xff0c;帮助你高效提升编程技能&#xff0c;轻松拿下世界 IT 名企 Dream Offer。https://leetcode.cn/problems/tuple-with-same-product/ 给你…

一文带你了解架构设计

一、架构简介 想做好架构设计&#xff0c;第一步是将一个 IT 系统从应用层级至底层基础设施&#xff0c;全部拆解为一个个应用模块&#xff0c;可以称之为“元素”或“组件”&#xff1b;第二步是保证各个模块间不能孤立存在&#xff0c;还要做好充分的协作&#xff0c;协作通…

vue重修之自定义项目、ESLint和代码规范修复

文章目录 VueCli 自定义创建项目ESlint代码规范及手动修复代码规范错误 VueCli 自定义创建项目 安装脚手架 (已安装) npm i vue/cli -g创建项目 vue create xxx选项 Vue CLI v5.0.8 ? Please pick a preset:Default ([Vue 3] babel, eslint)Default ([Vue 2] babel, eslint) …

yolov8x-p2 实现 tensorrt 推理

简述 在最开始的yolov8提供的不同size的版本&#xff0c;包括n、s、m、l、x&#xff08;模型规模依次增大&#xff0c;通过depth, width, max_channels控制大小&#xff09;&#xff0c;这些都是通过P3、P4和P5提取图片特征&#xff1b; 正常的yolov8对象检测模型输出层是P3、…

软考系列(系统架构师)- 2020年系统架构师软考案例分析考点

试题一 软件架构&#xff08;架构风格、质量属性&#xff09; 【问题1】&#xff08;13分&#xff09; 针对该系统的功能&#xff0c;李工建议采用管道-过滤器&#xff08;pipe and filter)的架构风格&#xff0c;而王工则建议采用仓库&#xff08;reposilory)架构风格。请指出…

数字信号处理期末复习(2)——z变换与DTFT

前言 本章主要学习的内容为z变换、离散时间傅里叶变换&#xff08;DTFT&#xff09;、离散时间系统的z变换域和频域&#xff08;傅里叶变换域&#xff09;的分析。 在z变换中&#xff0c;主要考查z变换和z反变换的计算、z变换的性质 在DTFT中&#xff0c;主要考查序列傅里叶变…

vue父子组件传值不能实时更新的解决方法

最近做项目,遇到个大坑,这会爬出来了,写个总结,避免下次掉坑。 vue父子组件传值不能实时更新问题,父组件将值传给了子组件,但子组件显示的值还是原来的初始值,并没有实时更新,为什么会出现这种问题呢? 出现这个问题,可能有以下两个原因: 一、 父组件没有把值传过…

提升药店效率:山海鲸医药零售大屏的成功案例

在医药行业中&#xff0c;特别是医药零售领域&#xff0c;高效的药品管理和客户服务至关重要。随着科技的飞速发展&#xff0c;数字化解决方案已经成为提高医药零售管控效率的有效工具之一。其中&#xff0c;医药零售管控大屏作为一种强大的工具&#xff0c;正在以独特的方式改…

SpringBoot+Mybatis 配置多数据源及事务管理

目录 1.多数据源 2.事务配置 项目搭建参考: 从零开始搭建SpringBoot项目_从0搭建springboot项目-CSDN博客 SpringBoot学习笔记(二) 整合redismybatisDubbo-CSDN博客 1.多数据源 添加依赖 <dependencies><dependency><groupId>org.springframework.boot&…

Docker Service 创建

Docker Swarm Mode Docker Swarm 集群搭建 Docker Swarm 节点维护 Docker Service 创建 service 只能依附于 docker swarm 集群&#xff0c;所以 service 的创建前提是&#xff0c;swarm 集群搭建完毕。 1. 创建 service docker service create 命令用于创建 service&#xff…