该帖已经被评为良好帖
|
|
---|---|
作者 | 正文 |
发表时间:2009-08-04
最后修改:2009-08-04
其实再仔细看看这个设计的代码,还是很有问题,不能满足这样的场景,500个线程,同时不停的从不同的帐户里面,向一个账户存款和取款,这个场景下,数据库报错的信息简直就会如雪花。
解决的方法也很简单,跳出Hibernate的Domain Object思维方式,象转帐这样的原子操作,就应该简单的执行一个update语句,通过Hibnernate执行也罢,spring控制事务,无须UVersion,那么数据库才会用自己的机制,把所有的update操作排队,用最小的代价,保证数据的一致性。 UVersion的代价很高的,而且也不是最好的数据库事务完整控制方式。 |
|
返回顶楼 | |
发表时间:2009-08-04
最后修改:2009-08-06
andyyehoo 写道 LZ还是没体会rain2005的批评
哈哈,俺认为正好相反。我想了又想,觉得rain2005主要是在批评我在数据库端造成的死锁,而不是java对象这边的线程冲突:因为我姑且相信他能正确理解 hibernate 不同缓存的不同作用。 andyyehoo 写道 一个测试不是变绿了,就表示万事大吉了,真这样的话,Mock的fans就可以横行无忌了 假如在5000个并发线程蹂躏3个帐号(很极端的情形)之后(而且是重复运行这个测试用例很多次之后),数据库状态还是保持了一致(总额不变),我认为这就是万事大吉了。 可能我们的基本观念有差异吧。 andyyehoo 写道 你的测试还存在很多漏洞,这些漏洞和数据库无关, 不同意。 andyyehoo 写道 目前看来,数据库的漏洞,通过乐观锁和事务,基本上已经解决了,虽然还不是最优的,但是可以说不会有bug了。 这一点我同意。 andyyehoo 写道 但是代码由于spring和hibernate,已经java多线程并发,还是有问题的。 public void transfer(Account to, Account from, BigDecimal amount) from.setBalance(from.getBalance().subtract(amount)); to.setBalance(to.getBalance().add(amount)); getDao().update(from); getDao().update(to); 这里首先隐藏了一个不确定因素,hibernate的findby,默认情况下,是同一个AccountId,由于缓存的原因,就会返回相同的Account对象。而Account这个对象,很明显就不是线程安全的,而且还是Rich Object,带了很多的业务逻辑。而且还是和Hibernate直接相关的domain Object,由hibernate的Find By直接返回。然后在accountService这样由spring负责管理的singleton的类进行操作,那么一个Account对象,就有可能即作为一个线程的From的同时,作为另外一个线程的To,这样的不严谨设计,在普通的系统都有问题,何况一个银行系统。单靠数据库的事务来控制,是不严谨的。 完全不同意。缺省状态下,hibernate 的二级缓存是关闭的,只有一级缓存起作用;而一级缓存的作用,仅仅是保证你从同一个 hibernate session 里面,用同样的主键拿出的是同一个对象实例。但假如 hibernate session 并不是同一个呢? 看我以下添加的代码: 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])); } //.... 以下省略 } 上面的 assertEquals 和 assertNotSame 均顺利通过。说明什么?说明了每次用同样的主键交给 accountService.findById 方法,这个方法会打开一个独立的 hibernate session,从数据库取得一个新的 Account 实例(因为只有一级缓存,没有二级缓存嘛),然后关闭刚打开的 hibernate session,最后返回 Account 实例。 所以,你这个帖子的最主要依据,其实是不成立的。 andyyehoo 写道 rain2005的意思是这样的场景: 1)账户A被线程1作为from账户,转帐100到B 2)账户A杯线程2作为to账户,C转帐50给它 那么在transfer的非线程安全代码中,线程1中,A被先-100,在线程1执行update(from)前,线程2做了+50,结果是加了50,然后在数据库的事务竞争中,线程1成功了,于是更新到数据库,而2的这个事务,被完整的不提交而失败。但是这个时候,帐户应该是不平衡的,代码是有漏洞的。 但是实际上,setBalance的操作是非常快的,所以to.setBalance到getDAO().update(from)的时间是非常短的,以LZ的测试强度,加上出现这样情况的概率,基本上很难插入,以至于很少出现这样的情况。更多的情况是,A作为from已经被正确写入数据库,而作为to的A,想要写入数据库的时候,已经由于数据库的脏数据读写问题,失败告终,所以LZ的测试,都是通过的。如果lz在update(from)前加上个随机等待0.1秒,我相信冲突的可能性是非常大的。 简单来说,就是代码的线程不安全性,现在已经被数据库的操作所掩盖,很难重现,但是还是有可能。 ...... 你说的这些就都没有意义了。因为现在我能保证,每个线程访问的是不同的 Account 实例!所以在Java JVM这边,完全不存在线程安全的问题。 当然,你的论述也说明了很有用的一点:在需要高并发修改数据的场合,慎用二级缓存! 为了大家有个完整的看法,我把最新的测试用例再贴一次: //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 = 500; // 测试线程总数(即转帐总次数) private static int successTransfers = 0; private ApplicationContext context; private AccountService accountService; private long[] accountIds; private Map<Long, BigDecimal> balanceTracking = new HashMap<Long, BigDecimal>();; /* (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 { // 验证在仅有一级缓存的情况下,用同样的主键交给 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", NUM_TRANSFER); // 记录测试前的所有户头总余额 BigDecimal total1 = accountService.getTotalBalance(accounts); // 记录测试前的所有户头的余额 for (Account account : accounts) { balanceTracking.put(account.getId(), account.getBalance()); } // 生成所有测试线程 TestRunnable[] tr = new TestRunnable[NUM_TRANSFER]; long start = System.currentTimeMillis(); for (int i = 0; i < tr.length; i++) { tr[i] = new TransferThread(accountService, accountIds, balanceTracking); } // 生成测试线程运行器 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); // 确认测试前后,所有户头余额与转帐记录相一致。 System.out.printf("Success transfers: %s\n", successTransfers); System.out.println(balanceTracking); for (Account account : accounts2) { assertEquals(balanceTracking.get(account.getId()), account.getBalance()); } } /* * 测试线程类定义 */ private static class TransferThread extends TestRunnable { private AccountService accountService; private long[] accountIds; private Map<Long, BigDecimal> balanceTracking; public TransferThread(AccountService accountService, long[] accountIds, Map<Long, BigDecimal> balanceTracking) { super(); this.accountService = accountService; this.accountIds = accountIds; this.balanceTracking = balanceTracking; } @Override public void runTest() throws Throwable { Random randomGenerator = new Random(); // 随机选取转出户头 long fromId = accountIds[ randomGenerator.nextInt(accountIds.length) ]; // 随机选取转入户头 long toId = accountIds[ randomGenerator.nextInt(accountIds.length) ]; // 确保转出、转入户头不是同一个 while (toId == fromId) { toId = accountIds[ randomGenerator.nextInt(accountIds.length) ]; } // 随机选取转帐数额(0 ~ 149元之间) BigDecimal amount = BigDecimal.valueOf(randomGenerator.nextInt(150)); 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); balanceTracking.put(fromId, oriFromBal.subtract(amount)); balanceTracking.put(toId, oriToBal.add(amount)); } } } } } |
|
返回顶楼 | |
发表时间:2009-08-04
最后修改:2009-08-04
andyyehoo 写道 其实再仔细看看这个设计的代码,还是很有问题,不能满足这样的场景,500个线程,同时不停的从不同的帐户里面,向一个账户存款和取款,这个场景下,数据库报错的信息简直就会如雪花。
...... 无所谓。只要系统能(1),正确的提示那些操作失败的用户“你的操作失败,请稍后再试”,并且(2),绝对保证数据的一致性:失败的操作没有任何效果,成功的操作能够被持久,银行没有损失,用户也没有损失——那么,一个健壮系统最基本的必要条件就满足了。 现在我的测试用例已经证明了这一点,所以我认为它是成功的。 当然,接下来的工作还很多。 |
|
返回顶楼 | |
发表时间:2009-08-04
楼主的代码是没有问题的,其实我想表达的意思就是楼主的测试并发大时必然死锁,从楼主的标题看是想测试spring事务的并发,楼主完全可以这样
线程1操作帐户A,B,线程2操作帐户C,D,线程3操作帐户E,F,这样才是真正的测试spring并发的事务正确性。 如果想测试程序的健壮性,如死锁可以再写测试用例。 总之保证,每个测试用例目标明确。 |
|
返回顶楼 | |
发表时间:2009-08-04
最后修改:2009-08-04
rain2005 写道 楼主的代码是没有问题的,其实我想表达的意思就是楼主的测试并发大时必然死锁,从楼主的标题看是想测试spring事务的并发,楼主完全可以这样
线程1操作帐户A,B,线程2操作帐户C,D,线程3操作帐户E,F,这样才是真正的测试spring并发的事务正确性。 如果想测试程序的健壮性,如死锁可以再写测试用例。 总之保证,每个测试用例目标明确。 “线程1操作帐户A,B,线程2操作帐户C,D,线程3操作帐户E,F,” —— 嘿嘿,如果只考虑这种井水不犯河水的操作,还要spring事务处理干嘛?普通的 servlet + jdbc 就搞定了.... 所以,俺的测试用例目标还是很明确的:多线程粗暴蹂躏共享的数据库记录,看你 spring + hibernate 如何反应... |
|
返回顶楼 | |
发表时间:2009-08-04
看了这么多回帖, 我只想说一个问题
Mysql数据库真的支持事务吗? 看看mysql 文档就可以找到答案, 对于MyISAM 表, 是不支持事务的, InnoDB 是对事务有限支持。 支持的方式就是用锁。 了解了这点, 就不难理解测试的结果了! |
|
返回顶楼 | |
发表时间:2009-08-04
mysaga 写道 rain2005 写道 楼主的代码是没有问题的,其实我想表达的意思就是楼主的测试并发大时必然死锁,从楼主的标题看是想测试spring事务的并发,楼主完全可以这样
线程1操作帐户A,B,线程2操作帐户C,D,线程3操作帐户E,F,这样才是真正的测试spring并发的事务正确性。 如果想测试程序的健壮性,如死锁可以再写测试用例。 总之保证,每个测试用例目标明确。 “线程1操作帐户A,B,线程2操作帐户C,D,线程3操作帐户E,F,” —— 嘿嘿,如果只考虑这种井水不犯河水的操作,还要spring事务处理干嘛?普通的 servlet + jdbc 就搞定了.... 所以,俺的测试用例目标还是很明确的:多线程粗暴蹂躏共享的数据库记录,看你 spring + hibernate 如何反应... 明白你的意思了。 spring + hibernate对待数据库记录的方式和servlet + jdbc没有什么区别吗?要保证事务一致性那是数据库的职责把。不就是更新锁和乐观锁?这好像不关spring + hibernate什么事把? |
|
返回顶楼 | |
发表时间:2009-08-04
mikewang 写道 看了这么多回帖, 我只想说一个问题
Mysql数据库真的支持事务吗? 看看mysql 文档就可以找到答案, 对于MyISAM 表, 是不支持事务的, InnoDB 是对事务有限支持。 支持的方式就是用锁。 了解了这点, 就不难理解测试的结果了! 哦,我不太侧重数据库方面。但我认为,InnoDB 的所谓“有限支持”,还是能应对我在顶楼提出的基本要求的。 以后有机会换个oracle之类的试试。 |
|
返回顶楼 | |
发表时间:2009-08-04
rain2005 写道 mysaga 写道 rain2005 写道 楼主的代码是没有问题的,其实我想表达的意思就是楼主的测试并发大时必然死锁,从楼主的标题看是想测试spring事务的并发,楼主完全可以这样
线程1操作帐户A,B,线程2操作帐户C,D,线程3操作帐户E,F,这样才是真正的测试spring并发的事务正确性。 如果想测试程序的健壮性,如死锁可以再写测试用例。 总之保证,每个测试用例目标明确。 “线程1操作帐户A,B,线程2操作帐户C,D,线程3操作帐户E,F,” —— 嘿嘿,如果只考虑这种井水不犯河水的操作,还要spring事务处理干嘛?普通的 servlet + jdbc 就搞定了.... 所以,俺的测试用例目标还是很明确的:多线程粗暴蹂躏共享的数据库记录,看你 spring + hibernate 如何反应... 明白你的意思了。 spring + hibernate对待数据库记录的方式和servlet + jdbc没有什么区别吗?要保证事务一致性那是数据库的职责把。不就是更新锁和乐观锁?这好像不关spring + hibernate什么事把? 哈哈,俺的意思是说,有了spring + hibernate,你自己就不用手写SQL代码来管理事务了——不是你不需要事务管理,而是 spring + hibernate 帮你搞定绝大多数东东。 而假如只考虑“线程1操作帐户A,B,线程2操作帐户C,D,线程3操作帐户E,F,”这种井水不犯河水的操作,就不用担心任何事务隔离级别、锁之类的东西。因此,即使只是使用 servlet + jdbc,也不用手写SQL代码来管理什么事务了——因为你根本不需要事务嘛。 |
|
返回顶楼 | |
发表时间:2009-08-04
最后修改:2009-08-04
即使只是使用 servlet + jdbc,也不用手写SQL代码来管理什么事务了——因为你根本不需要事务嘛。
这就没有什么可以说的了,第一次听说servlet + jdbc根本不需要事务。建议你先搞清楚什么是事务。。。 |
|
返回顶楼 | |