feat(nginx-log): 多节点适配(node_id 全链路 + 全局/按节点聚合 + 节点筛选 + v2.4.0 升级脚本)

- 6 张表加 node_id 字段 + position/stat 改复合唯一键(migration)
- 6 个 Scheme 同步 node_id 注解 + 唯一键
- Reader 带 node_id 参数 + 懒加载接管(getLastPosition 回退查 node_id='')
- Aggregator 全局行(node_id='')+ 按节点循环 insertAggregatesForScope
- Stat 所有查询方法加 where node_id 条件
- Dashboard 控制器/视图加节点筛选下拉框 + AJAX 带 node_id
- AccessLog 列表加节点列 + 采集进度卡片显示节点信息
- import 定时任务 run_type 改 all(每节点采集自己的日志)
- v2.4.0 升级脚本追加 ALTER TABLE(幂等 check-then-alter)
- 菜单调整:去掉 Nginx 顶级菜单 + 读取位置管理,改为系统管理下挂两个子菜单
- ulthon-timer 文档补充 run_type=all 多节点部署注意事项
This commit is contained in:
augushong
2026-08-08 10:33:20 +08:00
parent f1a2e8fd5d
commit 8faa52ce90
21 changed files with 578 additions and 206 deletions

View File

@@ -3,6 +3,7 @@
namespace base\common\service;
use app\admin\model\NginxLogPosition;
use app\common\service\HostService;
/**
* Nginx 日志增量读取 serviceBase 层).
@@ -10,12 +11,17 @@ use app\admin\model\NginxLogPosition;
* 职责:
* - 按文件 offset 增量读取日志行Generator不读整个文件到内存
* - 检测日志轮转inode 变化 OR filesize < offset覆盖 rename+rebuild 与 copytruncate 两种模式)
* - 将读取进度持久化到 NginxLogPosition 表upsert
* - 将读取进度持久化到 NginxLogPosition 表upsert,按 node_id 隔离
*
* 多节点适配position 表按 (node_id, file_path) 唯一定位读取进度。
* 旧数据node_id 为空)通过懒加载接管(见 getLastPosition首个查询到它的本节点原子 UPDATE node_id
* 避免 migration 全表回填(多节点共享 DB 时 migration 只跑一次,只有懒加载能确保每节点各自接管)。
*
* 不负责解析T5 Parser、聚合T7 Aggregator
*
* 依赖倒置:内部 model 调用走 `app\admin\model\NginxLogPosition`App 入口类),
* 使用者可在 app/ 重写该 model 拦截行为;本类不直接 `new NginxLogPosition`(静态调用满足多态)。
* 节点身份走 `app\common\service\HostService`App 入口类)。
*/
class NginxLogReaderServiceBase
{
@@ -159,15 +165,27 @@ class NginxLogReaderServiceBase
/**
* 查询文件的上次读取位置.
*
* 多节点适配where 条件带 node_id 隔离各节点进度。
* 懒加载接管:多节点共享 DB 时 migration 只跑一次,旧记录 node_id 为空,
* 首个查询到它的本节点原子 UPDATE node_id 接管,保证每节点各自有独立 position 记录。
*
* @param string $filePath 日志文件绝对路径
*
* @return array|null 命中时返回 [inode, offset, last_line_hash, parse_fail_count, parse_fail_samples],无记录返回 null
*/
public function getLastPosition(string $filePath): ?array
{
$row = NginxLogPosition::where('file_path', $filePath)->find();
$nodeId = HostService::getNodeId();
$row = NginxLogPosition::where('file_path', $filePath)
->where('node_id', $nodeId)
->find();
if ($row === null) {
return null;
// 懒加载接管:旧记录 node_id 为空,原子抢占接管到当前节点
$row = $this->lazyAdoptPosition($filePath, $nodeId);
if ($row === null) {
return null;
}
}
return [
@@ -179,6 +197,39 @@ class NginxLogReaderServiceBase
];
}
/**
* 懒加载接管:把旧的无 node_id 记录原子接管到当前节点.
*
* 多节点共享 DB 时 migration 只跑一次,无法为每节点预置 position 记录。
* 旧记录node_id='')由首个查询到它的本节点通过原子条件 UPDATE 接管:
* UPDATE ... SET node_id=current WHERE node_id='' AND file_path=X
* 并发安全WHERE node_id='' 保证只有一个节点能 affected=1其他节点查询时已被接管。
*
* @return \think\Model|null 接管成功返回接管后的记录,无旧记录或被其他节点抢占返回 null
*/
protected function lazyAdoptPosition(string $filePath, string $nodeId)
{
$legacy = NginxLogPosition::where('file_path', $filePath)
->where('node_id', '')
->find();
if ($legacy === null) {
return null;
}
// 原子条件 UPDATE只有 node_id='' 时才能被接管(防并发多节点同时接管同一记录)
$affected = NginxLogPosition::where('id', $legacy->getData('id'))
->where('node_id', '')
->update(['node_id' => $nodeId]);
if ($affected === 0) {
// 被其他节点抢先接管,本节点放弃(下次 savePosition 会创建新记录)
return null;
}
return NginxLogPosition::where('file_path', $filePath)
->where('node_id', $nodeId)
->find();
}
/**
* 保存读取位置upsert.
*
@@ -196,8 +247,11 @@ class NginxLogReaderServiceBase
public function savePosition(string $filePath, int $inode, int $offset, ?string $lastLineHash, int $failCount = 0, ?string $failSamples = null): void
{
$now = time();
$nodeId = HostService::getNodeId();
$existing = NginxLogPosition::where('file_path', $filePath)->find();
$existing = NginxLogPosition::where('file_path', $filePath)
->where('node_id', $nodeId)
->find();
if ($existing !== null) {
$existing->save([
'inode' => $inode,
@@ -212,6 +266,7 @@ class NginxLogReaderServiceBase
}
NginxLogPosition::create([
'node_id' => $nodeId,
'file_path' => $filePath,
'inode' => $inode,
'offset' => $offset,