php| 一次上线后数据库代理服务报错的排查

date: 2019-06-10 18:21:17
title: php| 一次上线后数据库代理服务报错的排查

选择 言简意赅 作为技术 blog 的写作风格, 放弃使用 故事型 风格, 这样:

  • 行文不会太长, 写起来容易, 读起来也轻松.
  • 围绕技术展开, 不会离题太远

前言说完了, 来看问题. 这个问题从发现到最后解决, 前后历时 2 天:

  • 排期好了, 业务等着使用, 既是压力, 也是动力
  • 尝试各种突破口, 前一晚折腾到了凌晨 2 点, 这种解决问题的 心流状态, 很难得了.

问题现场

新开了一个 数据库代理服务, 用来屏蔽使用的数据库资源的细节(rds-关系型数据库, drds-关系型数据库), 给业务方带来一致的使用体验.

新服务在测试环境跑了 2 周, 都没有问题, 切到线上环境使用, 使用 phpunit 跑单测报错.

报错原文:

TypeError:Argument 1 passed to Hyperf\Database\Connection::prepared() must be an instance of PDOStatement, boolean given, called in /data/vendor/hyperf/database/src/Connection.php on line 294(0) in /data/vendor/hyperf/database/src/Connection.php:977
Stack trace:
#0 /data/vendor/hyperf/database/src/Connection.php(294): Hyperf\Database\Connection->prepared(false)
#1 /data/vendor/hyperf/database/src/Connection.php(1079): Hyperf\Database\Connection->Hyperf\Database\{closure}('select id, type...', Array)
#2 /data/vendor/hyperf/database/src/Connection.php(1044): Hyperf\Database\Connection->runQueryCallback('select id, type...', Array, Object(Closure))
#3 /data/vendor/hyperf/database/src/Connection.php(301): Hyperf\Database\Connection->run('select id, type...', Array, Object(Closure))
#4 /data/vendor/hyperf/database/src/Query/Builder.php(2670): Hyperf\Database\Connection->select('select id, type...', Array, true)
#5 /data/vendor/hyperf/database/src/Query/Builder.php(1838): Hyperf\Database\Query\Builder->runSelect()
#6 /data/vendor/hyperf/database/src/Query/Builder.php(2810): Hyperf\Database\Query\Builder->Hyperf\Database\Query\{closure}()
#7 /data/vendor/hyperf/database/src/Query/Builder.php(1839): Hyperf\Database\Query\Builder->onceWithColumns(Array, Object(Closure))
#8 /data/app/Controller/DbController.php(154): Hyperf\Database\Query\Builder->get()
#9 /data/vendor/hyperf/http-server/src/CoreMiddleware.php(103): App\Controller\DbController->aftersale(Object(Hyperf\HttpServer\Request), Object(Hyperf\HttpServer\Response))
#10 /data/vendor/hyperf/http-server/src/CoreMiddleware.php(77): Hyperf\HttpServer\CoreMiddleware->handleFound(Array, Object(Hyperf\HttpMessage\Server\Request))
#11 /data/vendor/hyperf/dispatcher/src/AbstractRequestHandler.php(66): Hyperf\HttpServer\CoreMiddleware->process(Object(Hyperf\HttpMessage\Server\Request), Object(Hyperf\Dispatcher\HttpRequestHandler))
#12 /data/vendor/hyperf/dispatcher/src/HttpRequestHandler.php(27): Hyperf\Dispatcher\AbstractRequestHandler->handleRequest(Object(Hyperf\HttpMessage\Server\Request))
#13 /data/app/Middleware/AuthMiddleware.php(33): Hyperf\Dispatcher\HttpRequestHandler->handle(Object(Hyperf\HttpMessage\Server\Request))
#14 /data/vendor/hyperf/dispatcher/src/AbstractRequestHandler.php(66): App\Middleware\AuthMiddleware->process(Object(Hyperf\HttpMessage\Server\Request), Object(Hyperf\Dispatcher\HttpRequestHandler))
#15 /data/vendor/hyperf/dispatcher/src/HttpRequestHandler.php(27): Hyperf\Dispatcher\AbstractRequestHandler->handleRequest(Object(Hyperf\HttpMessage\Server\Request))
#16 /data/app/Middleware/HttpLogMiddleware.php(17): Hyperf\Dispatcher\HttpRequestHandler->handle(Object(Hyperf\HttpMessage\Server\Request))
#17 /data/vendor/hyperf/dispatcher/src/AbstractRequestHandler.php(66): App\Middleware\HttpLogMiddleware->process(Object(Hyperf\HttpMessage\Server\Request), Object(Hyperf\Dispatcher\HttpRequestHandler))
#18 /data/vendor/hyperf/dispatcher/src/HttpRequestHandler.php(27): Hyperf\Dispatcher\AbstractRequestHandler->handleRequest(Object(Hyperf\HttpMessage\Server\Request))
#19 /data/vendor/hyperf/dispatcher/src/HttpDispatcher.php(43): Hyperf\Dispatcher\HttpRequestHandler->handle(Object(Hyperf\HttpMessage\Server\Request))
#20 /data/vendor/hyperf/http-server/src/Server.php(103): Hyperf\Dispatcher\HttpDispatcher->dispatch(Object(Hyperf\HttpMessage\Server\Request), Array, Object(Hyperf\HttpServer\CoreMiddleware))
#21 {main}

排查第一步: 源码

一般报错, 都发生在自己写的代码里, 这样会形成一个心理(这里面隐藏着一个 二八法则, 不过多展开, 感兴趣可以继续翻书 -- 墨菲定理):

  • 自己写的代码出错更常见 -> 解决的更多 -> 心理上会感觉更轻松
  • 框架层的代码出错较少见 -> 解决的较少 -> 心理上会感觉更困难

告诉自己, 都是 PHP 代码, 有什么难的?! PHP is best !

数据库代理服务基于微服务框架 hyperf 来实现.

到了框架层, 代码往往耦合较少, 结构拆分很清晰, 虽然调用看起来很多, 但是核心代码就是 trace#1 的地方:

// 原函数
public function select(string $query, array $bindings = [], bool $useReadPdo = true): array
{
    return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) {
        if ($this->pretending()) {
            return [];
        }

        // For select statements, we'll simply execute the query and return an array
        // of the database result set. Each element in the array will be a single
        // row from the database table, and will either be an array or objects.
        $statement = $this->prepared($this->getPdoForSelect($useReadPdo)
            ->prepare($query));

        $this->bindValues($statement, $this->prepareBindings($bindings));

        $statement->execute();

        return $statement->fetchAll();
    });
}

继续抽丝剥茧:

// trace 中有行号
$statement = $this->prepared($this->getPdoForSelect($useReadPdo)
    ->prepare($query));

// 根据 exception message 进行确定范围
$statement = $this->prepared(false); // 报错来自这里
$this->getPdoForSelect($useReadPdo)
    ->prepare($query); // 这行代码返回了 false

// 这行代码等效于
$pdo->prepare($query);

这是关键的一步, 报错的来自 Pdo::prepare

排查第二步: 查

果然, 我们不太可能成为那个只有 70亿(地球人口)分之一的幸运儿, 这个坑果然有不少人踩过, Stack Overflow 有人提了相同的问题.

查文档, Stack Overflow 里给的回答, 就来自官方的文档 Pdo::prepare.

查的关键词:

  • 查搜索引擎: 百度/谷歌
  • 查文档

排查第三部: 加日志

目前只知道 pdo->prepare() 返回了 false, 还需要更多信息.

怎么获得更多信息? 加日志!

Log::get('sql')->info($query);
try {
    $pdo = $this->getPdoForSelect($useReadPdo);
    Log::info(var_export($pdo, true));
    $r = $pdo->prepare($query);
    Log::info(var_export($r, true));
    Log::info('errCode: '. $pdo->errorCode() . '|errInfo: '. json_encode($pdo->errorInfo()));
} catch (\Throwable $exception) {
    Log::get('sql')->info(format_throwable($exception));
}

$statement = $this->prepared($this->getPdoForSelect($useReadPdo)

加上日志后:

errCode: 00000|errInfo: ["00000",null,null]

false

PDO::__set_state(array(
))

select id,aftersale_id from `aftersale_step` where `aftersale_id` = ? limit 2

除了拿到 $query 的值以外, 好像没有拿到更有用的信息.

排查第四步: 交流

单打独斗许久之后, 尤其是打了日志还没拿到有用信息后, 确实有点 没头脑. 这个时候:

  • 不要放弃, 拖着拖着, 可能就真的放弃了
  • 集思广益: 和技术团队交流, 和技术社区交流

交流的好处:

  • 更多的尝试, 更多的突破口
  • 更多的知识, 更多技术细节

科学方法论: 找不同

正常态 -> 异常态, 而且还是必现, 那么肯定有 固定原因, 这个时候抛弃 量子跃迁 见鬼了 等等想法, 选择 科学方法:

科学实验的方法: 控制变量法. 换言之, 找不同.

明显的不同, 环境不一样:

  • 测试环境是好的: 测试环境使用的 rds(读写) + drds(读写)
  • 线上有问题: 线上使用正式的 rds(读写+只读) + drds(读写+只读)

添加测试代码来比较不同(方法来自于社区):

$dsn = 'xxx'; // 分别使用线上的使用的链接信息
$pdo = new \PDO("mysql:host={$dsn};dbname=xxx", 'xxx', 'xxx');
$sql = 'xxx'; // 使用日志中打出的 query
$stmt = $pdo->prepare($sql);
var_dump($stmt);
  • 测试代码正常返回 PDOStatement 对象, 不会返回 false

现在写出来, 只有关键的 2 点, 实际排查过程其实走了很多弯路, mark 一下, 引以为戒!

解决

既然有了 科学的方法, 那么就可以大胆的得出可靠的结局:

  • 环境的锅!!! 和 aliyun drds 技术人员确认, drds只读实例暂不支持 mysql prepare
  • 测试代码表现和框架不一致, PDO 一定有配置控制相关的表现

框架层基于 laravel ORM, 默认覆盖了 PDO 的一些属性(由 hyperf 社区 提供):

// vendor/hyperf/database/src/Connectors/Connector.php
protected $options = [
    PDO::ATTR_CASE => PDO::CASE_NATURAL,
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL,
    PDO::ATTR_STRINGIFY_FETCHES => false,
    PDO::ATTR_EMULATE_PREPARES => false,
];

很可能就是 `PDO::ATTR_EMULATE_PREPARES' 属性, 使用测试代码验证:

$dsn = 'xxx'; // 分别使用线上的使用的链接信息
$pdo = new \PDO("mysql:host={$dsn};dbname=xxx", 'xxx', 'xxx', [PDO::ATTR_EMULATE_PREPARES => false,]);
$sql = 'xxx'; // 使用日志中打出的 query
$stmt = $pdo->prepare($sql);
var_dump($stmt);
  • 测试代码果然返回 false

写在最后

梳理涉及到的技术知识:

  • (prepare sql: mysql prepare 协议使用说明](https://help.aliyun.com/document_detail/71326.html)
  • php 使用 PDO 访问 mysql, 可以通过 pdo->prepare() 和 mysql prepare 协议交互
  • PDO 有很多属性可以设置, 包括 prepare() 时的行为: `PDO::ATTR_EMULATE_PREPARES'

总结重要的几点:

  • 单测很重要, 上线后跑 phpunit, 立刻就发现了问题, 然后马上开始填坑
  • 心理很重要: 不要怕问题, 都是 PHP 代码, 有什么好怕的
  • 科学方法很重要: 看似做了 各种尝试, 但是没有科学的方法支撑, 反而在获取到越来越多的信息后, 更容易迷茫, 不敢下结论
  • 事上练: 增加和周围世界的联系, 技术也可以做到, 多和 团队/社区 交流想法和知识

历史类似经历:

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,997评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,603评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,359评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,309评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,346评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,258评论 1 300
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,122评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,970评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,403评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,596评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,769评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,464评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,075评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,705评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,848评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,831评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,678评论 2 354

推荐阅读更多精彩内容

  • pdo基本使用 【PDO是啥】 PDO是PHP 5新加入的一个重大功能,因为在PHP 5以前的php4/php3都...
    桖辶殇阅读 1,337评论 0 4
  • 1 什么是MVC MVC模式(Model-View-Controller)是软件工程中的一种软件架构模式。 MVC...
    申城墨道阅读 2,036评论 0 10
  • ​你登上列车 去往千里之外 回眸一笑 带走了我的思念 ​那异乡飘下的雪 不要冷了你的身 打湿了我的眼 不愿让你看见...
    雒云阅读 232评论 1 7
  • 家乡有一种非常引以为豪的树——槐树,每年的旧历四月末五月初正是满树盛开、槐花飘香的季节。我最喜欢这种空气中到处...
    缘末阅读 1,029评论 15 21
  • 上午模拟考试,起床晚了,到考场时就已经开始发试卷了。站在考场外不知所措,还是后面的同学催我进去。在众人注视下,我像...
    薄暮秋桦阅读 330评论 1 0