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 think\contract\LogHandlerInterface;
use think\facade\App;
use think\log\DebugLogToolkit;
/**
* 数据库调试日志驱动(同步).
*
* 正常路径:单次 save() 攒批多值 INSERT按行数/绑定体积分片)。
* 降级路径DB 不可达/重连耗尽 -> 熔断 60s + DebugLogToolkit 逐行写 JSONL。
*/
class DebugMysql implements LogHandlerInterface
{
protected $enableLog = true;
@@ -17,15 +24,35 @@ class DebugMysql implements LogHandlerInterface
*/
protected $pdo = null;
protected $file = null;
protected $fileRescource = null;
protected $tableName = '';
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;
@@ -74,47 +101,23 @@ class DebugMysql implements LogHandlerInterface
$this->config = array_merge($this->config, $config);
}
$this->tableName = ($config['prefix'] ?? 'ul_') . 'debug_log';
try {
$this->initConnect();
} catch (\Throwable $th) {
$this->pdo = null;
$this->initFile();
}
$this->tableName = $config['prefix'] . 'debug_log';
}
public function save(array $log): bool
{
$app_name = app('http')->getName() ?: '';
$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();
}
$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_level = $log_item->type;
$log_content = $log_item->message;
} else {
continue;
@@ -124,93 +127,162 @@ class DebugMysql implements LogHandlerInterface
$log_content = print_r($log_content, true);
}
$log_data = [
'level' => $log_level,
'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,
];
// 行构建CLI 判断前置、uid 解析)统一走 Toolkit
$rows[] = DebugLogToolkit::buildRow((string) $log_item->type, $log_content);
}
try {
if (!is_null($this->pdo)) {
$this->saveByConnect($log_data);
} else {
$this->saveByFile($log_data);
}
} catch (\Throwable $th) {
$this->saveByFile($log_data);
}
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;
}
protected function saveByConnect($log_data)
/**
* 批量写库:分片多值 INSERT断线时 while 循环重连重试.
*
* 重连最多 MAX_RECONNECT_TIMES 次(计数仅在写库成功一次后清零);
* 耗尽或重连失败 -> 熔断 + 剩余行全部文件降级,不再 throw。
*/
protected function saveByConnect(array $rows)
{
if (is_null($this->pdo)) {
$this->saveByFile($log_data);
$chunks = $this->splitRowsForInsert($rows);
$chunkCount = count($chunks);
return;
}
for ($i = 0; $i < $chunkCount; $i++) {
$inserted = false;
$this->devLog('save by connect');
$prepare_name = [];
foreach ($log_data as $key => $value) {
$prepare_name[] = ':' . $key;
}
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();
$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);";
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;
return;
}
$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');
// 如果文件日志超过100条尝试重新通过数据库连接
if ($this->fileLogTimes > 10) {
$this->fileLogTimes = 0;
$this->initConnect();
$this->saveByConnect($log_data);
return;
foreach ($rows as $row) {
DebugLogToolkit::appendLine(DebugLogToolkit::encodeRow($row));
}
try {
fputcsv($this->fileRescource, $log_data);
$this->fileLogTimes++;
} catch (\Throwable $th) {
$this->initFile();
$this->fileLogTimes++;
$this->saveByFile($log_data);
}
return true;
}
protected function initConnect()
@@ -221,13 +293,11 @@ class DebugMysql implements LogHandlerInterface
$this->pdo = null;
}
$this->reConnectTimes = 0;
$config = $this->config;
$dsn = $this->parseDsn($config);
try {
$pdo = $this->createPdo($dsn, $config['username'], $config['password'], $config['params']);
$pdo = $this->createPdo($dsn, $config['username'], $config['password'], $config['params'] ?? []);
$this->pdo = $pdo;
} catch (\Throwable $th) {
$this->pdo = null;
@@ -236,46 +306,6 @@ class DebugMysql implements LogHandlerInterface
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)
{
// 强制异常模式与连接超时,保证 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;
if (!is_null($this->fileRescource)) {
fclose($this->fileRescource);
}
}
protected function devLog($content)