167Java利用可重入锁避免并发下出现错误数据,并且避免死锁以及等待锁的时间过长

Posted zhangchao19890805

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了167Java利用可重入锁避免并发下出现错误数据,并且避免死锁以及等待锁的时间过长相关的知识,希望对你有一定的参考价值。

注意

本文只讲解使用可重入锁解决问题的方法,其他方案放在文末,也不考虑 select for update 的方案

1.场景

我以医院的病房管理系统为例来说明可重入锁。

先放数据库的表结构:

-- 房间表
CREATE TABLE IF NOT EXISTS public.room
(
    id bigint NOT NULL,   -- 主键
    room_no character varying(10) COLLATE pg_catalog."default", -- 房间号
    remark character varying(50) COLLATE pg_catalog."default",  -- 备注
    sort_no integer,   -- 排序
    CONSTRAINT yqgl_room_pkey PRIMARY KEY (id)
)


-- 病床表
CREATE TABLE IF NOT EXISTS public.t_bed
(
    id bigint NOT NULL,         -- 主键
    room_id bigint NOT NULL,    -- 房间ID
    user_id bigint NOT NULL,    -- 病人ID
    bed_no character varying(50) COLLATE pg_catalog."default",  -- 床的编号
    update_time timestamp without time zone NOT NULL,           -- 更新时间
    CONSTRAINT yqgl_bed_pkey PRIMARY KEY (id)
)

病床表里的数据是管理员提前按照房间ID和病床编号录入的。对于无人占用的病床记录,字段user_id设置成0。

在医院里每个病房的床位是有限的,护士需要在系统中选择有空床位的病房,来分配给新入院的病人入住。当系统分配完房间后还需要一系列后续操作,比如打印单据交给病人,或者给病人发送提示短信。医院有多名护士多台电脑来同时操作系统分配房间。这个时候并发条件下如果没有锁就有可能出现以下问题:

  1. 护士A发现101病房空着,点击按钮分配给病人A。
  2. 于此同时护士B也发现101病房空着,点击按钮分配给病人B。
  3. 服务器同时接到了两个请求并分配了两个线程A、B来同时处理两个请求。
  4. 线程A和B同时开启数据库事务。
  5. 线程A查询数据库发现101房间还有一个空床位,病人A当前没有占用床位。
  6. 同时线程B查询数据库也发现101房间还有一个空床位,病人B当前没有占用床位。
  7. 线程A更新数据库,把最后一个床位ID和病人A的ID关联。并执行后续操作。
  8. 线程B更新数据库,把最后一个床位ID和病人B的ID关联。并执行后续操作。
  9. 线程A提交数据库事务,返回给护士A成功提示。
  10. 线程B提交数据库事务,返回给护士B成功提示。

显然护士A的操作被护士B覆盖了。但是系统给出了有误导性的提示。更为严重的是,系统的后续操作会带来更多麻烦,比如打印出了错误的住院单,给病人发送了错误的提示短信。

2.必须要搞清楚锁的范围

首先说明,锁的范围要覆盖数据库事务的范围,即先加锁、再开启事务、然后提交或者回滚事务、最后解锁。如果把锁放在事务中(即先开启事务、再加锁、解锁、最后提交或者回滚事务),是没有效果的。为了弄清楚原因,我们先看数据库的四种隔离机制:

  1. read uncommited(读未提交)
  2. read commited (读已提交)
  3. repeatable read (可重复读)
  4. Serializable (串行化)

由于性能原因,几乎没有人使用 Serializable。为了数据正确性也几乎没有人使用 read uncommited(读未提交)。常用的是 read commited 和 repeatale read 。下面我们分开讨论:

2.1 read commited

如果我们把加锁的代码放到事务中,因为对数据库的更改未提交,很可能导致两个线程一前一后读取了旧版本的数据并做了修改,然后同时提交事务后,导致其中一个的数据被覆盖。

2.1 repeatable read

虽然可重复读能够让事务中的数据不受其他事务的影响,但是在本例中并不能解决问题。下面我们来演示以下:

  1. 线程A开启事务、获取锁、查询数据库检查确定可以更新、更新数据库、解锁,在提交前操作系统调度线程。CPU切换到线程B。
  2. 线程B开启事务、获取锁、查询数据库检查确定可以更新、更新数据库、解锁。因为线程A没有提交数据库,所以这里数据库读取的是旧快照。
  3. 线程AB提交事务,数据被其中一方覆盖。

3.解决方案

存放锁对象的类 LockUtils

import java.util.concurrent.locks.ReentrantLock;

public class LockUtils 
    public static final ReentrantLock BED_LOCK = new ReentrantLock();

为了保证锁覆盖事务,我把锁放到 Controller 层:

@RestController
@RequestMapping("/isolate/userInfo")
public class RoomController 
   /**
     * 分配房间
     */
	@PutMapping("/distributeRoom")
	public R<Void> distributeRoom(@RequestBody Map<String, Object> param) 
		// 检查参数的代码,已忽略
		
 		R r = R.error();
        // 使用重入锁,避免同一个房间被分配给多个人。
        try 
        	// 这里是最多等待十秒钟就放弃,提示系统用户稍后重试。
            boolean flag = LockUtils.BED_LOCK.tryLock(10L * 1000L, TimeUnit.MILLISECONDS);
            if (flag) 
                r = roomService.distributeRoom(patientId, roomId);
             else 
                r = R.error("系统繁忙,请稍后重试。");
            
         catch (InterruptedException e) 
            e.printStackTrace();
         finally 
            // 确保锁被释放
            LockUtils.BED_LOCK.unlock();
        
        return r;
	

业务类 RoomService,注意这里的方法声明了事务,确保锁的范围覆盖事务的范围。里面的业务使用伪代码编写。

@Service
public class RoomServiceImpl implements RoomService 
	@Override
    @Transactional(rollbackFor = Exception.class)
    public R<Void> distributeRoom(Long patientId, Long roomId) 
    	1. 查询数据库,判断房间有没有床位,判断病人当前有没有床位。
    	2. 如果房间没有床位或者病人已经占用了其他床位,终止执行,给出用户提示。
    	3. 如果房间有床位并且病人没有床位,就更新数据库,把床位分配给病人。
    	4. 执行其他相关操作。
	

以上是关于167Java利用可重入锁避免并发下出现错误数据,并且避免死锁以及等待锁的时间过长的主要内容,如果未能解决你的问题,请参考以下文章

Java并发程序设计(12)并发锁之可重入锁ReentrantLock

可重入锁

可重入锁 公平锁 读写锁

Java并发包4--可重入锁ReentrantLock的实现原理

Java中锁分类

6.23Java多线程可重入锁实现原理