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

news/2024/5/20 6:03:05/文章来源:https://blog.csdn.net/scm_2008/article/details/134000236

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, 如果成功则返回trueif (atomicLock.compareAndSet(false, true)) {try {// 2. 校验图书状态是否为0-闲置// 3. 向book_borrowing表插入一条【待审核】借阅记录// 4. 修改book表的图书状态为1-借阅中} finally {// 使用CAS将true改为falseatomicLock.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 {// 解锁移除keymap.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=1where 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 {// 解锁移除keymap.remove(bookId);}} else {throw new BizException("手慢了, 请稍后再试吧");}
}

最后

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

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

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

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

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

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

如若内容造成侵权/违法违规/事实不符,请联系dt猫网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【深圳1024开发者城市聚会定向征文】

在这个周末&#xff0c;我有幸参加了1024程序员节活动&#xff0c;这是一个专门为程序员们举办的活动&#xff0c;旨在庆祝程序员这个特殊的群体。在这个活动中&#xff0c;我不仅感受到了浓厚的编程氛围&#xff0c;还收获了许多宝贵的经验和知识。 活动在深圳湾科技生态园举…

Leetcode周赛368补题(3 / 3)

目录 1、元素和最小的山型三元组 | - 三层for暴力循环 2、元素和最小的山型三元组 || - 维护前后最小值 遍历 3、合法分组的最少组数 - 思维 哈希表 1、元素和最小的山型三元组 | - 三层for暴力循环 100106. 元素和最小的山形三元组 I class Solution {public int minimu…

Apache Doris (四十六): Doris数据更新与删除 - 批量删除

🏡 个人主页:IT贫道_大数据OLAP体系技术栈,Apache Doris,Clickhouse 技术-CSDN博客 🚩 私聊博主:加入大数据技术讨论群聊,获取更多大数据资料。 🔔 博主个人B栈地址:豹哥教你大数据的个人空间-豹哥教你大数据个人主页-哔哩哔哩视频 目录

#电子电器架构 —— 车载网关初入门

我是穿拖鞋的汉子,魔都中坚持长期主义的汽车电子工程师。 PS:小细节,本文字数7000+,详细描述了网关在车载框架中的具体性能设置。 老规矩,分享一段喜欢的文字,避免自己成为高知识低文化的工程师: 没有人关注你。也无需有人关注你。你必须承认自己的价值,你不能站在他…

51单片机实现换能器超声波测水深

一&#xff0c;超声波换能器定义&#xff1a; 定义1&#xff1a;可把电能、机械能或声能从一种形式转换为另一种形式的能的装置。 所属学科&#xff1a;测绘学下的测绘仪器。 定义2&#xff1a;能量转换的器件。在水声领域中常把声呐换能器、水声换能器、电声换能器统称换能器。…

博客后台模块续更(六)

十三、后台模块-用户列表 1. 查询用户 需要用户分页列表接口。 可以根据用户名模糊搜索。 可以进行手机号的搜索。 可以进行状态的查询。 1.1 接口分析 请求方式请求路径是否需求token头GETsystem/user/list是 请求参数query格式&#xff1a; pageNum: 页码pageSize…

【linux系统】如何在服务器上安装Anaconda

文章目录 1. 安装Anconda1.1. 下载Anaconda安装包1.2. 安装Anaconda1.2.1. 点击回车&#xff08;Enter&#xff09;1.2.2. 添加环境变量1.2.3. 激活环境变量 1.3. 检查是否安装成功 2. Anaconda安装pytorch2.1. 创建虚拟环境2.2. 激活(进入)虚拟环境2.3. 安装pytorch 1. 安装An…

C语言--程序环境和预处理(宏)

目录 前言 本章重点&#xff1a; 1. 程序的翻译环境和执行环境 2. 详解编译链接 2.1 翻译环境​编辑 2.2 编译本身也分为几个阶段 2.3 运行环境 3. 预处理详解 3.1 预定义符号 3.2 #define 3.2.1 #define 定义标识符 3.2.2 #define 定义宏 2.2.3 #define 替换规则 …

Mock测试详细教程入门这一篇就够了!

1、什么是mock测试 1.png Mock测试就是在测试活动中&#xff0c;对于某些不容易构造或者不容易获取的比较复杂的数据/场景&#xff0c;用一个虚拟的对象(Mock对象)来创建用于测试的测试方法。 2、为什么要进行Mock测试 Mock是为了解决不同的单元之间由于耦合而难于开发、测试…

高校教务系统登录页面JS分析——西安交通大学

高校教务系统密码加密逻辑及JS逆向 本文将介绍高校教务系统的密码加密逻辑以及使用JavaScript进行逆向分析的过程。通过本文&#xff0c;你将了解到密码加密的基本概念、常用加密算法以及如何通过逆向分析来破解密码。 本文仅供交流学习&#xff0c;勿用于非法用途。 一、密码加…

Android手机连接电脑弹出资源管理器

如图所示&#xff0c;很讨厌 关闭方法&#xff1a;

Node编写用户登录接口

目录 前言 服务器 编写登录接口API 使用sql语句查询数据库中是否有该用户 判断密码是否正确 生成JWT的Token字符串 配置解析token的中间件 配置捕获错误中间件 完整的登录接口代码 前言 本文介绍如何使用node编写登录接口以及解密生成token&#xff0c;如何编写注册接…

ROI的投入产出比是什么?

ROI的投入产出比是什么&#xff1f; 投入产出比&#xff08;Return on Investment, ROI&#xff09;是一种评估投资效益的财务指标&#xff0c;用于衡量投资带来的回报与投入成本之间的关系。它的计算公式如下&#xff1a; 投资收益&#xff1a;指的是投资带来的净收入&#x…

Python基础入门例程2-NP2 多行输出

描述 将字符串 Hello World! 存储到变量str1中&#xff0c;再将字符串 Hello Nowcoder! 存储到变量str2中&#xff0c;再使用print语句将其打印出来&#xff08;一行一个变量&#xff09;。 输入描述&#xff1a; 无 输出描述&#xff1a; 第一行输出字符串Hello World!&a…

DDOS直接攻击系统资源

DDOS ——直接攻击系统资源 思路&#xff1a; 攻击机利用三次握手机制&#xff0c;产生大量半连接&#xff0c;挤占受害者系统资源&#xff0c;使其无法正常提供服务。 1、先体验下受害者的正常网速。在受害者主机上执行以下命令 (1)开启Apache。 systemctl start apache2 (2…

SysTick—系统定时器

SysTick 简介 SysTick—系统定时器是属于CM3内核中的一个外设&#xff0c;内嵌在NVIC中。系统定时器是一个24bit 的向下递减的计数器&#xff0c;计数器每计数一次的时间为1/SYSCLK&#xff0c;一般我们设置系统时钟SYSCLK 等于72M。当重装载数值寄存器的值递减到0的时候&#…

LeetCode刷题---简单组(一)

文章目录 &#x1f352;题目一 507. 完美数&#x1f352;解法一 &#x1f352;题目二 2678. 老人的数目&#x1f352;解法一 &#x1f352;题目三 520. 检测大写字母&#x1f352;解法一&#x1f352;解法二 &#x1f352;题目一 507. 完美数 对于一个 正整数&#xff0c;如果它…

一文教你学会使用Cron表达式定时备份MySQL数据库

各位小伙伴大家好&#xff0c;今天我就来讲述一下作为一个运维&#xff0c;如何解放自己的双手去让服务器定时备份数据库数据&#xff0c;防止程序操作数据库出现数据丢失。 mysql_dump_script.sh脚本文件 #!/bin/bash#保存备份个数&#xff0c;备份7天数据 number7 #备份保存…

常见面试题-Netty专栏(一)

typora-copy-images-to: imgs Netty 是什么呢&#xff1f;Netty 用于做什么呢&#xff1f; 答&#xff1a; Netty 是一个 NIO 客户服务端框架&#xff0c;可以快速开发网络应用程序&#xff0c;如协议服务端和客户端&#xff0c;极大简化了网络编程&#xff0c;如 TCP 和 UDP …

【智能家居】

面向Apple developer学习&#xff1a;AirPlay | Apple Developer Documentation Airplay AirPlay允许人们将媒体内容从iOS、ipad、macOS和tvOS设备无线传输到支持AirPlay的Apple TV、HomePod以及电视和扬声器上。 网页链接的最佳实践 首选系统提供的媒体播放器。内置的媒体播…