该帖已经被评为良好帖
|
|
---|---|
作者 | 正文 |
发表时间:2009-07-31
最后修改:2009-08-04
其中,使用了标准的 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 的设置方面做得不够。。。。 希望有经验的人来聊聊。 声明:ITeye文章版权属于作者,受法律保护。没有作者书面许可不得转载。
推荐链接
|
|
返回顶楼 | |
发表时间:2009-07-31
没有看到什么地方有配置事务的呀?
|
|
返回顶楼 | |
发表时间:2009-07-31
sulong 写道 没有看到什么地方有配置事务的呀?
哦,补上。 很标准的 Spring 2.x 做法: ...... <bean id="txManager" class="org.springframework.orm.hibernate3.HibernateTransactionManager"> <property name="sessionFactory" ref="defaultDataSessionFactory" /> </bean> <tx:advice id="generalServiceAdvice" transaction-manager="txManager"> <tx:attributes> <tx:method name="create*" propagation="REQUIRED" /> <tx:method name="save*" propagation="REQUIRED" /> <tx:method name="update*" propagation="REQUIRED" /> <tx:method name="transfer*" propagation="REQUIRED" isolation="SERIALIZABLE" rollback-for="Throwable"/> <tx:method name="*" propagation="SUPPORTS" read-only="true" /> </tx:attributes> </tx:advice> <aop:config> <aop:pointcut id="serviceMethods" expression="execution(* test.spring.service.*Service.*(..))" /> <aop:advisor advice-ref="generalServiceAdvice" pointcut-ref="serviceMethods" /> </aop:config> ...... |
|
返回顶楼 | |
发表时间:2009-07-31
for (int i = 0; i < tr.length; i++) { //这里的 accounts 在众多线程里共享,但是好像没有互斥访问 tr[i] = new TransferThread(accountService, accounts); } //这里的from 和 to 都来自于内存中的 accounts 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); } 首先,没有看到哪里有设置使用事务的代码; 其次,transfer 方法的输入 from to 来源于放在内存里的 accounts, 而你没有在多线程的情况下对accounts的访问做互斥 因此,就算不用数据库,你的测试应当也是失败的。我能想到的改进方法是, 1,在transfer的时候每次都要从数据库里重新读出数据。 2,加上事务控制,transfer()应当在事务中,并且得有适当的隔离级别 |
|
返回顶楼 | |
发表时间:2009-07-31
最后修改:2009-07-31
sulong 写道 for (int i = 0; i < tr.length; i++) { //这里的 accounts 在众多线程里共享,但是好像没有互斥访问 tr[i] = new TransferThread(accountService, accounts); } //这里的from 和 to 都来自于内存中的 accounts public void transfer(Account to, Account from, BigDecimal amount) { ...... } 首先,没有看到哪里有设置使用事务的代码; 事务管理配置,我已经在3楼补上了。 sulong 写道 其次,transfer 方法的输入 from to 来源于放在内存里的 accounts, 而你没有在多线程的情况下对accounts的访问做互斥 有道理。。可能这里我没有考虑周全。 但问题是,我曾经在transfer 方法的开头(AccountServiceImpl.java 第47行)加过如下的代码: // 使用悲观锁,利用 DAO 中定义的 findById(PK, boolean) 方法重新获得转出/转入户头。 // 目的:当 findById 的第二参数为“true”时,它将调用 HibernateTemplate.get(type, id, LockMode.UPGRAGE). // 这实际上等于 SQL 中的“select ... from .... for update”。 to = getDao().findById(to.getId(), true); from = getDao().findById(from.getId(), true); 但结果依然不对! sulong 写道 因此,就算不用数据库,你的测试应当也是失败的。我能想到的改进方法是, 1,在transfer的时候每次都要从数据库里重新读出数据。 如上,已经试过了。。。。 sulong 写道 2,加上事务控制,transfer()应当在事务中,并且得有适当的隔离级别 看第3楼,事务管理配置,用的已经是最高级别,serializable。。。。 |
|
返回顶楼 | |
发表时间:2009-07-31
我在一个项目中也在并发环境下用到了声明式事务,也碰到了****DeadLock***异常,当时想了好久,应该没有哪个地方会导致死锁.后来猜测,是不是因为在不同的线程更新同一条数据时,后面的线程出现了锁等待的情况,导致资源等待最终抛出这样的异常.
后来我让不同的线程读写不同的数据,一切OK. 上面的仅供参考. 另外,你的应用中会存在多个线程并发访问同一条数据的情况吗?如果会,那就应该思考一下,这种方式是否合适. 如果不确定,用2个线程在调试环境下并发操作同一条数据(并发的意思是在第一个线程还没有提交事务前,第二个线程更新数据),看是否会抛出***DeadLock异常 |
|
返回顶楼 | |
发表时间:2009-07-31
任何一个环节出了错都有可能导致出错,不如你把代码打包传上来看看
|
|
返回顶楼 | |
发表时间:2009-07-31
恩~~ 不错,我也来在本机试试多线程情况~~
|
|
返回顶楼 | |
发表时间:2009-07-31
你的数据源配置在哪里?
|
|
返回顶楼 | |
发表时间:2009-07-31
// 为转出户头设置新余额
from.setBalance(from.getBalance().subtract(amount)); // 为转入户头设置新余额 to.setBalance(to.getBalance().add(amount)); 这段代码不是线程安全的 因为可能同时修改同一个Account实例,比如线程1修改了from,此时线程2也修改了from,即from.getBalance()首先由两个线程取出值,然后线程2调用subtract(amount),而线程才调用;同时to也是同样,即翻转。 1的from 2的from 2的to 1的to,可能就会出错吧,, 所有的Immutable对象(即:状态不能改变的对象)是线程安全的。因此:除了String的对象外,其它的如:Integer类的对象,BigDecimal类的对象,BigInteger类的对象等都是Immutable对象,都是线程安全的。 还有你说的死锁, 当使用 MySQL InnoDB 类型表格时,出现死锁异常:(JDBCExceptionReporter.java:101) - Deadlock found when trying to get lock; try restarting transaction 可能是你的数据库只支持10个并发,但有100个请求,而当事务1 更新编号为1的记录此时事务2更新编号为2的,后来就是事务1更新编号为2的事务2更新编号为1的。可能会死锁的。 这是我分析的,不知道对不对,呵呵。 |
|
返回顶楼 | |