diff --git a/extend/think/log/driver/DebugMysql.php b/extend/think/log/driver/DebugMysql.php index 44ac683..62f8fad 100644 --- a/extend/think/log/driver/DebugMysql.php +++ b/extend/think/log/driver/DebugMysql.php @@ -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> + */ + 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)