`
mysaga
  • 浏览: 18896 次
  • 性别: Icon_minigender_2
  • 来自: 成都
文章分类
社区版块
存档分类
最新评论

疑惑:有没有人真正用多线程工具(比如groboutils)测试过Spring的事务处理?

阅读更多
之前的公司里,曾经在几个项目里用过 Spring + Hibernate 架构。

其中,使用了标准的 Spring 声明式事务管理(相关的文章、示例在网上随处可见)。因为当时的项目对并发访问的要求并不高,加上赶进度,所以从来没有在真正高并发的情形下,测试过系统数据库事务管理是否正确。

(唯一的确认行为,就是打开数据库本身的记录,看里面是否有事务管理的SQL代码出现)

当然了,我自己也承认这样的做法可能隐含严重的问题,所以一直在想好好做一下测试。

最近比较闲一点,就自己编了个测试用例,在 Spring + Hibernate + MySQL 环境里,使用跟 junit 集成的多线程工具 groboutils 跑了一下。


做法:
同时并发比较大数量(比如说,3000个)的测试线程;在每个线程中,从一系列(比如说,10个)共享的“银行户头”里随机挑选两个,进行转帐;


期望:
在没有配置声明式事务管理(事务方式,隔离方式等)时,转帐前、后,所有“银行户头”的总额出现误差。

而在配置声明式事务管理后,转帐前、后的总额保持一致。


结果:
当使用 MySQL InnoDB 类型表格时,出现死锁异常:(JDBCExceptionReporter.java:101) - Deadlock found when trying to get lock; try restarting transaction

当使用 MySQL MyISAM 类型表格时(死马当活马,试试看),死锁异常没了,但是无论怎么配置事务管理,都不管用,assertEquals 失败,转帐前、后的总额不一致。


补充:
当使用 MySQL InnoDB 类型表格时,死锁异常一般出现在测试线程数量较多的时候。当减小测试线程数量(减到100个)、增加共享的“银行户头”数量(加到50个)时,死锁异常不再出现。但是!!事务管理照样不管用!转帐前、后,所有“银行户头”的总额不一致,有时候变多,有时候变少......

希望做过类似测试的进来讨论讨论。

重要部分的源代码如下:

JUnit testcase: AccountTransferMultiThreadTest.java(此测试用例最新最完整的代码在这一个跟贴的末尾)

// import 省略...

/**
 * 测试类 AccountTransferMultiThreadTest,使用了 groboutils 以实现多线程测试。
 *
 * 每个测试线程从一定数量的测试户头中随机选取一对 转出/转入 户头,然后进行一次随机数额的转帐。
 *
 * 测试户头总数由常量 NUM_ACC 设定。
 *
 * 测试线程总数由常量 NUM_TRANSFER 设定。
 *
 */
public class AccountTransferMultiThreadTest extends TestCase {
	// 每个测试户头的初始余额为1000元
	private static final BigDecimal INIT_BALANCE = BigDecimal.valueOf(100000L, 2); 
	private static final int NUM_ACC = 10; // 测试户头的总数
	private static final int NUM_TRANSFER = 3000; // 测试线程总数(即转帐总次数)
	private ApplicationContext context;
	private AccountService accountService;
	private long[] accountIds;

	/* (non-Javadoc)
	 * @see junit.framework.TestCase#setUp()
	 *
	 * 在setUp方法中,生成测试所需的Spring Application Context, 并在数据库中创建
	 * 一定数量的户头(Account),供多线程测试使用。
	 *
	 */
	protected void setUp() throws Exception {
		super.setUp();
		context = new ClassPathXmlApplicationContext("xiao/test/spring/*Context.xml");
		accountService = (AccountService) context.getBean("accountService");

		Account[] accounts = new Account[NUM_ACC];
		accountIds = new long[accounts.length];
		for (int i = 0; i < accounts.length; i++) {
			accounts[i] = new Account();
			accounts[i].setBalance(INIT_BALANCE);

			// 将当前生成的户头写入数据库
			accountService.create(accounts[i]);

			// 重要步骤!将当前生成的户头主键记录下来,以供测试线程使用
			accountIds[i] = (Long)accounts[i].getId();
		}
	}

	/* (non-Javadoc)
	 * @see junit.framework.TestCase#tearDown()
	 */
	protected void tearDown() throws Exception {
		super.tearDown();
	}

	private Account[] getAccounts() {
		Account[] accounts = new Account[accountIds.length];
		for (int i = 0; i < accountIds.length; i++) {
			// 从数据库获取这个户头对象
			accounts[i] = accountService.findById(accountIds[i]);
		}
		// 返回户头数组
		return accounts;
	}

	public void testMultiThreadTransfer() throws Throwable {
		// 获取户头对象数组
		Account[] accounts = getAccounts();
		System.out.printf("Starting %s transfers...\n", NUM_TRANSFER);

		// 记录测试前的所有户头总余额
		BigDecimal total1 = accountService.getTotalBalance(accounts);

		// 生成所有测试线程
		TestRunnable[] tr = new TestRunnable[NUM_TRANSFER];
		long start = System.currentTimeMillis();
		for (int i = 0; i < tr.length; i++) {
			tr[i] = new TransferThread(accountService, accounts);
		}

		// 生成测试线程运行器
		MultiThreadedTestRunner mttr = new MultiThreadedTestRunner(tr);

		// 运行测试线程
		mttr.runTestRunnables();

		// 显示转帐消耗时间
		long used = System.currentTimeMillis() - start;
		System.out.printf("%s transfers used %s milli-seconds.\n", NUM_TRANSFER, used);

		// 获取测试后所有户头总余额
		Account[] accounts2 = getAccounts();
		BigDecimal total2 = accountService.getTotalBalance(accounts2);

		// 确认测试前后,所有户头总余额还是一致的。
		assertEquals(total1, total2);
	}

	/*
	 * 测试线程类定义
	 */
	private static class TransferThread extends TestRunnable {
		private AccountService accountService;
		private Account[] accounts;
		public TransferThread(AccountService accountService, Account[] accounts) {
			super();
			this.accountService = accountService;
			this.accounts = accounts;
		}
		@Override
		public void runTest() throws Throwable {
			Random randomGenerator = new Random();

			// 随机选取转出户头
			int from = randomGenerator.nextInt(accounts.length);

			// 随机选取转入户头
			int to = randomGenerator.nextInt(accounts.length);

			// 确保转出、转入户头不是同一个
			while (to == from) {
				to = randomGenerator.nextInt(accounts.length);
			}

			// 随机选取转帐数额(0 ~ 149元之间)
			BigDecimal amount = BigDecimal.valueOf(randomGenerator.nextInt(150));

			// 转帐!
			try {
				accountService.transfer(accounts[to], accounts[from], amount);
			} catch (AppException ae) {
				// 捕捉运行时间异常“AppException”,并打印
				System.out.println(ae.getMessage());
			}
		}
	}
}


AccountService 实现类:AccountServiceImpl.java

// import 省略...

/**
 * AccountServiceImpl 是 AccountService 接口的实现类。
 *
 * AccountService 从父接口 EntityService 继承了一系列访问数据库所必需的基本方法的接口。
 *
 * AccountServiceImpl 从父类 EntityServiceDefaultImpl 继承了一系列访问数据库所必需的基本方法的实现。
 *
 * AccountService 定义了户头操作所特有的方法接口 (getTotalBalance 与 transfer。)
 *
 * AccountServiceImpl 定义了户头操作所特有的方法实现 (getTotalBalance 与 transfer。)
 *
 */
public final class AccountServiceImpl extends EntityServiceDefaultImpl<Account, Serializable>
		implements AccountService {

	/* (non-Javadoc)
	 * @see test.spring.service.AccountService#getTotalBalance(test.spring.entity.Account[])
	 */
	@Override
	public BigDecimal getTotalBalance(Account[] accounts) {
		BigDecimal total = BigDecimal.ZERO;
		if (null == accounts) {
			return total;
		}
		for (Account account : accounts) {
			if (null == account) {
				continue;
			}
			total = total.add(account.getBalance());
		}
		return total;
	}

	/* (non-Javadoc)
	 * @see test.spring.service.AccountService#transfer(test.spring.entity.Account, test.spring.entity.Account, java.math.BigDecimal)
	 */
	@Override
	public void transfer(Account to, Account from, BigDecimal amount) {
		if (null == to || null == from) {
			return;
		}
		if (null == amount || BigDecimal.ZERO.equals(amount)) {
			return;
		}
		
		// 如果转出户头的余额不足,抛出运行时间异常。
		if (from.getBalance().compareTo(amount) < 0) {
			String msg = String.format(
					"Account id [%s] has $%s left only, cannot transfer amount $%s out.",
					from.getId(), from.getBalance(), amount);
			//System.out.println(msg);
			throw new AppException(msg);
		}
		
		// 为转出户头设置新余额
		from.setBalance(from.getBalance().subtract(amount));
		
		// 为转入户头设置新余额
		to.setBalance(to.getBalance().add(amount));

		// 将转出户头写入数据库
		getDao().update(from);
		
		// 将转入户头写入数据库
		getDao().update(to);
	}

}


注1:所用的 DAO 继承了标准的 HibernateDaoSupport,就不贴出来了。
注2:AppException 是自己写的异常类,继承了 RuntimeException。

自己想了又想,觉得还是可能在 MySQL 的设置方面做得不够。。。。

希望有经验的人来聊聊。
分享到:
评论
64 楼 mislay 2009-08-25  
C_J 写道
引用
在一个事物未提交前,另一个事物不能开始,这样也能出现死锁吗?


在同一个事务为什么不会出现死锁呢?

例如:同一个事务,以下update操作:

update 1
update 2
update 1
update 2

commit;

LZ有3000个进程随机update就会出现这种状况~Oracle也是一样,应该所有数据库都是这样的,只是解决死锁问题的能力不一样。

不管LZ是否是进程还是线程,最终数据库只是自己维护自己的。

如果像你所说,一个事务都有可能发生死锁,那我是不是可以理解为一个事务是数据库中多个线程来完成的呢?因为一个线程来完成,我想多次拿锁不是问题的。

还是同一个问题:在一个事物未提交前,另一个事物不能开始,串行真的是这样吗?
63 楼 C_J 2009-08-25  
引用
在一个事物未提交前,另一个事物不能开始,这样也能出现死锁吗?


在同一个事务为什么不会出现死锁呢?

例如:同一个事务,以下update操作:

update 1
update 2
update 1
update 2

commit;

LZ有3000个进程随机update就会出现这种状况~Oracle也是一样,应该所有数据库都是这样的,只是解决死锁问题的能力不一样。
62 楼 mislay 2009-08-16  
所有的回帖看了一遍,但是也有了一些些疑问。

首先死锁问题可以肯定是出在数据库上面,但是隔离级别都用上串行了,在一个事物未提交前,另一个事物不能开始,这样也能出现死锁吗?又或者我是把这个隔离级别理解错了吗?又或者mysql自身机制问题?不知oracle是否如此。但是我感觉串行就不该有死锁发生.

另外一个问题,针对于这个典型的用例中,悲观锁下是否只有串行的隔离级别才能保证数据的完整性?异或可重复读?幻读?

        // 将转出户头写入数据库  
        getDao().update(from);  
          
        // 将转入户头写入数据库  
        getDao().update(to);

最后一个问题,采用乐观锁前提是需要保证遵循它自己的机制?如果是这样,那么hibernate的自带的悲观锁是否同样可保证?当然这里指的是单生产环境。

谢谢。
61 楼 mysaga 2009-08-15  
daquan198163 写道
mysaga 写道
现在回想起来,平常编程的时候,确实习惯了从 hibernate (无2级缓存)里面拿数据,处理,然后更新——那种情形下,很难出现这种多线程访问同一对象的错误,所以在写多线程测试用例的时候,就大意了。。。。。


不是说二级缓存没关系么?


我的意思是说,在 浏览器-服务器 编程环境下,你考虑的一般仅仅是当前用户的访问,自然也就是单线程的环境;在这种情况下,如果 hibernate 没有配置 2 级缓存,你拿出来的对象本身,基本上是不会出现并发线程访问的。

而因为习惯了这种方式,我在开始写这个测试用例的时候,就不小心忽略了对象线程安全的问题。
60 楼 daquan198163 2009-08-14  
mysaga 写道
现在回想起来,平常编程的时候,确实习惯了从 hibernate (无2级缓存)里面拿数据,处理,然后更新——那种情形下,很难出现这种多线程访问同一对象的错误,所以在写多线程测试用例的时候,就大意了。。。。。


不是说二级缓存没关系么?
nihongye 写道
引用
1.关于hibernate的二级缓存构造出来的对象是线程不安全的

这种说法是不对的,对象不是从缓存直接取出来的,缓存的是分解了的对象,每次取,对象是被重新构造出来的。
至于数据与数据库数据库一致性方面:因为二级缓存本身提供了几种策略,有不一致的也有一致的
59 楼 mysaga 2009-08-14  
C_J 写道
我想很多刚学习的人看懂这个帖子应该还是有点点难度的吧

反正我是了2遍才看明白:

原来楼主的有2个问题:

1,数据库本身的死锁,多线程操作2条记录以上时,出现相互等待update现象.
2,JVM对象的并发问题.当然是由于
int from = randomGenerator.nextInt(accounts.length);
这条代码引起的,可能上千个线程共用了一个accounts对象.

如上有错误的地方,还请大家指正.


基本上是这样的。

1,是真正困扰我的问题。

2,“JVM对象的并发”,纯粹是不小心,出的低级错误。有人指出后,我马上就改了。

现在回想起来,平常编程的时候,确实习惯了从 hibernate (无2级缓存)里面拿数据,处理,然后更新——那种情形下,很难出现这种多线程访问同一对象的错误,所以在写多线程测试用例的时候,就大意了。。。。。
58 楼 C_J 2009-08-11  
我想很多刚学习的人看懂这个帖子应该还是有点点难度的吧

反正我是了2遍才看明白:

原来楼主的有2个问题:

1,数据库本身的死锁,多线程操作2条记录以上时,出现相互等待update现象.
2,JVM对象的并发问题.当然是由于
int from = randomGenerator.nextInt(accounts.length);
这条代码引起的,可能上千个线程共用了一个accounts对象.

如上有错误的地方,还请大家指正.
57 楼 mysaga 2009-08-06  
haoxichuan 写道
非常感谢 daquan198163 mysaga 的解释
也就是说在高并发的情况下,数据库会出现一些死锁,或者其它错误,数据库能解决这些事情,但是他的解决方案不能保证数据的正确性.我们通过程序使用事务和锁去保证我们数据的正确性.使用 乐观或悲观 锁都可以解决是吧~~~


daquan198163在你楼下解释得很清楚了。。。。

haoxichuan 写道
LZ最开始的时候测试是没有加任何锁是吗?加悲观锁是不是只能在SQL语句上加,有没有其它可配置的地方?



这个问题有点复杂。

一句话答案:是的,最开始的时候没有加,是我的疏漏。所以那时候测试不能通过,数据不一致。

后来加上了,但是又被那些死锁的异常给迷惑了。

当然现在明白,死锁并不可怕,只要处理好就行了。而正因为加上了锁,测试才能通过,数据才变得一致。
56 楼 daquan198163 2009-08-06  
haoxichuan 写道
非常感谢 daquan198163 mysaga 的解释
也就是说在高并发的情况下,数据库会出现一些死锁,或者其它错误,数据库能解决这些事情,但是他的解决方案不能保证数据的正确性.我们通过程序使用事务和锁去保证我们数据的正确性.使用 乐观或悲观 锁都可以解决是吧~~~

你先看一下数据库的隔离级别的概念。
如果把数据库隔离级别设成serialize或repeatable read,也能保证数据的正确性,但性能差,也不是所有数据库都支持。所以,通常情况下,数据库都采用read commited,既保证了性能又保证了绝大多数(80%)情况下的一致性,
但时还有那20%——并发更新同一条记录——会出现更新丢失的问题,
于是就需要针对这20%的操作采用锁,如果预计很少出现并发冲突并且允许操作失败,就采用乐观锁,反之采用悲观锁。
引用
LZ最开始的时候测试是没有加任何锁是吗?加悲观锁是不是只能在SQL语句上加,有没有其它可配置的地方?

最开始没有加。 悲观锁最终都要体现到select for update,但hibernate提供了api,jdo可以配置。
参考《POJO in action》
55 楼 haoxichuan 2009-08-06  
非常感谢 daquan198163 mysaga 的解释
也就是说在高并发的情况下,数据库会出现一些死锁,或者其它错误,数据库能解决这些事情,但是他的解决方案不能保证数据的正确性.我们通过程序使用事务和锁去保证我们数据的正确性.使用 乐观或悲观 锁都可以解决是吧~~~

LZ最开始的时候测试是没有加任何锁是吗?加悲观锁是不是只能在SQL语句上加,有没有其它可配置的地方?
54 楼 mysaga 2009-08-06  
daquan198163 写道
先确认一下数据库死锁的定义:A B两个并发事务分别持有了对方需要的一行记录的更新锁,因此无论等待多久AB都无法得到需要的锁。

因此,楼主的测试可能会出现死锁,比如事务A B都要更新账户1和账户2的余额,只不过转账方向不同(对于企业账户,这种情况很正常),
如果A获得了账户1的锁,B获得了账户2的锁,于是出现互相等待。

现在的数据库可以帮我们自动解决绝大部分死锁,这里的解决指的应该是避免出现无限等待,一旦发现死锁就抛异常,强行终止一个事务。

前面说的都是数据库本身的特性,但它对于保证交易的一致性还不够,因为可能出现“更新丢失”现象(比如转账前后,帐不平),这只能靠应用(通过乐观锁或悲观锁)来解决,其中乐观锁不依赖数据库(靠version、时间戳等),而悲观锁依赖数据库的select for update特性。


赞成你的观点。特别是这一句:“现在的数据库可以帮我们自动解决绝大部分死锁,这里的解决指的应该是避免出现无限等待,一旦发现死锁就抛异常,强行终止一个事务。”这也是我现在的看法:在高并发的情形下,操作失败难以避免,系统的责任就是(1)正确处理异常(包括正确的提示用户),(2)严格保证数据一致。

关于锁,在我的实际测试中,也发现spring端的事务处理和锁(乐观或悲观),是二者缺一不可的。去掉任何一个,都会造成户头总额前后不一致。

通过发这个帖子以及后来的讨论,我确实学到许多东西。感谢各位热心的兄弟。
53 楼 mysaga 2009-08-06  
haoxichuan 写道
to mysaga
如果不用乐观锁,数据库也有自己的锁机制呀.为什么你测的这种多个线程操作同一帐户会出现死锁的情况呢?能把这种情况出现的具体说下吗?


我的看法是:数据库锁是保证数据一致的基本手段。而就是因为有了数据库锁,才会出现“死锁”——当然,没有数据库锁就没有死锁(我试过,不用任何一种锁,确实是没有异常的),但那样的话也就无法保证数据一致,那样的系统也就没什么人敢用了。

在我的测试里,如果使用乐观锁(hibernate 的 version 版本),出现的异常是

ERROR AbstractFlushingEventListener:324 - Could not synchronize database state with session
org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect)


如果使用悲观锁(LockMode.UPGRADE,其实也就是“select ... for update”),出现的异常是

ERROR JDBCExceptionReporter:101 - Deadlock found when trying to get lock; try restarting transaction



然而无论哪种锁哪种异常,在正确的捕捉异常后,我总可以看见 junit 的绿色条:因为数据完全保持了一致。我想这就是事务处理、锁、。。。。等等的意义所在。

而当然,在实际系统中,处理异常也是很重要的:至少,必须告诉相关的用户:“你刚刚的操作失败,请稍后再试。”
52 楼 daquan198163 2009-08-06  
Spring的事务处理绝对是线程安全的,否则也不要出来混了。
它把事务上下文、数据库连接、hibernate session等等这些非线程安全的东西都用threadlocal绑定到了当前线程,根本不存在线程安全的问题。
至于数据库连接池、hibernate session工厂等,本来就是线程安全的,不需要spring来保证。
51 楼 daquan198163 2009-08-06  
先确认一下数据库死锁的定义:A B两个并发事务分别持有了对方需要的一行记录的更新锁,因此无论等待多久AB都无法得到需要的锁。

因此,楼主的测试可能会出现死锁,比如事务A B都要更新账户1和账户2的余额,只不过转账方向不同(对于企业账户,这种情况很正常),
如果A获得了账户1的锁,B获得了账户2的锁,于是出现互相等待。

现在的数据库可以帮我们自动解决绝大部分死锁,这里的解决指的应该是避免出现无限等待,一旦发现死锁就抛异常,强行终止一个事务。

前面说的都是数据库本身的特性,但它对于保证交易的一致性还不够,因为可能出现“更新丢失”现象(比如转账前后,帐不平),这只能靠应用(通过乐观锁或悲观锁)来解决,其中乐观锁不依赖数据库(靠version、时间戳等),而悲观锁依赖数据库的select for update特性。
50 楼 haoxichuan 2009-08-06  
to mysaga
如果不用乐观锁,数据库也有自己的锁机制呀.为什么你测的这种多个线程操作同一帐户会出现死锁的情况呢?能把这种情况出现的具体说下吗?
49 楼 mysaga 2009-08-05  
rain2005 写道
楼主的代码是没有问题的,其实我想表达的意思就是楼主的测试并发大时必然死锁,从楼主的标题看是想测试spring事务的并发,楼主完全可以这样
线程1操作帐户A,B,线程2操作帐户C,D,线程3操作帐户E,F,这样才是真正的测试spring并发的事务正确性

如果想测试程序的健壮性,如死锁可以再写测试用例。

总之保证,每个测试用例目标明确。


好了!花了点时间,完善了我对上述rain2005 所臆想场景的模拟,并证明了此提议的荒谬。

我先给原本的测试类做了些必要的修改,然后为其添加子类 AccountTransferMultiThreadTestAccountsNotConflict。

目的:让每一个转帐线程所选取的转出(from)与转入(to)户头与其它线程相冲突。也就是说,模拟了场景:“楼主完全可以这样 线程1操作帐户A,B,线程2操作帐户C,D,线程3操作帐户E,F,这样才是真正的测试spring并发的事务正确性。”,并证明其提议之荒谬:在这种“井水不犯河水”的操作下,根本测不了什么“spring并发的事务正确性”。

结果:在打开 spring 声明式事务处理的情况下,父测试类 AccountTransferMultiThreadTest(各转帐线程选取的户头出现冲突)与本测试类 AccountTransferMultiThreadTestAccountsNotConflict(各转帐线程选取的户头没有任何冲突)均顺利通过,junit 显示绿色条;

在关闭 spring 声明式事务处理的情况下,父测试类 AccountTransferMultiThreadTest(各转帐线程选取的户头出现冲突)失败,junit 显示红色条。从错误信息发现,转帐前后,所有账户总额不一致;

而在同样关闭 spring 声明式事务处理的情况下,子测试类 AccountTransferMultiThreadTestAccountsNotConflict(各转帐线程选取的户头没有任何冲突)仍然顺利通过,junit 显示绿色条。从打印信息发现,转帐前后,所有账户总额保持一致,并且每个账户的最终余额和记录(balanceTracking)中完全相同;

证明:AccountTransferMultiThreadTestAccountsNotConflict 所模拟的这种(户头没有冲突的)操作完全测试不了“spring并发的事务正确性”,rain2005 的提案没有任何意义;而我在顶楼(当时有小错误,后来已修正)所提出的做法才能真正达到这个目的。

希望大家在发贴的时候要谨慎些。不管你自己有多菜,总有比你更菜的人。你那些不负责任的论断,极有可能给他们造成误导。



附代码:



子测试类:AccountTransferMultiThreadTestAccountsNotConflict.java:
//import 省略

/**
 *
 * 测试类 AccountTransferMultiThreadTestAccountsNotConflict 继承了
 * 测试类 AccountTransferMultiThreadTest。
 *
 * 目的:让每一个转帐线程所选取的转出(from)与转入(to)户头不与其它线程相冲突。也就是说,
 *      模拟了 iteye.com 中某某人所臆想的场景:<b>“楼主完全可以这样 线程1操作帐户A,B,
 *      线程2操作帐户C,D,线程3操作帐户E,F,这样才是真正的测试spring并发的事务正确性。”</b>,
 *      并证明他的提议之荒谬:在这种“井水不犯河水”的操作下,根本测不了什么“spring并发的事务正确性”。
 */
public class AccountTransferMultiThreadTestAccountsNotConflict extends
		AccountTransferMultiThreadTest {

	private LinkedList<Long> accountIdsNotChosen;

	public AccountTransferMultiThreadTestAccountsNotConflict() {
		super();

		// 重新设置父类中定义的 测试户头的总数 和 测试线程总数。
		numOfAccounts = 200; // 测试户头的总数。这里,它必须是偶数。
		numOfTransfers = 100; // 测试线程总数(即转帐总次数。这里,它必须等于 测试户头总数 的一半。)
	}

	protected void setUp() throws Exception {
		super.setUp();

		// 利用“accountIdsNotChosen”,避免重复选取户头。
		accountIdsNotChosen  = new LinkedList<Long>();
		for (Long id : accountIds) {
			accountIdsNotChosen.add(id);
		}
	}

	protected void tearDown() throws Exception {
		super.tearDown();
	}

	/* (non-Javadoc)
	 * @see xiao.test.spring.testCases.AccountTransferMultiThreadTest#generateTransferThread()
	 */
	@Override
	protected TransferThread generateTransferThread() {
		return new TransferThreadAccountsNotConflict(accountService, accountIds,
				accountIdsNotChosen, balanceTracking);
	}

	/* (non-Javadoc)
	 * @see xiao.test.spring.testCases.AccountTransferMultiThreadTest#testMultiThreadTransfer()
	 */
	@Override
	public void testMultiThreadTransfer() throws Throwable {
		super.testMultiThreadTransfer();
	}

	private static class TransferThreadAccountsNotConflict extends TransferThread {
		private LinkedList<Long> accountIdsNotChosen;

		public TransferThreadAccountsNotConflict(AccountService accountService,
				long[] accountIds, LinkedList<Long> accountIdsNotChosen, Map<Long, BigDecimal> balanceTracking) {

			super(accountService, accountIds, balanceTracking);

			this.accountIdsNotChosen = accountIdsNotChosen;
			if (accountIdsNotChosen.size() <= 1) {
				throw new AppException("There are at most 1 account in 'not chosen list', cannot"
						+ " choose 2 accounts to make a transfer!");
			}
		}

		/* (non-Javadoc)
		 * @see xiao.test.spring.testCases.AccountTransferMultiThreadTest.TransferThread#generateTransferOptions()
		 */
		@Override
		protected void generateTransferOptions() {
			Random randomGenerator = new Random();

			synchronized (accountIdsNotChosen) {
				// 随机选取转出户头
				int i = randomGenerator.nextInt(accountIdsNotChosen.size());
				fromId = accountIdsNotChosen.remove(i);

				// 随机选取转入户头
				i = randomGenerator.nextInt(accountIdsNotChosen.size());
				toId = accountIdsNotChosen.remove(i);
			}

			// 随机选取转帐数额(0 ~ 149元之间)
			amount = BigDecimal.valueOf(randomGenerator.nextInt(150));
		}

		/* (non-Javadoc)
		 * @see xiao.test.spring.testCases.AccountTransferMultiThreadTest.TransferThread#runTest()
		 */
		@Override
		public void runTest() throws Throwable {
			super.runTest();
		}

	}
}



原测试类 AccountTransferMultiThreadTest.java,已做必要修改:
//import 省略

/**
 * 测试类 AccountTransferMultiThreadTest,使用了 groboutils 以实现多线程测试。
 *
 * 每个测试线程从一定数量的测试户头中随机选取一对 转出/转入 户头,然后进行一次随机数额的转帐。
 *
 * 测试户头总数由常量 numOfAccounts 设定。
 *
 * 测试线程总数由常量 numOfTransfers 设定。
 *
 */
public class AccountTransferMultiThreadTest extends TestCase {
	// 每个测试户头的初始余额为1000元
	private static final BigDecimal INIT_BALANCE = BigDecimal.valueOf(100000L, 2);
	private static int successTransfers = 0;

	protected int numOfAccounts; // 测试户头的总数
	protected int numOfTransfers; // 测试线程总数(即转帐总次数)
	private ApplicationContext context;
	protected AccountService accountService;
	protected long[] accountIds;
	protected Map<Long, BigDecimal> balanceTracking = new HashMap<Long, BigDecimal>();;

	public AccountTransferMultiThreadTest() {
		super();
		numOfAccounts = 10; // 测试户头的总数
		numOfTransfers = 300; // 测试线程总数(即转帐总次数)

		context = new ClassPathXmlApplicationContext("xiao/test/spring/*Context.xml");
		accountService = (AccountService) context.getBean("accountService");
	}

	/* (non-Javadoc)
	 * @see junit.framework.TestCase#setUp()
	 *
	 * 在setUp方法中,生成测试所需的Spring Application Context, 并在数据库中创建
	 * 一定数量的户头(Account),供多线程测试使用。
	 *
	 */
	protected void setUp() throws Exception {
		super.setUp();

		Account[] accounts = new Account[numOfAccounts];
		accountIds = new long[accounts.length];
		for (int i = 0; i < accounts.length; i++) {
			accounts[i] = new Account();
			accounts[i].setBalance(INIT_BALANCE);

			// 将当前生成的户头写入数据库
			accountService.create(accounts[i]);

			// 重要步骤!将当前生成的户头主键记录下来,以供测试线程使用
			accountIds[i] = (Long)accounts[i].getId();
		}
	}

	/* (non-Javadoc)
	 * @see junit.framework.TestCase#tearDown()
	 */
	protected void tearDown() throws Exception {
		super.tearDown();
	}

	protected Account[] getAccounts() {
		Account[] accounts = new Account[accountIds.length];
		for (int i = 0; i < accountIds.length; i++) {
			// 从数据库获取这个户头对象
			accounts[i] = accountService.findById(accountIds[i]);
		}
		// 返回户头数组
		return accounts;
	}

	protected TransferThread generateTransferThread() {
		return new TransferThread(accountService, accountIds, balanceTracking);
	}

	public void testMultiThreadTransfer() throws Throwable {

		// 验证在仅有一级缓存的情况下,用同样的主键交给 accountService.findById,它每次
		// 返回的是相等,但并不同一的实例。
		// 当然了,Account 对象的 equals 必须被正确的覆盖先。
		for (int i = 0; i < accountIds.length; i++) {
			assertEquals(accountService.findById(accountIds[i]),
					accountService.findById(accountIds[i]));
			assertNotSame(accountService.findById(accountIds[i]),
					accountService.findById(accountIds[i]));
		}

		// 获取户头对象数组
		Account[] accounts = getAccounts();
		//System.out.printf("Starting %s transfers...\n", numOfTransfers);

		// 记录测试前的所有户头总余额
		BigDecimal total1 = accountService.getTotalBalance(accounts);

		// 记录测试前的所有户头的余额
		for (Account account : accounts) {
			balanceTracking.put(account.getId(), account.getBalance());
		}

		// 生成所有测试线程
		TestRunnable[] tr = new TestRunnable[numOfTransfers];
		long start = System.currentTimeMillis();
		for (int i = 0; i < tr.length; i++) {
			tr[i] = generateTransferThread();
		}

		// 生成测试线程运行器
		MultiThreadedTestRunner mttr = new MultiThreadedTestRunner(tr);

		// 运行测试线程
		mttr.runTestRunnables();
		long used = System.currentTimeMillis() - start;
		System.out.printf("Total: %s transfers used %s milli-seconds.\n", numOfTransfers, used);

		// 获取测试后所有户头总余额
		Account[] accounts2 = getAccounts();
		BigDecimal total2 = accountService.getTotalBalance(accounts2);

		// 确认测试前后,所有户头总余额还是一致的。
		assertEquals(total1, total2);

		// 确认测试前后,所有户头余额与转帐记录相一致。
		System.out.printf("Successful transfers: %s\n", successTransfers);
		System.out.println(balanceTracking);
		for (Account account : accounts2) {
			assertEquals(balanceTracking.get(account.getId()), account.getBalance());
		}
	}

	/*
	 * 测试线程类定义
	 */
	protected static class TransferThread extends TestRunnable {
		private AccountService accountService;
		private Map<Long, BigDecimal> balanceTracking;
		protected long[] accountIds;
		protected long fromId;
		protected long toId;
		protected BigDecimal amount;

		public TransferThread(AccountService accountService,
				long[] accountIds, Map<Long, BigDecimal> balanceTracking) {
			super();
			this.accountService = accountService;
			this.accountIds = accountIds;
			this.balanceTracking = balanceTracking;
		}

		protected void generateTransferOptions() {
			Random randomGenerator = new Random();

			// 随机选取转出户头
			fromId = accountIds[
			                      randomGenerator.nextInt(accountIds.length)
			                      ];

			// 随机选取转入户头
			toId = accountIds[
			                     randomGenerator.nextInt(accountIds.length)
			                     ];

			// 确保转出、转入户头不是同一个
			while (toId == fromId) {
				toId = accountIds[
				                randomGenerator.nextInt(accountIds.length)
				                ];
			}

			// 随机选取转帐数额(0 ~ 149元之间)
			amount = BigDecimal.valueOf(randomGenerator.nextInt(150));
		}

		@Override
		public void runTest() throws Throwable {
			generateTransferOptions();

			boolean success;
			// 转帐!
			try {
				accountService.transfer(
						accountService.findById(toId),
						accountService.findById(fromId),
						amount);
				success = true;
			} catch (AppException ae) {
				// 捕捉运行时间异常“AppException”。在真实的系统中,这里必须通知用户:转帐失败,请稍后再试。
				//System.out.println("AppException:" + ae.getMessage());
				success = false;
			} catch (Throwable t) {
				// 捕捉所有异常。在真实的系统中,这里必须通知用户:转帐失败,请稍后再试。
				success = false;
			}
			if (success) {
				// 以下记录每一次成功的转帐后,被影响户头的余额。假如在 accountService.transfer 中有异常抛出,
				// 这一记录动作将不会执行。
				synchronized (balanceTracking) {
					successTransfers ++;
					BigDecimal oriFromBal = balanceTracking.get(fromId);
					BigDecimal oriToBal = balanceTracking.get(toId);
					System.out.printf("Successful transfer no.%s: account[%s] (bal: %s) -> account[%s] (bal: %s),"
							+ " amount (%s)\n", successTransfers, fromId, oriFromBal, toId, oriToBal, amount);
					balanceTracking.put(fromId, oriFromBal.subtract(amount));
					balanceTracking.put(toId, oriToBal.add(amount));
				}
			}
		}
	}
}

48 楼 mysaga 2009-08-04  
nihongye 写道
引用
1.关于hibernate的二级缓存构造出来的对象是线程不安全的

这种说法是不对的,对象不是从缓存直接取出来的,缓存的是分解了的对象,每次取,对象是被重新构造出来的。
至于数据与数据库数据库一致性方面:因为二级缓存本身提供了几种策略,有不一致的也有一致的

引用
2.锁问题

悲观锁或是乐观锁在例子中高并发的情况下,死锁都是必然的,因为都要执行数据库更新,无可避免的产生行锁,
这个时候就依赖于数据库的死锁检查机制了,悲观锁从读就产生,所以死锁的几率高些;乐观锁不是读的时候就上锁,支持长事务,泡杯茶后继续那种。
至于使用java的锁机制:简单点,同步所有对账户的操作,并发效果差些,无死锁。
使用高级点的锁机制,一开始就锁定所有lock1.tryLock(),lock2.tryLock(),lock...,lockn.tryLock(),依然无死锁。


哦,受教了。
47 楼 mysaga 2009-08-04  
rain2005 写道
按照你的说法,线程1操作帐户A,B,线程2操作帐户C,D,线程3操作帐户E,F这个是不需要事务的???


你又来了。。。。任意曲解、发挥别人的话。。。

我只是说,你的理想场景测试不了“并发的事务控制”,因为三个转帐互不干扰!!

A给B转300块,C给D转500块,E给F转600块,这三个操作同时发生跟先后发生,有什么区别?

在我的测试用例里面如果改成这样,那我告诉你,spring context xml 里面加不加事务处理配置,junit 的测试结果都是绿色条!你还真以为我没试过???

rain2005 写道

spring并发的事务正确性按照你的理解就是死锁有提示?拜托,这好像跟spring没有什么关系把,我看你的标题应该是测试数据库的并发正确性。


又一次断章取义,被抓了现行。

我的原话:

mysaga 写道

无所谓。只要系统能(1),正确的提示那些操作失败的用户“你的操作失败,请稍后再试”,并且(2),绝对保证数据的一致性:失败的操作没有任何效果,成功的操作能够被持久,银行没有损失,用户也没有损失——那么,一个健壮系统最基本的必要条件就满足了。

现在我的测试用例已经证明了这一点,所以我认为它是成功的。


这怎么又跟 spring 没有关系了??难道这第(2)点不是由 spring 的声明式事务处理 + hibernate 帮忙的锁机制实现的吗?

对你,我无话可说。
46 楼 rain2005 2009-08-04  
mysaga 写道
rain2005 写道
即使只是使用 servlet + jdbc,也不用手写SQL代码来管理什么事务了——因为你根本不需要事务嘛。

这就没有什么可以说的了,第一次听说servlet + jdbc根本不需要事务。建议你先搞清楚什么是事务。。。


少在那边断章取义了。你怎么不引用我的全文??

就因为你先在那边嚷嚷什么:
rain2005 写道
......楼主完全可以这样
线程1操作帐户A,B,线程2操作帐户C,D,线程3操作帐户E,F,这样才是真正的测试spring并发的事务正确性
......


我才说:
mysaga 写道
“线程1操作帐户A,B,线程2操作帐户C,D,线程3操作帐户E,F,” —— 嘿嘿,如果只考虑这种井水不犯河水的操作,还要spring事务处理干嘛?普通的 servlet + jdbc 就搞定了....

所以,俺的测试用例目标还是很明确的:多线程粗暴蹂躏共享的数据库记录,看你 spring + hibernate 如何反应...


然后你说:
rain2005 写道
明白你的意思了。
spring + hibernate对待数据库记录的方式和servlet + jdbc没有什么区别吗?要保证事务一致性那是数据库的职责把。不就是更新锁和乐观锁?这好像不关spring + hibernate什么事把?


接下来才是我的那段话,注意看红色的全文:
mysaga 写道

哈哈,俺的意思是说,有了spring + hibernate,你自己就不用手写SQL代码来管理事务了——不是你不需要事务管理,而是 spring + hibernate 帮你搞定绝大多数东东。

而假如只考虑“线程1操作帐户A,B,线程2操作帐户C,D,线程3操作帐户E,F,”这种井水不犯河水的操作,就不用担心任何事务隔离级别、锁之类的东西。因此,即使只是使用 servlet + jdbc,不用手写SQL代码来管理什么事务了——因为你根本不需要事务嘛。


我是在指出你最早的谬误,而且我的本意是“servlet + jdbc”
同样需要(写在SQL里面的)事务管理,
在你所假想的场景下不需要,也因此,你提出的这个场景
根本测不了什么“spring并发的事务正确性”——只不过,我的话比较委婉罢了。

你却从我的整段话里截了后一半,把我的意思完全反了过来。这算什么??


按照你的说法,线程1操作帐户A,B,线程2操作帐户C,D,线程3操作帐户E,F这个是不需要事务的???
spring并发的事务正确性按照你的理解就是死锁有提示?拜托,这好像跟spring没有什么关系把,我看你的标题应该是测试数据库的并发正确性。
45 楼 mysaga 2009-08-04  
andyyehoo 写道


关于缓存方面,太久没用Hibernate,已经不确定默认的行为了,所以我说不确定因素。但是实际上,重点不是到底默认行为如何,不是重点,有这样的可能性就是危险。系统的关键代码,强健性本来就应该是重中之重,你的代码,只要是万一打开了Hibernate的二级缓存,就是不安全的,这样的设计,对于银行系统的来说,就是不够的。你换个角度,不是这段代码的编写者,而是作为公司的Code Reviewer,在单独看到这样的代码的时候,肯定会有这样的第一直觉浮现。



嘿嘿,这就是写测试用例的目的之一啊!

假如某天某人在我不知情的情况下打开了二级缓存,出现了问题,我再重新跑一遍测试,就发现了嘛!

	public void testMultiThreadTransfer() throws Throwable {

		// 验证在仅有一级缓存的情况下,用同样的主键交给 accountService.findById,它每次
		// 返回的是相等,但并不同一的实例。
		// 当然了,Account 对象的 equals 必须被正确的覆盖先。
		for (int i = 0; i < accountIds.length; i++) {
			assertEquals(accountService.findById(accountIds[i]),
					accountService.findById(accountIds[i]));
			assertNotSame(accountService.findById(accountIds[i]),
					accountService.findById(accountIds[i]));
		}
	}


这里的 assertNotSame 就会失败。

所以我说,在最基本的方面,我的用例到达了目的。

当然,你说的也是我的意思,接下来要做的还很多。

相关推荐

Global site tag (gtag.js) - Google Analytics