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

本文涉及的产品
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
云数据库 RDS MySQL,集群版 2核4GB 100GB
推荐场景:
搭建个人博客
云原生数据库 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 代码, 有什么好怕的
  • 科学方法很重要: 看似做了 各种尝试, 但是没有科学的方法支撑, 反而在获取到越来越多的信息后, 更容易迷茫, 不敢下结论
  • 事上练: 增加和周围世界的联系, 技术也可以做到, 多和 团队/社区 交流想法和知识

历史类似经历:

相关实践学习
助力游戏运营数据分析
本体验通过多产品组合构建了游戏数据运营分析平台,提供全面的游戏运营指标分析功能,并有效的分析渠道效果。更加有效地掌握游戏运营状态,也可充分利用数据分析的结果改进产品体验,提高游戏收益。
目录
相关文章
|
6天前
|
SQL 关系型数据库 MySQL
✅难得真实的生产数据库死锁问题排查过程
在MySQL 5.7的InnoDB环境中,一个生产问题涉及死锁,发生在更新`fund_transfer_stream`表时。死锁由两个并发事务引起,各自持有不同索引的锁并等待对方释放。事务1持有`idx_seller_transNo`索引锁,等待`PRIMARY`索引锁;事务2相反。问题源于`fund_transfer_order_no`的前20位相同导致的索引冲突,而这是非唯一索引。解决方法包括调整索引前缀长度或确保所有更新通过主键ID进行。死锁排查需查看执行计划和死锁日志,理解MySQL的加锁机制。
✅难得真实的生产数据库死锁问题排查过程
|
2天前
|
运维 数据管理 数据库
数据管理DMS产品使用合集之遇到报错:数据库账号没有权限执行,该如何排查
阿里云数据管理DMS提供了全面的数据管理、数据库运维、数据安全、数据迁移与同步等功能,助力企业高效、安全地进行数据库管理和运维工作。以下是DMS产品使用合集的详细介绍。
11 2
|
2天前
|
Java Devops API
阿里云云效操作报错合集之云效页面提示数据库保存不进去,该怎么办
本合集将整理呈现用户在使用过程中遇到的报错及其对应的解决办法,包括但不限于账户权限设置错误、项目配置不正确、代码提交冲突、构建任务执行失败、测试环境异常、需求流转阻塞等问题。阿里云云效是一站式企业级研发协同和DevOps平台,为企业提供从需求规划、开发、测试、发布到运维、运营的全流程端到端服务和工具支撑,致力于提升企业的研发效能和创新能力。
|
7天前
|
分布式计算 大数据 数据处理
MaxCompute操作报错合集之odps数据库T1有几百行的数据,为什么出来只有5行的数据
MaxCompute是阿里云提供的大规模离线数据处理服务,用于大数据分析、挖掘和报表生成等场景。在使用MaxCompute进行数据处理时,可能会遇到各种操作报错。以下是一些常见的MaxCompute操作报错及其可能的原因与解决措施的合集。
|
3天前
|
SQL 存储 监控
达梦数据库死锁排查与解决
达梦数据库死锁排查与解决
7 0
|
5天前
|
PHP 数据库
phpMyAdmin报错 in ./libraries/config/FormDisplay.php#661 continue targeting switch is equivalent to
phpMyAdmin报错 in ./libraries/config/FormDisplay.php#661 continue targeting switch is equivalent to
7 0
|
6天前
|
关系型数据库 MySQL 数据库
Mysql数据库服务的启动与停止及数据库选择
Mysql数据库服务的启动与停止及数据库选择
10 0
|
7天前
|
SQL 分布式计算 MaxCompute
MaxCompute操作报错合集之通过UDF(用户定义函数)请求外部数据库资源并遇到报错,是什么原因
MaxCompute是阿里云提供的大规模离线数据处理服务,用于大数据分析、挖掘和报表生成等场景。在使用MaxCompute进行数据处理时,可能会遇到各种操作报错。以下是一些常见的MaxCompute操作报错及其可能的原因与解决措施的合集。
|
13天前
|
关系型数据库 MySQL API
实时计算 Flink版操作报错合集之同步MySQL数据到另一个MySQL数据库,第一次同步后源表数据发生变化时目标表没有相应更新,且Web UI中看不到运行的任务,该怎么解决
在使用实时计算Flink版过程中,可能会遇到各种错误,了解这些错误的原因及解决方法对于高效排错至关重要。针对具体问题,查看Flink的日志是关键,它们通常会提供更详细的错误信息和堆栈跟踪,有助于定位问题。此外,Flink社区文档和官方论坛也是寻求帮助的好去处。以下是一些常见的操作报错及其可能的原因与解决策略。
|
3天前
|
存储 关系型数据库 MySQL