2016年11月的一天线上出了个Bug和PHP设计模式有关我排查了一夜。

说起来这次故障印象真的很深。那天本来是正常的一天我们上线了一个新功能测试环境测了没问题预发布环境也测了没问题就上线了。

上线之后刚开始也没问题一切正常。但是过了大概半个小时突然监控报警了说数据库连接数飙升已经快到最大连接数了。

我当时就心里一紧赶紧登录服务器查看情况。果然数据库的连接数一直在涨从平时的几十个很快就涨到了几百个然后上千个很快就把数据库的最大连接数占满了。

数据库连接数一满新的连接就建不起来了整个网站就无法访问了用户打开网站都是500错误。

当时已经是晚上八点多了我赶紧开始排查。没想到这一排查就是一夜直到第二天凌晨四点多才找到问题的根源解决了问题。

今天就来聊聊这次线上Bug的排查过程以及从中得到的经验和教训。

一、问题现象

先说说问题的现象。

我们的项目是一个PHP写的网站用的是LNMP架构Linux + Nginx + PHP-FPM + MySQL。数据库用的是MySQL主从架构一主一从。

平时数据库的连接数很稳定大概几十个最多也就一百多个远低于数据库的最大连接数(我们设的是1000)。

但是那天上线了新功能之后过了半个小时数据库的连接数突然开始飙升从几十个很快就涨到了几百个然后上千个很快就到了1000把最大连接数占满了。

而且这些连接大部分都是Sleep状态也就是空闲的连接没有在执行查询但是也没有释放。

连接数一满新的请求就无法建立数据库连接了整个网站就无法访问了用户打开网站都是500错误报错信息是,"SQLSTATE[HY000] [1040] Too many connections"。

当时第一反应是赶紧先恢复服务。于是我先把新功能回滚了然后重启了PHP-FPM释放了所有的数据库连接。

回滚之后数据库的连接数很快就降下来了网站也恢复了访问。

但是问题还没有找到根源。如果只是回滚不找到根源那么以后再上线类似的功能还是会出问题。

所以我开始排查问题的根源。

二、初步排查:以为是,新功能的,SQL有问题

刚开始我以为是新功能的SQL有问题比如有慢查询或者有死锁导致连接被占用无法释放。

于是我先查看了数据库的慢查询日志看看有没有慢查询。但是慢查询日志里没有什么特别慢的查询大部分查询都很快几毫秒就完成了。

然后我查看了数据库的进程列表看看有没有死锁或者长时间运行的查询。但是也没有大部分连接都是Sleep状态没有在执行查询。

这就奇怪了如果不是慢查询也不是死锁那么为什么连接数会飙升呢?

然后我又怀疑是不是PHP-FPM的进程数太多了每个进程都持有一个数据库连接导致连接数太多。

但是我们的PHP-FPM的进程数设的是动态的最大也就一百多个进程即使每个进程都持有一个数据库连接也就一百多个连接不会到一千个。

而且平时也是这个配置连接数一直很稳定只有几十个为什么今天突然就飙升了呢?

这说明问题不是PHP-FPM的进程数太多而是有其他的原因导致每个请求都创建了多个数据库连接或者数据库连接没有被正确地释放。

三、深入排查:发现,每个请求,创建了,多个,数据库连接

于是我开始在测试环境复现这个问题。

我把新功能的代码部署到测试环境然后用ab压测模拟并发请求看看数据库的连接数会不会飙升。

果然一压测数据库的连接数就开始飙升和线上的情况一样。

然后我在代码里加了一些日志打印每次创建数据库连接的时候的调用栈看看到底是哪里在创建数据库连接。

加了日志之后一压测我就发现了问题。

原来每个请求竟然创建了十几个甚至几十个数据库连接!

这就不对了。我们的项目用了单例模式来管理数据库连接按理说每个请求应该只有一个数据库连接才对为什么会创建这么多,呢?

于是我开始查看数据库连接的单例模式的代码。

我们的数据库连接单例类大概是这样的:

class Database
{
    private static $instance = null;
    private $pdo;

    private function __construct()
    {
        $this->pdo = new PDO(
            'mysql:host=127.0.0.1;dbname=test;charset=utf8mb4',
            'root',
            'password',
            [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]
        );
    }

    public static function getInstance()
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    public function getPdo()
    {
        return $this->pdo;
    }

    // 禁止克隆
    private function __clone() {}

    // 禁止反序列化
    private function __wakeup() {}
}

看起来这个单例类写得没问题啊标准的单例模式私有构造函数静态getInstance方法禁止克隆禁止反序列化该有的都有了。

那为什么每个请求会创建多个数据库连接呢?

我又查看了日志里的调用栈看看这些数据库连接都是从哪里创建的。

一看调用栈我就发现了问题。

原来这些数据库连接大部分都是从一个新写的类里创建的。这个类是这次新功能里新加的叫UserService大概是这样的:

class UserService
{
    private $db;

    public function __construct()
    {
        $this->db = Database::getInstance();
    }

    public function getUserById($id)
    {
        $pdo = $this->db->getPdo();
        $stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
        $stmt->execute([$id]);
        return $stmt->fetch();
    }

    // 其他方法...
}

看起来这个UserService也没问题啊在构造函数里获取Database的单例然后存在成员变量里用的时候取出来,用。

那为什么会创建多个数据库连接呢?

我又仔细看了看调用栈发现每次调用UserService的方法都会创建一个新的数据库连接。

这就奇怪了UserService不是在构造函数里获取了Database的单例,吗?为什么每次调用方法都会创建新的连接呢?

四、找到根源:单例被,继承,破坏了

我又仔细看了看UserService的代码终于发现了问题。

原来UserService不是直接用的Database类而是继承了一个叫BaseService的基类。而这个BaseService是这次新功能里新加的大概是这样的:

class BaseService
{
    protected $db;

    public function __construct()
    {
        // 注意这里!不是用的Database::getInstance(),而是new Database()!
        $this->db = new Database();
    }
}

class UserService extends BaseService
{
    public function getUserById($id)
    {
        $pdo = $this->db->getPdo();
        $stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
        $stmt->execute([$id]);
        return $stmt->fetch();
    }
}

看到了吗?问题就在这里!

BaseService的构造函数里不是用的,Database::getInstance(),来获取单例而是直接,new Database()

但是Database的构造函数不是私有的吗?怎么能直接new呢?

哦对了因为BaseService和Database是在同一个命名空间里而且PHP的私有构造函数只能在类的内部调用不能在类的外部调用。但是等等BaseService不是Database的子类啊怎么能调用Database的私有构造函数呢?

哦不对我又仔细看了看代码发现Database的构造函数不是private的而是protected的!

原来之前有个同事为了让Database类可以被继承就把构造函数从private改成了protected。

而protected的构造函数可以在子类里调用但是不能在类的外部调用。

但是BaseService不是Database的子类啊怎么能调用Database的protected构造函数呢?

哦不对我又仔细看了看代码发现BaseService其实是Database的子类!

原来那个同事不仅把Database的构造函数改成了protected还让BaseService继承了Database!

大概是这样的:

class Database
{
    protected static $instance = null;
    protected $pdo;

    // 构造函数是protected的,不是private的
    protected function __construct()
    {
        $this->pdo = new PDO(/* ... */);
    }

    public static function getInstance()
    {
        if (static::$instance === null) {
            static::$instance = new static();
        }
        return static::$instance;
    }

    // ...
}

// BaseService继承了Database!
class BaseService extends Database
{
    public function __construct()
    {
        // 因为继承了Database,所以可以调用protected的构造函数
        parent::__construct();
    }
}

class UserService extends BaseService
{
    // ...
}

看到了吗?这就是问题的根源!

BaseService继承了Database然后在自己的构造函数里调用了,parent::__construct(),也就是Database的构造函数。

而Database的构造函数里会创建一个新的PDO连接。

所以每次new UserService(),都会调用BaseService的构造函数然后调用Database的构造函数然后创建一个新的PDO连接!

这就完全破坏了单例模式!

本来单例模式的目的就是让整个应用只有一个Database实例只有一个数据库连接。但是现在因为BaseService继承了Database并且在构造函数里调用了parent::__construct(),所以每次new UserService(),都会创建一个新的Database实例创建一个新的数据库连接。

而我们的项目里很多地方都会new UserService(),比如每个Controller里都会new UserService(),甚至有的方法里也会new UserService()。所以每个请求都会创建十几个甚至几十个数据库连接。

平时没有这个BaseService的时候都是用的Database::getInstance(),所以每个请求只有一个数据库连接连接数很稳定。

但是这次新功能加了这个BaseService并且让它继承了Database还在构造函数里调用了parent::__construct(),就完全破坏了单例模式导致每个请求创建多个数据库连接连接数飙升最终占满了数据库的最大连接数导致整个网站无法访问。

找到问题的根源之后我真是又好气又好笑。气的是这个同事怎么能这么写代码把单例模式给完全破坏了都不知道。笑的是排查了一夜最后发现竟然是这么一个低级的错误。

五、解决方案

找到问题的根源之后解决方案就很简单了。

有几种解决方案:

方案一:把Database的构造函数改回private

最简单的方案就是把Database的构造函数从protected改回private这样就不能被继承了也就不能在子类里调用构造函数了。

但是这样那个同事之前为什么要改成protected呢?肯定是有原因的可能是想让Database类可以被继承做一些扩展。如果直接改回private可能会影响其他的代码。

所以这个方案虽然简单但是可能会有副作用。

方案二:修改BaseService不继承Database而是组合Database

更好的方案是修改BaseService不让它继承Database而是组合Database也就是在BaseService里持有一个Database的实例通过Database::getInstance(),获取。

大概是这样的:

class BaseService
{
    protected $db;

    public function __construct()
    {
        // 用getInstance获取单例,而不是new
        $this->db = Database::getInstance();
    }

    public function getDb()
    {
        return $this->db;
    }
}

class UserService extends BaseService
{
    public function getUserById($id)
    {
        $pdo = $this->db->getPdo();
        $stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
        $stmt->execute([$id]);
        return $stmt->fetch();
    }
}

这样BaseService就不再继承Database了而是组合Database通过getInstance获取单例。这样就不会破坏单例模式了每个请求还是只有一个数据库连接。

这个方案比较好因为它遵循了,"组合优于继承"的设计原则而且不会影响Database类本身。

方案三:在BaseService的构造函数里也用getInstance

如果不想修改继承关系那么也可以在BaseService的构造函数里不用parent::__construct(),而是用Database::getInstance(),获取单例然后赋值给成员变量。

但是这样其实和方案二差不多而且继承Database就没有意义了因为没有用到Database的任何属性和方法只是用了它的getInstance。

所以还是方案二比较,好。

最后我采用了方案二修改了BaseService不让它继承Database而是组合Database通过getInstance获取单例。

修改完之后在测试环境压测数据库的连接数果然不再飙升了每个请求只有一个数据库连接连接数很稳定。

然后把修改后的代码上线线上的数据库连接数也恢复了正常问题解决了。

这时候已经是第二天凌晨四点多了我熬了一夜终于把问题解决了。

六、经验和教训

这次线上Bug排查了一夜给了我很多经验和教训。

1. 单例模式一定要保证不能被破坏

单例模式的目的就是让整个应用只有一个实例。所以一定要保证单例不能被破坏。

要保证单例不被破坏需要注意以下几点:

  • 构造函数一定要是private的不要改成protected否则就可能被继承破坏。
  • 禁止克隆把,__clone方法设为private。
  • 禁止反序列化把,__wakeup方法设为private。
  • getInstance方法一定要正确地判断实例是否已经存在不要每次都创建新的实例。
  • 不要在其他地方直接new单例类一定要通过getInstance获取。

如果确实需要让单例类可以被继承那么一定要小心确保子类不会破坏单例。比如可以用静态变量存储实例并且在getInstance里用static关键字而不是self关键字这样就能支持子类的单例。但是即使这样也要小心确保子类不会在构造函数里创建新的连接或者其他的资源。

2. 组合优于继承

这次问题的根源就是滥用了继承。BaseService其实根本不需要继承Database只需要组合Database就行了。

继承是一种,"is-a"的关系也就是说子类是一种父类。比如Dog继承Animal因为Dog是一种Animal。

而组合是一种,"has-a"的关系也就是说一个类持有另一个类的实例。比如Car持有Engine的实例因为Car有一个Engine。

BaseService和Database的关系显然是,"has-a"的关系BaseService有一个Database用来操作数据库而不是BaseService是一种Database。所以应该用组合而不是继承。

滥用继承会导致很多问题比如父类的修改会影响子类子类会继承父类的很多不需要的方法和属性增加了耦合度等等。

所以在设计类的时候一定要遵循,"组合优于继承"的原则优先用组合而不是继承。只有当确实是,"is-a"的关系的时候才用继承。

3. 代码审查很重要

这次问题如果在代码审查的时候能发现那么就不会导致线上故障了。

但是我们的代码审查做得不够严格这个明显有问题的代码竟然通过了审查上线了。

所以代码审查真的很重要一定要严格地做代码审查不要让有问题的代码上线。

在代码审查的时候要注意以下几点:

  • 设计模式是否正确地使用了有没有被破坏。
  • 继承和组合是否合理有没有滥用继承。
  • 资源是否正确地管理了比如数据库连接文件句柄等有没有泄漏。
  • 错误处理是否完善有没有未处理的异常。
  • 性能是否有问题比如有没有N+1查询有没有慢查询,等。
  • 安全是否有问题比如有没有SQL注入XSS,CSRF等。

4. 监控和告警很重要

这次问题幸好我们有监控和告警数据库连接数一飙升就报警了我们才能及时发现及时处理。

如果没有监控和告警那么可能等用户反馈网站无法访问了我们才知道出问题了那影响就更大了。

所以监控和告警真的很重要一定要做好监控和告警对关键的指标比如数据库连接数CPU使用率内存使用率响应时间错误率等都要监控并且设置合理的告警阈值。

而且告警一定要能及时通知到相关的人员比如通过短信邮件即时通讯工具等不要告警了但是没人看到。

5. 压测很重要

这次问题在测试环境其实也能复现但是我们在上线前没有做充分的压测所以没有发现这个问题。

如果在上线前做了充分的压测模拟并发请求那么就能发现数据库连接数飙升的问题就能在上线前解决不会导致线上故障。

所以压测真的很重要特别是对于新功能或者有大的改动的功能一定要做充分的压测模拟真实的并发情况看看有没有性能问题有没有资源泄漏,等。

6. 排查问题要有条理不要瞎猜

这次排查问题我刚开始也走了一些弯路以为是慢查询或者死锁查了半天没查到。后来才想到加日志看看数据库连接是从哪里创建的才很快找到了问题的根源。

所以排查问题一定要有条理不要瞎猜。要先收集足够的信息比如日志监控数据错误信息等然后根据这些信息分析可能的原因然后一个一个验证直到找到问题的根源。

不要一上来就瞎猜然后乱改那样不仅找不到问题还可能引入新的问题。

七、写在最后

线上出了个PHP设计模式的Bug我排查了一夜。

这次故障虽然已经解决了但是给我的印象很深也给了我很多经验和教训。

设计模式是好东西能帮我们写出更优雅更易维护的代码。但是设计模式也不是银弹用不好反而会出问题甚至导致线上故障。

所以我们在使用设计模式的时候一定要理解设计模式的原理和注意事项正确地使用不要滥用也不要破坏设计模式。

特别是单例模式一定要保证不能被破坏否则可能会导致资源泄漏性能问题甚至线上故障。

还有在设计类的时候一定要遵循,"组合优于继承"的原则优先用组合而不是继承不要滥用继承。

最后代码审查监控告警压测这些工程实践都很重要一定要做好不要等出了线上故障才后悔没有做好。

用一句话结尾:

"设计模式是工具不是教条。用好了事半功倍用不好反受其害。"

愿大家都能正确地使用设计模式写出更优雅更健壮的代码少出线上故障。