前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >7.21 SpringBoot项目实战【图书借阅】并发最佳实践:细粒度Key锁、数据库乐观锁、synchronized、ReentrantLock

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

作者头像
天罡gg
发布2023-10-24 16:22:26
2580
发布2023-10-24 16:22:26
举报
文章被收录于专栏:天罡gg天罡gg

前言

上文的产品设计流程:查看图书列表 7.3 实现-》查看图书详情上文7.20 -》图书借阅(本文)。

就好比:一帮人 抢借一本书,这和秒杀1本书 如出一辙,大家都懂 这就存在 并发问题

本文会先写【业务实现】,再来谈【如何解决】并发问题!重点在第三段的并发实战:代码演示使用 synchronized、ReentrantLock、AtomicBoolean、细粒度Key锁、数据库乐观锁,以版本迭代的方式,逐个分析遇到的问题,以及解决的方案,助你理解这种场景的最佳实践!

一、编写服务层

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

代码语言:javascript
复制
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-借阅中

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

代码语言:javascript
复制
@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类新增方法:

代码语言:javascript
复制
@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 用法一 锁住整个方法,例如加在方法声明上:
代码语言:javascript
复制
public synchronized void borrowBook(Integer bookId, Integer userId) {
    略。。。
}

synchronized 用法二

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

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

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

代码语言:javascript
复制
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,用它实现代码如下:

代码语言:javascript
复制
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 实现代码如下:

代码语言:javascript
复制
// 初始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,代码如下:
代码语言:javascript
复制
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代码如下:

代码语言:javascript
复制
update book set status=1
where id=#{id} and status = 0

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

BookMapperExt 增加 updateBorrowStatus方法:

代码语言:javascript
复制
public interface BookMapperExt {
    int updateBorrowStatus(Integer id);
}

BookMapperExt.xml 对应的SQL如下:

代码语言:javascript
复制
<update id="updateBorrowStatus">
    update book set status=1
    where id=#{id} and status = 0
</update>

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

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

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

6. 最终service完整代码

代码语言:javascript
复制
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快速拥有企业级规范的项目实战经验!

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

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

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2023-10-23,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 一、编写服务层
  • 二、编写控制器
  • 三、并发实战
    • 1. synchronized关键字
      • 2. Lock 接口
        • 3. Atomic类
          • 4. 细粒度Key锁
            • 5. 数据库乐观锁
              • 6. 最终service完整代码
              • 最后
              相关产品与服务
              数据库
              云数据库为企业提供了完善的关系型数据库、非关系型数据库、分析型数据库和数据库生态工具。您可以通过产品选择和组合搭建,轻松实现高可靠、高可用性、高性能等数据库需求。云数据库服务也可大幅减少您的运维工作量,更专注于业务发展,让企业一站式享受数据上云及分布式架构的技术红利!
              领券
              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档