mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-31 05:05:33 +08:00
- 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 测试套件
287 lines
12 KiB
PHP
287 lines
12 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace base\common\service;
|
||
|
||
use app\admin\model\XhprofLogPosition;
|
||
use app\common\service\HostService;
|
||
|
||
/**
|
||
* XHProf jsonl 增量读取 service(Base 层).
|
||
*
|
||
* 与 NginxLogReaderServiceBase 的两处语义差异(有意为之,勿"顺手同步"回去):
|
||
* 1. 本类不自动 savePosition:jsonl 单行很大(实测 ~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。真实采样行 ~100KB,32MB 已是异常防御 */
|
||
protected const MAX_LINE_BYTES = 33554432;
|
||
|
||
/** @var int 当前文件 inode(read 期间有效) */
|
||
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)不 yield,currentOffset 停在半行起点,下次重读
|
||
* 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 === '') {
|
||
// EOF:buffer 为空说明干净收尾;有半行则 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,
|
||
]);
|
||
}
|
||
}
|