一致性
数据库是用于存储数据的中间件,如果存储了错误的数据,那数据库也就无意义了。
怎样的数据算是正确的数据?业务规则上正确的数据即是正确的数据,从数据库角度来说,就是一致性(所以,一致性是应用程序的属性,不是数据库的),数据库只能在某种程度上来保证一致性(通过原子性、隔离性、持久性),因为如果用户直接存储了错误的数据,导致数据一致性被破坏了,数据库也不能得知。
原子性
要做到业务上正确,首先业务的每一个逻辑运行单元,都必须被完整执行,不能只执行一半。比如转账操作,A账户转账100到B账户,不能A账户减去100后,就停止运行,这样数据就会发生错误。但应用程序经常都会发生执行到一半就中断的情况,比如程序异常、应用重启、断电。数据库的原子性可以防止这种程序执行一半导致数据不一致的情况发生,原子性保证一个逻辑运行单元(事务),要么被完整地运行,要么不被运行(通过撤销操作)。
持久性
持久性 是一个承诺,即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。在单节点数据库中,持久性通常意味着数据已被写入非易失性存储设备,如硬盘或SSD。它通常还包括预写日志或类似的文件,以便在磁盘上的数据结构损坏时进行恢复。在带复制的数据库中,持久性可能意味着数据已成功复制到一些节点。为了提供持久性保证,数据库必须等到这些写入或复制完成后,才能报告事务成功提交。
隔离性
当多个事务同时操作一个数据时,会发生竞争条件(并发),可以一个事务一个事务执行(序列化,强隔离级别),但这样会大大降低数据库的并发读写性能,数据库往往会选用较弱一些的隔离级别,以提高数据库并发性能,但较弱的隔离级别也意味着可能会造成一致性的破坏。
需要注意的是,隔离性提高的并发性能,主要是指并发读性能(通过去锁,或者降低锁范围),对于写操作,一般都是有加写锁的,所以弱隔离性可能会出现脏读、不可重复读、幻读问题。
1.读已提交实现
写加写锁,读可以加读锁,或者对已提交的数据加版本号,如果数据正在被写,则读取已经提交的最新版本,等写事务提交后,再次读取则是读取刚刚提交的版本。
读已提交有时会出现问题,比如:爱丽丝在银行有1000美元的储蓄,分为两个账户,每个500美元。现在一笔事务从她的一个账户中转移了100美元到另一个账户。如果她在事务处理的同时查看其账户余额列表,不幸地在转账事务完成前看到收款账户余额(余额为500美元),而在转账完成后看到另一个转出账户(已经转出100美元,余额400美元)。对爱丽丝来说,现在她的账户似乎只有900美元——看起来100美元已经消失了。
这种异常被称为不可重复读(nonrepeatable read)或读取偏差(read skew):如果Alice在事务结束时再次读取账户1的余额,她将看到与她之前的查询中看到的不同的值(600美元)。可重复读隔离级别可以避免这种问题。
2.可重复读实现
写加写锁。读使用数据库快照,事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。
快照隔离可以通过事务id(txid)、created_by、deleted_by字段完成。
当一个事务开始时,它被赋予一个唯一的,永远增长的事务ID( txid )。每当事务向数据库写入任何内容时,它所写入的数据都会被标记上写入者的事务ID。
表中的每一行都有一个 created_by 字段,其中包含将该行插入到表中的的事务ID。此外,每行都有一个 deleted_by 字段,最初是空的。如果某个事务删除了一行,那么该行实际上并未从数据库中删除,而是通过将 deleted_by 字段设置为请求删除的事务的ID来标记为删除。在稍后的时间,当确定没有事务可以再访问已删除的数据时,数据库中的垃圾收集过程会将所有带有删除标记的行移除,并释放其空间。
UPDATE 操作在内部翻译为 DELETE 和 INSERT 。如此一来,所有事务更新的数据,在数据库都留存了一个版本(由created_by字段标识)。
在快照隔离实现中,数据库必须能够保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它并排维护着多个版本的对象,所以这种技术被称为多版本并发控制(MVCC, multi-version concurrentcy control)。
在innodb的实现中,可重复读是没有幻读的,对于普通的select语句,使用MVCC(一致性非锁定读)避免了幻读,对于加锁的select语句(for update或lock in share mode),就不是mvcc了,只是普通加锁(一致性锁定读),这时就需要配合next-key locking来避免幻读。
3.可串行化实现
避免并发问题的最简单方法就是完全不要并发:在单个线程上按顺序一次只执行一个事务。但常用数据一般都不会这样实现,而是使用2PL。
两阶段锁定(2PL, two-phase locking)
- 如果事务A读取了一个对象,并且事务B想要写入该对象,那么B必须等到A提交或中止才能继续。
- 如果事务A写入了一个对象,并且事务B想要读取该对象,则B必须等到A提交或中止才能继续。
2PL用于MySQL(InnoDB)和SQL Server中的可序列化隔离级别,以及DB2中的可重复读隔离级别。
2PL实现
读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于共享模式(shared mode)或独占模式(exclusive mode)。锁使用如下:
- 若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务已经在对象上持有排它锁,则这些事务必须等待。
- 若事务要写入一个对象,它必须首先以独占模式获取该锁。没有其他事务可以同时持有锁(无论是共享模式还是独占模式),所以如果对象上存在任何锁,该事务必须等待。
- 如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的工作与直接获得排他锁相同。
- 事务获得锁之后,必须继续持有锁直到事务结束(提交或中止)。这就是“两阶段”这个名字的来源:第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁。
避免幻读(谓词锁,predicate lock)
具有可序列化隔离级别的数据库必须防止幻读。我们需要一个谓词锁(predicate lock)。它类似于前面描述的共享/排它锁,但不属于特定的对象(例如,表中的一行),它属于所有符合某些搜索条件的对象
如:谓词锁限制访问,如下所示:
- 如果事务A想要读取匹配某些条件的对象,就像在这个 SELECT 查询中那样,它必须获取查询条件上的共享谓词锁(shared-mode predicate lock)。如果另一个事务B持有任何满足这一查询条件对象的排它锁,那么A必须等到B释放它的锁之后才允许进行查询。
- 如果事务A想要插入,更新或删除任何对象,则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务B持有匹配的谓词锁,那么A必须等到B已经提交或中止后才能继续。
索引范围锁
不幸的是谓词锁性能不佳:如果活跃事务持有很多锁,检查匹配的锁会非常耗时。因此,大多数使用2PL的数据库实际上实现了索引范围锁(也称为间隙锁(next-key locking)),这是一个简化的近似版谓词锁。
通过使谓词匹配到一个更大的集合来简化谓词锁是安全的。例如,如果你有在中午和下午1点之间预订123号房间的谓词锁,则锁定123号房间的所有时间段,或者锁定12:00~13:00时间段的所有房间(不只是123号房间)是一个安全的近似,因为任何满足原始谓词的写入也一定会满足这种更松散的近似。
在房间预订数据库中,您可能会在 room_id 列上有一个索引,并且/或者在 start_time 和end_time 上有索引(否则前面的查询在大型数据库上的速度会非常慢):假设您的索引位于 room_id 上,并且数据库使用此索引查找123号房间的现有预订。现在数据库可以简单地将共享锁附加到这个索引项上,指示事务已搜索123号房间用于预订。或者,如果数据库使用基于时间的索引来查找现有预订,那么它可以将共享锁附加到该索引中的一系列值,指示事务已经将12:00~13:00时间段标记为用于预定。
无论哪种方式,搜索条件的近似值都附加到其中一个索引上。现在,如果另一个事务想要插入,更新或删除同一个房间和/或重叠时间段的预订,则它将不得不更新索引的相同部分。在这样做的过程中,它会遇到共享锁,它将被迫等到锁被释放。
这种方法能够有效防止幻读和写入偏差。索引范围锁并不像谓词锁那样精确(它们可能会锁定更大范围的对象,而不是维持可串行化所必需的范围),但是由于它们的开销较低,所以是一个很好的折衷。
网友评论