07 concurrency and Multi-version
本章提要
---------------------------------------------------------
对并发和锁的进一步补充
并发控制
事务的隔离级别
多版本控制读一致性的含义
写一致性
---------------------------------------------------------
开发多用户的数据库驱动的应用时, 最大的难题之一是, 一方面要力争最大的并发访问, 另一方面还要确保每个用户能以一致
方式读取和修改数据.
并发控制: 是数据库提供的函数集合, 允许多个人同时访问和修改数据.前一章曾经说过, 锁是管理共享数据库资源并发访问并
防止并发数据库事务之间"相互干涉"的核心机制之一.
但是, oracle 对并发的支持部仅仅只是高效的锁定, 它还实现了一种多版本控制体系结构, 这种体系结构提供了一种受控但
高度并发的数据访问, 多版本控制是指, oracle能同时物化多个版本的数据, 这也是oracle提供数据读一致视图的机制(读一致
视图, 是指相对于某个时间点有一致的结果)所以:(只是读)
oracle 与其他数据库一个显著的不一样的地方是, 读不会被写阻塞.
默认情况下, oracle的读一致性多版本模型应用与语句级(statement level), 也就是说, 应用于每一个查询, 另外还可以应用与
事务级(transaction level), 这说明, 至少提交到数据库的每一条SQL语句都会看到数据库的一个读一致视图.
数据库中事务的基本作用是将数据库从一种一致状态转变为另一种一致状态.
sql 规定的现象:
1)脏读(dirty read): 别的session还没有commit的内容, 你也可以读取, 这肯定是不好的
2)不可重复读(nonrepeatable read): 意思是, 如果你在t1时刻读取某一行, 在t2时刻重新读取这一行时, 这一行可能已经有所修改,
也许它已经消失.
3)幻像读(phantom read): 意思是, 你在t1时刻执行一个查询, 而在t2时刻再执行这个查询, 此时可能已经向数据库中增加了另外的行,
这会影响你的结果, 与不可重复读的区别在于, 在幻象读中, 已经读取的数据不会改变, 只是与以前相比, 会有更多的数据满足
你的查询条件.
通过以上3个现象, 个人感觉, 支持 不可重复读是好的, 其他两个都不怎么好...
通过以上sql规定的现象, 定义了以下的事务的隔离级别
read uncommited (脏读)
read committed 只有提交了才看的见 (oracle 支持)
repeatable read 可重复读, 可以用来防止丢失更新, 因为在读的时候其他数据库会加"共享锁".(其他数据库这样做,
共享锁会防止其他会话修改我们已经读取的数据, 这肯定会降低并发性)
serializable 可串行化隔离级别 (oracle 支持), 提供了最高程度的隔离性.
下面详细介绍:
read committed:
oracle 提供的默认事务隔离级别是 read committed, 这很好, 与之相对应的是脏读, 脏读即没提交的事务的结果, 也可以被读取,
脏读显然是不好的. read committed 是大多数据库采用的方法, 很少有采用其他方法的. 这种隔离级别可能存在不可重复读
和幻象读, read committed 并不是很容易实现, 除了oracle意外,其他数据库都实现不好.
serializable (oracle支持)
serializable 事务(一个事务整体, 从开始到结束)在一个环境中操作时, 就好像没有别的用户在修改数据库一样, 我们读取所有行
在重读取时都肯定完全一样, 所执行的查询在整个事务期间也总能返回相同的结果. 例如:
select * from t; // 时刻t1, 返回结果 1000 行
begin dbms_lock.sleep(60*60*24); end;
select * from t; // 时刻t2, 相比于t1, 已经过去了1天, 甚至更长时间, 但是返回的结果也还是 1000 行
// 因为, t1 与 t2 这两个时刻虽然相隔很远, 但是都在一个事务中, 而根据 serializable特性
// 其他事务的修改对查询是不可见的.
oracle中是这样实现serializable事务的, 原本通常在语句级得到的读一致性现在可以扩展到事务级.
结果并非相对于语句开始的那个时间点一致, 而是在事务开始的那一刻就固定了, 换句话说, oracle 使用 undo 段按事务开始时
数据的原样来重建数据, 而不是按语句开始时的样子重建. 这种隔离性是有代价的:
那么, 如果你在 serializable 事务中修改了数据, 那么有可能得到 ora-08177错误, 如果你在使用 serializable 事务, 就
不要指望它与其他事务一同更新同样的信息, 如果你确实要更新信息, 就应该使用select .. for update(这里说的是在
serializable 事务隔离级别的情况下), 如果要使用 serializable 隔离级别, 要保证以下几点:
1) 一般没有其他人修改相同的数据
2) 需要事务读一致性
3) 事务都很短(这有助于保证第一点)
要记住, 如果隔离级别设置成 serializable, 事务开始之后, 你不会看到数据库中作出的任何修改, 直到提交为止.(正是因为
你看不到其他事务的修改,而你在你自己事务中修改了某个数据, 而别的事务也修改了这个数据,那肯定会返回错误, 即便修改
操作时其他session先, 你这个session后)
read only (只oracle 提供)
另外, oracle 还提供了 read only 级别的事务隔离, 它的特性与 serializable 完全一样, 只是它的事务本身不准许修改.
因为不准许修改, 所以这种模式不会得到 ora-08177 错误. 不过在 read only 事务隔离机制中, 可能会遇到 ora-1555错误,
snapshot too old, 如果系统上有人正在修改你读取的数据, 就会发生这种错误, 对这个信息所做的修改(undo信息)将记录在
undo段中, 但是undo段是循环方式使用的, 所以可能会被覆盖. 如果出现这个错误, 那只能重头再来. 对于这个错误, 只能
适当调整 undo 段的大小.
多版本控制读一致性的含义
一种会失败的常用数据仓库技术:
1) 它们使用一个触发器维护源表中的一个 last_update 列.
2) 最初要填充数据仓库表时, 它们要记住当前的时间, 为此会选择源系统上的 sysdate, 例如早上 9:00
3) 然后它们从事务系统中拉出所有行, 这是一个完整的 select * from table查询, 可以得到最初填充的数据仓库
4) 要刷新这个数据仓库, 它们要再次记住现在时间, 例如, 假设已经过去了1个小时, 现在源系统上的时间为10:00, 然后拉出
自上午9:00(也就是第一次拉出数据之前的那个时刻)以来修改过的所有记录, 并把这些修改合并到数据仓库中.
这个例子的问题时, 在拉出的那个时间点上, 假设上午 9:00 至少有一个打开的未提交的事务, 因为拉出数据时我们看不到对这一行做
的修改, 而只能看到它的最后一个已提交的版本, 所以在上午9:00第一次拉数据期间我们读不到这一行的新版本, 上午10:00刷新
我们也拉不出这行, 因为上午10:00的刷新只会拉出自那天早上上午9:00以后修改的记录.现在我们稍作修改,就可以实现这个逻辑:
select nvl(min(to_date(start_time, 'mm/dd/rr hh24:mi:ss')), sysdate)
from v$transaction; -- 这个视图能查到当前活动的事务(即已经开始,并未提交的事务, start_time这个例子中就定义为9:00)
解释热表上超出期望的 I/O:
你查看查询执行的I/O时(生产环境下), 注意到它比你在开发系统中看到的I/O次数多很多. 而你无法解释这种现象, 测试说明原因:
在你测试系统中, 由于它是独立的, 所以不必撤销事务修改, 不过在生产系统中, 读一个给定的块时, 可能必须撤销(回滚)多个事务
所做的修改, 而且每个回滚都可能涉及I/O来获取undo信息并应用与系统.
可能只是要查询一个表, 但是这个表上发生了多个并发修改, 因此你看到oracle正在读undo段, 参考下例:
create table t(x int); insert into t values(1); exec dbms_stats.gather_table_stats(user, 'T'); select * from t; -- 我们使用 serializable 事务级别, 这样, 无论在会话中运行多少次查询, -- 得到事务开始时一样的结果 alter session set isolation_level = serializable; set autotrace on statistics select * from t; -- 注意结果返回的 statistic: consistent gets 是 15 -- another session begin for i in 1.. 10000 loop update t set x = x+1; commit; end loop; end; / -- 返回之前的 session, 重新运行 select * from t; -- 现在得到了很高的 consistent gets 100000 以上
以上的 I/O 是从哪里来的呢? 这是因为 oracle 回滚了对该数据库块的修改(因为 serializable), 在运行第二个查询时, oracle知道
查询获取和处理的所有块都必须针对事务开始的那个时刻, 到达缓冲区缓存时, 我们发现, 缓存中的块"太新了", 另一个会话已经把
这个块修改了10000次, 查询无法看到这些修改, 所以它开始查找Undo信息, 并撤销上一次所做的修改. 它发现这个混滚块还是太新了,
然后再对它做一次回滚, 这个工作会反复进行, 直至最后发现食物开始时的那个版本. 那么, 是不是只有使用serializable隔离级别时
才会遇到这个问题呢? 不, 绝对不是, 可以考虑一个运行5分钟的查询, 在查询运行的这5分钟期间, 它从缓冲区缓存获取块, 每次从
缓冲区缓存获取一个块时, 都会完成这样一个检查:"这个块是不是太新了? 如果是, 就将其回滚", 另外,要记住,查询运行时间越长,它
需要的块在此期间被修改的可能性就越大.
现在, 数据库希望进行这个检查(也就是说,查看块时不是"太新",并相应的回滚修改), 正是由于这个原因, 缓冲区缓存实际上可能在内存中
包含同一个块的多个版本, 通过这种方式, 很有可能你需要的版本就在缓存中, 已经准备好, 正等着你用, 而无需使用 undo 信息进行物化,
请看以下查询:
select file#, block#, count(*)
from v$bh
group by file#, block#
having count(*) > 3
order by 3
/
可以使用这个查询看这些块,一般而言, 你会发现在任何时间点上缓存中一个块的版本大约不超过 6 个, 但是这些版本可以由需要它们的
任何查询使用.
写一致性
到此为止, 我们已经了解了读一致性: oracle 可以使用 undo 信息来提供非阻塞的查询和一致(正确)的读, 查询时, oracle会从缓冲区
缓存中读出块, 它能保证这个块版本足够"旧", 能够被该查询看到.
我们不能修改块的老版本, 修改一行时, 必须修改该块的当前版本.
一致读 和 当前读
oracle 处理修改语句时会完成两类块获取, 它会执行以下两部
一致读(consistent read): "发现"要修改的行时, 锁完成的获取就是一致读.
当前读(current read): 得到块来实际更新所要修改的行时, 锁完成的获取就是当前读.
在一个正常查询中, 我们会遇到查询模式获取(一致读), 但是如果要修改, 更新模式(当前读)会获取得到现在的表块, 也就是包含待修改
行的块, 得到一个undo段块来开始事务, 以及一个Undo块, (undo segment中, 磁盘文件), 既然存在当前模式获取, 这就说明发生了某种
修改, 在oracle用新信息修改一个块之前, 它必须得到这个块的当前副本.
那么读一致性对修改有什么影响呢? 参考下边例子:
比如:你要执行一条 update t set x = x+1 where y = 5; 我们知道, 当你只是查询的时候, where y = 5部分(读一致模式来获取), 如果
update 语句从开始到结束用5分钟来进行处理, 而有人在此期间向表中增加了另外1条 y=5的记录, 那么update看不到这个心增加的记录,
因为一致读看不到新记录的. 这在预料之中, 也是正常的, 但问题是, 如果两个会话按顺序执行下面语句会发生什么情况?
update t set y = 10 where y = 5;
update t set x = x+1 where y = 5;
因为开始 update y=5时, 已经将y 修改成10了, update的一致读部分指出"你想更新这个记录, 因为我们开始时 y = 5", 但是根据块当前
版本(只能更新当前版本), 你会想"哦,这样不行, 我不能更新这一行(后面一个update), 因为 y 已经不是 5 了", 那么这个时候, oracle
怎么办? 如果只是忽略, 显然不好, 弄的最后更新了没有都不清不楚, 在这种情况下, oracle会在当前session(最后面的session)
选择重启动更新, 如果开始 y=5 的行现在包含值 y=10, oracle会悄悄地回滚更新(仅回滚更新, 不会混滚事务的任何其他不分),
并重启动(假设使用的是 read commited隔离级别), 如果你使用了 serializable 隔离级别, 此时这个事务就会收到一个
ORA-08177: can't serialize access for this transaction错误,使用 read committed 模式, 事务回滚你的更新后, 数据
库会重启动更新(也就是说, 修改更新相关时间点), 而且它并非重新更新数据, 而是
进入 select for update 模式, 并试图为你的会话锁住所有 where y=5的行, 一旦完成了这个锁定, 它会对这些锁定的数据运行update,
这样可以确保这一次能完成而不必(再次)重启动.
但是再想想, 如果重启动更新, 并进入 select for update 模式(与 update一样, 同样读一致获取)和读当前块获取, 开始select for update
时 y=5 的一行等到你想得到它的当前版本准备修改时发现y=11, 会发生什么? select for update 一样也要重新启动, 再循环一次.
个人理解, 对回滚更新表示怀疑, 请参考下例
查看完后面的例子后, 知道是在最后的当前session下回滚执行了前边的Update操作, 但是这看上去好像跟个人理解的一样
/* * test 1 */ -- init create table tttt( x int, y int ) / insert into ttt values(1, 5); -- session 1 select * from ttt; -- result x=1, y=5 -- 上锁 update ttt set y = 10 where y = 5; -- session 2 select * from ttt; -- result x=1, y=5, read committed 隔离级别 -- 阻塞 update ttt set x = x+1 where y = 5; -- 接下来 -- 1) session1 commit; 当前块的值为 x=1, y=10 -- 2) session2 的阻塞打开, 继续执行之前的update语句, 如果不满足执行条件 -- 比如, y值已经变更, 那么表中肯定没有之前y值的行了, 所以执行结果 -- 必然是 0 rows updated. -- 综上, 以上的过程是, 上锁, 阻塞, 解开, 执行 等过程,没看到回滚 和重新启动
演示查看重启动
drop table t; create table t ( x int, y int ); insert into t values ( 1, 1 ); commit; create or replace trigger t_bufer before update on t for each row begin dbms_output.put_line ( 'old.x = ' || :old.x || ', old.y = ' || :old.y ); dbms_output.put_line ( 'new.x = ' || :new.x || ', new.y = ' || :new.y ); end; / set serveroutput on update t set x = x+1; /* result session 1 old.x = 1, old.y = 1 new.x = 2, new.y = 1 */ -- recently, we do not commit, so the line is locked now. -- another session set serveroutput on update t set x = x+1 where x > 0; -- 这时会立刻阻塞, 然后我们回到session1, commit session1 我们会得到以下: -- 在 session2 中的结果 /* result session 2 old.x = 1, old.y = 1 new.x = 2, new.y = 1 old.x = 2, old.y = 1 new.x = 3, new.y = 1 */ -- 可以看到, 虽然你只修改了1行在 session2, 但是这个触发器被执行了 2 次. -- 1次是原来的版本, 执行一次, 一次是后边的版本执行一次, oracle 会确认 -- read-consistent version of the record and modifications we would like to -- have made to it.
为什么重启动对我们很重要?
想想看, 如果你有一个触发器会做一些非事务性的事情, 这可能就是一个相当严重的问题, 例如, 考虑这样一个触发器, 它要发出一个更新(电子邮件)
电子邮件正文是 "这是数据库以前的样子,它已经修改成现在的这个样子", 用户就会收到两个电子邮件.所以,考虑以下影响:
1) 考虑一个触发器, 它维护着一些PL/SQL全局变量, 如所处理行的个数, 重启动的语句回滚时, 对PL/SQL变量的修改不会"回滚"
2) 一般认为, 以 UTL_开头的几乎所有函数都会受到语句重启动的影响
3) 作为自治事务一部分的触发器肯定会受到影响, 语句重启动并回滚时, 自治事务无法回滚.
所有这些后果都要小心处理, 要想到对于每一行触发器可能会触发多次.