php| 一次上線后數(shù)據(jù)庫代理服務(wù)報(bào)錯(cuò)的排查

date: 2019-06-10 18:21:17
title: php| 一次上線后數(shù)據(jù)庫代理服務(wù)報(bào)錯(cuò)的排查

選擇 言簡意賅 作為技術(shù) blog 的寫作風(fēng)格, 放棄使用 故事型 風(fēng)格, 這樣:

  • 行文不會(huì)太長, 寫起來容易, 讀起來也輕松.
  • 圍繞技術(shù)展開, 不會(huì)離題太遠(yuǎn)

前言說完了, 來看問題. 這個(gè)問題從發(fā)現(xiàn)到最后解決, 前后歷時(shí) 2 天:

  • 排期好了, 業(yè)務(wù)等著使用, 既是壓力, 也是動(dòng)力
  • 嘗試各種突破口, 前一晚折騰到了凌晨 2 點(diǎn), 這種解決問題的 心流狀態(tài), 很難得了.

問題現(xiàn)場

新開了一個(gè) 數(shù)據(jù)庫代理服務(wù), 用來屏蔽使用的數(shù)據(jù)庫資源的細(xì)節(jié)(rds-關(guān)系型數(shù)據(jù)庫, drds-關(guān)系型數(shù)據(jù)庫), 給業(yè)務(wù)方帶來一致的使用體驗(yàn).

新服務(wù)在測試環(huán)境跑了 2 周, 都沒有問題, 切到線上環(huán)境使用, 使用 phpunit 跑單測報(bào)錯(cuò).

報(bào)錯(cuò)原文:

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}

排查第一步: 源碼

一般報(bào)錯(cuò), 都發(fā)生在自己寫的代碼里, 這樣會(huì)形成一個(gè)心理(這里面隱藏著一個(gè) 二八法則, 不過多展開, 感興趣可以繼續(xù)翻書 -- 墨菲定理):

  • 自己寫的代碼出錯(cuò)更常見 -> 解決的更多 -> 心理上會(huì)感覺更輕松
  • 框架層的代碼出錯(cuò)較少見 -> 解決的較少 -> 心理上會(huì)感覺更困難

告訴自己, 都是 PHP 代碼, 有什么難的?! PHP is best !

數(shù)據(jù)庫代理服務(wù)基于微服務(wù)框架 hyperf 來實(shí)現(xiàn).

到了框架層, 代碼往往耦合較少, 結(jié)構(gòu)拆分很清晰, 雖然調(diào)用看起來很多, 但是核心代碼就是 trace#1 的地方:

// 原函數(shù)
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();
    });
}

繼續(xù)抽絲剝繭:

// trace 中有行號(hào)
$statement = $this->prepared($this->getPdoForSelect($useReadPdo)
    ->prepare($query));

// 根據(jù) exception message 進(jìn)行確定范圍
$statement = $this->prepared(false); // 報(bào)錯(cuò)來自這里
$this->getPdoForSelect($useReadPdo)
    ->prepare($query); // 這行代碼返回了 false

// 這行代碼等效于
$pdo->prepare($query);

這是關(guān)鍵的一步, 報(bào)錯(cuò)的來自 Pdo::prepare

排查第二步: 查

果然, 我們不太可能成為那個(gè)只有 70億(地球人口)分之一的幸運(yùn)兒, 這個(gè)坑果然有不少人踩過, Stack Overflow 有人提了相同的問題.

查文檔, Stack Overflow 里給的回答, 就來自官方的文檔 Pdo::prepare.

查的關(guān)鍵詞:

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

排查第三部: 加日志

目前只知道 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 的值以外, 好像沒有拿到更有用的信息.

排查第四步: 交流

單打獨(dú)斗許久之后, 尤其是打了日志還沒拿到有用信息后, 確實(shí)有點(diǎn) 沒頭腦. 這個(gè)時(shí)候:

  • 不要放棄, 拖著拖著, 可能就真的放棄了
  • 集思廣益: 和技術(shù)團(tuán)隊(duì)交流, 和技術(shù)社區(qū)交流

交流的好處:

  • 更多的嘗試, 更多的突破口
  • 更多的知識(shí), 更多技術(shù)細(xì)節(jié)

科學(xué)方法論: 找不同

正常態(tài) -> 異常態(tài), 而且還是必現(xiàn), 那么肯定有 固定原因, 這個(gè)時(shí)候拋棄 量子躍遷 見鬼了 等等想法, 選擇 科學(xué)方法:

科學(xué)實(shí)驗(yàn)的方法: 控制變量法. 換言之, 找不同.

明顯的不同, 環(huán)境不一樣:

  • 測試環(huán)境是好的: 測試環(huán)境使用的 rds(讀寫) + drds(讀寫)
  • 線上有問題: 線上使用正式的 rds(讀寫+只讀) + drds(讀寫+只讀)

添加測試代碼來比較不同(方法來自于社區(qū)):

$dsn = 'xxx'; // 分別使用線上的使用的鏈接信息
$pdo = new \PDO("mysql:host={$dsn};dbname=xxx", 'xxx', 'xxx');
$sql = 'xxx'; // 使用日志中打出的 query
$stmt = $pdo->prepare($sql);
var_dump($stmt);
  • 測試代碼正常返回 PDOStatement 對(duì)象, 不會(huì)返回 false

現(xiàn)在寫出來, 只有關(guān)鍵的 2 點(diǎn), 實(shí)際排查過程其實(shí)走了很多彎路, mark 一下, 引以為戒!

解決

既然有了 科學(xué)的方法, 那么就可以大膽的得出可靠的結(jié)局:

  • 環(huán)境的鍋!!! 和 aliyun drds 技術(shù)人員確認(rèn), drds只讀實(shí)例暫不支持 mysql prepare
  • 測試代碼表現(xiàn)和框架不一致, PDO 一定有配置控制相關(guān)的表現(xiàn)

框架層基于 laravel ORM, 默認(rèn)覆蓋了 PDO 的一些屬性(由 hyperf 社區(qū) 提供):

// 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' 屬性, 使用測試代碼驗(yàn)證:

$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

寫在最后

梳理涉及到的技術(shù)知識(shí):

  • (prepare sql: mysql prepare 協(xié)議使用說明](https://help.aliyun.com/document_detail/71326.html)
  • php 使用 PDO 訪問 mysql, 可以通過 pdo->prepare() 和 mysql prepare 協(xié)議交互
  • PDO 有很多屬性可以設(shè)置, 包括 prepare() 時(shí)的行為: `PDO::ATTR_EMULATE_PREPARES'

總結(jié)重要的幾點(diǎn):

  • 單測很重要, 上線后跑 phpunit, 立刻就發(fā)現(xiàn)了問題, 然后馬上開始填坑
  • 心理很重要: 不要怕問題, 都是 PHP 代碼, 有什么好怕的
  • 科學(xué)方法很重要: 看似做了 各種嘗試, 但是沒有科學(xué)的方法支撐, 反而在獲取到越來越多的信息后, 更容易迷茫, 不敢下結(jié)論
  • 事上練: 增加和周圍世界的聯(lián)系, 技術(shù)也可以做到, 多和 團(tuán)隊(duì)/社區(qū) 交流想法和知識(shí)

歷史類似經(jīng)歷:

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • pdo基本使用 【PDO是啥】 PDO是PHP 5新加入的一個(gè)重大功能,因?yàn)樵赑HP 5以前的php4/php3都...
    桖辶殤閱讀 1,450評(píng)論 0 4
  • 1 什么是MVC MVC模式(Model-View-Controller)是軟件工程中的一種軟件架構(gòu)模式。 MVC...
    申城墨道閱讀 2,141評(píng)論 0 10
  • ?你登上列車 去往千里之外 回眸一笑 帶走了我的思念 ?那異鄉(xiāng)飄下的雪 不要冷了你的身 打濕了我的眼 不愿讓你看見...
    雒云閱讀 259評(píng)論 1 7
  • 家鄉(xiāng)有一種非常引以為豪的樹——槐樹,每年的舊歷四月末五月初正是滿樹盛開、槐花飄香的季節(jié)。我最喜歡這種空氣中到處...
    緣末閱讀 1,112評(píng)論 15 21
  • 上午模擬考試,起床晚了,到考場時(shí)就已經(jīng)開始發(fā)試卷了。站在考場外不知所措,還是后面的同學(xué)催我進(jìn)去。在眾人注視下,我像...
    薄暮秋樺閱讀 373評(píng)論 1 0

友情鏈接更多精彩內(nèi)容