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