任何关系型数据库中,ACID 是组成数据库的重要部分,是数据库事务的一组属性,该特性目的主要确保数据库在异常情况下保证数据的有效性。
数据库 ACID 特性
A(Atomicity)
原子性:
事务通常由多个语句组成。原子性保证将每个事务视为单个单元,该事务要么完全成功,要么完全失败。换句话说,如果在一个事务中,任何语句都未能完成,整个事务都会失败,未完成事务中的数据条目在数据库中保持不变。一个原子系统必须保证在任何情况下都具有原子性,包括电源故障,数据库错误和实例奔溃。同时,原子性可以防止在数据库中发生部分数据更新的情况。举一个例子,假设用户 USER1 和 USER2 之间进行转账。这之间会进行两个动作,第一个动作,从 USER1 账户中划钱;第二个动作,将从 USER1 账户中划拨的钱保存到 USER2 账户中。原子性在这个过程中可以保证数据库中的状态一致,即意味着从用户 USER1 账户减去(事务失败回滚不变)转账金额,在用户 USER2 账户中加上(事务失败回滚不变)转账金额。
上图中,假设用户 USER1 有 1000 元,USER2 用户有 500 元,现在用户 USER1 向用户 USER2 进行转账交易 500 元,如果在系统正常情况下,转账成功,那么 USER1 账户的将会减去 500,用户 USER2 账户的金额将会增加 500。整个过程在事务完成后,数据库中的数据状态也将发生变化,用户 USER1 将会被减去 500,用户 USER2 将会被加上 100,,并最终保持总账户金额一致。否则,用户之间的金额在数据库中保持原有数据状态。
C(Consistency)
一致性:
一致性确保事务只能使数据库从一个有效的状态进入到另一种有效的状态,并保证数据库数据最终的状态一致。一致性主要通过定义的一些规则来保证,比如在表对象上定义约束,触发器,级联或者之间组合而成的规则。通过这些规则来防止数据库中的非法事务(异常事务)。主外键主要维护数据库对象之间的参照完整性。例如在上面原子性的示例中,用户 USER1 向用户 USER2 转账,那么就要在事务规则的约束下验证 USER1 + USER2 = 1500 的一致有效性。假设在这个事务中,用户 USER1 向用户 USER2 转账 500 元,那么用户 USER1 账户减去 500,而 USER2 账户却没有变化,那么就要根据验证规则验证用户 USER1 + USER2 最终的状态是否为 1500,现在看来,USER1 账户转账 500 元,这个事务中用户 USER1 账户已经扣除了 500 元,那么用户 USER1 现在的账户上剩余 500 元,对于事务来说,实现了原子性,但是账户验证结果为用户 USER1 + USER2 = 1000 元,这与事务规则验证用户 USER1 + USER2 = 1500 的状态不一致,那么必须要取消这个事务,并将受影响进行转账的事务回滚到事务前的一个状态。如果在一个事务中,存在其它约束、触发器等行为,则在提交事务之前,以相同的验证规则去检查每个更改操作。可能还有其它的约束规则,如要求表中的两列 COL1 和 COL 2 必须为整数,那么如果此时输入 COL1 的值为一个浮点数,那么事务将会被取消,如果此列上有触发器,那么将会提示用户。还有在完整性约束的规则下,无论该表是参照表(主实体)或者引用表(子实体),是不允许删除其中一个表中的行,因为表主体之间收到主外键的约束影响。
I(Isolation)
隔离性:
隔离性主要保证多个事务可以同时进行,最简单的例子就是多个事务在对一张表同时进行读取和写入。隔离性可以保证多个事务之间同时进行,并互相不影响。同时,隔离性可以确保事务在并发执行时,数据库中的数据状态与执行事务所获取的的数据的状态是相同的。隔离性是事务并发控制的主要目标,根据不同的隔离特性,未完成的交易可能对于其他事务来说是不可见的。为了保证并发事务访问数据库对象,一般在关系型数据库中,会使用不同的方法来保证事务之间相互隔离。如 Oracle 使用 SI(Snapshot Isolation) 隔离特性,PostgreSQL 使用 SSL(Serializable Snapshot Isolation) 隔离特性。
在数据库中,因为事务隔离的等级不同,所以根据事务需要的隔离级别,将隔离级别划分为四个等级,分别是读未提交(read uncommitted),读已提交度(read committed),可重复读(repeatable read)和可串行化(serializable)。也因为不同的隔离等级而引发了不同隔离等级下的一些现象,如脏读(dirty read),不可重复读(nonrepeatable read),幻读(phantom read)和序列化异常(serialization anomaly)。而在 PostgreSQL 中,在读未提交的隔离级别下,脏读不可能发生,但是在 ANSI-SQL 标准中,是被允许的。同时在可重复的隔离级别下,幻读不可能发生,但是在 ANSI-SQL 标准中,是被允许的。正常情况下,隔离会出现事务之间的等待情况,换句话说,在两个事务之间,其中的一个事务必须要等到另一个完成才能保证隔离成功。例如,依然使用上面的例子,用户 USER1 账户有 1000 元,用户 USER2 账户有 500 元。现在有两个事务 T1 和 T2,T1 事务需要从用户 USER1 账户转账 200 到 用户 USER2 账户;T2 事务需要从用户 USER2 账户转账 100 到 用户 USER1 账户。那么最终的操作如下:
T1 事务:从用户 USER1 账户中减去 200,
T1 事务:给用户 USER2 账户中加上 200
T2 事务:从用户 USER2 账户中减去 100
T2 事务:给用户 USER1 账户中加上 100
如下过程:
hrdb=> CREATE TABLE tab\_account(id integer,name varchar,account numeric(10,2));
CREATE TABLE
hrdb=> INSERT INTO tab\_account VALUES(1,'USER1',1000.00);
INSERT 0 1
hrdb=> INSERT INTO tab\_account VALUES(2,'USER2',500.00);
INSERT 0 1
-- 事务 T1
BEGIN;
UPDATE tab\_account SET account = account - 200 WHERE name = 'USER1';
UPDATE tab\_account SET account = account + 200 WHERE name = 'USER2';
-- 事务 T2
BEGIN;
-- 此时更新第一条语句时将会发生等待
UPDATE tab\_account SET account = account - 100 WHERE name = 'USER2';
UPDATE tab\_account SET account = account + 100 WHERE name = 'USER1';
如果上面操作按照顺序执行,尽管 T2 事务需要等待,但是事务之间依然是隔离的。
如果上面的操作很可能不是按照顺序执行的,有可能交叉执行,如下:
T1 事务:从用户 USER1 账户中减去 200
T2 事务:从用户 USER2 账户中减去 100
T2 事务:给用户 USER1 账户中加上 100
T1 事务:给用户 USER2 账户中加上 200
此时,如果最后一个 T1 事务失败,T2 事务已经更改了用户 USER1 账户,那么如果不退出该无效的数据库,用户 USER1 的账户是不能够还原 T2 修改的用户 USER1 账户中的值的。由于两个事务视图写入同一数据字段,发生了写写失败, 实际上就是隔离失败导致发生的死锁。在一般的系统中,可以通过恢复到最后一个已知的事务的状态,取消失败的事务 T1 并从该状态中重启中断的事务 T2 来解决该问题。在 PostgreSQL 中的的示例:
\-- 事务 T1
hrdb=> BEGIN;
BEGIN
hrdb=> UPDATE tab\_account SET account = account - 200 WHERE name = 'USER1';
UPDATE 1
-- 此刻在该事务中 USER2 账户的状态是更新成功的
hrdb=> UPDATE tab\_account SET account = account + 200 WHERE name = 'USER2';
-- 被 PostgreSQL 自动捕获到死锁状态并释放该更新
ERROR: deadlock detected
DETAIL: Process 10788 waits for ShareLock on transaction 1186; blocked by process 10830.
Process 10830 waits for ShareLock on transaction 1185; blocked by process 10788.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,2) in relation 'tab\_account'
-- 此时,事务 T1 的状态全部被回滚
-- 事务 T2
BEGIN;
hrdb=> BEGIN;
BEGIN
hrdb=> UPDATE tab\_account SET account = account - 100 WHERE name = 'USER2';
UPDATE 1
-- 此刻更新的状态会处于等待状态,数据库自动检测到死锁并释放事务 T1 中的更新 USER2 账户更改信息
hrdb=> UPDATE tab\_account SET account = account + 100 WHERE name = 'USER1';
UPDATE 1
D(Durability)
持久性:
事务的持久性主要用来保证事务一旦被提交,及时在系统出现异常的情况下(如断电,实例奔溃),未完成的事务进行回滚,完成的事务将被自动提交并记录在永久性存储介质上。假设此时用户 USER1 向用户 USER2 账户转账,200,那么此时用户 USER1 账户减去 200,然后给用户 USER2 账户加上 200。此时,用户看到的信息是交易成功。此时的更改的数据依然被存放到磁盘 buffer 中或者说内存 buffer 中来等待被提交写入到磁盘,然而,假设此时未提交而发生了电源故障或者示例奔溃故障,那么更改将会由于没有写入到磁盘而丢失,而用户依然认为交易已经成功,此时该现象叫做持久化失败。那么为了维护这种持久化失败的情况,有两种方案可以解决该问题,预写日志记录和影子分页来解决。预写日志中记录在对数据更新之前将原始值记录在日志中来确保持久性。影子分页技术将更新条目保存为一个副本,并在事务提交时激活新的副本。关于影子分页技术,感兴趣的同学下去自行查找相关资料。
作者:宋少华
PostgreSQL 分会培训认证委员会委员、晟数科技首席技术专家、晟数学院金牌讲师、oracle 11g OCM、PostgreSQL 首批 PGCE。
曾服务于国家电网冀北电力有限公司建设大数据平台,为人社局和北京市卫计委构建 IT 基础服务,为多家银行和证券公司构建 web 服务器,系统及数据库维护;具有对税务局、国家电网、银行等政府行业和民营企业的 IT 培训经验;为相关安全行业设计 DW 数据仓库模型,使用 PostgreSQL,Greenplum,HUAWEIGaussDB,Vertica 和 Clickhouse 做数据基础服务,开发 TB 级数据落地程序及百 TB 级别数据迁移程序。