事务基本概念
事务是数据库逻辑执行的基本单位。
什么是事务?
事务可以具象为一组连续的操作,比如生成订单和减库存、消费和账户扣费。
事务有什么特性?
原子性(Atomicity) 事务中的任何操作都是不可分割的。
独立性(Isolation) 事务的独立性,事务执行过程中互相不影响。
另外,事务的特性与一致性(Consistency) 、持久性(Durability)共同构成关系型数据库的基本属性。
显式事务
BEGIN
<SQL语句>
COMMIT [ROLLBACK]
通常事务以BEGIN开始,以COMMIT或ROLLBACK结尾。
COMMIT代表事务提交,ROLLBACK代表事务回滚。
隐式事务
日常开发很少使用显式事务,事务的管理会上升到service层。
如果方法上不加@Translation注解,insert|update|delete语句都会默认隐式提交事务。
事务隔离级别
读未提交 Read UnCommitted
一个事务可以读取到另一个事务未提交的数据
读已提交 Read Committed
一个事务因为读取到另一个事务已提交的修改数据
可重复读 Repeatable Read
一个事务每次读取的值都是相同的
串行化Serializable
事务串行化执行
对应的问题
读未提交 | 脏读
TIME | Session A | Session B |
---|---|---|
T1 | BEGIN; | |
T2 | UPDATE USER SET NAME = “A” WHERE ID = 1; | BEGIN; |
T3 | SELECT NAME FROM USER WHERE ID = 1; | |
T4 | ROLLBACK; | COMMIT; |
Session B读取到Session A未提交的数据,Session A进行了回滚,这时Session B读取到的数据就是无效的,也就是脏数据。
读已提交 | 不可重复读
TIME | Session A | Session B |
---|---|---|
T1 | BEGIN; | |
T2 | BEGIN; | SELECT NAME FROM USER WHERE ID = 1; |
T3 | UPDATE USER SET NAME = “A” WHERE ID = 1; | |
T4 | COMMIT; | |
T5 | SELECT NAME FROM USER WHERE ID = 1; | |
T6 | COMMIT; |
Session B读取同一行的两次结果不一致,无法确定以哪次结果作为判断条件。
读可重复读 | 幻读
TIME | Session A | Session B |
---|---|---|
T1 | BEGIN; | |
T2 | BEGIN; | SELECT NAME FROM USER WHERE ID = 2; |
T3 | INSERT INTO USER (ID,NAME) VALUE (2,“B”); | |
T4 | COMMIT; | |
T5 | SELECT NAME FROM USER WHERE ID = 2; | |
T6 | COMMIT; |
与不可重复读有点类似,Session B读取的两次结果不一致,不同的是幻读是针对新增行记录的。
解决方案
MVCC,多版本的并发控制,Multi-Version Concurrency Control。
MVCC使得数据库读不会对数据加锁,普通的SELECT请求不会加锁,提高了数据库的并发处理能力。借助MVCC,数据库可以实现READ COMMITTED,REPEATABLE READ隔离级别,用户可以查看当前数据的前一个或者前几个历史版本,保证了ACID中的隔离性。
MVCC实现逻辑
InnoDB的MVCC是通过在每行记录后面保存两个隐藏的列来实现的。
一个保存了行的事务ID(DB_TRX_ID),一个保存了行的回滚指针(DB_ROLL_PT)。
每开始一个新的事务,都会自动递增产生一个新的事务id。
比如,执行了一条INSERT语句
第一次修改
当另一个事务第二次修改当前数据:
举例说明
初始化ID = 1,NAME = Mbappe
TIME | Session 777 | Session 888 | Session 999 |
---|---|---|---|
T1 | begin; | ||
T2 | begin; | begin; | |
T3 | UPDATE USER SET NAME = ‘CR7’ WHERE ID = 1; | ||
T4 | |||
T5 | UPDATE USER SET NAME = ‘Messi’ WHERE ID = 1; | SELECT * FROM USER where ID = 1; | |
T6 | commit; | ||
T7 | UPDATE USER SET name = ‘Neymar’ WHERE ID = 1; | ||
T8 | SELECT * FROM USER WHERE ID = 1; | ||
T9 | UPDATE user SET name = ‘Dybala’ WHERE id = 1; | ||
T10 | commit; | ||
T11 | SELECT * FROM user where id = 1; |
READ COMMITTED
每次读取数据前都生成一个ReadView (m_ids列表)
T5时
所以此时的活跃事务的ReadView的列表情况 m_ids:[777, 888] ,因此查询语句会根据当前版本链中小于m_ids中的最大的版本数据,即查询到的是 Mbappe。
T8时
因为当前的事务777已经提交,和事务888 未提交,所以此时的活跃事务的ReadView的列表情况 m_ids:[888] ,因此查询语句会根据当前版本链中小于 m_ids 中的最大的版本数据,即查询到的是Messi。
T11时
此时 SELECT 语句执行,当前数据的版本链如上,因为当前的事务777和事务888 都已经提交,所以此时的活跃事务的ReadView的列表为空 ,因此查询语句会直接查询当前数据库最新数据,即查询到的是 Dybala。
REPEATABLE READ
在事务开始后第一次读取数据时生成一个ReadView(m_ids列表)
T5时
再当前执行select语句时生成一个ReadView,此时 m_ids 内容是:[777,888],所以但前根据ReadView可见版本查询到的数据为 Mbappe。
T8时
此时在当前的 Transaction 999 的事务里。由于T5的时间点已经生成了ReadView,所以再当前的事务中只会生成一次ReadView,所以此时依然沿用T5时的m_ids:[777,888],所以此时查询数据依然是 Mbappe。
T11时
此时情况跟T8完全一样。由于T5的时间点已经生成了ReadView,所以再当前的事务中只会生成一次ReadView,所以此时依然沿用T5时的m_ids:[777,888],所以此时查询数据依然是 Mbappe。
思考
拿实际项目来举例,有一个实时订单项目使用mysql的innodb引擎,这里我们没有使用内存缓存数据,生成订单时要随机分配一个座位。现在我们给生成订单的Service加上事务@Translation,再给查询座位和锁定加上原子操作符保证万无一失,当然这一步也可以通过行锁来完成。最后我们使用jmeter对接口进行1秒1000次的压力测试,结果却是每个座位分配了两次。为什么会出现这种情况?
在Java并发编程中提到如果多个线程修改同一个数据的时候,就会形成Read-Then-Modify竞态条件,如果线程修改数据时没有通知其他线程就会引起并发问题,这里的通知其他线程显然破坏了事务的隔离性。尤其我们在设计高并发系统时,通常会通过负载均衡将请求压力分散到多个后端服务上。在这种情况下,我们还应该遵循ACID原则么?
尽管我们给查询和锁定加上了原子操作符,但是mysql innodb引擎的默认隔离级别是读已提交,事务执行过程中读取不到其他事务的修改。如果我们把注解改成@Translation(isolation = READ_UNCOMMITTED)后在进行1000次并发测试,果然每个座位只分配了一次。但是这样操作就破坏事务的独立性,一个事务的执行依赖于和它同时执行的事务的结果,如果其他事务执行失败进行了回滚,那么当前事务获取到的值就是一个脏数据,显然这并不是一个合理的做法。
乐观锁
为了不降低系统性能我们常使用乐观锁来解决这个问题
UPDATE SEATS SET STATE = 1 WHERE STATE = 0
先进行座位锁定,当返回结果为1时,在进行创建订单操作,这样我们就可以在读已提交级别下解决重复分配的问题。