上文的产品设计流程:查看图书列表 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:
先实现业务代码(并发问题后面考虑):
@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);
}
📢 前面都讲过,这里
简单
解读一下:
@Transactional
;Mybatis Mapper
查询,然后通过断言工具类Assert
做校验;
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 是 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个痛点
:
同样是悲观锁,但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个痛点
!
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个痛点
!
那么,有没有像分布式锁
那样只锁定某个Key的本地锁
?
答案肯定是有的:
synchronized
可以实现 只锁定某个Key的锁,因为本身synchronized
就支持锁定具体对象
,所以只要是同一个Key就可以!只不过当前场景不太适合,原因还是痛点1
的一直等待问题,这是synchronized
不能解决的!ReentrantLock
的话,也可以实现 只锁定某个Key的锁,方式之一是对每个Key 都生成一个ReentrantLock
,然后调用lock()
或tryLock()
,感觉差点意思!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
的方式,我们就同时解决了两个痛点
!👏
当然,细粒度的锁,第三方框架也有相关实现,这里不做扩展,后面找机会再分享~
上面实现的都是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抛出异常 来回滚事务!
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快速拥有企业级规范的项目实战经验!
具体的优势、规划、技术选型都可以在《开篇》试读!
订阅专栏后可以添加我的微信,我会为每一位用户进行针对性指导!