Files
ulthon_admin/extend/base/common/service/XhprofLogReaderServiceBase.php
augushong a43a9a48b9 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 测试套件
2026-08-15 06:11:01 +08:00

287 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace base\common\service;
use app\admin\model\XhprofLogPosition;
use app\common\service\HostService;
/**
* XHProf jsonl 增量读取 serviceBase 层).
*
* 与 NginxLogReaderServiceBase 的两处语义差异(有意为之,勿"顺手同步"回去):
* 1. 本类不自动 savePositionjsonl 单行很大(实测 ~100KB导入侧要求
* "insertBatch + savePosition 同一事务"消崩溃重复窗口,进度落盘由
* XhprofLogImportBase 在事务内显式调用 savePosition 完成。
* 每次 yield 前先推进 currentOffset消费方任意时刻调 getCurrentPosition()
* 取到的都是"最后一条完整行末尾"的安全偏移。
* EOF 无换行的尾部半行不 yield 不推进 offset下轮写入 \n 后补齐)。
* 2. 超长行不再像 nginx 那样 8MB 强制切行(会把一条 JSON 切成两条坏行),
* 而是整行丢弃并计 dropped缓冲超限时扫描到下一个换行符为止
*
* 其余协议与 nginx 版一致:按 offset 增量读、inode/size 双条件轮转检测归零、
* maxLines 上限、position 按 (node_id, file_path) 唯一定位。
*
* 依赖倒置model 走 app\admin\model\XhprofLogPosition 入口类;节点身份走
* app\common\service\HostService。本表为全新表node_id 默认 'default' 非 NULL
* 无 nginx 版的懒加载接管旧数据问题,故不含 lazyAdopt 逻辑。
*/
class XhprofLogReaderServiceBase
{
/** 单次 fread 的 buffer 大小64KB */
protected const BUFFER_SIZE = 65536;
/** 单行硬上限32MB超过整行丢弃计 dropped。真实采样行 ~100KB32MB 已是异常防御 */
protected const MAX_LINE_BYTES = 33554432;
/** @var int 当前文件 inoderead 期间有效) */
protected $currentInode = 0;
/** @var int 安全偏移:最后一条已 yield 完整行的末尾(含换行符) */
protected $currentOffset = 0;
/** @var string|null 最后一条已 yield 行的 md5 */
protected $currentLineHash = null;
/** @var bool 本轮 read 是否干净到达 EOF尾部无半行超长行丢弃至 EOF 也视为干净收尾) */
protected $reachedEof = false;
/** @var int 本轮 read 丢弃的超长行数 */
protected $droppedLines = 0;
/**
* 增量读取 jsonl 文件yield 完整行.
*
* 读取协议:
* 1. fopen + fstat 取当前 inode/size读 position无则视为新建 offset=0
* 2. 轮转检测inode 不一致 OR filesize < offset → 归零重读
* 3. fseek 到 offset循环 fread 64KB定位已读段内最后 \n仅 yield 完整行
* 4. 每次 yield 前 currentOffset 推进到该行末尾(含 \n保证事务内 savePosition 与已消费行严格对齐
* 5. 尾部半行(无 \n不 yieldcurrentOffset 停在半行起点,下次重读
* 6. 半行缓冲超过 MAX_LINE_BYTES整行丢弃计 dropped扫描至下一个 \n 继续
* 7. yield maxLines 后退出(不自动 savePosition由上层事务内落盘
*
* @param string $filePath jsonl 文件绝对路径
* @param int $maxLines 单次 yield 的最大行数
*
* @return \Generator<string> 每次迭代返回一行(不含换行符)
*
* @throws \RuntimeException 文件无法打开或 stat/fseek/fread 失败时抛出(交由上层 try-catch
*/
public function read(string $filePath, int $maxLines = 100000): \Generator
{
// per-run 状态复位(同一个 reader 实例可串行处理多个文件)
$this->reachedEof = false;
$this->droppedLines = 0;
$this->currentLineHash = null;
$fp = @fopen($filePath, 'rb');
if ($fp === false) {
throw new \RuntimeException("XhprofLogReaderService: 无法打开文件 {$filePath}");
}
try {
clearstatcache(true, $filePath);
$stat = fstat($fp);
if ($stat === false) {
throw new \RuntimeException("XhprofLogReaderService: fstat 失败 {$filePath}");
}
$currentInode = (int) $stat['ino'];
$currentSize = (int) $stat['size'];
$this->currentInode = $currentInode;
// 读 position无则默认 offset=0
$offset = 0;
$position = $this->getLastPosition($filePath);
if ($position !== null) {
$offset = (int) $position['offset'];
// 轮转检测inode 变化同日文件被重建OR size < offset防御性归零
if ((int) $position['inode'] !== $currentInode || $currentSize < $offset) {
$offset = 0;
}
}
$this->currentOffset = $offset;
if ($offset > 0 && fseek($fp, $offset) !== 0) {
throw new \RuntimeException("XhprofLogReaderService: fseek 失败 offset={$offset} file={$filePath}");
}
$yielded = 0;
$buffer = '';
// $bufferOffset 始终等于 buffer[0] 对应的文件字节偏移
$bufferOffset = $offset;
while (true) {
$chunk = fread($fp, self::BUFFER_SIZE);
if ($chunk === false) {
throw new \RuntimeException("XhprofLogReaderService: fread 失败 file={$filePath}");
}
if ($chunk === '') {
// EOFbuffer 为空说明干净收尾;有半行则 currentOffset 已停在最后完整行末尾
$this->reachedEof = ($buffer === '');
return;
}
$buffer .= $chunk;
// 内层循环:处理 buffer 中所有完整行(含末尾 \n 的)
$pos = 0;
while (($nlPos = strpos($buffer, "\n", $pos)) !== false) {
$line = substr($buffer, $pos, $nlPos - $pos);
$pos = $nlPos + 1;
// 先推进安全偏移再 yield消费方在事务内 savePosition 拿到的
// 一定是"已 yield 行末尾",与该事务内入库的数据严格对齐
$this->currentOffset = $bufferOffset + $pos;
$this->currentLineHash = md5($line);
yield $line;
$yielded++;
if ($yielded >= $maxLines) {
return;
}
}
// buffer 中已消费 $pos 字节半行buffer[$pos..])保留
$bufferOffset += $pos;
$buffer = substr($buffer, $pos);
// 超长行防御:整行丢弃(禁止强制切行——会把一条 JSON 切成两条坏行)
if (strlen($buffer) >= self::MAX_LINE_BYTES) {
$this->droppedLines++;
$buffer = '';
// 扫描至下一个换行符,将其后内容作为新 buffer 继续
while (true) {
$chunk = fread($fp, self::BUFFER_SIZE);
if ($chunk === false || $chunk === '') {
// 至 EOF 仍未等到换行符:整个尾部按已丢弃处理,偏移推到文件末尾
$bufferOffset = (int) ftell($fp);
$this->currentOffset = $bufferOffset;
$this->reachedEof = true;
return;
}
$nl = strpos($chunk, "\n");
if ($nl !== false) {
$bufferOffset = (int) ftell($fp) - (strlen($chunk) - $nl - 1);
$this->currentOffset = $bufferOffset;
$buffer = substr($chunk, $nl + 1);
break;
}
}
}
}
} finally {
if (is_resource($fp)) {
fclose($fp);
}
}
}
/**
* 当前安全读取位置read 期间/结束后可调).
*
* @return array{inode: int, offset: int, last_line_hash: string|null}
*/
public function getCurrentPosition(): array
{
return [
'inode' => $this->currentInode,
'offset' => $this->currentOffset,
'last_line_hash' => $this->currentLineHash,
];
}
/**
* 本轮 read 是否干净到达 EOF尾部无半行.
*
* 导入侧据此判断"文件已导完"reachedEof 且 offset >= filesize 才允许 unlink。
*/
public function reachedEof(): bool
{
return $this->reachedEof;
}
/**
* 本轮 read 丢弃的超长行数(由导入侧并入 fails 统计).
*/
public function getDroppedLines(): int
{
return $this->droppedLines;
}
/**
* 查询文件的上次读取位置(按 node_id 隔离).
*
* @return array|null 命中时返回 [inode, offset, last_line_hash, parse_fail_count, parse_fail_samples],无记录返回 null
*/
public function getLastPosition(string $filePath): ?array
{
$nodeId = HostService::getNodeId();
$row = XhprofLogPosition::where('file_path', $filePath)
->where('node_id', $nodeId)
->find();
if ($row === null) {
return null;
}
return [
'inode' => (int) $row->getData('inode'),
'offset' => (int) $row->getData('offset'),
'last_line_hash' => $row->getData('last_line_hash'),
'parse_fail_count' => (int) $row->getData('parse_fail_count'),
'parse_fail_samples' => $row->getData('parse_fail_samples'),
];
}
/**
* 保存读取位置upsert按 node_id 隔离).
*
* 由导入任务在"insertBatch 同一事务"内显式调用(本类不自动保存),
* 保证崩溃时入库与进度要么同时生效、要么同时回滚,消重复导入窗口。
*
* @param string $filePath jsonl 文件绝对路径
* @param int $inode 当前文件 inode
* @param int $offset 新的字节偏移(最后一条完整行末尾)
* @param string|null $lastLineHash 最后一条已入库行 md5
* @param int $failCount 本批解析失败累计次数
* @param string|null $failSamples 解析失败样本json 或 null
*/
public function savePosition(string $filePath, int $inode, int $offset, ?string $lastLineHash, int $failCount = 0, ?string $failSamples = null): void
{
$now = time();
$nodeId = HostService::getNodeId();
$existing = XhprofLogPosition::where('file_path', $filePath)
->where('node_id', $nodeId)
->find();
if ($existing !== null) {
$existing->save([
'inode' => $inode,
'offset' => $offset,
'last_read_time' => $now,
'last_line_hash' => $lastLineHash,
'parse_fail_count' => $failCount,
'parse_fail_samples' => $failSamples,
]);
return;
}
XhprofLogPosition::create([
'node_id' => $nodeId,
'file_path' => $filePath,
'inode' => $inode,
'offset' => $offset,
'last_read_time' => $now,
'last_line_hash' => $lastLineHash,
'parse_fail_count' => $failCount,
'parse_fail_samples' => $failSamples,
'create_time' => $now,
'update_time' => $now,
]);
}
}