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

本文涉及的产品
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
云原生数据库 PolarDB 分布式版,标准版 2核8GB
简介: 选择 `言简意赅` 作为技术 blog 的写作风格, 放弃使用 `故事型` 风格, 这样:- 行文不会太长, 写起来容易, 读起来也轻松. - 围绕技术展开, 不会离题太远

选择 言简意赅 作为技术 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 代码, 有什么好怕的
  • 科学方法很重要: 看似做了 各种尝试, 但是没有科学的方法支撑, 反而在获取到越来越多的信息后, 更容易迷茫, 不敢下结论
  • 事上练: 增加和周围世界的联系, 技术也可以做到, 多和 团队/社区 交流想法和知识

历史类似经历:

相关实践学习
快速体验PolarDB开源数据库
本实验环境已内置PostgreSQL数据库以及PolarDB开源数据库:PolarDB PostgreSQL版和PolarDB分布式版,支持一键拉起使用,方便各位开发者学习使用。
目录
相关文章
|
3月前
|
SQL 数据库
数据库数据恢复—SQL Server数据库报错“错误823”的数据恢复案例
SQL Server附加数据库出现错误823,附加数据库失败。数据库没有备份,无法通过备份恢复数据库。 SQL Server数据库出现823错误的可能原因有:数据库物理页面损坏、数据库物理页面校验值损坏导致无法识别该页面、断电或者文件系统问题导致页面丢失。
115 12
数据库数据恢复—SQL Server数据库报错“错误823”的数据恢复案例
|
2月前
|
存储 SQL 关系型数据库
PHP与数据库交互:从基础到进阶
【10月更文挑战第9天】在编程的世界里,数据是流动的血液,而数据库则是存储这些珍贵资源的心脏。PHP作为一门流行的服务器端脚本语言,其与数据库的交互能力至关重要。本文将带你从PHP与数据库的基本连接开始,逐步深入到复杂查询的编写和优化,以及如何使用PHP处理数据库结果。无论你是初学者还是有一定经验的开发者,这篇文章都将为你提供宝贵的知识和技巧,让你在PHP和数据库交互的道路上更加从容不迫。
|
3月前
|
关系型数据库 MySQL 数据库
ORM对mysql数据库中数据进行操作报错解决
ORM对mysql数据库中数据进行操作报错解决
99 2
|
1月前
|
存储 监控 数据处理
flink 向doris 数据库写入数据时出现背压如何排查?
本文介绍了如何确定和解决Flink任务向Doris数据库写入数据时遇到的背压问题。首先通过Flink Web UI和性能指标监控识别背压,然后从Doris数据库性能、网络连接稳定性、Flink任务数据处理逻辑及资源配置等方面排查原因,并通过分析相关日志进一步定位问题。
176 61
|
1月前
|
JSON Java 关系型数据库
Java更新数据库报错:Data truncation: Cannot create a JSON value from a string with CHARACTER SET 'binary'.
在Java中,使用mybatis-plus更新实体类对象到mysql,其中一个字段对应数据库中json数据类型,更新时报错:Data truncation: Cannot create a JSON value from a string with CHARACTER SET 'binary'.
84 4
Java更新数据库报错:Data truncation: Cannot create a JSON value from a string with CHARACTER SET 'binary'.
|
3月前
|
SQL 关系型数据库 数据库
PostgreSQL数据库报错 ERROR: multiple default values specified for column "" of table "" 如何解决?
PostgreSQL数据库报错 ERROR: multiple default values specified for column "" of table "" 如何解决?
384 59
|
1月前
|
SQL 关系型数据库 MySQL
12 PHP配置数据库MySQL
路老师分享了PHP操作MySQL数据库的方法,包括安装并连接MySQL服务器、选择数据库、执行SQL语句(如插入、更新、删除和查询),以及将结果集返回到数组。通过具体示例代码,详细介绍了每一步的操作流程,帮助读者快速入门PHP与MySQL的交互。
43 1
|
1月前
|
网络安全 数据库
gbase 8a 数据库 安装8ampp 常见ssh报错问题
gbase 8a 数据库 安装8ampp 常见ssh报错问题
|
1月前
|
关系型数据库 MySQL Linux
Linux系统如何设置自启动服务在MySQL数据库启动后执行?
【10月更文挑战第25天】Linux系统如何设置自启动服务在MySQL数据库启动后执行?
125 3
|
1月前
|
关系型数据库 MySQL Linux
在 CentOS 7 中通过编译源码方式安装 MySQL 数据库的详细步骤,包括准备工作、下载源码、编译安装、配置 MySQL 服务、登录设置等。
本文介绍了在 CentOS 7 中通过编译源码方式安装 MySQL 数据库的详细步骤,包括准备工作、下载源码、编译安装、配置 MySQL 服务、登录设置等。同时,文章还对比了编译源码安装与使用 RPM 包安装的优缺点,帮助读者根据需求选择最合适的方法。通过具体案例,展示了编译源码安装的灵活性和定制性。
169 2