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 代碼, 有什麼好怕的
  • 科學方法很重要: 看似做了 各種嘗試, 但是沒有科學的方法支撐, 反而在獲取到越來越多的信息後, 更容易迷茫, 不敢下結論
  • 事上練: 增加和周圍世界的聯繫, 技術也可以做到, 多和 團隊/社區 交流想法和知識

歷史類似經歷:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章