mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-30 20:55:32 +08:00
375 lines
11 KiB
PHP
375 lines
11 KiB
PHP
<?php
|
||
|
||
namespace think\log\driver;
|
||
|
||
use PDO;
|
||
use think\contract\LogHandlerInterface;
|
||
use think\facade\App;
|
||
use think\log\DebugLogToolkit;
|
||
|
||
/**
|
||
* 数据库调试日志驱动(同步).
|
||
*
|
||
* 正常路径:单次 save() 攒批多值 INSERT(按行数/绑定体积分片)。
|
||
* 降级路径:DB 不可达/重连耗尽 -> 熔断 60s + DebugLogToolkit 逐行写 JSONL。
|
||
*/
|
||
class DebugMysql implements LogHandlerInterface
|
||
{
|
||
protected $enableLog = true;
|
||
|
||
protected $config = [];
|
||
|
||
/**
|
||
* @var PDO
|
||
*/
|
||
protected $pdo = null;
|
||
|
||
protected $tableName = '';
|
||
|
||
protected $reConnectTimes = 0;
|
||
|
||
/**
|
||
* DB 熔断窗口截止时间戳:连接失败或重连耗尽时置 time()+60,
|
||
* 窗口内 save() 直接走文件降级;写库成功一次后清零.
|
||
*/
|
||
protected static int $breakerUntil = 0;
|
||
|
||
/**
|
||
* 断线重连最大尝试次数.
|
||
*/
|
||
protected const MAX_RECONNECT_TIMES = 3;
|
||
|
||
/**
|
||
* 熔断窗口时长(秒).
|
||
*/
|
||
protected const BREAKER_SECONDS = 60;
|
||
|
||
/**
|
||
* 批量 INSERT 分片上限:单条 INSERT 最大行数.
|
||
*/
|
||
protected const MAX_ROWS_PER_INSERT = 500;
|
||
|
||
/**
|
||
* 批量 INSERT 分片上限:单条 INSERT 绑定值累计字节数(与行数上限先到为准).
|
||
*/
|
||
protected const MAX_BIND_BYTES_PER_INSERT = 262144;
|
||
|
||
public $devMode = false;
|
||
|
||
/**
|
||
* 服务器断线标识字符.
|
||
*
|
||
* @var array
|
||
*/
|
||
protected $breakMatchStr = [
|
||
'server has gone away',
|
||
'no connection to the server',
|
||
'Lost connection',
|
||
'is dead or not enabled',
|
||
'Error while sending',
|
||
'decryption failed or bad record mac',
|
||
'server closed the connection unexpectedly',
|
||
'SSL connection has been closed unexpectedly',
|
||
'Error writing data to the connection',
|
||
'Resource deadlock avoided',
|
||
'failed with errno',
|
||
'child connection forced to terminate due to client_idle_limit',
|
||
'query_wait_timeout',
|
||
'reset by peer',
|
||
'Physical connection is not usable',
|
||
'TCP Provider: Error code 0x68',
|
||
'ORA-03114',
|
||
'Packets out of order. Expected',
|
||
'Adaptive Server connection failed',
|
||
'Communication link failure',
|
||
'connection is no longer usable',
|
||
'Login timeout expired',
|
||
'SQLSTATE[HY000] [2002] Connection refused',
|
||
'running with the --read-only option so it cannot execute this statement',
|
||
'The connection is broken and recovery is not possible. The connection is marked by the client driver as unrecoverable. No attempt was made to restore the connection.',
|
||
'SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo failed: Try again',
|
||
'SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo failed: Name or service not known',
|
||
'SQLSTATE[HY000]: General error: 7 SSL SYSCALL error: EOF detected',
|
||
'SQLSTATE[HY000] [2002] Connection timed out',
|
||
'SSL: Connection timed out',
|
||
'SQLSTATE[HY000]: General error: 1105 The last transaction was aborted due to Seamless Scaling. Please retry.',
|
||
];
|
||
|
||
public function __construct(App $app, $config = [])
|
||
{
|
||
if (is_array($config)) {
|
||
$this->config = array_merge($this->config, $config);
|
||
}
|
||
|
||
$this->tableName = ($config['prefix'] ?? 'ul_') . 'debug_log';
|
||
|
||
try {
|
||
$this->initConnect();
|
||
} catch (\Throwable $th) {
|
||
$this->pdo = null;
|
||
}
|
||
}
|
||
|
||
public function save(array $log): bool
|
||
{
|
||
$rows = [];
|
||
|
||
foreach ($log as $log_item) {
|
||
// 适配 ThinkPHP 8.x 的 LogRecord 对象格式
|
||
// 兼容旧格式:包含 type 和 message 属性的对象
|
||
if (is_object($log_item) && isset($log_item->type) && isset($log_item->message)) {
|
||
$log_content = $log_item->message;
|
||
} else {
|
||
continue;
|
||
}
|
||
|
||
if (!is_string($log_content)) {
|
||
$log_content = print_r($log_content, true);
|
||
}
|
||
|
||
// 行构建(CLI 判断前置、uid 解析)统一走 Toolkit
|
||
$rows[] = DebugLogToolkit::buildRow((string) $log_item->type, $log_content);
|
||
}
|
||
|
||
if ($rows === []) {
|
||
return true;
|
||
}
|
||
|
||
// DB 熔断窗口内直接走文件路径,不尝试 DB
|
||
if (time() < self::$breakerUntil) {
|
||
return $this->saveByFile($rows);
|
||
}
|
||
|
||
if (is_null($this->pdo)) {
|
||
$this->initConnect();
|
||
}
|
||
|
||
if (is_null($this->pdo)) {
|
||
// 连接失败:进入熔断窗口并降级写文件
|
||
self::$breakerUntil = time() + static::BREAKER_SECONDS;
|
||
|
||
return $this->saveByFile($rows);
|
||
}
|
||
|
||
try {
|
||
$this->saveByConnect($rows);
|
||
} catch (\Throwable $th) {
|
||
// 兜底:批插逃逸异常 -> 整批降级逐行写文件
|
||
$this->saveByFile($rows);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 批量写库:分片多值 INSERT,断线时 while 循环重连重试.
|
||
*
|
||
* 重连最多 MAX_RECONNECT_TIMES 次(计数仅在写库成功一次后清零);
|
||
* 耗尽或重连失败 -> 熔断 + 剩余行全部文件降级,不再 throw。
|
||
*/
|
||
protected function saveByConnect(array $rows)
|
||
{
|
||
$chunks = $this->splitRowsForInsert($rows);
|
||
$chunkCount = count($chunks);
|
||
|
||
for ($i = 0; $i < $chunkCount; $i++) {
|
||
$inserted = false;
|
||
|
||
while (!$inserted) {
|
||
try {
|
||
$this->insertRows($chunks[$i]);
|
||
$inserted = true;
|
||
} catch (\Throwable $th) {
|
||
if ($this->isBreak($th)) {
|
||
if ($this->reConnectTimes < static::MAX_RECONNECT_TIMES) {
|
||
$this->reConnectTimes++;
|
||
$this->devLog('reconnect ' . $this->reConnectTimes);
|
||
$this->initConnect();
|
||
|
||
if (!is_null($this->pdo)) {
|
||
// 重连成功,重试当前分片
|
||
continue;
|
||
}
|
||
|
||
// 重连失败:连接失败熔断
|
||
self::$breakerUntil = time() + static::BREAKER_SECONDS;
|
||
} else {
|
||
// 重连耗尽:熔断
|
||
self::$breakerUntil = time() + static::BREAKER_SECONDS;
|
||
}
|
||
}
|
||
|
||
// 非 break 异常 / 熔断后:当前及剩余分片全部降级写文件
|
||
$this->saveByFile(array_merge(...array_slice($chunks, $i)));
|
||
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 成功写库一次:清零重连计数与熔断窗口
|
||
$this->reConnectTimes = 0;
|
||
self::$breakerUntil = 0;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 单分片多值 INSERT(占位符 ? 绑定).
|
||
*/
|
||
protected function insertRows(array $rows)
|
||
{
|
||
$fields = DebugLogToolkit::FIELDS;
|
||
|
||
$rowPlaceholder = '(' . implode(',', array_fill(0, count($fields), '?')) . ')';
|
||
$sql = 'INSERT INTO ' . $this->tableName . ' (' . implode(',', $fields) . ') VALUES '
|
||
. implode(',', array_fill(0, count($rows), $rowPlaceholder));
|
||
|
||
$bindValues = [];
|
||
foreach ($rows as $row) {
|
||
foreach ($fields as $field) {
|
||
$bindValues[] = $row[$field];
|
||
}
|
||
}
|
||
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute($bindValues);
|
||
}
|
||
|
||
/**
|
||
* 按 MAX_ROWS_PER_INSERT 行 / MAX_BIND_BYTES_PER_INSERT 绑定字节数(先到为准)分片.
|
||
*
|
||
* @return array<array<int, array>>
|
||
*/
|
||
protected function splitRowsForInsert(array $rows): array
|
||
{
|
||
$chunks = [];
|
||
$currentRows = [];
|
||
$currentBytes = 0;
|
||
|
||
foreach ($rows as $row) {
|
||
$rowBytes = 0;
|
||
foreach ($row as $value) {
|
||
$rowBytes += strlen((string) $value);
|
||
}
|
||
|
||
$rowsFull = count($currentRows) >= static::MAX_ROWS_PER_INSERT;
|
||
$bytesFull = $currentBytes + $rowBytes > static::MAX_BIND_BYTES_PER_INSERT;
|
||
|
||
if (($rowsFull || $bytesFull) && $currentRows !== []) {
|
||
$chunks[] = $currentRows;
|
||
$currentRows = [];
|
||
$currentBytes = 0;
|
||
}
|
||
|
||
$currentRows[] = $row;
|
||
$currentBytes += $rowBytes;
|
||
}
|
||
|
||
if ($currentRows !== []) {
|
||
$chunks[] = $currentRows;
|
||
}
|
||
|
||
return $chunks;
|
||
}
|
||
|
||
/**
|
||
* 文件降级:逐行经 Toolkit 写 JSONL(Toolkit 绝不抛异常,写失败返回 false 静默).
|
||
*/
|
||
protected function saveByFile(array $rows)
|
||
{
|
||
$this->devLog('save by file');
|
||
|
||
foreach ($rows as $row) {
|
||
DebugLogToolkit::appendLine(DebugLogToolkit::encodeRow($row));
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
protected function initConnect()
|
||
{
|
||
$this->devLog('init connect');
|
||
|
||
if (!is_null($this->pdo)) {
|
||
$this->pdo = null;
|
||
}
|
||
|
||
$config = $this->config;
|
||
|
||
$dsn = $this->parseDsn($config);
|
||
try {
|
||
$pdo = $this->createPdo($dsn, $config['username'], $config['password'], $config['params'] ?? []);
|
||
$this->pdo = $pdo;
|
||
} catch (\Throwable $th) {
|
||
$this->pdo = null;
|
||
}
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* 是否断线
|
||
*
|
||
* @param \PDOException|\Exception $e 异常对象
|
||
*
|
||
* @return bool
|
||
*/
|
||
protected function isBreak($e): bool
|
||
{
|
||
$error = $e->getMessage();
|
||
|
||
foreach ($this->breakMatchStr as $msg) {
|
||
if (false !== stripos($error, $msg)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 解析pdo连接的dsn信息.
|
||
* @param array $config 连接信息
|
||
* @return string
|
||
*/
|
||
protected function parseDsn(array $config): string
|
||
{
|
||
if (!empty($config['socket'])) {
|
||
$dsn = 'mysql:unix_socket=' . $config['socket'];
|
||
} elseif (!empty($config['hostport'])) {
|
||
$dsn = 'mysql:host=' . $config['hostname'] . ';port=' . $config['hostport'];
|
||
} else {
|
||
$dsn = 'mysql:host=' . $config['hostname'];
|
||
}
|
||
$dsn .= ';dbname=' . $config['database'];
|
||
|
||
if (!empty($config['charset'])) {
|
||
$dsn .= ';charset=' . $config['charset'];
|
||
}
|
||
|
||
return $dsn;
|
||
}
|
||
|
||
protected function createPdo($dsn, $username, $password, $params)
|
||
{
|
||
// 强制异常模式与连接超时,保证 prepare/execute 失败走异常路径(用户 params 可覆盖)
|
||
$params = array_merge([
|
||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||
PDO::ATTR_TIMEOUT => 3,
|
||
], (array) $params);
|
||
|
||
return new PDO($dsn, $username, $password, $params);
|
||
}
|
||
|
||
public function __destruct()
|
||
{
|
||
$this->pdo = null;
|
||
}
|
||
|
||
protected function devLog($content)
|
||
{
|
||
if ($this->devMode) {
|
||
dump($content);
|
||
}
|
||
}
|
||
}
|