fix(log): 修复 DebugMysql 驱动稳定性缺陷并统一 JSONL 降级

This commit is contained in:
augushong
2026-08-16 07:43:19 +08:00
parent 7ff83587b6
commit 413038b47d

View File

@@ -5,7 +5,14 @@ namespace think\log\driver;
use PDO; use PDO;
use think\contract\LogHandlerInterface; use think\contract\LogHandlerInterface;
use think\facade\App; use think\facade\App;
use think\log\DebugLogToolkit;
/**
* 数据库调试日志驱动(同步).
*
* 正常路径:单次 save() 攒批多值 INSERT按行数/绑定体积分片)。
* 降级路径DB 不可达/重连耗尽 -> 熔断 60s + DebugLogToolkit 逐行写 JSONL。
*/
class DebugMysql implements LogHandlerInterface class DebugMysql implements LogHandlerInterface
{ {
protected $enableLog = true; protected $enableLog = true;
@@ -17,15 +24,35 @@ class DebugMysql implements LogHandlerInterface
*/ */
protected $pdo = null; protected $pdo = null;
protected $file = null;
protected $fileRescource = null;
protected $tableName = ''; protected $tableName = '';
protected $reConnectTimes = 0; protected $reConnectTimes = 0;
protected $fileLogTimes = 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; public $devMode = false;
@@ -74,47 +101,23 @@ class DebugMysql implements LogHandlerInterface
$this->config = array_merge($this->config, $config); $this->config = array_merge($this->config, $config);
} }
$this->tableName = ($config['prefix'] ?? 'ul_') . 'debug_log';
try { try {
$this->initConnect(); $this->initConnect();
} catch (\Throwable $th) { } catch (\Throwable $th) {
$this->pdo = null; $this->pdo = null;
$this->initFile();
} }
$this->tableName = $config['prefix'] . 'debug_log';
} }
public function save(array $log): bool public function save(array $log): bool
{ {
$app_name = app('http')->getName() ?: ''; $rows = [];
$controller_name = '';
$action_name = '';
if (App::runningInConsole()) {
$app_name = 'cli';
} else {
$controller_name = request()->controller();
$action_name = request()->action();
}
$create_time = time();
$create_time_title = date('Y-m-d H:i:s', $create_time);
$log_key = '';
if (defined('REUQEST_UID')) {
$log_key = REUQEST_UID;
} else {
$log_key = uniqid();
}
foreach ($log as $log_item) { foreach ($log as $log_item) {
// 适配 ThinkPHP 8.x 的 LogRecord 对象格式 // 适配 ThinkPHP 8.x 的 LogRecord 对象格式
// 兼容旧格式:包含 type 和 message 属性的对象 // 兼容旧格式:包含 type 和 message 属性的对象
if (is_object($log_item) && isset($log_item->type) && isset($log_item->message)) { if (is_object($log_item) && isset($log_item->type) && isset($log_item->message)) {
$log_level = $log_item->type;
$log_content = $log_item->message; $log_content = $log_item->message;
} else { } else {
continue; continue;
@@ -124,93 +127,162 @@ class DebugMysql implements LogHandlerInterface
$log_content = print_r($log_content, true); $log_content = print_r($log_content, true);
} }
$log_data = [ // 行构建CLI 判断前置、uid 解析)统一走 Toolkit
'level' => $log_level, $rows[] = DebugLogToolkit::buildRow((string) $log_item->type, $log_content);
'content' => $log_content, }
'create_time' => $create_time,
'create_time_title' => $create_time_title,
'uid' => $log_key,
'app_name' => $app_name,
'controller_name' => $controller_name,
'action_name' => $action_name,
];
try { if ($rows === []) {
if (!is_null($this->pdo)) { return true;
$this->saveByConnect($log_data); }
} else {
$this->saveByFile($log_data); // DB 熔断窗口内直接走文件路径,不尝试 DB
} if (time() < self::$breakerUntil) {
} catch (\Throwable $th) { return $this->saveByFile($rows);
$this->saveByFile($log_data); }
}
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; return true;
} }
protected function saveByConnect($log_data) /**
* 批量写库:分片多值 INSERT断线时 while 循环重连重试.
*
* 重连最多 MAX_RECONNECT_TIMES 次(计数仅在写库成功一次后清零);
* 耗尽或重连失败 -> 熔断 + 剩余行全部文件降级,不再 throw。
*/
protected function saveByConnect(array $rows)
{ {
if (is_null($this->pdo)) { $chunks = $this->splitRowsForInsert($rows);
$this->saveByFile($log_data); $chunkCount = count($chunks);
return; for ($i = 0; $i < $chunkCount; $i++) {
} $inserted = false;
$this->devLog('save by connect'); while (!$inserted) {
$prepare_name = []; try {
foreach ($log_data as $key => $value) { $this->insertRows($chunks[$i]);
$prepare_name[] = ':' . $key; $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();
$data_keys = array_keys($log_data); if (!is_null($this->pdo)) {
// 重连成功,重试当前分片
continue;
}
$data_keys_in_sql = implode(',', $data_keys); // 重连失败:连接失败熔断
self::$breakerUntil = time() + static::BREAKER_SECONDS;
} else {
// 重连耗尽:熔断
self::$breakerUntil = time() + static::BREAKER_SECONDS;
}
}
$prepare_name_in_sql = implode(',', $prepare_name); // 非 break 异常 / 熔断后:当前及剩余分片全部降级写文件
$this->saveByFile(array_merge(...array_slice($chunks, $i)));
$sql = "INSERT INTO {$this->tableName} ($data_keys_in_sql) VALUES ($prepare_name_in_sql);"; return;
try {
$stmt = $this->pdo->prepare($sql);
$stmt->execute($log_data);
} catch (\Exception $th) {
if ($this->isBreak($th)) {
if ($this->reConnectTimes > 3) {
$this->initFile();
throw $th;
} }
$this->initConnect();
$this->reConnectTimes++;
$this->devLog('reconnect ' . $this->reConnectTimes);
$this->saveByConnect($log_data);
} else {
$this->saveByFile($log_data);
} }
// 成功写库一次:清零重连计数与熔断窗口
$this->reConnectTimes = 0;
self::$breakerUntil = 0;
} }
} }
protected function saveByFile($log_data) /**
* 单分片多值 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'); $this->devLog('save by file');
// 如果文件日志超过100条尝试重新通过数据库连接 foreach ($rows as $row) {
if ($this->fileLogTimes > 10) { DebugLogToolkit::appendLine(DebugLogToolkit::encodeRow($row));
$this->fileLogTimes = 0;
$this->initConnect();
$this->saveByConnect($log_data);
return;
} }
try { return true;
fputcsv($this->fileRescource, $log_data);
$this->fileLogTimes++;
} catch (\Throwable $th) {
$this->initFile();
$this->fileLogTimes++;
$this->saveByFile($log_data);
}
} }
protected function initConnect() protected function initConnect()
@@ -221,13 +293,11 @@ class DebugMysql implements LogHandlerInterface
$this->pdo = null; $this->pdo = null;
} }
$this->reConnectTimes = 0;
$config = $this->config; $config = $this->config;
$dsn = $this->parseDsn($config); $dsn = $this->parseDsn($config);
try { try {
$pdo = $this->createPdo($dsn, $config['username'], $config['password'], $config['params']); $pdo = $this->createPdo($dsn, $config['username'], $config['password'], $config['params'] ?? []);
$this->pdo = $pdo; $this->pdo = $pdo;
} catch (\Throwable $th) { } catch (\Throwable $th) {
$this->pdo = null; $this->pdo = null;
@@ -236,46 +306,6 @@ class DebugMysql implements LogHandlerInterface
return $this; return $this;
} }
protected function initFile()
{
$this->devLog('init file');
if (!is_null($this->fileRescource)) {
return $this;
}
$log_path = App::getRuntimePath() . 'log/' . date('ymd') . '.csv';
$dirname = dirname($log_path);
if (!is_dir($dirname)) {
mkdir($dirname, 0777, true);
}
$first_line = false;
if (!file_exists($log_path)) {
$first_line = true;
}
$this->fileRescource = fopen($log_path, 'a');
if ($first_line) {
$fields = [
'level',
'content',
'create_time',
'create_time_title',
'uid',
'app_name',
'controller_name',
'action_name',
];
fputcsv($this->fileRescource, $fields);
}
return $this;
}
/** /**
* 是否断线 * 是否断线
* *
@@ -321,16 +351,18 @@ class DebugMysql implements LogHandlerInterface
protected function createPdo($dsn, $username, $password, $params) 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); return new PDO($dsn, $username, $password, $params);
} }
public function __destruct() public function __destruct()
{ {
$this->pdo = null; $this->pdo = null;
if (!is_null($this->fileRescource)) {
fclose($this->fileRescource);
}
} }
protected function devLog($content) protected function devLog($content)