为什么先读后写容易踩坑?

9 阅读4分钟

在项目中,你是不是也在写这样的代码?

@Override
@Transactional
public void transfer(TransferVO transferVO) {

    User from=transferMapper.get(transferVO.getFrom());
    User to=transferMapper.get(transferVO.getTo());

    transferMapper.transfer(transferVO.getFrom(),from.getAccount()-transferVO.getAmount());
    transferMapper.transfer(transferVO.getTo(),to.getAccount()+transferVO.getAmount());

}

先查数据库,再修改数据,加个@Tranactional,方法在事务中运行,觉得万事大吉? 那你很有可能要踩坑了.来看看咋回事?

先回顾下,当前读和快照读.

快照读, 就是执行普通select语句时,给数据拍个照,照片上能看到的就是能读到的数据,看不到的就是不能读的数据.这个快照就是Read View .

和redis拷贝内存页表生成快照不同,mysql生成的快照更加轻量,它是通过最小活跃事务id和最大事务id来表示快照的边界.

mysql在全局事务系统中维护所有读写事务(已分配id,活跃/未提交事务)链表和下一个即将要分配的事务id.

//伪代码,我不会c,委屈大家.
public class trx_sys{
    //所有活跃事务id列表
    List<TrxNode> active_trx_list;
    //下一个即将要分配的事务id
    long max_trx_id;
    
}

当mysql要生成Read View时,会遍历活跃事务id列表,将这些id收集到一个数组中,同时记录min_trx_id;

在Read View中包含以下几个关键点: 1, 最小活跃事务id: min_trx_id 2, 系统即将要分配事务id: max_trx_id 3, 当前系统中活跃的所有事务id列表: trx_ids 4, 当前事务id(事务中只有读操作,可能不分配事务id):cur_trx_id

使用InnoDB存储引擎,mysql中的每行数据都有三个隐藏列,DB_ROW_ID(行ID) ,DB_TRX_ID(上次更新这条数据的事务ID),DB_ROLL_PTR(回滚指针).

判断一条数据对当前事务是否可见,满足如下规则:

cur_trx_id == DB_TRX_ID: 当前事务修改,数据可见.

DB_TRX_ID < min_trx_id: Read View生成时,DB_TRX_ID就提交了,数据可见.

DB_TRX_ID >= max_trx_id: Read View生成后,DB_TRX_ID才提交,数据不可见.

min_trx_id <= DB_TRX_ID < max_trx_id: DB_TRX_ID在活跃事务列表中,数据不可见. DB_TRX_ID不在活跃事务列表中,表明Read View生成时事务已提交,数据可见.

数据不可见,就要根据undo log版本链回溯,直到旧版本数据中的DB_TRX_ID满足可见规则.

以上流程就是MVCC,undo log版本链+Read View 实现快照读.

不同隔离级别,RR和RC之间的区别点,就是生成Read View的时机不同. RR:事务第一次执行普通select时,生成Read View,事务过程中只生成一次. RC: 每次执行select时,重新生成一次Read View.

从上述可知:其实就是利用MVCC,实现读不加锁,读写不冲突.

当前读: 读数据库中的最新版本,同时也会给读取的数据加锁,也叫加锁读. 如: select .. for update , select .. lock in share mode, update ,delete,insert.

回顾快照读和当前读之后,回到一开始的问题,为什么先读后写会有问题?

快照读读取事务开始时的数据快照,不加锁.

而所有的写操作都是当前读,读取数据库中数据的最新版本,并给数据加锁.

如果有两个事务同时基于快照读,如下情况: 1,依据快照读拿到可能过时的数据,写依赖读到的数据,做了错误判断. 2,数据没加锁,事务写数据之前,其他事务先写了,错上加错. 3,没做版本校验,导致数据覆盖,错到离谱.

image.png

同时,如果是先做统计查询,后依据统计结果更新数据,还有可能因为幻读导致数据更新错误. 比如:查到5条数据需要更新数据状态,结果更新的时候,数据库中又新增了三条数据,导致业务混乱.

那么怎么避免这种问题?

1,写不依赖读到的数据,就别读啦,直接写.

2,写依赖读到的数据,就用当前读,给数据上锁,同时其他事务也不能修改数据.

3,如果只是纯读,那么快照读就行,不加锁,性能还好.

突然想到一个常见的场景面试题: 待支付订单,用户在29:59秒支付,这种极限场景.

那么通过今天的分享,就知道,绝对不能采用先读后写的方案.

要么就加锁读, select .. for update给数据上锁,都别改,让我先来.

要么就使用乐观锁做版本校验,update ... where order_id='xxx' and status="待支付".

总之一句话,别给其他事务留下能修改数据的空间.