问题提出
我们知道,在PHP+Swoole的协程框架中,默认情况下,所有变量赋值等操作都是安全的。协程切换只会发生在IO等待的时候。在大多数情况下,我们完全可以用同步的方式来写异步程序。
在项目开发实施过程中我发现会有导致数据库死锁的情况出现。这通常发生在两个数据库事务中。当2个事务同时执行,然后去修改同一条记录的时候。虽然我们可以通过在事务开始之前通过 获取记录锁来避免。但这种方法给人感觉不够轻量级。
解决方法
在我设计的框架中 每一个客户端的请求都是在一个单独的协程下执行的。同时为了减轻数据库负载,我将所有当前业务数据编写成一个PHP对象放在内存中并且可以全局索引获取到。我们可以利用协程的特性来避免数据库死锁这一情况的发生。只要我将需要更新的对象在当前协程中获取锁。避免其他协程对这个对象进行更新操作即可。
我为对象设计了 update 方法, 它会自动比较成员属性值,生成 oldvalue, 同时返回数据库更新Task。 在数据库事务执行过程中,如果执行失败会滚,内存中PHP对象的属性值也可以同时会滚。而Task对象在数据库执行完成后,可以交给同步对象进行同步操作。确保服务端和终端数据的一致性。
例如,当一个商户的商品销售。通常我们需要做如下操作:
- 计算价格、检查库存。
- 完成数款(现金、付款码、扫码收款等。)
- 创建订单
- 生成商品出货记录
- 减去商品库存
- 生成资金流水记录
- 更新商户账户余额。
在上述步骤中, 步骤6 和步骤8 是可能产生并发冲突的。我们需要对其进行锁定。并让其它要操作相同数据的协程等待即可。实现方法也比较简单,利用 PHP 的 traits 将需要用到锁的对象 use它即可。实现代码如下:
<?php
namespace lib\data\traits;
trait WaitLock {
private int $_cid_ = -1;
private int $_wait_counter = 0; # 等待计数器
protected ?\Swoole\Coroutine\Channel $_wait_lock_channel_ = null;
/**
* 防并发锁.
*/
public function waitLock() :static {
$cid = \Swoole\Coroutine::getCid();
if($cid === $this->_cid_){
return $this;
}
# 如果对象正被其它协程使用, 则自动进行等待.
if($this->_cid_ !== -1){
$this->_wait_counter++;
if(null === $this->_wait_lock_channel_){
$this->_wait_lock_channel_ = new \Swoole\Coroutine\Channel(1);
}
$this->_wait_lock_channel_->pop();
$this->_wait_counter--;
if(0 == $this->_wait_counter){
$this->_wait_lock_channel_->close();
$this->_wait_lock_channel_ = null;
}
}
# 获取锁, 并在协程结束后自动释放.
$this->_cid_ = $cid;
\Swoole\Coroutine::defer(function(){
$this->_cid_ = -1;
if($this->_wait_lock_channel_ != null){
$this->_wait_lock_channel_->push(true);
}
});
return $this;
}
}