为什么这是一个问题
出现这里的问题是因为一个事务不知道另一个事务的结果余额是多少,它也不知道这个问题的答案,因为它可能发生在单独的线程/进程和数据库连接中.
在这些从一个用户到另一个用户的交易中,您可能在一个步骤中完成了以下所有操作:
- 在一个账户中增加/扣除一笔金额
- 计算该帐户的余额
- 增加/扣除另一帐户中的金额
- 计算另一账户的余额
基本上,如果余额计算依赖于same table中的其他数据,如果允许该表在读取后更改,则在这里总是会遇到并发问题.
如何修复它?
要摆脱这一点,您需要将余额从插入事务中移开.
余额实际上是用户账户上所有交易的summary.因此,您应该总是能够通过对给定用户的所有交易进行SUM
次计算来计算余额.这意味着你不应该在桌子上需要一个流动的平衡.
虽然这是真的,但每次添加新事务时,简单地对TRANSACTIONS表运行SUM
查询是非常幼稚的,如果transactions
表达到任何实际大小(如数百万行),则可能会导致性能非常差.
这就是锁之类的东西的用武之地,因为它有助于确保正在读取的值(在本例中为John的balance
)在该过程中不会改变.
但是,为了从这种锁定中获得最大价值,您需要重新架构表,以使balance
驻留在TRANSACTIONS表之外.
你的交易真的应该只是交易.
如果您在其他地方保持运行平衡,您还将拥有一个简单得多的数据集,因为这可能只是每个用户的单个值.例如,你可以把它放在你的users
表中.
实际上,它应该在另一个名为accounts
的表中,特别是如果这是在模仿一家银行,在那里用户可以有多个账户……但在这里让事情变得简单.
然后,当您收到从一个帐户中扣除的请求时,您将获得users
表上的锁(有关更多详细信息,请参阅关于Pessimistic Locking的文档),以确保balance
不会首先被任何其他事务更改.
锁定发生在MySQL级别,作为数据库事务的一部分,并且实际上阻止任何其他请求编辑此行.这意味着在处理此事务时传入的任何其他事务都将被阻止.
$john = DB::table('users')
->where('user', 'John')
->sharedLock()
->get()
->sole();
然后你可以阅读当前的balance
,知道它不会改变!
$balance = $john->balance;
然后添加新的交易记录并更新John的余额:
$amount = -500; // Obviously variable, coming from your request
if ($amount < 0 && $john->balance < -$amount) {
throw new \Exception('Insufficient funds');
}
DB::insert('insert into transactions (user, amount) values (?, ?)', ['John', $amount]);
$new_balance = $john->balance + $amount;
DB::update('update users set balance = ? where user = "John"', [$new_balance])
现在,只有当John的余额足以支付款项时,才会处理扣减交易,并且只会更新John的余额.
所有这些都作为database transaction的一部分完成,一旦事务提交,users
表将自动解锁.现在一起来看...
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($amount, $from_user, $to_user) {
$from = DB::table('users')
->where('user', $from_user)
->sharedLock()
->get()
->sole();
if ($amount < 0 && $from->balance < -$amount) {
throw new \Exception('Insufficient funds');
}
DB::insert('insert into transactions (user, amount) values (?, ?)', [$from_user, $amount]);
$new_balance = $from->balance + $amount;
DB::update('update users set balance = ? where user = ?', [$from_user, $new_balance])
}, 5);
NB: For brevity, I've skipped the parts that relate to the other user (100) and leave this as an exercise for the reader...
结果是
如果两个请求发生在同一时刻,这仍然不是MySQL的同一时刻.它将决定首先运行一个,锁定users
表并运行事务,然后try 运行下一个.
当第二个运行时,它很可能会因为资金不足的例外而失败.这将允许您catch
个异常,并给用户一个有用的消息.