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