文章目录
- 前言
- 一、编写服务层
- 二、编写控制器
- 三、并发实战
- 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个insert和1个update SQL语句,所以支持事务:
@Transactional
;- 前两步是通过
Mybatis Mapper
查询,然后通过断言工具类Assert
做校验;- 第三步是执行insert,按照book_borrowing表结构设计来设置数据;
- 第四步是执行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个
痛点
:- 所有线程都会一直等待 执行 2+3+4 代码,试想一下,1个线程执行200ms,10个是2秒,100个就是20秒,1000个就是200秒,显然不符合我们的期望:当有人借到书了,其它人就可以散了,不必再执行2+3+4的代码!
- 借不同的书,也会相互阻塞!这就更说不过去了,我们更期望的是:你锁你的,我锁我的!
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
的线程安全
,只要将Keyput
成功则加锁成功,解锁也只是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