乐观锁定
不能总是依赖各个应用来维护这个字段,原因是多方面的。例如,这样会增加应用程序代码,而且只要是表中需要修改的地方,都必须重复这些代码,并正确地实现。在一个大型应用中,这样的地方可能很多。另外,将来开发的每个应用也必须遵循这些规则。应用程序代码中很可能会“遗漏”某一处,未能适当地使用这个字段。因此,如果应用程序代码本身不负责维护这个
LAST_MOD字段,我相信应用也不应负责检查这个
LAST_MOD字段(如果它确实能执行检查,当然也能执行更新!)。所以在这种情况下,我建议把更新逻辑封装到一个存储过程中,而不要让应用直接更新表。如果无法相信应用能维护这个字段的值,那么也无法相信它能正确地检查这个字段。存储过程可以取以上更新中使用的绑定变量作为输入,执行同样的更新。当检测到更新了0行时,存储过程会向客户返回一个异常,让客户知道更新实际上失败了。
可以看到,散列值就是一个很大的16进制位串。
DBMS_CRYPTO的返回值是一个
RAW变量,显示时,它会隐式地转换为
HEX。这个值会在更新前使用。为了执行更新,需要在数据库中获取这一行,并按其现在的样子
锁定,然后计算所获取的行的散列值,将这个新散列值与从数据库读出数据时计算的散列值进行比较。上述逻辑表示如下(当然,在实际中,可能使用绑定变量而不是散列值直接量):
下面重新建立我们的表,启用
ROWDEPENDENCIES。可以使用
DBMS_REDEFINITION中(Oracle提供的另一个包)的在线重建功能来执行,但是对于一个这么小的任务,我们还是从头开始更好一些:
所以从长远看不能依赖这种转换。
第二种方法称为
乐观锁定(optimistic locking),即把所有锁定都延迟到即将执行更新之前才做。换句话说,我们会修改屏幕上的信息而不要锁。我们很乐观,认为数据不会被其他用户修改;因此,会等到最后一刻才去看我们的想法对不对。
这种锁定方法在所有环境下都行得通,但是采用这种方法的话,执行更新的用户“失败”的可能性会加大。这说明,这个用户要更新他的数据行时,发现数据已经修改过,所以他必须从头再来。
可以在应用中同时保留旧值和新值,然后在更新数据时使用如下的更新语句,这是乐观锁定的一种流行实现:
![](https://i-blog.csdnimg.cn/blog_migrate/5c72a56389c09480c7fed775c2f0855c.png)
![](https://i-blog.csdnimg.cn/blog_migrate/59bd636f2e942dffa4aeb62c1256663c.png)
在此,我们乐观地认为数据没有修改。在这种情况下,如果更新语句更新了
一
行,那我们很幸运;这说明,在读数据和提交更新之间,数据没有改变。但是如果更新了
零
行,我们就会失败;另外一个人已经修改了数据,现在我们必须确定应用中下一步要做什么。是让最终用户查询这一行现在的新值,然后再重新开始事务呢(这可能会让用户很受打击,因为这一行有可能又被修改了)?还是应该根据业务规则解决更新冲突,试图合并两个更新的值(这需要大量的代码)?
实际上,前面的
UPDATE
能避免丢失更新,但是确实有可能被阻塞,在等待另一个会话执行对这一行的
UPDATE
时,它会挂起。如果所有应用(会话)都使用乐观锁定,那么使用直接的
UPDATE
一般没什么问题,因为执行更新并提交时,行只会被锁定很短的时间。不过,如果某些应用使用了悲观锁定,它会在一段相对较长的时间内持有行上的锁,你可能就会考虑使用
SELECT FOR UPDATE NOWAIT
,以此来验证行是否未被修改,并在即将
UPDATE
之前锁定来避免被另一个会话阻塞。
实现乐观并发控制的方法有很多种。我们已经讨论了这样的一种方法,即应用本身会存储行的所有“前”(before)映像。在后几节中,我们将介绍另外三种方法,分别是:
q 使用一个特殊的列,这个列由一个数据库触发器或应用程序代码维护,可以告诉我们记录的“版本”
q 使用一个校验和或散列值,这是使用原来的数据计算得出的
q 使用新增的Oracle 10
g特性
ORA_ROWSCN。
1. 使用版本列的乐观锁定
这是一个简单的实现,如果你想保护数据库表不出现丢失更新问题,应对每个要保护的表增加一列。这一列一般是
NUMBER或
DATE/TIMESTAMP列,通常通过表上的一个行触发器来维护。每次修改行时,这个触发器要负责递增
NUMBER列中的值,或者更新
DATE/TIMESTAMP列。
如果应用要实现乐观并发控制,只需要保存这个附加列的值,而不需要保存其他列的所有“前”映像。应用只需验证请求更新那一刻,数据库中这一列的值与最初读出的值是否匹配。如果两个值相等,就说明这一行未被更新过。
下面使用
SCOTT.DEPT表的一个副本来看看乐观锁定的实现。我们可以使用以下数据定义语言(Data Definition Language,DDL)来创建这个表:
![](https://i-blog.csdnimg.cn/blog_migrate/1bb78f7750bb4f4a704e2bb1df76f48e.png)
![](https://i-blog.csdnimg.cn/blog_migrate/7ecb2b22a27d1e945f43ab1464f85fef.png)
![](https://i-blog.csdnimg.cn/blog_migrate/53147196b4e973a42d28681d46ed6457.png)
然后向这个表
INSERT(插入)
DEPT数据的一个副本:
![](https://i-blog.csdnimg.cn/blog_migrate/4897e5941f3db9e8a0af74a61373abef.png)
以上代码会重建
DEPT表,但是将有一个附加的
LAST_MOD列,这个列使用
TIMESTAMP WITH TIME ZONE数据类型(Oracle9
i 及以上版本中才有这个数据类型)。我们将这个列定义为
NOT NULL,以保证这个列必须填有数据,其默认值是当前的系统时间。
这个
TIMESTAMP
数据类型在
Oracle
中精度最高,通常可以精确到微秒(百万分之一秒)。如果应用要考虑到用户的思考时间,这种
TIMESTAMP
级的精度实在是绰绰有余,而且数据库获取一行后,人看到这一行,然后修改,再向数据库发回更新,一般不太可能在不到
1
秒钟的片刻时间内执行整个过程。两个人在同样短的时间内(不到
1
秒钟)读取和修改同一行的几率实在太小了。
接下来,需要一种方法来维护这个值。我们有两种选择:可以由应用维护这一列,更新记录时将
LAST_MOD
列的值设置为
SYSTIMESTAMP
;也可以由触发器
/
存储过程来维护。如果让应用维护
LAST_MOD
,这比基于触发器的方法表现更好,因为触发器会代表
Oracle
对修改增加额外的处理。不过这并不是说:无论什么情况,你都要
依赖
所有应用在表中经过修改的所有位置上一致地维护
LAST_MOD
。所以,如果要由各个应用负责维护这个字段,就需要一致地验证
LAST_MOD
列未被修改,并把
LAST_MOD
列设置为当前的
SYSTIMESTAMP
。例如,如果应用查询
DEPTNO=10
这一行:
![](https://i-blog.csdnimg.cn/blog_migrate/58aa12b3e322035b53dd4a3763adb309.png)
![](https://i-blog.csdnimg.cn/blog_migrate/e155c406eb159d09c4cb28547203c66a.png)
![](https://i-blog.csdnimg.cn/blog_migrate/afde90b954fdfaf442ea434945f69880.png)
目前我们看到的是:
![](https://i-blog.csdnimg.cn/blog_migrate/02c4b0b8f6f2ae5dbf667322dd94b3d6.png)
再使用下面的更新语句来修改信息。最后一行执行了一个非常重要的检查,以确保时间戳没有改变,并使用内置函数
TO_TIMESTAMP_TZ(
TZ是TimeZone的缩写,即时区)将以上
select(选择)得到的串转换为适当的数据类型。另外,如果发现行已经更新,以下更新语句中的第3行会把
LAST_MOD列更新为当前时间:
![](https://i-blog.csdnimg.cn/blog_migrate/09ab3e09121d41897853940ce0f83468.png)
可以看到,这里更新了一行,也就是我们关心的那一行。在此按主键(
DEPTNO
)更新了这一行,并验证从最初读取记录到执行更新这段时间,
LAST_MOD
列未被其他会话修改。如果我们想尝试再更新这个记录,仍然使用同样的逻辑,不过没有获取新的
LAST_MOD
值,就会观察到以下情况:
![](https://i-blog.csdnimg.cn/blog_migrate/f85f2ab3b8d652526c2db26cbfb615d6.png)
注意到这一次报告称“
0 rows updated”(更新了0行),因为关于
LAST_MOD的谓词条件不能满足。尽管
DEPTNO 10还存在,但是想要执行更新的那个时刻的
LAST_MOD值与查询行时的时间戳值不再匹配。所以,应用知道,既然未能修改行,就说明数据库中的数据已经(被别人)改变,现在它必须得出下一步要对此做什么。
![](https://i-blog.csdnimg.cn/blog_migrate/b32c54767a31ad73ca012a7722ce0154.png)
还有一种实现是使用一个触发器来维护这个
LAST_MOD字段,但是对于这么简单的工作,我建议还是避免使用触发器,而让DML来负责。触发器会引入大量开销,而且在这种情况下没有必要使用它们。
2. 使用校验和的乐观锁定
这与前面的版本列方法很相似,不过在此要使用基数据本身来计算一个“虚拟的”版本列。为了帮助解释有关校验和或散列函数的目标和概念,以下引用了Oracle 10
g
PL/SQL Supplied Packages Guide中的一段话(尽管现在还没有介绍如何使用Oracle提供的任何一个包!):
单向散列函数取一个变长输入串(即数据),并把它转换为一个定长的输出串(通常更小),这个输出称为散列值(hash value
)。散列值充当输入数据的一个惟一标识符(就像指纹一样)。可以使用散列值来验证数据是否被修改。
需要注意,单向散列函数只能在一个方向上应用。从输入数据计算散列值很容易,但是要生成能散列为某个特定值的数据却很难。
散列值或校验和并非真正惟一。只能说,通过适当地设计,能使出现冲突的可能性相当小,也就是说,两个随机的串有相同校验和或散列值的可能性极小,足以忽略不计。
与使用版本列的做法一样,我们可以采用同样的方法使用这些散列值或校验和,只需把从数据库读出数据时得到的散列或校验和值与修改数据前得到的散列或校验和值进行比较。在我们读出数据之后,但是在修改数据之前,如果有人在这段时间内修改了这一行的值,散列值或校验和值往往会大不相同。
有很多方法来计算散列或校验和。这里列出其中的3种方法,分别在以下3个小节中介绍。所有这些方法都利用了Oracle提供的数据库包:
q
OWA_OPT_LOCK.CHECKSUM
:这个方法在Oracle8
i 8.1.5及以上版本中提供。给定一个串,其中一个函数会返回一个16位的校验和。给定ROWID时,另一个函数会计算该行的16位校验和,而且同时将这一行锁定。出现冲突的可能性是65 536分之一(65 536个串中有一个冲突,这是假警报的最大几率)。
![](https://i-blog.csdnimg.cn/blog_migrate/90f8952a6fea07bb3f50260bbf68d0ed.png)
q
DBMS_OBFUSCATION_TOOLKIT.MD5
:这个方法在 Oracle8
i 8.1.7及以上版本中提供。它会计算一个128位的消息摘要。冲突的可能性是3.4028E+38分之一(非常小)。
q
DBMS_CRYPTO.HASH
:这个方法在Oracle 10
gRelease 1及以上版本中提供。它能计算一个SHA-1(安全散列算法1,Secure Hash Algorithm 1)或MD4/MD5消息摘要。建议你使用SHA-1算法。
注意 很多编程语言中都提供了一些散列和校验和函数,所以还可以使用数据库之外的散列和校验和函数。
下面的例子显示了如何使用Oracle 10
g中的
DBMS_CRYPTO内置包来计算这些散列/校验和。这个技术也适用于以上所列的另外两个包;逻辑上差别不大,但是调用的API可能不同。
下面在某个应用中查询并显示部门10的信息。查询信息之后,紧接着我们使用
DBMS_CRYPTO包计算散列。这是应用中要保留的“版本”信息:
![](https://i-blog.csdnimg.cn/blog_migrate/8a42a44f46b6015f31b5a9c8210f6eee.png)
![](https://i-blog.csdnimg.cn/blog_migrate/22bdff9a0dc01849ffed9aef7b06bd24.png)
![](https://i-blog.csdnimg.cn/blog_migrate/afea09a1393d45660b6bb871ae03aec5.png)
![](https://i-blog.csdnimg.cn/blog_migrate/543c2cafedef55033abd55286ffffd55.png)
更新后,重新查询数据,并再次计算散列值,此时可以看到散列值大不相同。如果有人抢在我们前面先修改了这一行,我们的散列值比较就不会成功:
![](https://i-blog.csdnimg.cn/blog_migrate/9fb1fcbcb8f64dede77042ebcc56022c.png)
![](https://i-blog.csdnimg.cn/blog_migrate/12ddee1a506395a64a46ab44fcf75ebe.png)
这个例子显示了如何利用散列或校验和来实现乐观锁定。要记住,计算散列或校验和是一个CPU密集型操作(相当占用CPU),其计算代价很昂贵。如果系统上CPU是稀有资源,在这种系统上就必须充分考虑到这一点。不过,如果从“网络友好性”角度看,这种方法会比较好,因为只需在网络上传输相当小的散列值,而不是行的完整的前映像和后映像(以便逐列地进行比较),所以消耗的资源会少得多。下面最后一个例子会使用一个新的Oracle 10
g函数
ORA_ROWSCN,它不仅很小(类似于散列),而且计算时不是CPU密集的(不会过多占用CPU)。
3. 使用
ORA_ROWSCN的乐观锁定
从Oracle 10
gRelease 1开始,你还可以使用内置的
ORA_ROWSCN函数。它的工作与前面所述的版本列技术很相似,但是可以由Oracle自动执行,而不需要在表中增加额外的列,也不需要额外的更新/维护代码来更新这个值。
ORA_ROWSCN建立在内部Oracle系统时钟(SCN)基础上。在Oracle中,每次提交时,SCN都会推进(其他情况也可能导致SCN推进,要注意,SCN只会推进,绝对不会后退)。这个概念与前面在获取数据时得到
ORA_ROWSCN的方法是一样的,更新数据时要验证SCN未修改过。之所以我会强调这一点(而不是草草带过),原因是除非你创建表时支持在行级维护
ORA_ROWSCN,否则Oracle会在块级维护。也就是说,默认情况下,一个块上的多行会共享相同的
ORA_ROWSCN值。如果更新一个块上的某一行,而且这个块上还有另外50行,那么这些行的
ORA_ROWSCN也会推进。这往往会导致许多假警报,你认为某一行已经修改,但实际上它并没有改动。因此,需要注意这一点,并了解如何改变这种行为。
我们想查看这种行为,然后进行修改,为此还要使用前面的小表
DEPT:
![](https://i-blog.csdnimg.cn/blog_migrate/bf881d9d7c9fc3ba7fb0d8ebf56d20ff.png)
现在可以观察到每一行分别在哪个块上(在这种情况下,可以假设它们都在同一个文件中,所以如果块号相同,就说明它们在同一个块上)。我使用的块大小是8 KB,一行的宽度大约3 550字节,所以我预料这个例子中每块上有两行:
![](https://i-blog.csdnimg.cn/blog_migrate/9efcce39916130966b01f647153e515b.png)
![](https://i-blog.csdnimg.cn/blog_migrate/d59b0df451cfab2cc0eb285ba4a1e0be.png)
![](https://i-blog.csdnimg.cn/blog_migrate/fb8c1c2eb7bcd3c266c6515a1a6c7704.png)
不错,我们观察的结果也是这样,每块有两行。所以,下面来更新块20972上
DEPTNO = 10的那一行:
![](https://i-blog.csdnimg.cn/blog_migrate/38017636373a6e67c270332f06edb2b9.png)
![](https://i-blog.csdnimg.cn/blog_migrate/72cb60c3ccb3c3df6241534219137933.png)
接下来观察到,
ORA_ROWSCN的结果在块级维护。我们只修改了一行,也只提交了这一行的修改,但是块20972上两行的
ORA_ROWSCN值都推进了:
![](https://i-blog.csdnimg.cn/blog_migrate/433c90bc2b140ff37e5519e358abbb85.png)
如果有人读取
DEPTNO=20
这一行,看起来这一行已经修改了,但实际上并非如此。块20973上的行是“安全”的,我们没有修改这些行,所以它们没有推进。不过,如果更新其中任何一行,两行都将推进。所以现在的问题是:如何修改这种默认行为。遗憾的是,我们必须启用
ROWDEPENDENCIES再重新创建这个段。
Oracle9
i为数据库增加了行依赖性跟踪,可以支持推进复制,以便更好地并行传播修改。在Oracle 10
g之前,这个特性只能在复制环境中使用;但是从Oracle 10
g开始,还可以利用这个特性用
ORA_ROWSCN来实现一种有效的乐观锁定技术。它会为每行增加6字节的开销(所以与自己增加版本列的方法(即DIY版本列方法)相比,并不会节省空间),而实际上,也正是因为这个原因,所以需要重新创建表,而不只是简单地
ALTER TABLE:必须修改物理块结构来适应这个特性。
![](https://i-blog.csdnimg.cn/blog_migrate/789555c6bf112c682903256fc893b8f5.png)
![](https://i-blog.csdnimg.cn/blog_migrate/29476a5aecd9e43740aca70d5af87846.png)
![](https://i-blog.csdnimg.cn/blog_migrate/6a92674c705afd0154e23ba3627451ae.png)
又回到前面:两个块上有4行,它们都有相同的
ORA_ROWSCN值。现在,更新
DEPTNO=10的那一行时:
![](https://i-blog.csdnimg.cn/blog_migrate/1c830c7b9e006a4e937b0a949f0daf84.png)
查询
DEPT表时应该能观察到以下结果:
![](https://i-blog.csdnimg.cn/blog_migrate/73fa40bc3a58ab5e04294c8da8147948.png)
![](https://i-blog.csdnimg.cn/blog_migrate/6992ab5eb888518ac9778fa9a30d28df.png)
此时,只有
DEPTNO = 10
这一行的
ORA_ROWSCN改变,这正是我们所希望的。现在可以依靠
ORA_ROWSCN来为我们检测行级修改了。
将SCN转换为墙上时钟时间
使用透明的
ORA_ROWSCN列还有一个好处:可以把SCN转换为近似的墙上时钟时间(有+/–3秒的偏差),从而发现行最后一次修改发生在什么时间。例如,可以执行以下查询:
![](https://i-blog.csdnimg.cn/blog_migrate/372e0ed526877ed53a5e7907cb34f402.png)
在此可以看到,在表的最初创建和更新
DEPTNO = 10行之间,我等了大约3分钟。不过,从SCN到墙上时钟时间的这种转换有一些限制:数据库的正常运行时间只有5天左右。例如,如果查看一个“旧”表,查找其中最旧的
ORA_ROWSCN(注意,在此我作为
SCOTT登录;没有使用前面的新表):
![](https://i-blog.csdnimg.cn/blog_migrate/91ce2d7380453fefcbed789a39570788.png)
如果我试图把这个SCN转换为一个时间戳,可能看到以下结果(取决于
DEPT表有多旧!):
![](https://i-blog.csdnimg.cn/blog_migrate/e608064abbef48bd202946bf9ca71d94.png)
![](https://i-blog.csdnimg.cn/blog_migrate/bc7ac1428b78c57e1028d22d4ed7c77a.png)