国产数据库与Oracle数据库事务差异分析
数据库中的ACID是事务的基本特性,而在Oracle等数据库迁移到国产数据库国产中,可能因为不同数据库事务处理机制的不同,在迁移后的业务逻辑处理上存在差异。本文简要介绍了事务的ACID属性、事务的隔离级别、回滚机制和超时机制,并总结SAVEPOINT的使用,以总结。
1、数据库中事务基本概念
事务是数据库中的基本逻辑操作单元,由一组不可分割的数据库操作序列组成,这些操作要么全部成功执行,要么全部失败回滚。其核心目的是确保数据的完整性和一致性,尤其在并发操作或系统故障时维护数据库的可靠状态。
1.1 事务基本属性ACID是事务的基本特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。
原子性:事务中的所有操作必须作为一个整体执行,要么全部执行成功、要么全部失败回滚,不允许出现部分成功的情况。在数据库中通常是通过日志记录(如undo log)来实现回滚操作,若事务执行失败,系统跟进日志撤销已执行的操作。一致性:事务执行前后,数据库必须保持一致性状态。所有数据必须满足预定义的完整性约束(如主键、外键、唯一性约束等)。即使事务失败,数据库也不能破坏这些规则。在数据库中通过一些约束和检查来确保数据库的完整性约束。隔离性:多个事务并发执行时,每个事务的操作应与其他事务相互隔离,使得每个事务感觉不到其他事务的存在,最终效果应与事务串行执行的结果一致。数据库中通过锁机制(Locking)或多版本并发控制(MVCC)实现,不同的隔离级别提供不同程度的隔离性。持久性:事务一旦提交,其对数据库的修改就是永久性的,即使系统发生故障(如断电、崩溃),修改也不会丢失。数据库中通过重做日志(Redo Log)实现持久性。提交事务时,对数据的修改首先写入日志,再异步写入数据库文件中。当数据库崩溃恢复时,通过重放日志恢复数据。以转账交易为例,通过undo日志实现原子性,确保“扣款”和“存款”两个操作要么全部成功,要么全部失败;一致性是确保转账前后,数据库必须满足业务规则(如余额不为负、总额不变);通过锁机制和MVCC多版本并发控制来实现事务的隔离性,多个并发转账操作互不干扰,结果与串行执行一致;持久性则是一旦转账成功,即使系统崩溃,修改也不会丢失。
事务隔离级别是数据库事务处理的基础,SQL-92标准定义了4种隔离级别:读未提交(READ UNCOMMITTED)、读已提交(READ COMMITTED)、可重复读(REPEATABLE READ)、串行化(SERIALIZABLE)。详见下表:
不同的隔离级别有不同的现象,并有不同的锁和并发机制。隔离级别越高,数据库的并发性能就越差。
1.2.1 脏读/不可重复读/幻读现象1)脏读A事务读取B事务尚未提交的更改数据,并在这个数据的基础上进行操作,这时候如果事务B回滚,那么A事务读到的数据是不被承认的。
2)不可重复读不可重复读是指在同一个事务中,同一个查询在T1时刻读取一行数据,在T2时刻重新读取这一行数据的时候,发现这一行数据已经发生了修改(被更新或者删除)。假如A在取款事务的过程中,B往该账户转账100,A两次读取的余额发生不一致。
3)幻读幻读是指在同一个事务中,当同一个查询多次执行的时候,由于其它插入操作的事务提交,会导致每次返回不同的结果集。不可重复读和幻读的区别是:前者是指读到了已经提交的事务的更改数据(修改或删除),后者是指读到了其他已经提交事务的新增数据。
图片
比如说User A对page hold住S-lock,如果User B想对page请求X-lock,则User A的lockmode会拒绝User B的请求。
1.2.3 隔离级别1)读未提交(Read Uncommitted)读未提交,就是一个事务可以读取另一个未提交事务的数据,也称为脏读。在读数据时候不加锁,写数据时候加行级别的共享锁,提交时释放锁。行级别的共享锁,不会对读产生影响,但是可以防止两个同时的写操作
2)读已提交(Read Committed)读提交,就是一个事务要等另一个事务提交后才能读取它的数据,否则是读取不到另外一个事务的更改的数据。事务读取数据(读到数据的时候)加行级共享S锁,读完释放;事务写数据时候(写操作发生的瞬间)加行级独占X锁,事务结束释放。由于事务写操作加上独占X锁,因此事务写操作时,读操作也不能进行,因此,不能读到事务的未提交数据,避免了脏读的问题。但是由于,读操作的锁加在读上面,而不是加在事务之上,所以,在同一事务的两次读操作之间可以插入其他事务的写操作,所以可能发生不可重复读的问题。
3)可重复读(Repeatable Read)当事务隔离级别为可重复读时,只能读到该事务启动时已经提交的其他事务修改的数据,未提交的数据或在事务启动后其他事务提交的数据是不可见的。对于本事务而言,事务语句可以看到之前的语句做出的修改。事务读取数据在读操作开始的瞬间就加上行级共享S锁,而且在事务结束的时候才释放。但是,由于加的是行级别的锁,仍然可能发生幻读的问题。
4)序列化(Serialization)最严格的隔离级别,强制事务串行执行,使之不可能冲突,从而解决幻读的问题,资源消耗最大。在读操作时,加表级共享锁,事务结束时释放;写操作时候,加表级独占锁,事务结束时释放。在这个级别,可能会导致大量的锁超时和锁竞争现象,实际上也很少用到。
1.2.4 不同数据库隔离级别不同数据库支持的隔离级别也不同
图片
事务的原子性要求事务要么全部执行成功、要么全部执行失败回滚,但是对于Oracle数据库支持语句级的原子性,也就是一个事务中单个语句执行失败,则只会回滚该语句执行的操作,不会导致在当前事务中丢失之前的任何工作。如果需要回滚整个事务,需要处理错误并且主动调用ROLLBACK。这种语句级的回滚对于处理一些长时间运行的批处理任务有用,逻辑上希望能够处理错误,不需要回滚已经完成的所有操作。
图片
不过从Oracle数据库迁移到国产数据库中,大部分数据库在事务的回滚机制上并不支持语句级别的,因此需要通过采用SAVEPOINT保存点的方式。使用SAVEPOINT虽然可以解决语句级别功能上的需求,但是不合理的使用可能引发其它问题。
1.4 事务超时机制数据库中事务会设置不同的超时机制,防止因为出现等锁而出现无限等待,超过这个时间后会出现等锁超时,事务会回滚。
图片
前文提到Oracle数据库中支持语句级别的回滚,在迁移到国产数据库后,为了兼容Oracle数据库这个特性,很多数据库支持SAVEPOINT机制。SAVEPOINT是事务中的一个逻辑标记点,用于标识事务执行到某个特定位置的状态。通过ROLLBACK TO SAVEPOINT可以回滚到该标记点,撤销该点之后的所有操作,但保留该点之前的操作。SAVEPOINT可以提供细粒度的事务控制,避免因整个事务回滚导致的数据丢失。对于一些复杂或长时间运行的事务中,可以分阶段提交或回滚操作。
但是在一个长事务中不规范的使用SAVEPOINT可能会导致实例内存上涨、事务执行时间异常的问题。比如在Oracle数据库中的一个游标查询语句,每1K笔执行一次,迁移到GaussDB之后,URL串中指定了autosave参数,这样在每次游标访问时候都会执行一次SAVEPOINT动作,原来几分钟的任务可能几个小时都执行不完,出现很多SAVEPOINT等待事件,并且数据库实例的内存逐渐上涨。
那么对于一些复杂的业务场景,如何合理的使用SAVEPOINT?
禁止自动设置SAVEPOINT,只在必要时设置SAVEPOINT,如在数据更新、复杂逻辑分支操作上设置;将长事务拆分为多个小事务,每处理一定的数据后提交,以释放资源;避免在游标循环中频繁执行SAVEPOINT,改用批量处理或分页查询;及时回滚或释放无用SAVEPOINT,在逻辑分支完成后,主动回滚到最近的SAVEPOINT并释放资源;避免嵌套过深的SAVEPOINT:过多的嵌套SAVEPOINT会增加回滚段的复杂度。参考资料:https://pigsty.cc/blog/db/oracle-pg-xact/TiDB中的事务处理机制