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)歷: