PDO 连接池死锁现象分析

问题起因

我在项目中对PDOPool进行了二次封装, 并写了一个简易的SqlBuilder, 目的还是为了简化开发. 从开发过程到项目上线都没发现问题。本以为一切顺利,然而直到一天,服务器升级一个功能需要重启的时候,意外出现了。系统启动的时候卡死~, 直觉告诉我,这是发生死锁了。然而,服务不能停,智能多次重启的方法,总算成功启动了一次。接下来是排查问题的过程。在程序入口多处加入日志打印后发现,通过模拟程序启动的时候的大量重连进来的现场后发现,程序卡死在 DbPool->get,原来真相在这。

问题分析

Swoole 协程数目是没有做限制的,只要有新的连接进来,就会启动一个协程进行处理,这个思路本身没有什么问题,然而, DBPool 是通过继承Channel 实现的,Channel的队列其实就是PDOMysql 连接。
正常情况下,我们每次使用一个数据库连接,用完后交还给连接池。当没有可用连接的时候 DbPool->get() 就会刮起当前协程,直到有可用的数据库连接再唤醒。 因此 无论多少协程,都会自觉排队等待PDOMysql连接处理。然而,当你一个“不小心”。 在一个协程内用到多个数据库连接到时候。是否发生死锁完全看系统繁忙程度了。 死锁发生过程如下:

首先,我们假定连接池有2个连接,并且有2个正在运行的协程。

1
2
3
4
5
6
7
# 协程 A                协程B                  
$db1 = $dbpool->get(); $db1 = $dbpool->get(); # 由于连接池中连接够,所以都是成功的
# do something # do something
$db2 = $dbpool->get(); $db2 = $dbpool->get(); # 连接池没有了连接, 死锁已成,后面的代码永远不会执行了.
# do something # do something
$dbpool->put($db2); $dbpool->put($db2); # 连接交换永远不会发生
$dbpool->put($db1); $dbpool->put($db1); # 连接交还永远不会发生

解决问题

既然问题已经找到了, 由于项目比较复杂,完全走一遍流程排查修改可能需要较长的时间, 这里在数据库驱动底层写一个死锁检查代码, 然后跑一遍完整接口测试. 基本上死锁问题便都找出来了。 具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Db{
protected static $mark = []; #保存协程ID 是否持有数据库连接的数组

protected CheckDeadLock(int $cid){
if($cid < 0) return;
if(static::$cidMark[$cid] ?? false){
throw new \Exception("监测到可能的死锁! {$cid}, 同一个协程在同一时刻只允许持有一个数据库连接.");
}
}

protected function getConn() : PDOProxy {
if($this->conn !== null)
return $this->conn;
if(false !== ($this->cid = \Swoole\Coroutine::getCid())){
static::CheckDeadLock($this->cid);
}
return static::$pool[$this->name]->get();
}

/**
* @param $conn null|PDOProxy
*/
protected function putConn(?PDOProxy $conn) {
if($this->_trans_level > 0) {
Log::write('错误! 您有忘记提交的事务,该次数据库操作将被丢弃!!!!', 'Db','ERROR');
throw new Exception('错误! 您有忘记提交的事务, 该次数据库操作将被丢弃!!!!', 0);
}
static::$pool[$this->name]->put($conn);
# 死锁检测标记删除
if(false !== $this->cid){
unset(static::$cidMark[$this->cid]);
}
}
}

有了这个代码, 就不用再担心死锁问题了。 一旦你的代码中有可能造成死锁的代码, 运行的时候回立即抛出一个异常。

连接对象优化。

在编写协程化程序的时候, 我们应该尽量避免让一个协程从头到尾持有一个数据库连接. 这会让协程性能大打折扣,遵循以下几个标准重新设计数据库连接类:

  1. new DB 的时候并不会直接从 连接池中拿取连接.
  2. 只有在执行Sql的时候才去从连接池中拿取连接。 查询语句执行完后立即将连接交还给连接池.
  3. 只有在启动事务的时候, 对象才在整个事务生命周期期间长期持有连接, 事务提交或者回滚后立即将连接交还.

PDO 连接池死锁现象分析

https://doc.exti.cc/2022/01/08/pdo-lock/

作者

bywayboy

发布于

2022-01-08

更新于

2022-03-02

许可协议