数据库并发一致性案例分析

转载@http://www.cnblogs.com/zhenyulu/articles/633486.html

本部分内容为《数据库原理》课程中的一个课堂案例,幻灯片提供的动画演示有助于理解并发控制的本质,本文内容为幻灯片的摘要。

1、下载本文所对应的幻灯片; 2、下载本文对应的VS2005代码

如果你对自己并发控制的能力很有自信的话,读完“一、问题提出”后直接可以跳转到“四、看来问题真不简单”处阅读。

本文最后给出了部分测试用代码的简单讲解。

 

一、问题提出

设某银行存款帐户数据如下表:

现在要求编写一程序,完成两项功能:存款取款。每次操作完成后向明细表中插入一行记录并更新帐户余额。

 

二、问题似乎很简单

  • 解决办法:

① 读取最后一行记录的帐户余额数据

② 根据存、取款金额计算出新的帐户余额

③ 将新的记录插入表中

  • 真的这么简单?

在不考虑并发问题的情况下是可行的

如果考虑并发,问题就多了(导致余额计算错误!请参考幻灯片与案例代码)

 

三、让我来想一想

既然存在并发问题,那么解决并发问题的最好办法就是加锁呀!动手试试~~

怎么加锁?加什么锁?

读之前加共享锁?不行!(参考幻灯片)

读之前加排它锁?还是不行!(参考幻灯片)

当然,问题还不止这些!如何读取最后一行记录?你会发现随着明细记录的增加越来越没效率。

 

四、看来问题真的不是这么简单

问题出在哪里那?从系统设计一开始我们就走错了!重新设计!

 

  • 为什么引入冗余数据?

确保帐户余额在唯一的地方进行存储

避免了读取帐户余额时访问大量数据并排序

  • 新的问题:

我们无法直接对数据库进行锁操作

必须通过合理的事务隔离级别完成并发控制(ReadUnCommitted、ReadCommitted、RepeatableRead、Serializable),哪一种好呢?

 

五、着急吃不着热豆腐

看来我们必须对各事务隔离级别逐一分析

① ReadUnCommitted

显然不行

在这个事务隔离级别下连脏数据都可能读到,何况“脏”帐户余额数据。

② ReadCommitted

也不行

该隔离级别与二级封锁协议相对应。读数据前加共享锁,读完就释放。前面分析过,此处不再赘述。

③ RepeatableRead

这个隔离级别比较迷惑人,需要仔细分析:

RepeatableRead对应第三级封锁协议:读前加共享锁,事务完成才释放。

(过程参考幻灯片,结论:可以避免并发问题,但带来了死锁!)

④ Serializable

该事务隔离级别在执行时可以避免幻影读。

但对于本案例执行效果与RepeatableRead一样(效率低下,成功率低,还有讨厌的死锁!)。

似乎走到了绝路

经过重新设计后仍然无法让人满意的解决问题!连最高隔离级别都会在高度并发时因为死锁造成很大一部分事务执行失败!

 

六、绝处逢生

  • 原因分析

死锁的原因是因为读前加S锁,而写前要将S锁提升为X锁,由于S锁允许共享,导致X锁提升失败,产生死锁。

  • 解决办法

如果在读时就加上X锁,就可避免上述问题(从封锁协议角度这似乎不可能,但确完全可行!)

其实SQL Server允许在一条命令中同时完成读、写操作,这就为我们提供了入手点。

在更新帐户余额的同时读取帐户余额,就等同于在读数据前加X锁。命令如下:

UPDATE Account
SET @newBalance = Balance = Balance + 100
WHERE AccountID = 1

上面的命令对帐户余额增加100元(粗体部分)

同时读取更新后的帐户余额到变量@newBalance中

由于读取操作融入写操作中,实现了读时加X锁,避免因锁的提升造成死锁。

完成存取款的操作可由下面的伪代码实现:

@amount = 存取款的金额
BEGIN TRANSACTION
Try
{
UPDATE Account
SET @newBalance = Balance = Balance + @amount
WHERE AccountID = 1
INSERT INTO AccountDetail (AccountID, Amount, Balance)
VALUES (1, @amount, @newBalance)
COMMIT
}
Catch
{
ROLLBACK
}
  • 改造结果:

通过上述改造,事务中只有写操作而没有了读操作

因此甚至将事务隔离级别设置为ReadUnCommitted都能确保成功执行

写前加X锁,避免了因提升S锁造成死锁的可能

  • 实验结果:

所有并行执行的事务全部成功

帐户余额全部正确

程序执行时间同串行执行各事务相同

 

七、事情并没有结束

还有可优化的余地:网络带宽受到限制时,数据在网络上传输的时间往往比对数据进行读写操作的时间要长。

  • 一个典型的更新过程:

1、读前加锁

2、帐户数据从网上传过来

3、修改、插入新记录

4、将改后的数据通过网络传回去

5、数据库提交更新并解锁。

如果网速很慢,资源锁定时间就很长。

  • 解决办法:

使用存储过程,修改后的更新过程:

1、将存、取款用到的数据通过网络发给存储过程。

2、数据加锁、修改、解锁。

3、将结果通过网络回传。

将网络延迟放到了事务之外,提高了事务效率。

  • 实验结果

由于在同一台机器上执行数据库与应用程序,实验结果表明存储过程的执行效率不如直接在应用程序中通过命令调用高。

如果能在一个带宽受到限制的网络上将数据库与应用程序分离,然后测试,相信会有令人满意的结果。(有待具体实验证实)

 

八、思考

最近园子里面关于O/R Mapping讨论得很激烈,想问大家一个问题,就是对于上述问题,O/R Mapping是否提供了解决办法,允许在Mapping的同时更加精细的控制更新手段呢?


 

附:代码分析

本文测试用代码共有5个项目,分别是:

1、SimpleUpdate(最简单的更新,在没有并发时工作得很好)

2、SimpleUpdateInMultiThread(引入并发,10个线程同时工作,结果上面的更新策略出现了问题)

3、RepeatableReadUpdate(本文第五部分中,使用RepealableRead事务隔离级别的并发更新,随没有错误,但导致了死锁)

4、AnotherMethod(本文最后给出的更新方式,高效且没有死锁)

5、UseStoredProcedure(使用存储过程完成更新)创建存储过程的代码可以从DataBase目录下找到。

  • 准备工作

首先在SQL Server 2005中建立一空数据库DBApp,程序执行时会自动在此数据库中创建所需要的表以及记录。

  • 1、SimpleUpdate
public void Operation(double amount)
{
SqlConnection conn = new SqlConnection(ConfigurationManager.AppSettings.Get("ConnectionString"));
SqlCommand cmd = new SqlCommand();
cmd.Connection = conn;
conn.Open();
cmd.CommandText = "SELECT TOP 1 Balance FROM AccountDetail WHERE AccountID = 1 ORDER BY AccountDetailID DESC";
double oldBalance = Convert.ToDouble(cmd.ExecuteScalar());
double newBalance = oldBalance + amount;
cmd.CommandText = "INSERT INTO AccountDetail (AccountID, Amount, Balance) VALUES (1, " +
amount.ToString() + ", " + newBalance.ToString() + ")";
cmd.ExecuteNonQuery();
conn.Close();
}

这段代码没有考虑任何并发问题,也没有使用事务,仅仅是读取最后一条记录的余额数据,然后根据余额和存取钱金额算出最新余额,并将数据插入到明细记录中。在没有并发问题时,该程序可以很好的执行。调用该段代码的主程序如下:

public static void Main()
{
double[] amounts = {-100, 2000, -500, 300, 150, -800, -50, 100, -400, 200};
Account account = new Account();
foreach(double amount in amounts)
{
account.Operation(amount);
}
}

该程序模拟了10次存取款操作,程序执行结果完全正确。

  • 2、SimpleUpdateInMultiThread

在这段代码中引入了并发操作,通过10个线程模拟10个人同时进行存取款操作,为了使得模拟真实有效,特意在两条SQL命令执行之间随机休息了一段时间,其它代码同上没有什么变化,结果会发现,帐户余额计算多处出现错误。

......
public static void Main()
{
double[] amounts = {-100, 2000, -500, 300, 150, -800, -50, 100, -400, 200};
ManualResetEvent[] doneEvents = new ManualResetEvent[amounts.Length];
Account[] accountArray = new Account[amounts.Length];
for(int i=0; i<amounts.Length; i++)
{
doneEvents[i] = new ManualResetEvent(false);
accountArray[i] = new Account(amounts[i],  doneEvents[i]);
ThreadPool.QueueUserWorkItem(new WaitCallback(accountArray[i].ThreadPoolCallback), i);
}
WaitHandle.WaitAll(doneEvents);
ShowResult();
}
......
public void Operation()
{
......
double newBalance = oldBalance + amount;
//为了表示随机性,先随机休息一段时间。
Thread.Sleep(rand.Next(500));
cmd.CommandText = "INSERT INTO AccountDetail (AccountID, Amount, Balance) VALUES (1, " +
amount.ToString() + ", " + newBalance.ToString() + ")";
......
}
  • 3、RepeatableReadUpdate

该段代码引入了事务,并将事务隔离级别设置为RepeatableRead,程序经过漫长的执行后,你会发现尽管没有出现任何余额计算错误,但10个线程中仅有一半左右执行成功,其它线程执行失败,这是由于内部死锁问题造成的。感兴趣的话可以查看SQL Server中锁的状态。

public void Operation()
{
SqlConnection conn = new SqlConnection(ConfigurationManager.AppSettings.Get("ConnectionString"));
SqlCommand cmd1 = new SqlCommand();
SqlCommand cmd2 = new SqlCommand();
SqlCommand cmd3 = new SqlCommand();
cmd1.Connection = conn;
cmd2.Connection = conn;
cmd3.Connection = conn;
conn.Open();
SqlTransaction tx = conn.BeginTransaction(IsolationLevel.RepeatableRead);
try
{
cmd1.CommandText = "SELECT Balance FROM Account WHERE AccountID = 1";
cmd1.Transaction = tx;
double oldBalance = double.Parse(cmd1.ExecuteScalar().ToString());
double newBalance = oldBalance + amount;
//为了表示随机性,先随机休息一段时间。
Thread.Sleep(rand.Next(500));
cmd2.CommandText = "INSERT INTO AccountDetail (AccountID, Amount, Balance) VALUES (1, " +
amount.ToString() + ", " + newBalance.ToString() + ")";
cmd2.Transaction = tx;
cmd2.ExecuteNonQuery();
cmd3.CommandText = "UPDATE Account SET Balance = " + newBalance.ToString() + " WHERE AccountID=1";
cmd3.Transaction = tx;
cmd3.ExecuteNonQuery();
tx.Commit();
}
catch
{
tx.Rollback();
throw new Exception("Transaction Error!");
}
conn.Close();
}
  • 4、AnotherMethod

该段代码实现了在更新的同时完成读操作,避免了因锁的提升带来的并发问题。10个线程同时执行成功,并且执行时间与串行执行的时间几乎相同,真正意义上实现了可串行化。

public void Operation()
{
SqlConnection conn = new SqlConnection(ConfigurationManager.AppSettings.Get("ConnectionString"));
SqlCommand cmd1 = new SqlCommand();
SqlCommand cmd2 = new SqlCommand();
cmd1.Connection = conn;
cmd2.Connection = conn;
conn.Open();
SqlTransaction tx = conn.BeginTransaction(IsolationLevel.ReadUnCommitted);
try
{
cmd1.CommandText = "UPDATE Account SET @newBalance = Balance = Balance +" + this.amount.ToString() +
" WHERE AccountID = 1";
SqlParameter param = new SqlParameter("@newBalance", SqlDbType.Money, 8);
param.Direction = ParameterDirection.Output;
cmd1.Parameters.Add(param);
cmd1.Transaction = tx;
cmd1.ExecuteNonQuery();
double newBalance = Convert.ToDouble(cmd1.Parameters["@newBalance"].Value);
//为了表示随机性,先随机休息一段时间。
//Thread.Sleep(rand.Next(500));
cmd2.CommandText = "INSERT INTO AccountDetail (AccountID, Amount, Balance) VALUES (1, " +
amount.ToString() + ", " + newBalance.ToString() + ")";
cmd2.Transaction = tx;
cmd2.ExecuteNonQuery();
tx.Commit();
}
catch
{
tx.Rollback();
throw new Exception("Transaction Error!");
}
conn.Close();
}
  • 5、UseStoredProcedure

该段代码使用存储过程实现。存储过程如下,利用了SQL Server 2005中提供的Try...Catch结构配合事务也可以很好的完成上述任务。

CREATE PROCEDURE [dbo].[Operation]
-- Add the parameters for the stored procedure here
@amount money,
@successed char(1) output
AS
BEGIN
-- SET NOCOUNT ON added to prevent extra result sets from
-- interfering with SELECT statements.
SET NOCOUNT ON;
DECLARE @newBalance money
BEGIN TRY
BEGIN TRANSACTION
UPDATE Account SET
@newBalance = Balance = Balance + @amount
WHERE AccountID = 1
INSERT INTO AccountDetail(AccountID, Amount, Balance)
VALUES (1, @amount, @newBalance)
COMMIT TRANSACTION
SET @successed = 'T'
END TRY
BEGIN CATCH
ROLLBACK TRANSACTION
SET @successed = 'F'
END CATCH
END
0
0
(请您对文章做出评价)
« 博主前一篇: 为什么VB.net的Shared(共享)方法在C#中叫Static(静态)?
» 博主后一篇: 数据库并发一致性案例分析
posted on 2007-01-29 15:25  吕震宇 阅读(8757)  评论(31)   编辑  收藏
 

评论

#1楼   2007-01-29 16:58  阿水       
没有那么复杂吧?这样的情况,只要是管理软件的话
基本都碰到过,没有哪个业务能简单到直接更新或者新增一条记录。
我的做法是,在开始处理数据时 给 指定记录加锁。
所谓加锁就是 update table1 set no = no where no = 1
这样的语句,作用是出了你别人不能修改这条记录。
等你处理完了,事务提交,那么别的更新语句才能进入。
另ORM能不能解决这个问题,我觉得挺难。
比较好的设计或许能避免吧。
  回复  引用  查看    

#2楼 [ 楼主2007-01-29 17:23  吕震宇       
@阿水 
是有些复杂,但在你的系统里,有没有遇到过有人将no设置为1,但却忘了改回来(因为意外或为其他什么原因)的时候呢?我做数据库时发现什么不可能发生的事情都可能发生。如果再手工改回来就有点太麻烦了,维护任务太艰巨。
  回复  引用  查看    

#3楼   2007-01-29 17:40  小春[匿名][未注册用户]
第四条:AnotherMethod
因为update的时候。已经为这条记录X锁了。还有必要在特意指出“UNCOMMITTED”隔离级别吗?
  回复  引用    

#4楼   2007-01-29 17:44  小春[匿名][未注册用户]
也许我会在事务中先
select amount读取金额
insert 插入记录
update 更新金额

这样就出错了?也许在select的时候造成了脏读?!以后我要注意这个问题了:)

谢谢。对SQL的锁又有了一定的心得。
  回复  引用    

#5楼   2007-01-29 18:02  阿水       
update table1 set no = no where no = 1
刚搂主说 有没有遇到过有人将no设置为1,但却忘了改回来
我这里没有把NO改成1,
只是一条不更改数据的UPDATE作用就是锁定数据,
只要你UPDATE了,虽然什么都没修改,但是
数据库还是会锁定这条记录的,直到你的事务提交。

加派他锁的时候,条件
一般是主键的,
比如我处理一张单据的总数量。
那么开始处理之前,我需要这样写

开始事务
UPDATE Bill01Mt set Bill01No = Bill01No where Bill01No = '123'
然后开始处理业务逻辑
(这是必须保证,没有人能更新Bill01No = '123'这条记录)
提交事务

这里不说异常会滚得情况了。

其实并发,我们现在的系统里也存在隐患的
不过那时没有办法的,只是发生概率很低 

另外个人认为ORM处理业务逻辑的话,对并发的控制
肯定要比用存储过程差(全是猜得,没有测试过)
  回复  引用  查看    

#6楼 [ 楼主2007-01-29 19:47  吕震宇       
@小春[匿名] 
确实没有必要特意指出“UNCOMMITTED”隔离级别,我只是想说“UnCommitted”事务隔离级别遵循的是一级封锁协议,即写前加X锁,事务完成后释放,因此完全符合本案例的要求。 

@阿水 
呵呵,我没有看到是set no = no,原来是个虚假更新,不错!这样确实可以确保此类并发问题!我也认为这是一个非常可行的方法。
  回复  引用  查看    

#7楼   2007-01-29 20:51  yus[未注册用户]
我认为不能依赖数据库的事务锁,可以考虑使用保证所有业务同步进行来确保在同一时刻只有一个线程在运行。比如使用lock来同步线程!
  回复  引用    

#8楼   2007-01-29 21:11  qmxle[未注册用户]
如果是Oracle的话,可以用Select ... for Update
  回复  引用    

#9楼   2007-01-29 21:12  qmxle[未注册用户]
@yus 
这样的话,如果是负债均衡就不行了。
  回复  引用    

#10楼 [ 楼主2007-01-29 21:26  吕震宇       
@qmxle 

查了查SQL Server 2005的文档,摘了一段: 

“在 SQL Server 2005 中,除一些例外情况之外,仅当使用了 WITH 关键字指定表提示时,FROM 子句中才支持这些提示。指定表提示时必须使用括号。 

使用或不使用 WITH 关键字均可使用的表提示如下:NOLOCK、READUNCOMMITTED、UPDLOCK、REPEATABLEREAD、SERIALIZABLE、READCOMMITTED、FASTFIRSTROW、TABLOCK、TABLOCKX、PAGLOCK、ROWLOCK、NOWAIT、READPAST、XLOCK 和 NOEXPAND。如果指定的表提示不含 WITH 关键字,则必须单独指定该提示。” 

例如:FROM t WITH (FASTFIRSTROW, INDEX(myindex)) 

也许这可以和Oracle中的for update对应吧。不过我还没有测试过,有时间会试试的。
  回复  引用  查看    

#11楼 [ 楼主2007-01-29 21:41  吕震宇       
@qmxle 

测试了一下,完全可行! 
如果将示例3、RepeatableReadUpdate 中的SELECT命令修改一下就可以避免死锁问题,解决办法如下: 

将代码中的 

SELECT Balance FROM Account WHERE AccountID = 1 

更改为: 

SELECT Balance FROM Account WITH(UPDLOCK) WHERE AccountID = 1 

然后编译执行,一切OK!不过这种用法似乎只能在SQL Server 2005中使用。
  回复  引用  查看    

#12楼   2007-01-29 22:48  极地银狐.NET       
好文章我一定要mark的
  回复  引用  查看    

#13楼   2007-01-30 00:17  赵宝民[未注册用户]
恩 写的很是不错 业务出发 好 

是否可以把 帐户余额数据 这类会发生并发冲突的对象作为动态数据对象放在应用服务器上 而不是做为数据库中的相对静态的数据来处理呢 
也许这样能好一些吧 不过实现比较麻烦
  回复  引用    

#14楼   2007-01-30 09:50  阿水       
并发问题,在数据库类型的开发中,非常常见的
我以前还碰到过有的业务,会发生死锁(理论上)
但是也没有办法,最后就那么用了,用户大概用了
半年吧,发生过一次问题。不是死锁,而是数据异
常了。复杂的业务有的时候真的没办法,当然了
设计上也是不够完美。哈哈 
我一般避免死锁的办法就是使用虚假更新,
一般的数据库都可以(只要支持事务)
不过 楼主,把这个问题提出来,还是很有意思的 
尤其是对很多没有意识到并发的危险性的人 哈哈
  回复  引用  查看    

#15楼   2007-02-01 10:15  net[未注册用户]
不 mark 对不起高人
  回复  引用    

#16楼   2007-02-01 10:49  大剑师       
@阿水
^_^
  回复  引用  查看    

#17楼   2007-02-03 23:30  lizhiwen       
好久没有看见吕老师写文章了,支持!~
  回复  引用  查看    

#18楼   2007-02-08 17:54  生米煮成稀饭       
我都是直接通过指定加锁类型来避免的
  回复  引用  查看    

#19楼   2007-02-26 16:02  wengwen[未注册用户]
^_^^_^^_^,支持!~
  回复  引用    

#20楼   2007-03-26 12:24  bengxia       
这个SQL SERVER 都2005了,怎么不学学ORACLE的锁机制呢。。。
  回复  引用  查看    

#21楼   2007-04-09 13:53  yunhuasheng       
very good,对事务理解加深了。
  回复  引用  查看    

#22楼   2007-05-21 17:40  fangsang[未注册用户]
必读
  回复  引用    

#23楼   2007-06-04 21:28  king[未注册用户]
楼主有个例子没有说清楚呀!我觉得还是比较重要的就是加排他锁时所发生的幻影读的问题
  回复  引用    

#24楼   2007-07-21 15:35  Lantian[未注册用户]
@吕震宇 

吕老师,表锁定提示在2000就有了,也可以使用 
您可以找到SQL2000的帮助文档,里边就有介绍
  回复  引用    

#25楼   2008-05-31 17:44  easyskys       
这种情况在单据号的自动增加中会经常用到,先取得最大的单据号,再+1,然后insert,看了这篇文单后发现原来写法都有问题,想了很久也没想到办法,请问有什么好的解决办法?非常感谢!
  回复  引用  查看    

#26楼   2008-06-19 19:38  cn_xzw[未注册用户]
老师讲的很有启发!目前做的项目中一个财务的资金流水表就碰到并发问题.不能等到用户出现问题再去解决,应该先研究可能出现问题,然后尽力解决;

@easyskys 单据号问题也会发生多用户同时保存出现单据号重复的可能,一个笨方法是,告诉用户先保存空单据,把单据号先占用;避免用户输入数据数据之后出现,单据重复问题!
  回复  引用    

#27楼   2008-07-17 13:21  Kevin.Qian[未注册用户]
这种情况可以考虑用单独一个表用来存放当前可以使用的最大单据号,这个表可以只有一条数据,这样子你很容易用上面提到的方法操作这个表中的最大单号 

to easyskys: 
这种情况在单据号的自动增加中会经常用到,先取得最大的单据号,再+1,然后insert,看了这篇文单后发现原来写法都有问题,想了很久也没想到办法,请问有什么好的解决办法?非常感谢! 
  回复  引用    

#28楼   2008-11-25 14:24  dachuan       
不错!学习
  回复  引用  查看    

#29楼   2009-11-19 14:43  seuhuan[未注册用户]
没怎么看懂~~~

......................................................
读之前加共享锁?不行!(参考幻灯片)
读之前加排它锁?还是不行!(参考幻灯片)
......................................................
我看过lz的ppt slide9,还是有点不懂
读之前既然加上了排它锁,那么单位那侧读不了数据,直到银行那边对其更新commit了....这样单位读的时候就是300,而不是400了把?这样该不会有啥问题了把?
  回复  引用    

#30楼   2010-05-25 16:41  yellowsail       
读之前加排它锁?还是不行!(参考幻灯片)
一级封锁协议,修改前加排他锁,是不会有丢失修改。所以我觉得是行的
  回复  引用  查看    
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值