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

CSDN成就一亿技术人

文章目录

  • 前言
  • 一、编写服务层
  • 二、编写控制器
  • 三、并发实战
  • 最后


前言

上文的产品设计流程:查看图书列表 7.3 实现-》查看图书详情上文7.20 -》图书借阅(本文)。
就好比:一帮人 抢借一本书,这和秒杀1本书 如出一辙,大家都懂 这就存在 并发问题
本文会先写【业务实现】,再来谈【如何解决】并发问题!重点在第三段的并发实战:代码演示使用 synchronizedReentrantLock、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();
}

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


三、并发实战

synchronized_103">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.niftyadmin.cn/n/5115912.html

相关文章

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

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

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

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

Android View拖拽startDragAndDrop,Kotlin

Android View拖拽startDragAndDrop&#xff0c;Kotlin import android.os.Bundle import android.util.Log import android.view.DragEvent import android.view.View import android.view.View.OnDragListener import android.view.View.OnLongClickListener import android.w…

【华为路由器】配置企业通过5G链路接入Internet示例

场景介绍 5G Cellular接口是路由器用来实现5G技术的物理接口&#xff0c;它为用户提供了企业级的无线广域网接入服务&#xff0c;主要用于eMBB场景。与LTE相比&#xff0c;5G系统可以为企业用户提供更大带宽的无线广域接入服务。 路由器的5G功能&#xff0c;可以实现企业分支…

nginx安装详细步骤和使用说明

下载地址&#xff1a; https://download.csdn.net/download/jinhuding/88463932 详细说明和使用参考&#xff1a; 地址&#xff1a;http://www.gxcode.top/code 一 nginx安装步骤&#xff1a; 1.nginx安装与运行 官网 http://nginx.org/1.1安装gcc环境 # yum install gcc-c…

OpenCV官方教程中文版 —— 图像金字塔

OpenCV官方教程中文版 —— 图像金字塔 前言一、原理二、使用金字塔进行图像融合 前言 • 学习图像金字塔 • 使用图像创建一个新水果&#xff1a;“橘子苹果” • 将要学习的函数有&#xff1a;cv2.pyrUp()&#xff0c;cv2.pyrDown()。 一、原理 一般情况下&#xff0c;我…

2019年亚太杯APMCM数学建模大赛B题区域经济活力及其影响因素的分析与决策求解全过程文档及程序

2019年亚太杯APMCM数学建模大赛 B题 区域经济活力及其影响因素的分析与决策 原题再现 区域&#xff08;或城市或省级&#xff09;经济活力是区域综合竞争力的重要组成部分。近年来&#xff0c;为了提高经济活力&#xff0c;一些地区推出了许多刺激经济活力的优惠政策&#xf…

2.6.C++项目:网络版五子棋对战之数据管理模块-游戏房间管理模块的设计

文章目录 一、意义二、功能三、作用四、游戏房间类基本框架五、游戏房间管理类基本框架七、游戏房间类代码八、游戏房间管理类代码 一、意义 对匹配成功的玩家创建房间&#xff0c;建立起一个小范围的玩家之间的关联关系&#xff01; 房间里一个玩家产生的动作将会广播给房间里…