天天看点

数据库事务与锁

作者:软件开发之道

数据库事务是一个原子性的操作单元,在这个单元里涉及到的数据库操作,要么全部执行成功,要么不执行。若存在某个操作失败,则数据全部回滚。事务具有ACID四个属性:

A:原子性。事务中的操作要么不执行,要么完全执行。如果执行到一半,宕机重启,已执行的一半要回滚回去。

C:一致性。事务前后数据的完整性保持一致。例如在事务完成后,无论是正常执行还是失败退出,数据要符合逻辑运算。

I:隔离性。多个事务并发执行时,其中一个事务不能受到其他事务的数据操作干扰,多个并发事务之间要相互隔离。

D:持久性。一个事务一旦被提交,数据会被持久化,即使后面发生数据库故障也不会影响到数据丢失

事务并发执行会引起以下问题:

  • 丢失更新:两个事务同时更新同一条记录,事务A的更新被事务B覆盖,例如:有个账户,它的余额一开始为0,事务A对账户新增1000元,同时事务B对账户新增2000元,事务A比事务B执行快,事务A提交了,此时账户余额变为1000元。接着事务B执行会有两种情况,一是执行失败,账户余额回滚为0,二是执行成功,账户余额变为2000元。事务A的更新丢失了,发生两次更新,账户余额正确是3000元,才符合计算逻辑。
  • 脏读:事务A读到事务B未提交记录的值,基于这个值去做业务逻辑,但在事务A提交之前,事务B失败回滚了该记录,导致事务A读到这条记录的脏数据。
  • 不可重复读:在同一个事务里面,两次读取同一行记录的值,两次结果不一样。这是因为有另一个事务对这条记录进行更新操作。
  • 幻读:在同一个事务里面,T1时刻查询数据和T2时查询到的条目数不一样,因为在T1与T2时刻之间有另一个事务对表记录进行插入或删除操作。与不可重复读的区别是,在幻读中,已经读取的数据不变,和以前相比,会有更多数据满足你的查询条件。比如事务里执行更新操作,按查询范围匹配更新,更新完后,在T2时刻发现还有记录没有更新成功。多查询出的记录是另一个事务在插入数据导致的。

丢失更新场景,如下图:

时刻 事务A 事务B 备注
T1 查询余额为0 ——
T2 —— 查询余额为0
T3 账户转账1000元 ——
T4 提交事务,余额1000元 账户转账2000元
T5 ——

提交事务,有两种情况

一是执行失败回滚数据,余额0元

二是执行成功,余额2000元

两次更新,账户余额正确是3000元,前一个事务丢失更新

数据库提供事务的隔离机制来解决脏读、不可重复读、幻读的问题。数据库处理并发问题是通过加锁,事务的隔离级别与并发性有关,隔离级别越高并发性越差,锁定资源的时间越长。以MySQL InnoDB引擎为例,InnoDB实现了两种类型的锁机制:共享锁(S)和排他锁(X)。共享锁允许一个事务在读数据时,其他事务只允许读数据,不允许修改。排他锁是修改数据时加的锁,在锁没释放前,其他事务不允许读和写数据。熟悉了InnoDB锁的实现原理,会更清楚理解事务隔离机制。

MySQL InnoDB引擎的事务的隔离级别

  • 未提交读(Read Uncommitted):允许读到未提交的数据,事务之间读和读允许并发。这种隔离级别没有解决任何问题,会导致脏读、不可重复读以及幻读。
  • 已提交读(Read Committed):事务A在读取数据时增加共享锁,一旦读取完,立即释放锁。在加锁时间内,其他事务只能读取数据,不能修改。这种隔离级别,可以避免脏读。
  • 可重复读(Repeatable Read):事务A在读取数据时增加共享锁,事务结束,才释放锁。在事务A没结束事务之前,其他事务只能读数据,不能修改。这种隔离级别,可以避免脏读、不可重复读。但依然有幻读的问题。因为可重复读是使得同一条数据库记录读/写不能并发,能保证同一条记录的数据一致性。而幻读是在对多条记录进行读/写的场景下产生的。
  • 可序列化(Serializable):与上面的隔离级别相比,可序列化它的级别最高、并发性最低。它是在事务读取修改数据时增加了表级排他锁,锁全表,同一时间内只允许一个事务对表读/写,直到事务结束才释放锁。这种隔离级别,能解决所有由事务并发产生数据不一致的问题。

MySql默认隔离级别是可重复读,幻读和丢失更新的问题,除了用可序列化的隔离级别解决,还可以使用数据库提供的悲观锁来解决,即在查询时在事务中使用select xx for update语句来实现一个排他锁,有where条件的select语句会生成一个gap锁,锁住where查询范围,锁会在事务结束后释放,保证在该事务结束之前其他事务无法插入/更新数据。

悲观锁有潜在问题,一个事务拿到锁后,其他事务访问该记录会被阻塞,在高并发场景下会造成大量用户请求阻塞。如果事务在commit之前出问题,会造成锁不能释放,数据库死锁。再加上大量用户请求阻塞迟迟未释放,最终导致服务器拒绝服务。

如果你的业务是不用先查询,后更新的,可以不用for update上锁,利用单条语句更新。因为你不知道在读完数据后到提交更新的这段时间里数据是否有被修改过,你查询到的数据可能是旧数据,用它做业务逻辑计算后,再更新回库就会丢失前一个事务的更新。所以先查询后更新,两步操作保证不了原子性,要改成一步操作完成。

start transaction
update T set balance = balance + 100 where user_id = 1
commit           

单条语句更新有局限性,因为不知道更新前的值是什么,实际场景往往是需要根据之前的值做业务逻辑,再更新回库。

假如你认为发生并发冲突的概率比较小,可以用乐观锁来避免丢失更新的问题。读之前不上锁,等到写回库时再判断数据是否被其他事务修改过。即是多线程的CAS(比较更新)原理,其核心思想是如何判断数据是否被修改过。

CAS原理:先把要更新的数据读出来,假设数据为X,然后拿X去做业务逻辑计算得出一个新值Y,在更新回库时判断数据库的值是不是X,如果是表示原来的值没被修改过,更新Y值。否则说明已被修改,处理办法是放弃这次更新,等一段时间间隔,再把值读出来重新计算更新回库,循环执行。循环更新次数要有限制,不能无节制地重试更新,可以设计重试三次,如果都更新不成功,结束重试,提示用户稍后再操作。

X = select X from T where user_id = 1
Y = X + 50
// X做各种逻辑计算,得出新值Y,然后更新
update T set balance = Y where user_id = 1 and balance = X           

上面乐观锁的更新方法存在ABA问题,假设资源X=A,线程1和线程2并发获取资源X做业务计算,线程2比线程1快,优先抢占到资源,在线程1获取到X之后,线程2修改X=B,然后线程1基于X=B的值去做了业务逻辑,后面线程2又修改X=A,这时线程1要把计算值更新回库,于是判断X值是否被其他线程修改过,发现X=A,值更新成功了。问题出在线程1读到X=B的错值去做业务逻辑,最后还更新回库,导致计算的业务逻辑是错误的。如下图:

时刻 线程1 线程2 备注
T0 —— —— 初始化X=A
T1 读入X=A ——
T2 —— 读入X=A
T3 处理线程1业务逻辑 X=B 修改共享变量为B
T4 处理线程2业务逻辑第一段
T5 X=A 还原变量为A
T6 因为判断X=A,所以更新数据 处理线程2业务逻辑第二段 此时线程1无法知道线程2是否修改X,引发业务逻辑错误
T7 —— 更新数据

ABA问题的发生,是因为业务逻辑存在回退的可能,如果加入一个非业务逻辑的属性,比如在一个数据中加入版本号(version),约定数据的版本号只能递增,不能回退,即使业务数据发生回退,它也只会递增,那么ABA问题就解决了。

把上面SQL语句加入版本号判断数据是否被修改。如下:

X, version = select X, version from T where user_id = 1
Y = X + 50
// 假设version原来值是0,X做各种逻辑计算得出新值Y,然后更新
update T set balance = Y, version = version + 1 where user_id = 1 an           

继续阅读