mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-30 04:35:33 +08:00
fix(log): 修复 DebugMysql 驱动稳定性缺陷并统一 JSONL 降级
This commit is contained in:
@@ -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 写 JSONL(Toolkit 绝不抛异常,写失败返回 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)
|
||||
|
||||
Reference in New Issue
Block a user