feat(tools): XHProf 日志导入三层服务与函数监控物化

- XhprofProfileParserService(Base/App):纯数组解析 jsonl 行,
  meta 字段以真实 fixture 为准;simple_url 缺失兜底与采集端回调同算法;
  watch 正则按 callee 物化(ct/wt_sum/wt_max),非法正则编译计 fail 跳过
- XhprofLogReaderService(Base/App):增量读取,半行缓冲超限整行丢弃
  (不照抄 nginx 8MB 强制切行);EOF 无换行半行不 yield 不推进 offset;
  进度不自动落盘,由导入侧在同一事务内显式 savePosition
- XhprofLogImport 定时任务(Base/App):不受 XHPROF_ENABLE 门控,
  runs-*.jsonl 按文件名序增量导入;run 逐条 insert + detail chunk 20
  insertAll + run_watch insertAll 与 position 同事务落盘;导至 EOF 后
  只删已导完文件
- 修复 T1 缺陷:request_time decimal(12,3) 装不下 10 位时间戳,
  Scheme 与 migration 同步改 decimal(13,3)
- 新增 parser 单元测试(真实 fixture 断言,9 用例 42 断言)
  与 jsonl fixture;phpunit 注册 unit 测试套件
This commit is contained in:
augushong
2026-08-15 06:11:01 +08:00
parent b472df8c1d
commit a43a9a48b9
11 changed files with 1054 additions and 2 deletions

View File

@@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
namespace base\tools\controller\timer;
use app\admin\model\XhprofRun;
use app\admin\model\XhprofRunDetail;
use app\admin\model\XhprofRunWatch;
use app\admin\model\XhprofWatch;
use app\common\controller\TimerController;
use think\facade\Db;
use think\facade\Log;
/**
* XHProf jsonl 导入定时任务Base 层).
*
* 流程:
* 1. 扫描 XHPROF_FILE_DIR 下 runs-*.jsonl 按文件名序(旧日期先导)
* 2. 依赖倒置:实例化 app 层 Parser / Readerwatch 正则 per 批次编译一次
* 3. 每文件增量读取position 按 node_id+file_path 定位inode 变化归零)→
* run 逐条 insert 拿自增 id → detail chunk 20 insertAll →
* run_watch 物化聚合 insertAll同一事务内 insertBatch + savePosition消崩溃重复窗口
* 4. 文件导至 EOF 且 position 落盘后 unlink只删已导完文件
*
* 门控约定:本任务不受 XHPROF_ENABLE 门控——开关只管采集端,
* 已落盘的残留文件必须能导完(关闭采集后存量数据照常入库)。
*
* 目录单一来源getenv('XHPROF_FILE_DIR'),与采集端 bootstrap 读同一变量,
* 禁止与 ThinkPHP env() 混用(两处漂移会导致导入读不到文件)。
*
* 业务侧如需定制导入逻辑,重写 app/tools/controller/timer/XhprofLogImport.php 对应方法即可拦截。
*/
class XhprofLogImportBase extends TimerController
{
/**
* 防刷间隔(秒).
* 仅做控制器侧防刷,不影响定时器侧 frequency 节流。
*/
protected $frequency = 60;
/**
* 并发上限 cap单分片id=0。jsonl 是节点本地文件,多节点各自导自己的。
*/
protected $concurrency = 1;
/**
* run 行缓冲上限(同时是 detail insertAll 的 chunk单行实测 ~100KB20 条一批防单条 SQL 过大).
*/
protected const RUN_BATCH = 20;
/**
* 单文件单轮最大读取行数行大2000 行 ≈ 200MB 上界已足够单轮消化;未读完下轮继续).
*/
protected const READ_MAX_LINES = 2000;
/**
* parse_fail_samples 最大记录条数.
*/
protected const FAIL_SAMPLES_MAX = 10;
public function do()
{
// 注意:不受 XHPROF_ENABLE 门控(开关只管采集,残留文件必须能导完)
$dir = $this->fileDir();
$files = glob($dir . '/runs-*.jsonl');
if ($files === false || $files === []) {
return json_encode([
'files' => 0, 'runs' => 0, 'watches' => 0, 'fails' => 0, 'deleted' => 0,
], JSON_UNESCAPED_UNICODE);
}
sort($files); // 按文件名序runs-{Ymd} 字典序即日期序),旧日期先导
// 依赖倒置:使用 app 层入口类(业务侧可重写拦截)
$parser = new \app\common\service\XhprofProfileParserService();
$reader = new \app\common\service\XhprofLogReaderService();
// watch 正则 per 批次编译:本节点启用的规则一次编译,导入全程复用
$watchRows = XhprofWatch::field('id,regex')->where('status', 1)->select()->toArray();
$compiled = $parser->compileWatchRegexes($watchRows);
$patterns = $compiled['patterns'];
$totalFiles = 0;
$totalRuns = 0;
$totalWatches = 0;
$totalFails = $compiled['fails'];
$deleted = 0;
$errors = [];
foreach ($files as $file) {
try {
$stats = $this->processFile($parser, $reader, $file, $patterns);
$totalFiles++;
$totalRuns += $stats['runs'];
$totalWatches += $stats['watches'];
$totalFails += $stats['fails'];
if ($stats['deleted']) {
$deleted++;
}
} catch (\Throwable $e) {
Log::error('xhprof_log_import: 处理文件异常 ' . $file . ' - ' . $e->getMessage());
$errors[] = 'error:' . basename($file) . ':' . mb_substr($e->getMessage(), 0, 200);
}
}
return json_encode([
'files' => $totalFiles,
'runs' => $totalRuns,
'watches' => $totalWatches,
'fails' => $totalFails,
'deleted' => $deleted,
'errors' => $errors,
], JSON_UNESCAPED_UNICODE);
}
/**
* jsonl 落盘目录(与采集端 bootstrap getenv 读同一变量,禁止 ThinkPHP env().
*/
protected function fileDir(): string
{
return getenv('XHPROF_FILE_DIR') ?: '/var/www/html/runtime/xhprof';
}
/**
* 处理单个文件:增量读取、解析、批量入库(事务内 savePosition、导完删除.
*
* @return array{runs: int, watches: int, fails: int, deleted: bool}
*/
protected function processFile(
\app\common\service\XhprofProfileParserService $parser,
\app\common\service\XhprofLogReaderService $reader,
string $file,
array $patterns
): array {
$runs = 0;
$watches = 0;
$fails = 0;
$samples = [];
$buffer = [];
// Reader 是 Generator让其自然走完maxLines / EOF 自然结束,进度由本类事务内落盘)
foreach ($reader->read($file, self::READ_MAX_LINES) as $line) {
$parsed = $parser->parse($line);
if ($parsed === null) {
$fails++;
$this->addFailSample($samples, $line);
continue;
}
if ($parsed['degraded']) {
// main() 缺失:行仍入库(降级 0但计 fail 留痕
$fails++;
$this->addFailSample($samples, $line);
}
$buffer[] = [
'run' => $this->buildRunRow($parsed),
'raw' => $line,
'profile' => $parsed['profile'],
];
if (count($buffer) >= self::RUN_BATCH) {
$flushed = $this->flushBatch($parser, $reader, $file, $buffer, $patterns, $fails, $samples);
$runs += $flushed['runs'];
$watches += $flushed['watches'];
$buffer = [];
}
}
// flush 剩余buffer 为空时也补一次 position 落盘(推进被失败行消费掉的区间)
if (!empty($buffer)) {
$flushed = $this->flushBatch($parser, $reader, $file, $buffer, $patterns, $fails, $samples);
$runs += $flushed['runs'];
$watches += $flushed['watches'];
} else {
$position = $reader->getCurrentPosition();
$reader->savePosition(
$file,
$position['inode'],
$position['offset'],
$position['last_line_hash'],
$fails,
$this->encodeSamples($samples)
);
}
// 超长行丢弃并入 failsReader 整行丢弃计数)
$fails += $reader->getDroppedLines();
// 文件导至 EOF 且 position 落盘后才可删除(只删已导完文件,未导完/尾部半行不删)
$isDeleted = false;
if ($reader->reachedEof()) {
clearstatcache(true, $file);
$position = $reader->getCurrentPosition();
$size = filesize($file);
if ($size !== false && $position['offset'] >= $size) {
$isDeleted = @unlink($file);
if ($isDeleted) {
Log::info('xhprof_log_import: 文件已导完删除 ' . $file);
}
}
}
Log::info("xhprof_log_import: 文件 {$file} 完成 runs={$runs} watches={$watches} fails={$fails}");
return ['runs' => $runs, 'watches' => $watches, 'fails' => $fails, 'deleted' => $isDeleted];
}
/**
* 构造 run 入库行(字段对齐 ul_xhprof_run单位 μs/字节).
*/
protected function buildRunRow(array $parsed): array
{
return [
'node_id' => \app\common\service\HostService::getNodeId(),
'url' => $parsed['url'],
'simple_url' => $parsed['simple_url'],
'method' => $parsed['method'],
'wall_time' => $parsed['wall_time'],
'cpu_time' => $parsed['cpu_time'],
'memory_peak' => $parsed['memory_peak'],
'request_time' => $parsed['request_time'],
'create_time' => time(),
];
}
/**
* 批量入库:同一事务内 run 逐条 insert拿自增 id→ detail/run_watch insertAll → savePosition.
*
* 事务语义:入库与 position 落盘要么同时生效、要么同时回滚——
* 崩溃恢复后从 position 处重读,不会重复导入已提交批次。
*
* @param array $buffer [['run' => array, 'raw' => string, 'profile' => array], ...]
*
* @return array{runs: int, watches: int}
*/
protected function flushBatch(
\app\common\service\XhprofProfileParserService $parser,
\app\common\service\XhprofLogReaderService $reader,
string $file,
array $buffer,
array $patterns,
int $fails,
array $samples
): array {
// 捕获当前安全偏移(最后一条已 yield 完整行末尾),事务内与数据同落盘
$position = $reader->getCurrentPosition();
$samplesJson = $this->encodeSamples($samples);
$runs = 0;
$watches = 0;
Db::transaction(function () use ($parser, $reader, $file, $buffer, $patterns, $position, $fails, $samplesJson, &$runs, &$watches) {
$detailBatch = [];
$watchBatch = [];
$now = time();
foreach ($buffer as $item) {
$runId = (int) XhprofRun::insertGetId($item['run']);
$runs++;
$detailBatch[] = [
'id' => $runId,
'profile_data' => $item['raw'],
];
foreach ($parser->materializeWatches($item['profile'], $patterns) as $watchId => $agg) {
$watchBatch[] = [
'run_id' => $runId,
'watch_id' => $watchId,
'ct' => $agg['ct'],
'wt_sum' => $agg['wt_sum'],
'wt_max' => $agg['wt_max'],
'create_time' => $now,
];
$watches++;
}
}
if (!empty($detailBatch)) {
XhprofRunDetail::insertAll($detailBatch);
}
if (!empty($watchBatch)) {
XhprofRunWatch::insertAll($watchBatch);
}
$reader->savePosition(
$file,
$position['inode'],
$position['offset'],
$position['last_line_hash'],
$fails,
$samplesJson
);
});
return ['runs' => $runs, 'watches' => $watches];
}
/**
* 收集解析失败样本(截断至 500 字符,最多 FAIL_SAMPLES_MAX 条).
*/
protected function addFailSample(array &$samples, string $line): void
{
if (count($samples) >= self::FAIL_SAMPLES_MAX) {
return;
}
$samples[] = mb_substr($line, 0, 500);
}
/**
* 失败样本编码为 json空样本返回 null与 position 列 nullable 对齐).
*/
protected function encodeSamples(array $samples): ?string
{
return empty($samples) ? null : json_encode($samples, JSON_UNESCAPED_UNICODE);
}
}