Files
ulthon_admin/extend/think/log/driver/DebugMysql.php

375 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 写 JSONLToolkit 绝不抛异常,写失败返回 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);
}
}
}