Compare commits
24
Commits
eaa10fd6b0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1822ae3129 | ||
|
|
07d7f77a8a | ||
|
|
414259a5d7 | ||
|
|
bd5a4532ff | ||
|
|
43978a4023 | ||
|
|
7f477972ba | ||
|
|
5bfa386312 | ||
|
|
593798efe8 | ||
|
|
ef5a3504ec | ||
|
|
b59e4e92a4 | ||
|
|
cb8bb28b18 | ||
|
|
5063100a90 | ||
|
|
220008f5c3 | ||
|
|
cf72a14cd0 | ||
|
|
da6b6cf235 | ||
|
|
91eb61a1a8 | ||
|
|
1be06f7e9e | ||
|
|
0572999bc7 | ||
|
|
a2e1f0d923 | ||
|
|
329d0e4c5d | ||
|
|
053d3347f3 | ||
|
|
642b370c7a | ||
|
|
f17eef3f74 | ||
|
|
f0444e5879 |
@@ -0,0 +1,526 @@
|
||||
# ProfileMax Storage Hook / StorageFS 开发设计
|
||||
|
||||
> 项目:ProfileMax
|
||||
> 模块:ProfileMax Node Storage Hook
|
||||
> 目标:为 ProfileMax Node 上所有 Profile 数据提供统一存储入口,统一管理持久化磁盘与 tmpfs 临时存储。
|
||||
> 原则:IO 优先、低 CPU、不压缩、不依赖节点数据库;所有 Profile 数据访问均经过 Storage Hook。
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
Storage Hook 是 ProfileMax Node 上**所有 Profile 存储的统一入口**,不只是 Chromium Profile 主目录的磁盘分配器。Chromium、impersonate-proxy 以及 Profile 相关服务产生的文件,都必须通过 Storage Hook 管理。
|
||||
|
||||
Storage Hook 统一提供两类存储:
|
||||
|
||||
1. **持久化存储**:多磁盘 StorageFS,保存需要跨进程重启、Profile 重启继续存在的数据。
|
||||
2. **临时存储**:由 Storage Hook 创建和管理的 tmpfs,承载可丢弃、可重新生成或已由代理层缓存的重复数据。
|
||||
|
||||
核心目标:
|
||||
|
||||
1. **IO 优先**:读写路径短,减少复制、锁竞争、系统调用和不必要的元数据操作。
|
||||
2. **低 CPU**:不做压缩/解压,不做内容哈希,不在热路径执行复杂算法。
|
||||
3. **避免重复缓存**:impersonate-proxy 已缓存的 HTTP 资源,不再让 Chromium 将同一份 HTTP Cache 持久化到磁盘。
|
||||
4. **减少底层 inode**:按收益选择 Pack,不强制所有文件打包。
|
||||
5. **统一入口**:所有 Profile 数据,包括 impersonate-proxy 的缓存目录,都由 Storage Hook 分配、挂载和监控。
|
||||
6. **节点无数据库**:Storage Hook 不连接 PostgreSQL、SQLite、Redis 或 Admin 数据库。
|
||||
7. **数据语义正确**:对 SQLite、LevelDB 等活跃数据库文件提供正确的锁、同步、重命名和随机读写语义。
|
||||
|
||||
不以压缩率、去重率或极限节省磁盘空间作为目标。
|
||||
|
||||
## 2. 总体架构
|
||||
|
||||
```text
|
||||
ProfileMax Admin
|
||||
(唯一配置数据库持有方)
|
||||
|
|
||||
Storage Hook 配置下发
|
||||
|
|
||||
v
|
||||
+--------------------------------------------------------------+
|
||||
| ProfileMax Node |
|
||||
| |
|
||||
| Chromium Profile impersonate-proxy Profile Services |
|
||||
| | | | |
|
||||
| +-----------------------+--------------------+ |
|
||||
| | |
|
||||
| v |
|
||||
| Storage Hook (统一入口) |
|
||||
| | |
|
||||
| +-------------------+-------------------+ |
|
||||
| | | |
|
||||
| v v |
|
||||
| Persistent Storage tmpfs Manager |
|
||||
| StorageFS / FUSE Linux tmpfs |
|
||||
| 多磁盘 / Direct / Pack 临时缓存路径 |
|
||||
| | | |
|
||||
| v v |
|
||||
| /mnt/storage-* /run/profilemax/* |
|
||||
| 持久化 Profile 数据 可丢弃缓存数据 |
|
||||
+--------------------------------------------------------------+
|
||||
```
|
||||
|
||||
职责边界:
|
||||
|
||||
- Admin 数据库只保存每个 Node 的 Storage Hook 配置,不保存文件、目录、inode、Pack 或 Profile 文件映射。
|
||||
- Node 接收配置后,在本地完成持久化存储、tmpfs 创建、路径挂载、容量控制和恢复。
|
||||
- Chromium 与 impersonate-proxy 都使用 Storage Hook 提供的路径,不允许各自绕过 Hook 写入未管理目录。
|
||||
- Storage Hook 不连接 Admin 数据库,也不运行 SQLite 等数据库。
|
||||
- Node 持久化元数据使用 StorageFS 自己的二进制索引、日志和快照文件;tmpfs 内容不进入持久化元数据恢复流程。
|
||||
|
||||
Admin 与 Node 的职责严格分离:
|
||||
|
||||
- Admin 数据库只保存节点 Storage Hook 的配置,不保存文件、目录、inode、Pack 或 Profile 文件映射。
|
||||
- Node 接收配置后在本地执行挂载、分配、读写和恢复。
|
||||
- Node Storage Hook 不连接 Admin 数据库,也不运行 SQLite 等数据库。
|
||||
- Node 的本地元数据使用 StorageFS 自己的二进制索引、日志和快照文件,不将其实现成数据库服务。
|
||||
|
||||
## 3. Admin 配置模型
|
||||
|
||||
Admin 数据库只需要保存每个 Node 的 Storage Hook 配置。配置内容包括:
|
||||
|
||||
- 是否启用 Storage Hook。
|
||||
- 统一存储根目录和元数据目录。
|
||||
- 多个持久化 Volume 的标识、物理目录、容量上限、分配权重。
|
||||
- tmpfs 总容量、单 Profile 上限、挂载路径规则及目录路由规则。
|
||||
- Chromium 缓存目录策略与 impersonate-proxy 缓存目录策略。
|
||||
- Pack 大小和小文件阈值(如果启用 Pack)。
|
||||
- IO、刷盘和预留空间等运行参数。
|
||||
- 配置版本号,用于 Node 热更新和回滚。
|
||||
|
||||
示例配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"mount_point": "/var/lib/profilemax/storage",
|
||||
"metadata_path": "/var/lib/profilemax/storage-meta",
|
||||
"volumes": [
|
||||
{
|
||||
"id": "volume-a",
|
||||
"path": "/mnt/storage-a/profilemax",
|
||||
"capacity_gib": 500,
|
||||
"weight": 1
|
||||
},
|
||||
{
|
||||
"id": "volume-b",
|
||||
"path": "/mnt/storage-b/profilemax",
|
||||
"capacity_gib": 1000,
|
||||
"weight": 2
|
||||
}
|
||||
],
|
||||
"tmpfs": {
|
||||
"enabled": true,
|
||||
"root": "/run/profilemax",
|
||||
"total_size_gib": 16,
|
||||
"per_profile_limit_mib": 512,
|
||||
"mode": "per-profile"
|
||||
},
|
||||
"routes": {
|
||||
"chromium_http_cache": "tmpfs",
|
||||
"chromium_rebuildable_cache": "tmpfs",
|
||||
"impersonate_proxy_cache": "persistent"
|
||||
},
|
||||
"io": {
|
||||
"pack_enabled": true,
|
||||
"small_file_threshold": 65536,
|
||||
"pack_segment_size_mib": 256,
|
||||
"read_cache_mib": 256,
|
||||
"write_batch_max_ms": 2,
|
||||
"sync_policy": "data"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
以上为配置结构示例。容量单位统一使用 GiB;持久化 Volume 容量上限是 StorageFS 逻辑分配上限;tmpfs 容量是内存/交换空间预算,必须单独限制,不能与磁盘容量混为一谈。路由规则必须支持按 Profile 相对路径匹配,并由 Node 在启动 Profile 前完成挂载。
|
||||
|
||||
以上为配置结构示例。容量单位统一使用 GiB;容量上限是 StorageFS 的逻辑分配上限,不等同于底层文件系统的硬配额。
|
||||
|
||||
配置下发流程:
|
||||
|
||||
1. Admin 保存配置并递增 config_version。
|
||||
2. Node 收到配置后校验路径、权限、磁盘可用空间和配置版本。
|
||||
3. Node 创建缺失目录并初始化 Volume。
|
||||
4. Storage Hook 应用配置并挂载 StorageFS。
|
||||
5. Node 上报实际挂载状态、Volume 可用容量和错误信息。
|
||||
6. 配置变更不得直接删除已有数据;Volume 下线必须先进入迁移/只读流程。
|
||||
|
||||
Node 可以在本地保存一份配置快照用于重启恢复,但该文件只是配置缓存,不是数据库,也不作为配置权威来源。
|
||||
|
||||
## 4. Node 本地目录布局
|
||||
|
||||
建议:
|
||||
|
||||
```text
|
||||
/var/lib/profilemax/
|
||||
├── storage/ # StorageFS 统一持久化挂载点
|
||||
└── storage-meta/ # StorageFS 内部元数据
|
||||
|
||||
/run/profilemax/
|
||||
└── profiles/
|
||||
└── <profile-id>/ # 每个 Profile 的 tmpfs 挂载目录
|
||||
|
||||
/mnt/storage-a/profilemax/
|
||||
├── packs/
|
||||
└── direct/
|
||||
|
||||
/mnt/storage-b/profilemax/
|
||||
├── packs/
|
||||
└── direct/
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- metadata 与数据 Volume 分离,避免 Pack 写入挤占元数据日志空间。
|
||||
- Pack 使用预分配或按需扩展的固定上限 Segment。
|
||||
- Segment 不压缩,文件内容按原始字节存储。
|
||||
- 不对每个 Profile 创建一套独立的内部数据库。
|
||||
- tmpfs 由 Storage Hook 统一创建、限额、挂载和清理,不由 Chromium 或代理进程自行创建。
|
||||
- tmpfs 不得覆盖整个 Profile 根目录,只挂载到明确允许丢弃的子目录。
|
||||
- Profile 退出时按策略销毁该 Profile 的 tmpfs;重新启动时重新创建空缓存目录。
|
||||
|
||||
## 4.1 Profile 路径路由与 tmpfs 策略
|
||||
|
||||
Storage Hook 必须维护一份明确的路径路由规则。Profile 根目录和所有子目录仍属于 Storage Hook 的统一命名空间,但不同子目录可以映射到不同后端。
|
||||
|
||||
**A. 浏览器 HTTP Cache:tmpfs**
|
||||
|
||||
impersonate-proxy 已经缓存 HTTP 响应资源时,Chromium 本地 HTTP Cache 属于重复缓存。浏览器侧对应目录应挂载到 tmpfs,避免重复写入持久化磁盘:
|
||||
|
||||
- `Default/Cache/`
|
||||
- Chromium 实际版本使用的 HTTP Cache 数据目录及其索引文件。
|
||||
|
||||
该策略只针对浏览器 HTTP 网络缓存,不代表所有网站存储都可以丢弃。
|
||||
|
||||
**B. 可重建缓存:tmpfs**
|
||||
|
||||
以下目录通常可重新生成,默认可路由到 tmpfs:
|
||||
|
||||
- `Default/Code Cache/`
|
||||
- `GPUCache/`
|
||||
- `GrShaderCache/`
|
||||
- `ShaderCache/`
|
||||
|
||||
实际路径应根据 Chromium 版本和启动参数检测,不得依赖单一固定目录名。
|
||||
|
||||
**C. 必须持久化的数据**
|
||||
|
||||
以下数据默认留在 StorageFS 持久化后端:
|
||||
|
||||
- Cookies、Local Storage、IndexedDB。
|
||||
- Preferences、登录态及 Profile 配置。
|
||||
- Extension 数据及其状态。
|
||||
- Service Worker 注册信息和 CacheStorage(除非业务明确允许清空)。
|
||||
- SQLite/LevelDB 数据目录及其 WAL/SHM 文件。
|
||||
- 其他无法确认可重建的 Profile 数据。
|
||||
|
||||
禁止将整个 `Default/`、整个 `Service Worker/` 或整个 Profile 目录直接挂载到 tmpfs。
|
||||
|
||||
**D. impersonate-proxy 缓存**
|
||||
|
||||
impersonate-proxy 的缓存目录也必须位于 Storage Hook 命名空间内,不能绕过 Hook 直接写入任意节点目录。默认将代理缓存放在持久化 StorageFS 中,以便跨 Profile/浏览器重启复用;如果代理缓存被明确配置为短期缓存,也允许由 Admin 将该目录路由到 tmpfs。
|
||||
|
||||
浏览器侧 HTTP Cache 与代理侧缓存是两份不同的缓存:默认保留代理侧缓存,浏览器侧重复 HTTP Cache 使用 tmpfs。两者不得误配置成同一个目录或互相覆盖。
|
||||
|
||||
**E. 挂载生命周期**
|
||||
|
||||
1. Node 启动时先挂载 StorageFS。
|
||||
2. 创建 Profile 时,Storage Hook 创建该 Profile 的 tmpfs 目录并应用路径路由。
|
||||
3. Chromium 与 impersonate-proxy 启动前,检查所有必需挂载均已就绪。
|
||||
4. Profile 运行期间,Storage Hook 统计 tmpfs 使用量并执行硬容量限制。
|
||||
5. Profile 正常停止后销毁 tmpfs;异常退出后由 Node 清理残留挂载。
|
||||
6. tmpfs 满时只允许缓存写入失败或按明确策略清理可重建缓存,不得影响 Cookies、IndexedDB 等持久化数据。
|
||||
|
||||
## 5. 文件存储策略
|
||||
|
||||
StorageFS 使用 Hybrid Backend,不强制所有文件都进入 Pack。
|
||||
|
||||
### 5.1 Pack Backend:小文件
|
||||
|
||||
小文件可合并写入 Pack Segment,以减少底层 inode 数量。
|
||||
|
||||
Pack 记录包含:
|
||||
|
||||
- object_id
|
||||
- segment_id
|
||||
- offset
|
||||
- length
|
||||
- generation
|
||||
- flags
|
||||
|
||||
元数据索引负责把虚拟文件映射到 Pack 的 offset/length。
|
||||
|
||||
写入流程:
|
||||
|
||||
1. 从内存索引查询或创建文件元数据。
|
||||
2. 在当前 Segment 分配连续空间。
|
||||
3. 直接写入原始数据,不压缩、不加密、不计算内容哈希。
|
||||
4. 追加必要的元数据日志。
|
||||
5. 更新内存索引。
|
||||
|
||||
读取流程:
|
||||
|
||||
1. 从内存索引获取 segment、offset 和 length。
|
||||
2. 使用 pread/preadv 读取目标范围。
|
||||
3. 尽可能使用内核 Page Cache。
|
||||
4. 不经过解压、反序列化或额外的数据转换。
|
||||
|
||||
Pack 适合大量小型、非频繁随机修改的文件。Pack 记录更新采用新位置写入和旧记录回收,不在原位置进行复杂的碎片整理。
|
||||
|
||||
### 5.2 Direct Backend:活跃数据库和大文件
|
||||
|
||||
以下文件/目录优先使用 Direct Backend:
|
||||
|
||||
- SQLite 数据库及其 WAL/SHM 文件。
|
||||
- LevelDB/RocksDB 等依赖文件锁和随机写入语义的目录。
|
||||
- 大文件。
|
||||
- 高频随机更新文件。
|
||||
- 需要 mmap、fallocate 或严格 POSIX 语义的文件。
|
||||
|
||||
Direct Backend 由底层文件系统直接承载,StorageFS 只管理命名空间和路径映射,尽可能通过 FUSE passthrough 或等效机制减少数据面拷贝。
|
||||
|
||||
**不得把 SQLite、LevelDB 的活跃文件打包后再用简化接口模拟普通文件。** 如果无法完整满足锁、mmap、fsync、rename 等语义,必须使用 Direct Backend。
|
||||
|
||||
### 5.3 默认阈值
|
||||
|
||||
初始建议:
|
||||
|
||||
- 小于 64 KiB:允许进入 Pack。
|
||||
- 大于或等于 64 KiB:Direct Backend。
|
||||
- 数据库文件及其关联目录:强制 Direct Backend。
|
||||
- 阈值必须可由 Admin 配置,后续通过实测调整。
|
||||
|
||||
Pack 不意味着所有小文件都适合打包。高频修改、频繁 truncate 的小文件也应允许强制 Direct。
|
||||
|
||||
## 6. IO 与 CPU 优化要求
|
||||
|
||||
性能目标是减少热路径开销,而不是增加复杂的存储算法。
|
||||
|
||||
### 6.1 禁止进入热路径的操作
|
||||
|
||||
- 不压缩、不解压。
|
||||
- 不做内容哈希或跨 Profile 去重。
|
||||
- 不进行每次读写的 JSON 序列化。
|
||||
- 不在每次文件访问时访问 Admin。
|
||||
- 不在每次 IO 时扫描目录或扫描 Pack。
|
||||
- 不在读路径执行 GC、全量统计或容量重平衡。
|
||||
- 不为每个文件启动独立任务或线程。
|
||||
|
||||
### 6.2 数据面优化
|
||||
|
||||
- 使用 Rust 实现,尽量减少不必要的内存分配和数据复制。
|
||||
- 使用 FUSE low-level API,避免高层封装带来的额外开销。
|
||||
- 优先评估 Linux FUSE passthrough,让 Direct Backend 的数据读写尽可能绕过用户态数据复制。
|
||||
- 对 Pack 使用 pread/pwrite、preadv/pwritev 等位置 IO,避免共享文件游标锁。
|
||||
- 使用有界 IO 队列和批量提交,避免无限队列导致内存增长。
|
||||
- 元数据索引常驻内存,启动时通过快照和日志恢复,正常访问不查磁盘索引。
|
||||
- 利用 Linux Page Cache;不要默认使用 O_DIRECT,避免绕过有效缓存。
|
||||
- Segment 可预分配,降低写入时的文件扩展和碎片开销。
|
||||
- 对同一 Segment 的写入采用轻量分配器,避免全局大锁。
|
||||
|
||||
### 6.3 元数据优化
|
||||
|
||||
- 使用紧凑二进制结构,不使用 JSON 存储运行时元数据。
|
||||
- 目录项和 inode 元数据采用分层索引。
|
||||
- inode/object ID 单调分配,避免频繁随机生成。
|
||||
- 目录项查询优先走内存索引。
|
||||
- 元数据日志采用 append-only 方式,减少随机更新。
|
||||
- 周期性生成快照;恢复时加载快照并重放增量日志。
|
||||
- 元数据日志与数据 Pack 分开存放。
|
||||
|
||||
## 7. 元数据与恢复机制
|
||||
|
||||
StorageFS 不使用数据库。
|
||||
|
||||
本地元数据由以下文件构成:
|
||||
|
||||
```text
|
||||
storage-meta/
|
||||
├── superblock
|
||||
├── metadata.snapshot
|
||||
├── metadata.wal
|
||||
└── volume.state
|
||||
```
|
||||
|
||||
- superblock:文件系统版本、UUID、配置版本和基础参数。
|
||||
- metadata.snapshot:inode、目录项和对象映射的紧凑快照。
|
||||
- metadata.wal:快照之后的增量元数据操作。
|
||||
- volume.state:各 Volume、Segment 的分配与恢复状态。
|
||||
|
||||
要求:
|
||||
|
||||
1. WAL 记录具备长度和版本字段,能够识别尾部不完整记录。
|
||||
2. 启动时先加载快照,再顺序重放 WAL。
|
||||
3. 崩溃恢复不得依赖 Admin 在线。
|
||||
4. WAL 达到阈值后生成新快照,再安全截断旧日志。
|
||||
5. 数据写入与元数据提交必须定义明确的顺序,避免索引指向尚未落盘的数据。
|
||||
6. 通过 fsync/fdatasync 提供明确的持久化语义,不得用异步写入冒充同步完成。
|
||||
|
||||
## 8. 多磁盘分配
|
||||
|
||||
Allocator 负责选择 Volume 和 Segment,不参与文件数据读写。
|
||||
|
||||
分配原则:
|
||||
|
||||
1. 只向已启用、可写且未达到容量上限的 Volume 分配。
|
||||
2. 根据剩余容量、配置权重和当前 IO 压力选择 Volume。
|
||||
3. 新文件/新 Pack 优先分配,不在每次读写时重新平衡。
|
||||
4. 已存在的对象不因权重变化而自动搬迁。
|
||||
5. 磁盘达到水位时停止新分配并上报 Admin。
|
||||
6. Volume 故障时不得静默丢弃元数据或把原数据映射到其他 Volume。
|
||||
|
||||
初始实现使用容量权重分配即可,不引入复杂的实时全局调度算法。IO 压力指标可先用于监控,后续有实测收益再参与分配决策。
|
||||
|
||||
## 9. Profile 命名空间
|
||||
|
||||
Profile 在 StorageFS 中表现为普通目录:
|
||||
|
||||
```text
|
||||
/var/lib/profilemax/storage/
|
||||
├── profiles/
|
||||
│ ├── <profile-id-1>/
|
||||
│ ├── <profile-id-2>/
|
||||
│ └── <profile-id-3>/
|
||||
└── shared/
|
||||
```
|
||||
|
||||
- Profile 生命周期由 ProfileMax Node 管理。
|
||||
- Storage Hook 不保存 Profile 业务数据,不建立 profiles 数据库表。
|
||||
- Profile 目录的 inode、目录项和对象映射属于 StorageFS 内部元数据。
|
||||
- Profile 删除由 Node 调用 StorageFS 接口完成,删除过程必须支持可恢复状态,避免中途崩溃产生悬挂对象。
|
||||
- 是否提供 Profile 级容量限制作为后续能力;第一阶段不增加不必要的配额索引和统计热路径。
|
||||
|
||||
## 10. POSIX 兼容要求
|
||||
|
||||
StorageFS 面向 Chromium Profile,必须重点验证:
|
||||
|
||||
- open / close / read / write / pread / pwrite
|
||||
- mkdir / rmdir / unlink / rename
|
||||
- stat / fstat / readdir
|
||||
- truncate / ftruncate
|
||||
- fsync / fdatasync
|
||||
- flock / fcntl 文件锁
|
||||
- mmap / msync
|
||||
- fallocate
|
||||
- hard link / symbolic link(按 Chromium 实际需求支持)
|
||||
|
||||
SQLite、LevelDB 等目录默认走 Direct Backend。不能完整支持的操作不得静默返回成功;必须返回正确错误,或通过 Direct Backend 提供原生语义。
|
||||
|
||||
## 11. 运行与管理接口
|
||||
|
||||
Storage Hook 作为 ProfileMax Node 的本地组件运行,不独立提供公网 API。
|
||||
|
||||
Node 内部接口建议:
|
||||
|
||||
- `Mount(config)`:初始化 Volume、StorageFS 和 tmpfs 路径规则。
|
||||
- `ReloadConfig(config, version)`:应用新配置。
|
||||
- `GetStatus()`:返回持久化挂载、tmpfs 挂载和 Volume 状态。
|
||||
- `GetMetrics()`:返回磁盘 IO、tmpfs 使用量、容量、错误和队列指标。
|
||||
- `CreateProfile(profileID)`:创建 Profile 持久化目录及对应 tmpfs 挂载。
|
||||
- `GetProfilePath(profileID, category)`:向 Chromium、impersonate-proxy 返回受管路径。
|
||||
- `DeleteProfile(profileID)`:停止相关进程后清理 Profile 数据及 tmpfs。
|
||||
- `Flush()`:按策略刷盘;tmpfs 内容不执行持久化刷盘。
|
||||
- `Unmount()`:停止接收新 IO,完成必要刷盘后卸载。
|
||||
|
||||
Admin 通过 ProfileMax Node 现有控制通道下发配置并读取状态,不让 Storage Hook 自己连接 Admin 数据库。
|
||||
|
||||
## 12. 监控指标
|
||||
|
||||
至少提供:
|
||||
|
||||
- 持久化 Volume 总容量、已分配容量、可分配容量。
|
||||
- tmpfs 总容量、每 Profile 使用量、峰值和 OOM/ENOSPC 次数。
|
||||
- Pack Segment 数量和利用率。
|
||||
- Direct Backend 文件数量与容量。
|
||||
- 按后端分类的读写吞吐量、IOPS、平均延迟、P95/P99 延迟。
|
||||
- FUSE 请求数、请求排队时间。
|
||||
- 元数据缓存命中率。
|
||||
- WAL 大小、快照耗时和恢复耗时。
|
||||
- CPU 使用率、RSS、线程数。
|
||||
- IO 错误、Volume 不可用、容量不足次数。
|
||||
|
||||
指标采集必须低频、低开销,不允许每次文件 IO 都进行复杂统计。
|
||||
|
||||
## 13. 实施阶段
|
||||
|
||||
### Phase 1:存储路由与最小可用版本
|
||||
|
||||
- 先建立原生 ext4/XFS 与 tmpfs 基准。
|
||||
- 实现 Storage Hook 的统一 Profile 路径入口。
|
||||
- 实现持久化 Direct Backend 和 tmpfs Manager。
|
||||
- 配置 Chromium HTTP Cache、Code Cache、GPU/Shader Cache 的 tmpfs 路由。
|
||||
- 将 impersonate-proxy 缓存目录纳入 Storage Hook,默认持久化。
|
||||
- 验证 Cookies、Local Storage、IndexedDB、Service Worker 等持久化数据不受影响。
|
||||
- 验证 Chromium 与 impersonate-proxy 都无法绕过 Storage Hook 写入未管理目录。
|
||||
|
||||
### Phase 2:Pack Backend
|
||||
|
||||
- 通过基准测试确认 inode 压力是否值得引入 Pack。
|
||||
- 实现小文件 Pack Segment、内存索引、追加写和随机读。
|
||||
- 实现对象删除标记和后台回收。
|
||||
- 增加 Direct/Pack/tmpfs 路由规则。
|
||||
- 对比 Pack、Direct、tmpfs 与原生文件系统的 CPU、IO 延迟和文件数量。
|
||||
|
||||
### Phase 3:多 Volume
|
||||
|
||||
- 实现容量权重分配。
|
||||
- 实现 Volume 状态检测和容量水位。
|
||||
- 实现配置热更新。
|
||||
- 验证单盘满载、掉盘、重启等异常场景。
|
||||
|
||||
### Phase 4:性能优化
|
||||
|
||||
- 评估 FUSE passthrough。
|
||||
- 优化请求批处理、锁竞争和元数据缓存。
|
||||
- 根据真实 Profile IO 负载调整 Pack 阈值和 Segment 大小。
|
||||
- 只有基准数据证明有收益时才增加更复杂的缓存或调度策略。
|
||||
|
||||
## 14. 性能验收
|
||||
|
||||
所有性能测试必须与原生 ext4/XFS 目录进行对照,使用相同磁盘、挂载参数和测试数据。
|
||||
|
||||
测试场景:
|
||||
|
||||
1. 大量小文件创建、读取、删除。
|
||||
2. 大量小文件随机读取。
|
||||
3. 大文件顺序读写。
|
||||
4. SQLite WAL 模式读写。
|
||||
5. LevelDB 高频随机读写。
|
||||
6. Chromium HTTP Cache 写入 tmpfs 后的启动和页面加载表现。
|
||||
7. impersonate-proxy 缓存命中时,浏览器侧不再产生持久化 HTTP Cache 写入。
|
||||
8. Cookies、IndexedDB、Service Worker 等持久化数据重启后完整保留。
|
||||
9. 多 Profile 并发运行时 tmpfs 总量与单 Profile 限额。
|
||||
10. Profile 正常退出、异常退出后的 tmpfs 清理。
|
||||
11. 异常终止后的 StorageFS 恢复时间。
|
||||
|
||||
核心指标:
|
||||
|
||||
- IO 吞吐量。
|
||||
- IOPS。
|
||||
- P50/P95/P99 延迟。
|
||||
- CPU 使用率和 CPU 时间/GB。
|
||||
- RSS、tmpfs 使用量和元数据内存占用。
|
||||
- 底层 inode 使用量。
|
||||
- 浏览器侧重复 HTTP Cache 的持久化写入量(目标为零)。
|
||||
- 崩溃恢复时间。
|
||||
|
||||
性能决策原则:
|
||||
|
||||
- 如果 FUSE 在某类 IO 上明显慢于原生文件系统,该类文件必须走 Direct Backend 或 passthrough。
|
||||
- 如果 Pack 节省 inode 但显著增加 CPU 或 P99 延迟,应提高 Pack 适用门槛,而不是强制打包。
|
||||
- 不以单纯减少文件数量作为成功标准;IO 性能和 CPU 开销优先。
|
||||
- 不启用压缩。
|
||||
|
||||
## 15. 明确不做的事情
|
||||
|
||||
- 不使用节点数据库。
|
||||
- 不把 Admin 数据库复制到 Node。
|
||||
- 不做数据压缩或解压。
|
||||
- 不做跨 Profile 内容去重。
|
||||
- 不做内容哈希校验热路径。
|
||||
- 不把所有文件强制放进 Pack。
|
||||
- 不允许 Chromium 或 impersonate-proxy 绕过 Storage Hook。
|
||||
- 不把整个 Profile 或未经分类的网站数据目录放进 tmpfs。
|
||||
- 不在每次 IO 时请求 Admin。
|
||||
- 不在第一阶段实现复杂的自动数据均衡。
|
||||
- 不为了抽象而引入独立的存储集群服务。
|
||||
|
||||
最终交付应是一个由 ProfileMax Node 管理的本地 Storage Hook:Admin 负责配置,Node 负责执行;所有 Profile 数据统一经由 Storage Hook;持久化数据进入多磁盘 StorageFS,重复 HTTP Cache 和可重建缓存进入受限 tmpfs,impersonate-proxy 缓存也由 Hook 管理;无节点数据库、无压缩,并以 IO 性能和 CPU 效率作为主要优化方向。
|
||||
@@ -0,0 +1,223 @@
|
||||
# xLightpanda 温池切换 + Storage Hook 部署手册(v0.47.0 / xlp 0.10.0)
|
||||
|
||||
> 日期:2026-11-03/04。交付:xLightpanda 接管 SERP 浏览器温池 + Storage Hook Phase 1(tmpfs 路由 + 共享 ShaderCache)。
|
||||
> 代码:SerpMax node v0.47.0(zcode/serpmax-node 已推送);xLightpanda upstream 提交 `4f69ccd2`(0.10.0 显式指纹导入)。
|
||||
> **部署路径勘误(2026-11-04)**:三节点 systemd 服务 WorkingDirectory=/app,实际运行代码在 `/app/src`(bundle 含 /app/node_modules,勿动);
|
||||
> `/opt/{serpmax,profilemax}/src` 是滞留副本。部署 = rsync src → 节点 `/app/src` + package.json → `/app` + 更新日期戳 `/app/version` → restart。
|
||||
|
||||
## 1. 交付物
|
||||
|
||||
| 产物 | 位置 | 说明 |
|
||||
|---|---|---|
|
||||
| lightpanda 二进制(ReleaseFast,glibc 2.36 目标) | `/opt/{serpmax,profilemax}/bin/lightpanda` | 222MB;xlp 0.10.0;**必须用 `-Dtarget=x86_64-linux-gnu.2.36` 构建**(节点 Debian 12 glibc 2.36,宿主 2.39 构建会缺 `GLIBC_2.38/__isoc23_strtoull`) |
|
||||
| SerpMax node 源码 v0.47.0 | `/opt/{serpmax,profilemax}/src` + package.json | xlightpanda kind / SERP_BROWSER_KIND / storage.hook / /storage/hook 端点 |
|
||||
|
||||
## 2. 温池引擎切换(每节点)
|
||||
|
||||
```bash
|
||||
# 1) 二进制与代码
|
||||
rsync -az upstream/zig-out/bin/lightpanda NODE:/opt/serpmax/bin/lightpanda
|
||||
rsync -az --delete node/src/ NODE:/opt/serpmax/src/ # 源码内含 node_modules 除外
|
||||
rsync -az node/package.json NODE:/opt/serpmax/
|
||||
|
||||
# 2) node.env 追加(二进制路径必配;SERP_BROWSER_KIND 分两步灰度)
|
||||
grep -q XLIGHTPANDA_BINARY_PATH /opt/serpmax/node.env || \
|
||||
echo 'XLIGHTPANDA_BINARY_PATH=/opt/serpmax/bin/lightpanda' >> /opt/serpmax/node.env
|
||||
# 灰度第二阶段才加:
|
||||
echo 'SERP_BROWSER_KIND=xlightpanda' >> /opt/serpmax/node.env
|
||||
|
||||
# 3) 重启并验证
|
||||
systemctl restart serpmax-node
|
||||
curl -s localhost:3100/version # 版本号 0.47.0
|
||||
tail -f /opt/serpmax/data/logs/* # 观察 browser.started_xlightpanda / serp.pool_warm
|
||||
```
|
||||
|
||||
- **回滚**:node.env 删掉 `SERP_BROWSER_KIND`(或置空)→ `systemctl restart serpmax-node`,温池立即回到 Profile kind(veil/cloak)。Profile 元数据不动。
|
||||
- 温池入池门槛/搜索逻辑(serp-pool.ts)零改动;媒体拦截 `page.route` 与页内 fetch 在 xLightpanda 上已实测(CDP Fetch domain / Runtime.evaluate)。
|
||||
- xLightpanda 仅无头运行(无渲染引擎);kind=xlightpanda 的 Profile 请求有头(VNC/WebDesk)会显式报错。
|
||||
|
||||
## 3. Storage Hook Phase 1(每节点,默认关闭)
|
||||
|
||||
配置:`DATA_DIR/node-settings.json` 合并(注意保留既有键;HZ-DE-245 已有 httpCacheDir/profileDir):
|
||||
|
||||
```json
|
||||
{
|
||||
"httpCacheDir": "…(保留)",
|
||||
"profileDir": "…(保留)",
|
||||
"storageHook": {
|
||||
"enabled": true,
|
||||
"tmpfsRoot": "/run/serpmax",
|
||||
"perProfileCacheSizeMb": 256,
|
||||
"sharedShaderCache": true,
|
||||
"sharedShaderSizeMb": 256
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 生效后 veil/cloak 浏览器启动前:Cache/Code Cache/GPUCache/Dawn/Media Cache → 每 Profile 独立 tmpfs(256MB 上限);ShaderCache/GrShaderCache → bind 到 `/run/serpmax/shared/shader` 共享 tmpfs。停机/崩溃/hardReset 自动 lazy umount。
|
||||
- **验证**:`curl -s localhost:3100/storage/hook`(activeProfiles/mounts/recentErrors);浏览器启动后 `mount | grep <profileDir>`;`du` 确认 Cache 目录不再占磁盘;Cookies/IndexedDB 重启保留(路由清单永不含存储类目录)。
|
||||
- **回滚**:node-settings 置 `"enabled": false` → 重启。挂载失败(无 root/CAP_SYS_ADMIN)自动降级直写磁盘并记 `storage.hook_no_root` 告警,不影响业务。
|
||||
- xLightpanda 温池实例无此问题(无渲染无 shader cache;HTTP cache 为单 sqlite 文件),storage.hook 只对 veil/cloak 分支挂载。
|
||||
|
||||
## 4. 已验证结论(2026-11-03/04,本机)
|
||||
|
||||
1. **种子一致性**:6 seed(10000/42/123456/999999/573891/248166)veil-chromium vs xLightpanda 全字段对比,均只剩同 7 处引擎基底差异(canvas/webgl 像素、rects 布局基底、audio DSP),身份字段逐字段一致(xLightpanda docs/SEED_PARITY.md)。
|
||||
2. **温池可行性**:google 首页入池门槛(搜索框)、登录门槛 evaluate、页内 fetch+DOMParser、route 媒体拦截、导航回退全部通过;fetch 拿到 enablejs 空壳时 results=0 → serp-pool 原有回退逻辑按设计工作。
|
||||
3. **Cookie 桥**:注入真实形 Google 会话 cookie(SID/HSID/SSID/SAPISID/NID)导航后全部存活。注意:Google 会对请求中出现的**陌生名字** cookie 主动下发删除 Set-Cookie(fake 名必被清,系 Google 侧行为,真实 Chrome 同样)。
|
||||
4. **Storage Hook**:unshare 用户命名空间实测 18/18 挂载(含带空格目录)、共享 shader 互通、卸载干净、tmpfs 易失语义、disabled 零动作。
|
||||
5. **导入连贯性守卫**:cloak UA(151)误配 derivation=veil(内核 153)时启动即失败 `InconsistentUserAgentVersion`——node 侧转换器已按 `origin==="veil" && uaMajor===153` 门控。
|
||||
|
||||
## 5. 温池灰度现状与遗留问题(2026-11-04)
|
||||
|
||||
**当前舰队状态:OVH-ETH-33 已开 xLightpanda 灰度(0.10.5,`SERP_BROWSER_KIND=xlightpanda`);HZ-DE-245 / OVH-DEDI-KS5B 暂保持 veil/cloak 兜底。三节点 lightpanda 0.10.5 二进制已就位;Storage Hook 三节点全启用。**
|
||||
|
||||
### 已突破
|
||||
- 引擎接入全链路可用:克隆启动、impersonate MITM 链、cookie 桥、入池门槛、平均 RSS 75MB(veil 同池 ~450MB)。
|
||||
- Google 的 **captcha/sorry 硬门已突破**(0.10.2-0.10.4 头集修复:UA-CH 三件套含引号、navigation accept 全段、accept-encoding 值序)。
|
||||
- **enablejs/critical-ch 软门已突破(0.10.5,2026-11-04)**:给 impersonate-proxy 加了受控转发头 dump(env 门控 `IMPERSONATE_HEADER_DUMP=<目录>`(+可选 `_FILTER=<url子串>),默认零开销;源码在 /home/bigcode/data/dev/impersonate-proxy,该仓库 deployed-but-uncommitted 状态保持),veil vs xlp 同 Profile 同 IP 同 MITM 抓包对比,锁定门因:
|
||||
- **Accept-CH client hints 全套缺失**。Google 响应声明 accept-ch/critical-ch 后,Chromium 对同源后续请求自动回带全部高熵提示(arch/bitness/full-version(-list)/model/platform-version/wow64/form-factors/prefers-color-scheme/downlink/rtt/ect);xlp 一概不带,Google critical-ch-check 判客户端撒谎 → enablejs 门壳 → hop2 302 /sorry。
|
||||
- 次要差异:accept-language 多了 `,en;q=0.8` 第三段(veil/Chromium 实测 `de-DE,de;q=0.9` 无英文兜底)。
|
||||
- 修复:0.10.5 Client.noteAcceptCh/appendClientHints(Accept-CH 持久化+每跳回带,值取自指纹 ctx 高熵集合)+ accept-language 去兜底。
|
||||
- 验证:ETH-33 xlp 灰度池 pagefetch `ok=true 9 结果 0.9~1.7s`(与 veil 同形);新鲜实例单测通过。
|
||||
- **已知的非引擎限制**:同一实例被连续高频手打搜索(≥7 次)后会在 navigate 回退跳吃 /sorry——频率疲劳,veil 同样如此,非引擎门;温池轮换天然规避。
|
||||
|
||||
### 灰度操作
|
||||
- 开:`node.env` 加 `SERP_BROWSER_KIND=xlightpanda` → `systemctl restart serpmax-node`。
|
||||
- 关:删该行 → 重启,立即回 veil/cloak(Profile 元数据不动)。
|
||||
- 二进制升级:`upstream/zig-out/bin/lightpanda`(ReleaseFast + `-Dtarget=x86_64-linux-gnu.2.36`)→ 节点 `/opt/{serpmax,profilemax}/bin/lightpanda`;先 `sudo pkill -f "lightpanda [s]erve"`(注意 pkill 模式勿自匹配 ssh 命令行,否则 ssh 会话被杀 exit 255)。
|
||||
|
||||
## 6. 后续(不在本轮)
|
||||
|
||||
- StorageFS FUSE/Rust/Pack(02_STORAGE_HOOK.md Phase 2+),另行立项。
|
||||
- xLightpanda localStorage/IndexedDB 落盘(SERP 场景 cookie 足够)。
|
||||
|
||||
## 6.5 typed 搜索调查(2026-11-04 下午,node 0.49.0/0.49.1)
|
||||
|
||||
**用户判断(定稿):go-fetch 打 302/门壳标的 Profile,WebDesk 里 veil 手动输入搜索全畅通 ⇒ 只要 xLightpanda 能像真实浏览器一样打开 google.com 在输入框填词回车,搜索应当没问题;两处部署动作:①`/nodes` 编辑弹窗设置多了加 Tabs 分类;②温池搜索路径保证「粘真实浏览器」。受此推动做的实测链:**
|
||||
|
||||
1. **pagefetch 门壳不再直接采信(0.49.0,1475479)**:`~10k Profile 只绑 3 个上游 IP` 的密度下,pagefetch 类 XHR 拿 0 结果/sorry/verify 概率高,而真浏览器路径畅通 ⇒ 改为仅当 `results>0 且 !sorry&&!verify` 采信,其余一律回退 **fill 搜索词 + Enter** 导航路径(真实浏览器行为)。
|
||||
2. **JS 引导就绪门(0.49.1,55eb63d)**——本地 xLightpanda 复现实锤:
|
||||
- Google 主包(xjs)未初始化完成时填词提交 → **原生 form GET**(抓到 `biw=&bih=` 空、`ie=GB2312`、无 sxsrf)→ Google 判异常流量 **302 /sorry**(title "302 Moved" 半秒转sorry);
|
||||
- 等就绪(`readyState=complete` + `gk 命名空间≥30/联想容器 .UUbT9/隐藏域 biw` 有值,约需 10-15s 本地)之后同样操作 → **自然结果**(带 sxsrf/sca_esv 富参数);
|
||||
- 故 navigate 提交前等待就绪标记(上限 10s,日志 `serp.pool_boot_gate`),温页早已就绪因此实测 `ms≈1-2` 即通过,零延迟副作用。
|
||||
3. **ETH-33 生产复测:typed 就绪后提交,入参富化正确(sei= 等),但 Google 仍 302→/sorry** —— 剩余根因嫌疑二选一:① 每上游 IP 的 trip 计数(池 64 ≈21/IP, Typed 探针集中于少数 Profile);② impersonate MITM 链对**导航型请求**的头序/TLS 会话特征与真 Chromium 型有残余差异(veilWebDesk 同代理畅通是对照组)。**待 MITM 详查** = `IMPERSONATE_HEADER_DUMP` 开窗抓 xlp 导航 vs 规范浏览器导航头序对比(xlp 0.10.5 已留工具)。
|
||||
4. 部署注记:node 0.49.1 三节点 `/app/version=20261104.1300`(ETH/HZ/KS5B 全部 xlightpanda 温池态,版本戳一处更新)。
|
||||
5. admin 1.81.0(144955f):`/nodes` 编辑节点弹窗改 **Tabs 三页(基本信息/存储与缓存/温池与守卫)**;Hook 与守卫面板改为各自「恢复默认」按钮(原「联动恢复默认」取消)。前端已构建,HZ-DE-245 侧 profilemax-admin 已重启生效。
|
||||
|
||||
**排查小径(可复用)**:在服务器上 spawn 一只不走 MITM 的 `lightpanda serve` + `/app/node_modules/playwright-core` 装载 typed 探针,可隔离 MITM 因素做二元定位;直连(无 proxy 无 MITM)typed 探针发现 Google 主包就绪但 Enter 提交**不触发导航**,生产池路径(有 MITM)却能正常提交——驱动/键事件传递在直连实例上的差异也收入待修(独立小问题,非 sorry 主因)。
|
||||
|
||||
### 6.6 typed 决胜局:生产 dump 对照(2026-11-04 下午二)(0.49.1→0.49.2)
|
||||
|
||||
**背景**:用户拿 `24447424 / 2305745 / 65368457` 三只被限 Profile 在 WebDesk(veil)手测**全畅通** ⇒ IP 无恙,差异收敛到 xlp 引擎/MITM 链。用 `impersonate-proxy-dbg`(IMP dump 工具)在生产抓上游可见头,拿到关键证据并各进一环:
|
||||
|
||||
1. **生产 dump 三连发(34221790)**:req0 pagefetch XHR(`sec-fetch-mode:cors`/`accept:*/*`,形正常);**req1 typed 导航 = `sec-fetch-site:none` + 无 Referer + 裸 q 提交**(真 Chrome 表单回车应为 `same-origin` + `Referer:https://www.google.com/` + sxsrf 富化);req2(sei= 重定向链,same-origin+referer 正确)。→ 引擎把「激活型提交」送出了地址栏/CDP 型导航特征,Google 判异常。
|
||||
2. **本地直连 typed 对照(reprobe10/11)**:引导就绪后首轮与结果页二连 typed 均 **organic**(sxsrf/newwindow/sca_esv 富化,10 结果);本地 echo 自建表单提交分类 `same-origin+referer` 正确 ⇒ 引擎直连形态本身良好,问题在生产恢复页态 + MITM 链。
|
||||
3. **0.49.1 进站门实测有效但不够**:boot 门标志(`readyState+gk/ac/biw`)温页 1-2ms 即过(免延迟),但提交后 Typed 每 0 结果 —— gate 抓的是页内引导而非请求指纹。
|
||||
4. **0.49.2(55b594c)「typed 回家门」**:pagefetch 门壳回退时若温页是结果/sorry 页,先 `goto google 首页` 再输入(与用户「新开 google → 输入框填词」路径完全对齐),boot 门上限扩到 15s。部署三节点(版本戳 20261104.1240)。
|
||||
5. **复测仍 0 结果** —— 回家门在位、提交链通,Google 依旧 302→/sorry:剩余根因收敛于 **impersonate MITM 链对 document 导航请求的 HTTP/2/TLS 帧特征(h2fp 的 SETTINGS/伪头序/TLS preset chrome 与 Chrome 146 不匹配)**。同一链路 pagefetch(XHR 类)如常 0.9~1.7s organic,而 document(GET)一击即中 —— Google 对 document GET 的 JS/一致性门更严。**待修专项:Chrome 146 帧级对齐 h2fp(与上述直连 typed 字同步联动验证)。**
|
||||
|
||||
压测余量与守卫机制不受影响;搜流量主体照旧 pagefetch 健康;typed 兜底环节在 h2fp 对齐完成前保持现状。
|
||||
|
||||
### 6.7 内核 TLS 对齐(2026-11-04 下午三,node 0.49.3)
|
||||
|
||||
**用户定案走专项①后,第一手就是对齐上游 TLS。逐项对照 Real Chrome 148 akamai 指纹后确认 h2 帧参数层(SETTINGS/WINDOW_UPDATE/无 PRIORITY/伪头序 m,a,s,p)已逐位一致,剩余差异收敛到 TLS ClientHello;部署态 `preset: chrome` 是 uTLS 静态模板而非真内核。**
|
||||
|
||||
1. **采样器(impersonate-proxy,deployed-but-uncommitted 状态保持)**:`proxy/clienthello.go` 新增浏览器侧 ClientHello 字面采样(env 门控 `IMPERSONATE_CLIENT_HELLO_DUMP=<json 路径>`,MITM 终结前截读解析、成功一次落盘,零开销直通),输出与 node `kernel-hello.json` 同型(ciphers/extensions/curves/versions,另记 ext_bodies)。
|
||||
2. **ETH-33 独立链采样**(不经 node:手工 impersonate 实例 + 手工 veil Chromium 153 `--headless=new` 打 Google):真内核是 **Chromium 153.0.8010.52**(与指纹品牌号 146 是两码事),hello 完整特征:GREASE×2(头 0xωaωa + 尾 compliance-GREASE **0xCA34/51764,恒定,186 字节结构化 payload**)、ALPS 0x44cd、ECH 0xfe0d、`x25519mlkem768`(IANA 4588=0x11EC)。
|
||||
3. **修 dialer 三个兼容点(fp/dialer.go)**:① `0xCA34` 不占 uTLS 动态 GREASE 槽,以 GenericExtension 发结构化体(config 新增 `ext_bodies`,缺省带采样同型骨架)——**实锤**:CA34 发 1 字节短体时 Google 上游直接 `tls: error decoding message`(186 字节真 payload 即过,两端皆证);② hybrid 组映射纠正:0x11EC 是 `x25519mlkem768`(此前误标 kyber768 → utls 发出已废弃的 0x6399);③ `keySharesForCurves` 补 Kyber 分支混合复用。
|
||||
4. **kernel-hello.json 转正 + node 侧下发**:采样体放 `DATA_DIR/impersonate/kernel-hello.json`,node `buildImpersonateYaml` 自动生成 `tls: preset: custom + custom_hello`(含 ext_bodies hex);profile config.yaml 重生成即生效(0.49.3,git 54574fe)。
|
||||
5. 部署:新二进制 + kernel-hello.json + node src → 三节点(/app/version=20261104.1330);ETH 全池 config 已带 `preset: custom`;Google HTTPS 经 custom hello 200(4 连测)。
|
||||
|
||||
### 6.8 typed 决胜第三局:根因是引擎不跟 302(2026-11-04 下午四,node 0.49.4)
|
||||
|
||||
1. **dump 窗口复测(0.49.2 回家门 + 0.49.3 TLS 对齐后)**:typed 提交形态已完全正确(`same-origin`+`document`+`referer`+sxsrf/sca_esv 全富化)——但落点页 `document.title = "302 Moved"`。Google 对 document 导航回 302(sei/sxsrf 令牌轮转),**xlp 显示 302 静态体而非跟随**,轮询环拿不到结果 → results=0、无 sorry(此前所有 typed 0 结果的真正根因)。
|
||||
2. 引擎侧排查:本地复现(跨主机 302/多跳链/playwright 拦截四对照)全部正常跟随 —— 生产差异在 Google 的 sei 跳转语义(出口 SG → google.com 302 富化),未再深挖引擎内因。
|
||||
3. **节点侧绕法(实证有效)**:`+sei` 落点 URL 直接再取即 200 结果页(生产 execute_js 实证)。0.49.4 在 typed 轮询环检测 `302 Moved` 标题 → `page.goto(当前URL)` 手动吃跳转(至多两跳)→ 继续轮询。
|
||||
4. **附:此前"typed 0 结果"探针有一半假阴性** —— `verify492_0`/`tlsfixNNNN` 乱码词 Google 合法返回零结果页;验证探针要用真实查询词。
|
||||
|
||||
### 6.9 goto 缓存重构(2026-11-04,node 0.49.4 / admin 1.82.0,git bc56b63 / 80e56bc)
|
||||
|
||||
**用户定稿:goto 缓存从 admin 库单点改成「节点文件缓存 + admin 共享库」双层,把「节点搜完 → HTML→admin→admin 解析 goto→节点逐条 fetch location」的解析环砍掉。**
|
||||
|
||||
- 键不变:`sha256(title+换行+cite)`(与 admin goto_cache 逐字节同型,两端互读)。
|
||||
- 节点 `core/serp/goto.cache.ts`:搜索拿到结果 HTML 后就地提取(typed/页内 fetch 两路统一 DOMParser;`a[href*="/goto?url="]` 且含 h3,近 4 层找 cite)→ 本地文件缓存(`DATA_DIR/goto-cache/<key>`,内容=location)→ 未命中批量直连 admin(`/api/node/serp/goto-cache`,节点密钥,不经 CF)→ 仍无则同 Profile impersonate 链路并发取 location(8 并发,3xx+Location 绝对地址且非 goto 才采信)→ 双写(本地文件 + 异步回填 admin 库跨节点共享)→ **HTML 就地改写为真实地址再返回**。
|
||||
- admin `POST /api/node/serp/goto-cache`(authorizeSerp 鉴权):`keys` 批量查(脏值=仍为 goto 形状的值不采信)、`entries` 回填(过滤非法形状);先回填后批查,节点上一轮写入本轮即可命中。
|
||||
- admin 侧 `resolveGotoLinks` 无需改动:节点已改写的 HTML 扫到 0 目标,旧解析环(含逐条 fetchViaNode)自然整段跳过。
|
||||
- 单测 `tests/goto.cache.test.ts` 5 例(键同型/写读回环/脏值防护/三变体改写/零命中不动);bun test 195 全过。
|
||||
|
||||
- **端到端实测(0.49.6 部署后)**:typed 手动跟随机制逐次触发(journal `pool_typed_302_follow`),信任态正常的会话 2/3 organic(9/10 结果,11-15s);随后 ETH-33 整机一度过载(15:02 admin UI 侧 reboot),回填后 ETH/HZ 会话出现持续 302+enablejs 壳。
|
||||
- **~~「舰队级 IP 信任态塌陷」结论已撤回(2026-11-04 晚)**:用户用同一批指纹 ID(ETH 13594328/2302407/2302770/2303411/2305556/2305846/2306594/38879162/67196721,HZ 32955298/77526684)走正常指纹浏览器(cloak)手工复测,**全部干净无 302/enablejs**。复探针亦证:cloak 同 Profile 走**同一条 impersonate 链**(uTLS+h2fp)typed GET → 200 organic。门态非 IP 级、非 TLS/h2 级。
|
||||
- **真根因(MITM 头 dump 实证,`IMPERSONATE_HEADER_DUMP=/tmp/hdump3`)**:lightpanda 引擎**自造的 Client Hints 残缺**,Google document 门态对残缺 CH 的 typed 导航下发 302。cloak(真 Chromium 内核生成 CH)干净 vs xlp 池被门,同为 typed(sec-fetch-site:none、无 referer)、同链路:
|
||||
- `sec-ch-ua`:cloak `"Not-A.Brand";v="24",…`(Chrome 标准 grease)vs xlp `"Not/A)Brand";v="8",…`(lightpanda 自造 grease v8);
|
||||
- cloak 有 `sec-ch-ua-full-version-list`(146.0.7680.177)/`sec-ch-ua-model`/`sec-ch-ua-wow64`/`sec-ch-ua-bitness`,**xlp 全缺**;
|
||||
- `downlink`/`rtt`:cloak 0.4/450(真实感)vs xlp 10/50(满分值)。
|
||||
- **修复方向**:在 impersonate-proxy rewriter(或 xlp 启动参数)对 google document 导航**覆写 Client Hints** 为与 UA 一致的完整 CH(grease 用 Not-A.Brand v24、补 full-version-list/model/wow64/bitness),对齐 cloak 实测值;修后用 pinned 搜索(`/api/serp/html/search?node=…&runtime=browser`)A/B 验证 302 消失。
|
||||
- **排查流程规则(用户定稿)**:xlp 调试中出现 302/门态时,**必须先用正常指纹浏览器(同 Profile)复测一次再下结论**——否则会把 xlp 特有的请求层缺陷误判为 IP/外部门态,导致修复方向偏差(本轮实证)。
|
||||
- ETH-33 dump 窗口(调试用)`IMPERSONATE_HEADER_DUMP=/tmp/hdump3` 经 systemd drop-in 仍开着(机器重启 /tmp 已清空重写;**注意 dumpRequest 不建目录,重启后需 `mkdir -p /tmp/hdump3`**);排查关闭:`rm /etc/systemd/system/serpmax-node.service.d/dump.conf && systemctl daemon-reload && systemctl restart serpmax-node`。
|
||||
|
||||
### 6.10 引擎侧修复落地:xLightpanda 0.10.6(Google typed-document CH 门,2026-11-04 晚)
|
||||
|
||||
**用户定案:0.10.6 不走 MITM 覆写(impersonate-proxy 维持 deployed-but-uncommitted 不动),直接在 xLightpanda 引擎自体把 UA-CH 修真。修复内容详录见 xLightpanda `docs/CH_GATE_FIX.md`(§1-§4 全量),要点:**
|
||||
|
||||
1. **GREASE 与品牌顺序改为 major 纯函数**(providers/Navigator.zig):移植 Chromium `GetGreasedUserAgentBrandVersion` 原算法 —— `Not{c}A{c}Brand`(11 字符表)+ `{8,99,24}[major%3]` + 顺序表 `order_table[major%6]`(6 个公开实测 major 交叉锚定)。146 → `Not-A.Brand`/`24` 顺序 [Chromium,grease,Chrome](与 cloak-146 200 样本全同);153 → `Not_A Brand`/`8` 顺序 [Google Chrome,grease,Chromium](ETH-33 抓包同)。0.10.4 以前按 module_seed 随机 5×3 组合发出真实 Chrome 从未发过的组合,门由此识别。
|
||||
2. **高熵提示默认回带**(HttpClient appendClientHints):已声明源仍按 Accept-CH 声明回带(Set-Persist 语义);未声明可信源在指纹模式按 cloak-200 样本字段集(11 头,无 ect)回带默认全集 —— 解 xlp 冷启动永远收不到声明的鸡生蛋。裸启动(无指纹 seed)不回带。
|
||||
3. **downlink/rtt 档位化**:9 档真实采样(10/50…0.4/450),network 模块 seed 派生、实例内稳定;HTTP 头与 JS `navigator.connection` 同一 NetworkQuality(禁漂移),替代全舰队 10/50 满分值。
|
||||
4. **平台版本与真实构建号**:Persona.platformVersion linux→"6.11.0"/macos→"26.2.0";导入 UA(cloak/veil 快照)四段真实构建号(`Chrome/153.0.8010.52`)提取进 full-version 与品牌 full-version-list —— 真实 Chrome 从不发 `{major}.0.0.0`。
|
||||
5. **生产验证(ETH-33,dump 窗开启)**:0.10.6 部署重启后 pinned 浏览器搜索 11.8s/1 attempt → **无 gate 头、475KB 真结果页**,xlp typed GET(sec-fetch-site:none)含 `downlink/rtt/prefers-color-scheme/arch/form-factors/platform-version/full-version(153.0.8010.52)` 7 头,与 cloak-146 200 样本逐字段结构一致(GREASE 对 + 顺序按 153 算法,real build 走导入 UA 提取);后续连发两查询进 `/sorry`(每 IP 会话配额,§7.2 已存现象,与 CH 门无关 —— sorry URL 带 sca_esv/sxsrf,doc-gate 302+enablejs 未再复现)。dump 数据存 `/tmp/hdump3.20261104.bak`(dumpRequest 不建目录)。**部署:ETH-33 + HZ-DE-245(备份 lightpanda.0.10.5.bak);FR/KS5B 节点仅跑 proxy 基建,无 xlp 池,不在本轮。**举一反六实现全量 `make test` 通过,版本 0.10.6(package.json),engine 仓库 xLightpanda 未提交待验证定稿后推送。
|
||||
|
||||
### 6.11 指纹自定义/导入第一步:真实构建号全链路(2026-11-04 深夜,node 0.49.7 / xlp 0.10.7,git 130f808 / 90e758ef7)
|
||||
|
||||
**用户定稿三步走(自定义 persona + veil/cloak 快照导入 + 温池代班)的第一步:把构建号列入 persona/导入 schema,消掉 0.10.6 已知边界「纯派生 profile full-version 发 {major}.0.0.0」。**
|
||||
|
||||
1. **xlp 0.10.7 漂移修正(实为 0.10.6 缺陷)**:derive 路径的 `sec-ch-ua-full-version-list` 与 JS `js_full_version_brands` 在导入 UA 覆写块**之前**物化 → HTTP 头与 JS fullVersionList 仍发 `{major}.0.0.0`,只有单值 full-version 拿到真实构建号。0.10.7 把两处物化移到全部覆写之后(回归测试入档)。
|
||||
2. **xlp 0.10.7 `overrides.uaFullVersion`**:四段全数字构建号显式钉定(自定义 persona 无导入 UA 用),显式 wins 于导入 UA 提取;仅 `derivation=xlightpanda` 生效。Profile.zig 校验 ≥4 段全数字。
|
||||
3. **node 0.49.7**:`generateFingerprint` 快照 UA 直接带真实四段构建号(cloak wrapper 五段截前四);`xlightpanda.launcher` 对存量 `.0.0.0` 快照 UA 用 `extra.chromeVersion` 就地改写(存量 Profile 免迁移),**major 取改写后 UA**(否则 persona 钉错被 xlp Validator 拒启动)。
|
||||
4. 验证:xlp `make test` 全绿;node `bun test` 197 全过(新增 xlightpanda.launcher.test 2 例 + veil.test 2 例改期望)。部署与生产 A/B 见 §6.10 同款流程(pinned 搜索 + dump 窗),验证项:typed GET 的 UA 构建号段、full-version、full-version-list 三处同为真实构建号。
|
||||
5. **node 0.49.8(f380615)—— 自定义 persona 生成 + veil/cloak 快照导入面(三步走第二步的节点 API 半)**:
|
||||
- `generateFingerprint` 自定义 persona:`fingerprint.userAgent/webgl/screen` 显式参数优先;**声明版本随 UA 构建号段归一**(UA ↔ extra.chromeVersion ↔ impersonate TLS 档案三者一致,身份锚 = 页面 UA;仅主版本 UA 补全四段)。
|
||||
- `importFingerprintSnapshot`:`createProfile` 请求 `fingerprint.snapshot` 收编一份完整 FingerprintSnapshot 形状 JSON(本系统导出格式,veilbrowser/cloakbrowser 出身通用)—— seed/UA/screen 原样收编**身份不重抽**,id 重生成、出身保留、importedAt 盖章、platformKey/chromeVersion 缺省从 platform/UA 补全。导入优先级 snapshot > admin import > 生成器。
|
||||
- bun test 204 全过(新增 fingerprint.custom.test 7 例);两节点已随 0.49.7 同款 rsync 部署。
|
||||
- **0.49.9 + admin 1.83.0(919b98a)—— admin 接线完成,导入面 API 全链路可测**:
|
||||
- node `importAdminBrowserFingerprint` 顶层 seed 形状自适应:veil/cloak 快照自动走快照导入(身份不重抽),admin BrowserFingerprint 形状不变 —— **admin relay 零改造**。
|
||||
- admin `POST /api/profiles` 新增 `fingerprintSnapshot`(须含 seed/userAgent):原样收编为 Profile browser 指纹(跳过随机生成),补 version(供 TLS 档案)/languages 派生键;`BROWSER_KINDS` 对齐 node 加 `xlightpanda`。
|
||||
- **端到端实测**:admin API 建号(seed 31415926/UA 真实构建号原样)✓;node 直连导入建号 → xlp spawn 真实构建号 UA → google 导航成功 ✓;0.10.7 full-version-list 真实构建号生产 dump 实证 ✓。测试产物已清理,dump 窗已关。
|
||||
- 待办:admin 前端 UI 入口(自定义 Tab + 快照上传);温池代班批量验证 = 三步走第三步(需 Profile 带 task_state=serp + 代理入池)。
|
||||
6. **0.10.7 部署与 dump 复核(ETH-33)**:二进制(90e758ef7)两节点部署,launcher 真实构建号改写已实证生效(新 spawn 的 profile.json:`Chrome/146.0.7680.177` + major 146、`Chrome/153.0.8010.52` + major 153)。dump 复核 typed GET:`sec-ch-ua-full-version 153.0.8010.52`、GREASE/顺序 153 算法正确、档位 10/150(seed 派生)。
|
||||
- **重要校准认知(免误判)**:dump 中 UA 串 `Chrome/153.0.0.0` vs full-version `153.0.8010.52` 并非缺陷 —— veil_compat 0.9.0 期对真实 veil 内核(fingerprint-chromium)的标定即此形态:**UA 串构建号段缩减({major}.0.0.0),fullVersionList 带完整构建号**(kernel_version vs kernel_full_version 双常量即为此设计)。真实内核同款行为,Google 门已实证放行(§6.10 round1)。曾计划 0.10.8 在 veil 路径跳过占位 UA 覆写,复核标定后确认无行为差异,已撤销(树保持 0.10.7)。
|
||||
- full-version-list 头的 dump 复核(0.10.7 漂移修正在 HTTP 侧的实证)待 /sorry 冷却后下轮搜索补验;代码路径已有回归测试(0.10.7 提交)。
|
||||
|
||||
## 7. 极限压测 + 全节点温池化(2026-11-04,node 0.48.0 / admin 1.80.0)
|
||||
|
||||
**用户定稿:ETH-33 旧 veil 池架构经常死机(实测 10-24~11-03 五崩四次,11-03 xlp 灰度后稳定);测新架构极限,系统资源保留 20% 保证不死机;全部节点切温池,全部 SERP 请求走温池路径。**
|
||||
|
||||
### 7.1 节点资源守卫(node 0.48.0,git f50a13f)
|
||||
- `src/core/system/resource.guard.ts`:可用内存(MemTotal-MemAvailable)< 20% 或 load1/核心数 > 80% ⇒ 拒绝**新预热**(SERP_POOL_FULL 语义,admin 补池 15s 重试自愈);池内 ready/busy 与进行中搜索不动。配置 `node-settings.json` 的 `resourceGuard` 键(admin /nodes 可视化编辑,1.80.0)+ `RESOURCE_GUARD_ENABLED=0` 逃生口;判定用 load1(load5 衰减太慢会压着守卫十几分钟)。失败读数不阻塞(降级语义)。
|
||||
- /serp/pool 响应即带 guard 水位;metrics 新增 mem_free_pct / system_load_pct / serp_pool_guard_rejections_total;`resource.guard.test.ts` 5 用例(bun test 190 全过)。
|
||||
- xLightpanda 端口扫描数可配 `XLIGHTPANDA_PORT_COUNT`(默认 64 → 压测时可放更深)。
|
||||
|
||||
### 7.2 ETH-33 极限压测数据(12 核 / 64G,守卫版压测)
|
||||
| 档位 | 池态 | lightpanda 总 RSS(≈单实例) | 内存可用 | 稳态 load1 | 结论 |
|
||||
|---|---|---|---|---|---|
|
||||
| 64 | 64/64 ready | 6.1G(95MB) | 82-89% | ~0.2(闲) | 资源余量极大 |
|
||||
| 96 | 96/96 ready | 8.6G(88MB) | 85.6% | ~0.2(闲) | 同上 |
|
||||
| 128 | 128/128 ready | 11.7G(91MB) | 82% | ~0.04(闲) | 内存仍远未到保留线 |
|
||||
|
||||
- **机器资源极限远未到**:128 深池内存只占 18%,闲时 CPU≈0;负载尖峰(ll 12-19)全部来自共租任务(保活 veil 浏览器 churn、重启风暴),守卫在尖峰窗口正确拒绝增容、尖峰过后(分钟级)自动恢复补池——“保留 20% 保证不死机”机制实测有效。
|
||||
- **真正的瓶颈是 Google 每 IP 会话配额**:ETH-33 全部 ~10k Profile 只绑 3 个上游代理 IP。池深 20(≈7 会话/IP)时搜索 0.9-1.7s 正常;128 深突发(43 会话/IP + 6 分钟 128 连发)后单实例首搜即 302→/sorry,5 分钟未恢复。**操作定值:ETH-33 池收在 64(≈21/IP)**;曾冲到 128 的会话密度待 IP 冷却后另行评估。
|
||||
- 压测期间 admin 补池 15s×4/轮,自 0 填满 128 仅 ~7 分钟(重启风暴窗口守卫会自动压住几分钟后恢复)。
|
||||
|
||||
### 7.3 全节点温池化(2026-11-04 16:xx 前)
|
||||
| 节点 | SERP_POOL_SIZE | admin override | 状态 |
|
||||
|---|---|---|---|
|
||||
| OVH-ETH-33 | 64 | 64 | xlightpanda,64/64 回填完成 |
|
||||
| HZ-DE-245 | 50 | 50 | xlightpanda(原 veil 兜底),回填中 |
|
||||
| OVH-DEDI-KS5B | 80 | 80 | xlightpanda(原 veil 兜底),回填中 |
|
||||
- 服务名:ETH/HZ=serpmax-node(/opt/serpmax),KS5B=profilemax-node(/opt/profilemax);统一 WorkingDirectory=/app;三节点 /app/version=20261104.1300(/system/version 显示之)。
|
||||
- admin serpConfig `fetchPerSec: 1→0`:令牌桶永不放行 go-fetch,**SERP 请求 100% 走浏览器温池**(go-fetch 代码保留;回滚 = 改回正数即可)。依据:错误请求集中于 go-fetch 直连路径,温池路径无此问题(用户 2026-11-04 定稿)。
|
||||
- Storage Hook 三节点保持 enabled(合并写修复后 /system/dirs 保存不再覆盖 storageHook 块);资源守卫全部默认开启(20/20)。
|
||||
- 回滚:节点删 node.env 的 SERP_BROWSER_KIND 行重启即回 veil/cloak 温池。
|
||||
|
||||
### 7.4 admin 1.80.0(git 07f696c)
|
||||
- /api/nodes/:name/settings(中继节点 GET /system/dirs)+ PUT /api/nodes/:name 支持 storageHook/resourceGuard(undefined=不动/null=恢复默认/对象=合并下发)。
|
||||
- /nodes 编辑弹窗新增「Storage Hook(缓存路由 tmpfs)」与「资源守卫(温池准入)」设置区,打开即回显节点现值;节点 /system/dirs 增加 GET,保存按顶层键合并(修复只改目录导致手配 storageHook 被抿掉的隐患)。前端已构建,profilemax-admin 已重启。
|
||||
(注:守卫与 Hook 值均为节点侧即读即生效,保存无需重启。)
|
||||
@@ -0,0 +1,142 @@
|
||||
# dp6 24小时实测优化报告(fetch+浏览器双路径)
|
||||
|
||||
- **运行区间**: 2026-11-01 01:22:43 UTC → 2026-11-02 01:21:43 UTC(24h 跑满,进程正常退出,final 已写)
|
||||
- **版本**: SerpMax v1.78.2 + 压测客户端控制器 v4(`stress/serp-dualpath-optimize.ts`,runtime=auto 双路径,workers=12,速率区间 [3,400])
|
||||
- **数据源**: `/opt/serpmax/data/dp6-final.json`、`dp6.jsonl`(分钟)、`dp6-req.jsonl`(逐请求 117,724 条)、`dp6.log`;对照 `dp5-final.json`、`dp5-req.jsonl`。全部为服务器落盘的真实测量值,无任何外推/估算。
|
||||
- **对照说明**: dp1–dp4 为短程调优试探(服务器上无 24h final 文件);完整 24h 对照只有 dp5(v1.78.1+控制器 v3)与 dp6(v1.78.2+控制器 v4)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论摘要
|
||||
|
||||
| 指标 | dp5(v3) | dp6(v4) | 变化 |
|
||||
|---|---|---|---|
|
||||
| 总请求 | 80,974 | **104,440** | **+29.0%** |
|
||||
| 成功 | 80,301 | 102,461 | — |
|
||||
| 成功率 | 99.17% | **98.11%** | −1.06pp,仍 >98% ✓ |
|
||||
| 日均速率 | 56.3/min | **72.6/min** | **+29.0%** |
|
||||
| captcha 命中 | 335(0.41%) | 1,537(1.47%) | 当日波次更密 |
|
||||
| 门壳结果 gate | 270 | 246 | — |
|
||||
| 池空 poolEmpty | 0 | 75(最差 1 小时 23 次) | v1.78.2 轮转取缓存代价 |
|
||||
| httpErr / netErr | 10 / 7 | 65 / 0 | httpErr 全部集中在 23 时波浪 |
|
||||
| fetch / browser 分流 | 41,773 / 38,849 | 48,573 / 54,190 | browser 承接溢出 52.7% |
|
||||
|
||||
**核心结论**: 在 >98% 成功率约束下,控制器 v4 把 24h 真实日均吞吐从 56.3 提到 **72.6 请求/分钟(+29%)**,代价是成功率贴着约束线收在 98.11%。这是约束边界上的实测最优交换。
|
||||
|
||||
---
|
||||
|
||||
## 2. dp6 全天逐小时实测(UTC)
|
||||
|
||||
| 小时 | 请求 | 成功率 | captcha | gate | poolE | fetch | browser | 均值/min |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 01日01时(38min) | 3,762 | 99.39% | 19 | 3 | 0 | 1,558 | 2,185 | 99 |
|
||||
| 02时 | 5,214 | 98.68% | 59 | 8 | 0 | 2,415 | 2,740 | 87 |
|
||||
| 03时 | 5,416 | 98.47% | 54 | 25 | 0 | 2,265 | 3,097 | 90 |
|
||||
| 04时 | 4,266 | 96.91% | 115 | 12 | 1 | 2,094 | 2,056 | 71 |
|
||||
| 05时 | 1,667 | 98.86% | 9 | 10 | 0 | 1,632 | 26 | 28 |
|
||||
| 06时 | 5,098 | 99.86% | 2 | 4 | 1 | 2,189 | 2,906 | 85 |
|
||||
| 07时 | 5,063 | 99.09% | 32 | 14 | 0 | 2,006 | 3,025 | 84 |
|
||||
| 08时 | 5,189 | 98.50% | 67 | 10 | 0 | 2,003 | 3,119 | 86 |
|
||||
| 09时 | 5,146 | 98.15% | 72 | 18 | 1 | 2,105 | 2,968 | 86 |
|
||||
| 10时 | 4,583 | 98.23% | 68 | 13 | 0 | 2,206 | 2,309 | 76 |
|
||||
| 11时 | 3,601 | 98.03% | 45 | 14 | 7 | 2,014 | 1,531 | 60 |
|
||||
| 12时 | 4,470 | 99.22% | 17 | 13 | 3 | 2,093 | 2,356 | 74 |
|
||||
| 13时 | 4,678 | 97.26% | 101 | 14 | 8 | 1,792 | 2,773 | 78 |
|
||||
| 14时 | 3,505 | 97.60% | 55 | 5 | 2 | 2,196 | 1,231 | 58 |
|
||||
| 15时 | 3,231 | 97.62% | 70 | 6 | 0 | 2,165 | 996 | 54 |
|
||||
| 16时 | 5,352 | 97.42% | 112 | 14 | 0 | 2,080 | 3,158 | 89 |
|
||||
| 17时 | 4,509 | 96.70% | 116 | 6 | 14 | 2,094 | 2,282 | 75 |
|
||||
| 18时 | 2,708 | 98.08% | 46 | 3 | 0 | 1,575 | 1,087 | 45 |
|
||||
| 19时 | 4,712 | 98.58% | 61 | 2 | 2 | 2,025 | 2,624 | 79 |
|
||||
| 20时 | 5,228 | 97.42% | 111 | 15 | 3 | 2,037 | 3,076 | 87 |
|
||||
| 21时 | 4,673 | 98.44% | 57 | 8 | 6 | 2,325 | 2,285 | 78 |
|
||||
| 22时 | 4,752 | 97.92% | 91 | 7 | 0 | 1,910 | 2,751 | 79 |
|
||||
| 23时(最差) | 4,114 | **94.90%** | **146** | 14 | **23** | 1,650 | 2,268 | 69 |
|
||||
| 02日00时 | 1,613 | 98.64% | 12 | 6 | 1 | 1,374 | 224 | 27 |
|
||||
| 02日01时(21min) | 1,840 | 99.73% | 0 | 1 | 3 | 747 | 1,090 | 88 |
|
||||
|
||||
- jsonl 口径合计 104,390;final.json 权威口径 **104,440 / 98.11%**(差值为跨小时边界分钟)。
|
||||
- 全天净错误 netErr=0,无一次网络层失败。
|
||||
- 最差小时 23时(94.90%)对全天累计仅拖累约 0.13pp——深度削减把该小时暴露量压到最小,是"深切保护累计"策略的实测验证。
|
||||
|
||||
---
|
||||
|
||||
## 3. 速率段分布与控制器行为结构(实测)
|
||||
|
||||
**速率段时间分布(1,439 分钟):**
|
||||
|
||||
| 速率段 | 分钟数 | 发出请求 | 请求占比 |
|
||||
|---|---|---|---|
|
||||
| <50/min | 250 min | 7,119 | 6.8% |
|
||||
| 50–100 | 252 min | 17,691 | 16.9% |
|
||||
| 100–200 | 517 min | 44,402 | 42.5% |
|
||||
| 200–300 | 337 min | 28,047 | 26.9% |
|
||||
| 300–400 | 53 min | 4,356 | 4.2% |
|
||||
| =400 | 30 min | 2,775 | 2.7% |
|
||||
|
||||
**行为结构(来自 dp6.log 全量速率变更史,共 113 次调率):**
|
||||
- **5 次深切瀑布**(×0.7 连锁到 <30/min):04:50→23.9、09:32→47、14:10→39、17:54→11.1、23:57→6.3/min(末次为 ×0.5 四连)。每次都在波浪过后由 clean-fast ×1.3 链恢复。
|
||||
- **10 次 clean-fast ×1.3 恢复链**,最快 9 拍从 40 拉到 331/min(14:46–14:53),典型 6–7 拍。
|
||||
- **×1.08 常规爬坡**接在恢复链后,爬到 280–400 区间;全天共 228 个 ramp 序列。
|
||||
- **12 次验证码软切**(×0.7)、**2 次池空软切**(×0.85)、多次 30 分钟入场切(<98% 成功)。
|
||||
- 完整周期 1.5–2h:波浪 → 切(最深 ×0.5)→ 低速持守到 captcha==0 → ×1.3 链 → ×1.08 爬坡 → 重复。
|
||||
- 结尾 00:56–01:13 恢复链一路拉回 400/min,最后 21 分钟以 352–400/min 跑出 99.73%。
|
||||
|
||||
**饱和天花板(重要实测发现)**: 速率上限虽设 400/min,但 =400 段实际只发出 92.5 请求/min(2,775/30min)。瓶颈是 workers=12 × 平均延迟(ok 均值 7.5s)→ 理论上限 ≈96/min;实测高速率段稳定落在 100–135/min。**400 的桶只是天花板标记,有效吞吐由 worker 数与延迟决定**——这是 v5 最直接的扩容方向。
|
||||
|
||||
---
|
||||
|
||||
## 4. 双路径经济学(逐请求实测)
|
||||
|
||||
| 路径/结果 | dp6 条数 | dp6 均延迟 | 多次尝试占比 | dp5 对照 |
|
||||
|---|---|---|---|---|
|
||||
| fetch/ok | 48,467 | **5,160ms** | 0.70% | 41,663 / 4,465ms / 0.90% |
|
||||
| browser/ok | 53,994 | **9,668ms** | 2.26% | 38,638 / 7,446ms / 2.89% |
|
||||
| fetch/gate | 106 | 10,873ms | 100% | 110 / 10,649ms |
|
||||
| browser/gate | 140 | **39,677ms** | 100% | 160 / 30,851ms |
|
||||
| browser/empty | 56 | 31,469ms | — | 51 / 24,546ms |
|
||||
| captcha(未分派) | 1,537 | 4,530ms | — | 335 / 4,927ms |
|
||||
| poolEmpty / httpErr | 75 / 65 | 31.2s / 42.2s | — | 0 / 10 |
|
||||
|
||||
- browser 单请求耗时是 fetch 的 1.87 倍,但承接了 52.7% 的已分派流量(dp5 为 48.0%)——高速率段 fetch 桶(60/min 封顶)只够供给约 15%,溢出全部由浏览器热池消化,溢出设计按预期工作。
|
||||
- 门壳尝试 874 次(0.83%,dp5 为 0.95%):browser 门壳每次烧 39.7s,是最贵的失败形态;v1.78.2 的指纹轮换使门壳占比略降。
|
||||
- captcha 检测均耗时 4.5s,本身便宜;真正昂贵的是它触发的降速与爬坡时间。
|
||||
|
||||
---
|
||||
|
||||
## 5. 稀释论的实测验证
|
||||
|
||||
**论题**: captcha 供给由 Google 侧按波次发放(逐小时 2–146 个,与本地速率弱相关),因此同等波次下速率越高、captcha 占比越低、窗口成功率越高;**波浪中降速反而抬高 captcha 占比、恶化窗口成功率**。
|
||||
|
||||
实测证据:
|
||||
- 配对小时:19时(79/min,captcha 61)→ 98.58%;17时(75/min,captcha 116)→ 96.70%。速率相近,成败由波次强度决定。
|
||||
- 高速率窗口:400/min 段窗口成功率 99.6–99.9%,captcha 短窗占比 0(capPctS=0)。
|
||||
- 低速率反例:05:24 在 31.3/min 处,窗口内 3 个 captcha 就构成 1.13% 占比触发软切——同样的 3 个 captcha 在 400/min 下只占 0.09%。
|
||||
- 但波浪总量确实随速率升高而增多(dp6 1,537 vs dp5 335):提速换来的是分母稀释,不是 captcha 绝对数减少。
|
||||
|
||||
---
|
||||
|
||||
## 6. 控制器 v4 的三个残余缺陷(实测例证,已列入 v5 待修)
|
||||
|
||||
1. **ongoing 切不感知量(量盲)**: 低速率下小分母放大占比。05:24 因"验证码 3 个占比 1.13%"把 31.3 切到 25/min;00:02–00:03 波浪实际已近尾声(~00:20 结束),仍以窗口 93.4%/93.2%(每窗仅几十个请求)连续 ×0.5 切到 6.3/min 并滞留到 00:30。代价:05时全天第二低的 28/min 却有 98.86% 成功率——明显压不满。
|
||||
2. **低速恢复锁死**: 恢复条件要求 10 分钟短窗 successS≥0.995,低速涓流(~1% captcha 占比)下永远达不到;实际恢复全靠 captcha==0 清洁段触发 clean-fast。05:00–06:10 在 24–42/min 滞留 70 分钟,11:35–12:14 滞留 39 分钟。
|
||||
3. **累计盲**: 控制器只看窗口不看全天累计。23时 94.90% 把累计从 98.19% 压到 98.06%,控制器没有任何累计感知的削波/放行;反过来,结尾累计已锁定 >98% 时,00:43 仍因 4 个 captcha(0.96%)从 73.3 切到 51.3,未能利用富余度。深切能保护累计是"暴露量最小化"的副产品,不是设计使然。
|
||||
|
||||
---
|
||||
|
||||
## 7. v5 优化方向(待下一个真实 24h 验证,本报告不做任何数字预测)
|
||||
|
||||
1. **恢复条件按量归一**: 低速时用绝对失败数而非占比判恢复(如短窗 captcha≤1 且 sent≥N,或改用指数衰减基线),消除 05时/11时式滞留。
|
||||
2. **ongoing 切加量下限**: 窗口 sent 不足阈值时只记信号不切速,避免小分母瀑布。
|
||||
3. **累计感知**: 把全天累计成功率与 98% 线的余量引入削波深度(余量大→浅切)与恢复速度(余量大→快爬)。
|
||||
4. **扩 workers**: 实测饱和 ≈96–135/min 由 workers=12 × 延迟决定;提高 worker 数是把 100–400 段天花板变为真实吞吐的最短路径,同时配合 fetch 桶扩容评估。
|
||||
5. 池空 75 次来自 v1.78.2 节点轮换取缓存,最差小时 23 次;可对波浪期保留最小缓存水位以归零该类错误。
|
||||
|
||||
---
|
||||
|
||||
## 8. 口径备注
|
||||
|
||||
- 成功率 = ok/sent;sent 含 captcha/gate/empty/poolEmpty/httpErr 全部结果,netErr 另计。
|
||||
- dp6 全天 fetch 48,573 + browser 54,190 = 102,763 条有路径归属;其余 1,677 条为未按路径记录的结果(池空/httpErr 等)。
|
||||
- dp1–dp4 仅有试探性产物(dp1 26KB / dp2–dp3 ~1.7KB / dp4 137KB jsonl),无 24h final,不参与定量对照。
|
||||
- 服务器监控(serp-monitor)h24 口径混有 dp6 之外的生产流量,本报告一律不采用;只用 dp6 系列文件自身口径。
|
||||
@@ -0,0 +1,382 @@
|
||||
/** # 脚本说明
|
||||
* SERP 双路径(fetch+浏览器)24h 自适应吞吐优化测试(用户 2026-10-30 目标;
|
||||
* 2026-11-02 用户要求可用率约束上调至 >99%):
|
||||
* 在保证客户端成功率 >99% 的约束下,自适应逼近系统可持续的最大每分钟请求数。
|
||||
* 与 serp-profile-capacity.ts(单号 sticky+fetch,绕过池/令牌)不同,本脚本走生产
|
||||
* 双路径原形态(runtime=auto,无 sticky,全池公平负载选号):
|
||||
* GET /api/serp/json/search?q=...&num=10
|
||||
* 令牌内走 go-fetch(全局限流 fetchPerSec),限流外并发走温池浏览器 —— 组合即生产形态。
|
||||
* AIMD 控制器 v5.4(dp6 全天 + dp7/dp7b/dp7c/dp7d 四轮前段实测定稿;核心=故障二分):
|
||||
* Google 侧(captcha/门壳/空)→ 零砍速,波次骑行:四轮实测一致证明砍速在任何速率下
|
||||
* 都让占比恶化(8/min 时 148/时波次占比 10.5% 窗口 88%;117/min 4.2%;300/min 1.6%
|
||||
* 窗口 98.4%)—— 唯一出路是分母,宁骑不砍;
|
||||
* 我方故障(httpErr/netErr;poolEmpty 仅低速 <60/min 计入,高速期池空是需求伴生现象)
|
||||
* → AIMD:入段(浅破线 <99% 失败 ≥8 且窗口 ≥200 样本 / 深破线 <96% 失败 ≥4)×0.7/
|
||||
* ×0.5,续砍需短窗 <96% 且增量新失败 ≥2,地板 HARD_FLOOR=8,低速池空软砍 ×0.85;
|
||||
* 噪声预算退段/爬坡(稀释论):零我方故障且联合噪声(captcha+门壳)≤6 → 退段(短窗
|
||||
* ≥96%)/≤8 → 可爬坡(短窗 ≥96%),水位下 ×1.3/拍,之上 ×1.08/5 拍;
|
||||
* workers 默认 24(页内 fetch 1.1~2s/请求,12 worker 天花板 ~135/min 是波次稀释瓶颈)。
|
||||
* 干净爬坡:不在退化段 且 短窗干净 且 无池空 且(验证码 0 个或样本 ≥300)→ 向「干净
|
||||
* 水位」(30min 窗 ≥99.5% 时的最高速率)以 ×1.3/拍快速回收,水位之上每 5 拍 ×1.08。
|
||||
* v5 修 dp6 实测「低速恢复锁死」:短窗成功率 ≥99.5% 在低速涓流下永不可达
|
||||
* (05:00-06:10 滞留 70min 在 24-42/min,该小时实际 98.86%)→ 增加备选条件
|
||||
* 「验证码 ≤1 个且短窗 ≥98.5%」(稀释论:同量涓流在高速率下占比可忽略,可爬)。
|
||||
* 速率区间 [3, 400] req/min;并发 worker 默认 12(真实并发让溢出自然流向浏览器路径)。
|
||||
* 判定(客户端,JSON 出口):ok = 200 且 organic>0;gate = 200 且 organic=0 且
|
||||
* attempts 含 gate;empty = 200 且 organic=0 无门壳尝试;错误按响应 code 分类
|
||||
* (CAPTCHA_DETECTED / SERP_POOL_EMPTY / 其余 httpErr);网络层失败记 netErr。
|
||||
* 输出:data/<label>.jsonl 每分钟聚合、<label>-req.jsonl 逐请求、<label>-live.json
|
||||
* 每 5min 快照、<label>-final.json 终局;控制台每 5min 摘要。
|
||||
* 用法:SERP_TOKEN=xxx bun serp-dualpath-optimize.ts --hours 24 --label dp1 --rate0 15
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
|
||||
/** # 成员变量 服务基址(同机直连 admin) **/
|
||||
const BASE = "http://127.0.0.1:53100";
|
||||
const args = process.argv.slice(2);
|
||||
function argOf(k: string, d: string): string { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; }
|
||||
/** # 成员变量 测试时长(小时) **/
|
||||
const HOURS = Number(argOf("--hours", "24"));
|
||||
/** # 成员变量 输出标签(文件名前缀) **/
|
||||
const LABEL = argOf("--label", "dp1");
|
||||
/** # 成员变量 初始速率(req/min) **/
|
||||
const RATE0 = Number(argOf("--rate0", "15"));
|
||||
/** # 成员变量 速率下限/上限(req/min) **/
|
||||
const RATE_MIN = Number(argOf("--min", "3"));
|
||||
const RATE_MAX = Number(argOf("--max", "400"));
|
||||
/** # 成员变量 并发 worker 数(v5.4:默认 12→24 —— 页内 fetch 后浏览器单请求 1.1~2s,
|
||||
* 12 worker 的吞吐天花板(~135/min 实测)成了灾难波次稀释(需 ≥250/min)的瓶颈) **/
|
||||
const WORKERS = Number(argOf("--workers", "24"));
|
||||
/** # 成员变量 控制器窗口(分钟)与加速步间隔(分钟) **/
|
||||
const WINDOW_MIN = 30;
|
||||
const RAMP_EVERY_MIN = 5;
|
||||
const TOKEN = process.env.SERP_TOKEN ?? "";
|
||||
if (!TOKEN) { console.error("缺少 SERP_TOKEN"); process.exit(1); }
|
||||
|
||||
/** # 成员变量 输出目录(data/,相对脚本) **/
|
||||
const DATA = new URL("./data/", import.meta.url).pathname;
|
||||
fs.mkdirSync(DATA, { recursive: true });
|
||||
const JSONL = `${DATA}${LABEL}.jsonl`;
|
||||
const REQ_JSONL = `${DATA}${LABEL}-req.jsonl`;
|
||||
const LIVE = `${DATA}${LABEL}-live.json`;
|
||||
|
||||
/** # 成员变量 长尾查询词表(4 维组合,2026-10-30 用户反馈改版):
|
||||
* 旧 A×B×C(2000 组合)相似度太高,/goto 解析缓存命中虚高,同 Profile 并发 /goto
|
||||
* 的真实负载被洗掉。改为 修饰×品类×限定×城市 4 维(40×60×40×60 = 576 万组合),
|
||||
* 随机不重复消费 —— 每次搜索结果集与 /goto 链接基本不同,缓存命中回落到真实水平。
|
||||
*/
|
||||
const A = ["best", "top rated", "cheap", "review of", "how to choose a", "buy", "compare", "2026 guide for", "price of", "warranty for", "where to buy", "discount on", "is it worth buying", "reliability of", "problems with", "alternatives to", "user reviews for", "long term test of", "repair costs for", "energy use of", "setup guide for", "maintenance tips for", "refurbished", "open box deals on", "wholesale price of", "used value of", "safety rating of", "recalled models of", "accessories for", "replacement parts for", "manual pdf for", "dimensions of", "weight limit of", "installation cost of", "monthly deals on", "black friday price for", "cyber monday deal for", "student discount for", "military discount for", "trade in value of"];
|
||||
const B = ["coffee maker", "running shoes", "laptop stand", "rice cooker", "air fryer", "standing desk", "office chair", "mechanical keyboard", "monitor arm", "webcam", "espresso machine", "vacuum cleaner", "electric kettle", "gaming mouse", "noise cancelling headphones", "desk lamp", "water bottle", "yoga mat", "blender", "space heater", "air purifier", "electric toothbrush", "smart thermostat", "robot vacuum", "ergonomic mouse", "usb-c hub", "portable ssd", "wireless charger", "dash cam", "action camera", "trail running shoes", "hiking backpack", "camping tent", "sleeping bag", "kayak paddle", "bicycle helmet", "electric scooter", "board games", "3d printer", "drawing tablet", "studio microphone", "audio interface", "midi keyboard", "electric guitar", "drum pad", "telescope", "binoculars", "bird feeder", "grill smoker", "pizza oven", "garden hose", "leaf blower", "chainsaw", "pressure washer", "snow shovel", "garage door opener", "stud finder", "laser level", "tool set", "sewing machine"];
|
||||
const C = ["for home use", "for small office", "under 100 dollars", "in 2026", "with long warranty", "for beginners", "pro model", "on sale", "quiet operation", "energy efficient", "with fast shipping", "highly rated", "compact size", "heavy duty", "for students", "for professionals", "with extra accessories", "easy to clean", "portable version", "premium edition", "budget option", "luxury model", "with 2 year warranty", "top ranked", "editor's choice", "best seller", "new release", "on clearance", "with free returns", "for tall people", "for small spaces", "made in usa", "eco friendly", "with app control", "voice assistant compatible", "wifi enabled", "with hepa filter", "waterproof model", "lightweight version", "industrial grade"];
|
||||
const D = ["in Austin tx", "in Denver co", "in Seattle wa", "in Portland or", "in Chicago il", "in Boston ma", "in Miami fl", "in Phoenix az", "in Dallas tx", "in Atlanta ga", "in New York ny", "in Los Angeles ca", "in Houston tx", "in Philadelphia pa", "in San Diego ca", "in Las Vegas nv", "in Nashville tn", "in Detroit mi", "in Minneapolis mn", "in Charlotte nc", "in Raleigh nc", "in Omaha ne", "in Boise id", "in Salt Lake City ut", "in Columbus oh", "in Indianapolis in", "in Milwaukee wi", "in Kansas City mo", "in St Louis mo", "in Cincinnati oh", "in Pittsburgh pa", "in New Orleans la", "in Memphis tn", "in Louisville ky", "in Albuquerque nm", "in Tucson az", "in Fresno ca", "in Sacramento ca", "in Portland me", "in Burlington vt", "in Providence ri", "in Hartford ct", "in Newark nj", "in Baltimore md", "in Richmond va", "in Charleston sc", "in Savannah ga", "in Orlando fl", "in Tampa fl", "in San Antonio tx", "in El Paso tx", "in Oklahoma City ok", "in Tulsa ok", "in Wichita ks", "in Des Moines ia", "in Little Rock ar", "in Jackson ms", "in Billings mt", "in Fargo nd", "in Charleston wv", "in Eugene or"];
|
||||
const COMBO_TOTAL = A.length * B.length * C.length * D.length;
|
||||
/** # 成员变量 已消费组合索引(随机不重复;24h 全程用量远低于组合空间,碰撞回退微乎其微) **/
|
||||
const usedCombos = new Set<number>();
|
||||
function nextQuery(): string {
|
||||
let idx = Math.floor(Math.random() * COMBO_TOTAL);
|
||||
while (usedCombos.has(idx)) idx = (idx + 1) % COMBO_TOTAL;
|
||||
usedCombos.add(idx);
|
||||
const d = idx % D.length;
|
||||
const c = Math.floor(idx / D.length) % C.length;
|
||||
const b = Math.floor(idx / (D.length * C.length)) % B.length;
|
||||
const a = Math.floor(idx / (D.length * C.length * B.length)) % A.length;
|
||||
return `${A[a]} ${B[b]} ${C[c]} ${D[d]}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 自适应令牌桶(速率由控制器动态调整)
|
||||
// ============================================================================
|
||||
|
||||
/** # 成员变量 当前速率(req/min) **/
|
||||
let rate = RATE0;
|
||||
/** # 成员变量 桶状态:令牌数/上次补充时刻(容量 = max(1, ceil(每秒速率))) **/
|
||||
let bucket = { tokens: 0, lastMs: Date.now() };
|
||||
|
||||
/** # 函数
|
||||
* 取一枚发送令牌(不足时等待;速率调整即时生效,无锁 —— 单线程事件循环内安全)。
|
||||
*/
|
||||
async function acquire(): Promise<void> {
|
||||
for (;;) {
|
||||
const rps = rate / 60;
|
||||
const cap = Math.max(1, Math.ceil(rps));
|
||||
const now = Date.now();
|
||||
bucket.tokens = Math.min(cap, bucket.tokens + ((now - bucket.lastMs) / 1000) * rps);
|
||||
bucket.lastMs = now;
|
||||
if (bucket.tokens >= 1) { bucket.tokens -= 1; return; }
|
||||
await Bun.sleep(200);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 计数与控制器
|
||||
// ============================================================================
|
||||
|
||||
/** # 成员变量 单分钟计数桶 **/
|
||||
type Counter = {
|
||||
sent: number; ok: number; gate: number; empty: number;
|
||||
captcha: number; poolEmpty: number; httpErr: number; netErr: number;
|
||||
fetch: number; browser: number; attemptsTotal: number; gateAttempts: number;
|
||||
};
|
||||
function newCounter(): Counter {
|
||||
return { sent: 0, ok: 0, gate: 0, empty: 0, captcha: 0, poolEmpty: 0, httpErr: 0, netErr: 0, fetch: 0, browser: 0, attemptsTotal: 0, gateAttempts: 0 };
|
||||
}
|
||||
/** # 成员变量 当前分钟计数 / 滚动窗口(最近 WINDOW_MIN 个分钟桶) **/
|
||||
let cur = newCounter();
|
||||
const window: { ts: string; c: Counter }[] = [];
|
||||
/** # 成员变量 全程累计(终局汇总用) **/
|
||||
const totals = newCounter();
|
||||
/** # 成员变量 控制器决策日志 **/
|
||||
const decisions: Record<string, unknown>[] = [];
|
||||
/** # 成员变量 成功指纹计数(分布公平性观察用) **/
|
||||
const fpMap = new Map<string, number>();
|
||||
|
||||
/** # 函数
|
||||
* 窗口聚合求和(参数 n:取最近 n 个分钟桶;不传 = 全窗口)。
|
||||
*/
|
||||
function sumWindow(n = WINDOW_MIN): Counter {
|
||||
const s = newCounter();
|
||||
for (const { c } of window.slice(-n)) {
|
||||
for (const k of Object.keys(s) as (keyof Counter)[]) s[k] += c[k];
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** # 成员变量 软信号事件段状态(迟滞:进段砍一次,退段重置;防同一段波逐分钟连砍) **/
|
||||
let captchaWave = false;
|
||||
let gateWave = false;
|
||||
let poolEmptyWave = false;
|
||||
/** # 成员变量 硬约束退化段(进段砍一次;段内连砍需增量新失败;30min 窗恢复≥99% 退段) **/
|
||||
let degraded = false;
|
||||
/** # 成员变量 上次硬砍时的全程累计失败数(段内连砍要求全程新失败 ≥2,dp5 教训:同一波旧失败被短窗反复引用,10:24-10:28 五分钟连砍五刀 175→30/min) **/
|
||||
let failsAtLastCut = 0;
|
||||
/** # 成员变量 干净水位:30min 窗 ≥99.5% 且短窗 ≥99% 时达到过的最高速率(深跌后向水位快速回收;dp5 教训:×1.08/5 拍从 3/min 爬回需 3 小时,温池 150 ready 全程空转) **/
|
||||
let maxCleanRate = 0;
|
||||
/** # 成员变量 软砍速率下限(req/min):背景验证码供给恒定,速率越低比率越高,比率规则在低位永真 → 钉死地板(dp5 实测 12:00-17:00 自 52/min 自残至 3/min)。软规则不砍破此位。v5.1 上调 40:99% 目标下 25/min 时涓流(~14 个/时)占比已达 0.93% 贴线,40/min 摊薄到 0.58% 留出安全余量(稀释论) **/
|
||||
const SOFT_FLOOR = 40;
|
||||
/** # 成员变量 硬砍速率下限(req/min;v5.2 新增):dp7 实测 99% 线下小样本噪音(4 个失败)即可驱动 ×0.5 级联到 3/min —— 硬规则也设地板,真退化到 8/min 已足够压缩暴露量,3/min 只会恶化涓流占比(稀释论) **/
|
||||
const HARD_FLOOR = 8;
|
||||
|
||||
/** # 函数
|
||||
* 控制器决策(每 60s 调用):按窗口成功率/内部压力调整速率,返回决策记录或 null。
|
||||
* v5(dp6 教训):目标上调 99%;爬坡备选条件「验证码 ≤1 且短窗 ≥98.5%」修低速恢复锁死。
|
||||
* v4(dp5 教训):①硬砍连砍需「增量新失败」(全程失败数较上次砍时新增 ≥2),同一波旧失败不再反复引用;
|
||||
* ②软信号加绝对数护栏(10min 窗内验证码 ≥3 / 门壳 ≥3 才允许比率规则),低速率噪音不再触发;
|
||||
* ③软砍下限 SOFT_FLOOR,消除比率陷阱钉死地板;④爬坡:向干净水位快速回收 ×1.3/拍,水位之上 ×1.08/5 拍。
|
||||
*/
|
||||
function controlTick(): Record<string, unknown> | null {
|
||||
const wH = sumWindow(30); // 硬约束窗口:成功率即约束本身
|
||||
const tot = wH.sent;
|
||||
if (tot < 10) return null; // 信号不足,保持
|
||||
const wS = sumWindow(10); // 软信号短窗:波动退去快,防旧波滞留连砍(dp1 教训)
|
||||
const success = wH.ok / tot;
|
||||
const gateRatio = wH.attemptsTotal > 0 ? wH.gateAttempts / wH.attemptsTotal : 0;
|
||||
const gateRatioS = wS.attemptsTotal > 0 ? wS.gateAttempts / wS.attemptsTotal : 0;
|
||||
const capPctS = wS.sent > 0 ? wS.captcha / wS.sent : 0;
|
||||
const from = rate;
|
||||
let to = from;
|
||||
let reason = "hold";
|
||||
// 故障二分(v5.4,dp7d 实测定稿:148/时灾难波次下,poolEmpty 3~4 个(高需求伴生)误判
|
||||
// 我方故障解锁硬地板 8 → 砸到 8/min 窗口 88%(captcha 占比 10.5%);而同一波次在
|
||||
// 117/min 为 95.8%、300/min 为 98.4% —— Google 侧事件引发砍速在任何速率下都是毒药):
|
||||
// infraFails = 纯我方故障(httpErr/netErr;poolEmpty 仅低速 <60/min 时计入 —— 高速期
|
||||
// 池空是需求压力的伴生现象,温池自补,砍速只会加重浏览器饿死);
|
||||
// Google 侧(captcha/门壳/空)→ 零砍速,波次骑行:当前速率下占比已是最优解,
|
||||
// 砍速减分母提占比、提速摊薄降占比,唯一出路在分母;
|
||||
// ① 退段:窗口 ≥99%,或零我方故障且联合噪声 ≤6 且短窗 ≥96%;
|
||||
// ② 入段/续砍:仅我方故障 ≥3 且(浅破线 <99% 失败 ≥8 窗口 ≥200 样本 / 深破线 <96%
|
||||
// 失败 ≥4 / 短窗 <96% 增量新失败 ≥2),地板 8;
|
||||
// ③ 爬坡:零我方故障且联合噪声 ≤8 且短窗 ≥96%(水位下 ×1.3/拍,之上 ×1.08/5 拍)
|
||||
const fails = tot - wH.ok;
|
||||
const allFails = totals.sent - totals.ok;
|
||||
const successS = wS.sent > 0 ? wS.ok / wS.sent : 1;
|
||||
const infraFails = wH.httpErr + wH.netErr + (from < 60 ? wH.poolEmpty : 0);
|
||||
const googleNoiseS = wS.captcha + wS.gate;
|
||||
// v5.5:入段/续砍用 10min 短窗「现役」我方故障证据 —— dp7f 实测:30min 窗里 6 个
|
||||
// 零星 httpErr 在 420/时 captcha 灾难波次下持续解锁硬规则,级联到 8/min;现役证据
|
||||
// 才配得上「正在发生的我方故障」。混合证据(联合噪声 >8)时地板抬到 SOFT_FLOOR,
|
||||
// 杜绝 dp7d 式「2 个伴生池空解锁硬地板 8」
|
||||
const infraFailsS = wS.httpErr + wS.netErr + (from < 60 ? wS.poolEmpty : 0);
|
||||
const trickle = infraFails === 0 && googleNoiseS <= 6;
|
||||
if (success >= 0.99 || (trickle && successS >= 0.96)) degraded = false;
|
||||
const entry = !degraded && !trickle && infraFailsS >= 2 && ((success < 0.99 && fails >= 8 && tot >= 200) || (success < 0.96 && fails >= 4));
|
||||
const ongoing = degraded && wS.sent >= 30 && successS < 0.96 && infraFailsS >= 2 && allFails - failsAtLastCut >= 2;
|
||||
if (entry || ongoing) {
|
||||
degraded = true;
|
||||
failsAtLastCut = allFails;
|
||||
to = success < 0.96 ? from * 0.5 : from * 0.7;
|
||||
to = Math.max(googleNoiseS > 8 ? SOFT_FLOOR : HARD_FLOOR, to);
|
||||
reason = `success=${(success * 100).toFixed(1)}%<${success < 0.96 ? 96 : 99}%(失败 ${fails}${entry ? "" : ";短窗<96%+新失败≥2"}) ×${success < 0.96 ? 0.5 : 0.7}`;
|
||||
} else {
|
||||
// 软信号(v5.4:仅剩池空,且仅低速期 —— 高速期池空是需求伴生现象,温池自补,
|
||||
// 砍速只会加重浏览器饿死;captcha/门壳软砍废除,灾难波次骑行,dp7 四轮实测)
|
||||
if (from < 100 && wS.poolEmpty > 5 && !poolEmptyWave) {
|
||||
poolEmptyWave = true;
|
||||
to = Math.max(SOFT_FLOOR, from * 0.85);
|
||||
reason = `池空 ${wS.poolEmpty} ×0.85`;
|
||||
} else if (wS.poolEmpty === 0) poolEmptyWave = false;
|
||||
}
|
||||
// 干净水位记账(v5.2:门槛 99.5%→99%,99% 线下 ≥99.5% 的 30min 窗在涓流时段不可达,
|
||||
// 水位永远立不起来 → 深跌后只剩 ×1.08/5 拍龟速爬坡,dp7 实测):窗口 ≥99% 且短窗 ≥99%
|
||||
// 时的速率即为「实测可行」
|
||||
if (success >= 0.99 && successS >= 0.99 && wS.sent >= 10) maxCleanRate = Math.max(maxCleanRate, from);
|
||||
// 干净爬坡(v5.3):退化段中不爬;主条件短窗 ≥99.5%,备选「零我方故障且联合噪声 ≤8
|
||||
// 且短窗 ≥96%」(与退段口径一致 —— 低速小分母下高窗口线不可达,爬坡本身就是摊薄手段,
|
||||
// ×1.3/拍 中速率每上台阶噪声占比自动减半,自校正);池空独查;样本/占比条件废除,
|
||||
// 绝对护栏统一交给软砍规则
|
||||
if (to === from && !degraded && wS.sent >= 10) {
|
||||
const cleanS = successS >= 0.995 || (infraFails === 0 && googleNoiseS <= 8 && successS >= 0.96);
|
||||
if (cleanS && wS.poolEmpty === 0) {
|
||||
rampSeq++;
|
||||
if (maxCleanRate > 0 && from < maxCleanRate * 0.75) {
|
||||
to = from * 1.3 + 0.2;
|
||||
reason = `clean-fast(水位 ${maxCleanRate.toFixed(0)}) ×1.3`;
|
||||
} else if (rampSeq % RAMP_EVERY_MIN === 0) { to = from * 1.08 + 0.2; reason = `clean(success=${(successS * 100).toFixed(1)}%,gate=${(gateRatioS * 100).toFixed(1)}%) ×1.08`; }
|
||||
}
|
||||
}
|
||||
to = Math.round(Math.min(RATE_MAX, Math.max(RATE_MIN, to)) * 10) / 10;
|
||||
rate = to;
|
||||
const rec = { ts: new Date().toISOString(), from: +from.toFixed(1), to: +to.toFixed(1), reason, rampSeq, rampTick: rampSeq % RAMP_EVERY_MIN, window: { sent: tot, ok: wH.ok, gate: wH.gate, captcha: wH.captcha, poolEmpty: wH.poolEmpty, success: +(success * 100).toFixed(2), gateRatio: +(gateRatio * 100).toFixed(2), capPctS: +(capPctS * 100).toFixed(2) }, waves: { captcha: captchaWave, gate: gateWave, poolEmpty: poolEmptyWave } };
|
||||
decisions.push(rec);
|
||||
if (decisions.length > 500) decisions.splice(0, decisions.length - 500);
|
||||
if (to !== from) console.log(`[${rec.ts}] 速率 ${rec.from} → ${rec.to} req/min(${reason})`);
|
||||
return rec;
|
||||
}
|
||||
/** # 成员变量 控制器节拍序号(加速步按其取模) **/
|
||||
let rampSeq = 0;
|
||||
|
||||
// ============================================================================
|
||||
// 请求发射
|
||||
// ============================================================================
|
||||
|
||||
/** # 函数
|
||||
* 记逐请求行(紧凑:仅分类所需字段)。
|
||||
*/
|
||||
function recReq(o: Record<string, unknown>): void {
|
||||
try { fs.appendFileSync(REQ_JSONL, JSON.stringify(o) + "\n"); } catch {}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 发射单次搜索请求并记账(成功/门壳/空结果/验证码/池空/上游失败/网络失败)。
|
||||
* 路径与指纹取自响应体(search_parameters.runtime / search_metadata.profile.id),
|
||||
* 内部门壳压力取 timing_stages.attempts 的 outcome 统计。
|
||||
*/
|
||||
async function fire(): Promise<void> {
|
||||
const q = nextQuery();
|
||||
const t = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${BASE}/api/serp/json/search?q=${encodeURIComponent(q)}&num=10`, {
|
||||
headers: { authorization: `Bearer ${TOKEN}` },
|
||||
signal: AbortSignal.timeout(240_000),
|
||||
});
|
||||
const ms = Date.now() - t;
|
||||
if (res.status === 401) { console.error("Token 无效(401),终止"); process.exit(1); }
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => null) as any;
|
||||
const code = String(j?.code ?? `HTTP${res.status}`);
|
||||
// 发出与结果同刻记账:分钟桶边界处的在途请求计入完成那一桶,避免「发而不中」虚报
|
||||
cur.sent++; totals.sent++;
|
||||
if (code === "CAPTCHA_DETECTED") { cur.captcha++; totals.captcha++; }
|
||||
else if (code === "SERP_POOL_EMPTY") { cur.poolEmpty++; totals.poolEmpty++; }
|
||||
else { cur.httpErr++; totals.httpErr++; }
|
||||
recReq({ ts: new Date().toISOString(), kind: code === "CAPTCHA_DETECTED" ? "captcha" : code === "SERP_POOL_EMPTY" ? "poolEmpty" : "httpErr", code, ms });
|
||||
return;
|
||||
}
|
||||
const j = await res.json().catch(() => null) as any;
|
||||
const organic = Array.isArray(j?.organic) ? j.organic.length : 0;
|
||||
const path = j?.search_parameters?.runtime === "browser" ? "browser" : "fetch";
|
||||
const fp = String(j?.search_metadata?.profile?.id ?? "");
|
||||
const attempts: any[] = Array.isArray(j?.timing_stages?.attempts) ? j.timing_stages.attempts : [];
|
||||
const gAtt = attempts.filter(a => a?.outcome === "gate" || a?.outcome === "redirect").length;
|
||||
cur.sent++; totals.sent++;
|
||||
cur.attemptsTotal += Math.max(1, attempts.length); totals.attemptsTotal += Math.max(1, attempts.length);
|
||||
cur.gateAttempts += gAtt; totals.gateAttempts += gAtt;
|
||||
cur[path]++; totals[path]++;
|
||||
if (fp) fpMap.set(fp, (fpMap.get(fp) ?? 0) + 1);
|
||||
if (organic > 0) {
|
||||
cur.ok++; totals.ok++;
|
||||
recReq({ ts: new Date().toISOString(), kind: "ok", path, fp, ms, attempts: attempts.length, organic });
|
||||
} else if (gAtt > 0 || attempts.some(a => a?.outcome === "gate")) {
|
||||
cur.gate++; totals.gate++;
|
||||
recReq({ ts: new Date().toISOString(), kind: "gate", path, fp, ms, attempts: attempts.length, gateAttempts: gAtt });
|
||||
} else {
|
||||
cur.empty++; totals.empty++;
|
||||
recReq({ ts: new Date().toISOString(), kind: "empty", path, fp, ms, attempts: attempts.length, organic: 0 });
|
||||
}
|
||||
} catch (e) {
|
||||
cur.sent++; totals.sent++;
|
||||
cur.netErr++; totals.netErr++;
|
||||
recReq({ ts: new Date().toISOString(), kind: "netErr", error: String(e).slice(0, 140), ms: Date.now() - t });
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* worker 主循环:取令牌 → 发射,直到结束时刻。
|
||||
*/
|
||||
async function worker(endAt: number): Promise<void> {
|
||||
while (Date.now() < endAt) {
|
||||
await acquire();
|
||||
if (Date.now() >= endAt) break;
|
||||
await fire();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 快照与汇总
|
||||
// ============================================================================
|
||||
|
||||
/** # 函数
|
||||
* 组装快照(窗口+全程+路径占比+指纹 Top20+决策尾部)。
|
||||
*/
|
||||
function snapshot(): Record<string, unknown> {
|
||||
const w = sumWindow();
|
||||
const topFps = [...fpMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20).map(([fp, n]) => ({ fp, n }));
|
||||
return {
|
||||
at: new Date().toISOString(),
|
||||
hoursElapsed: +((Date.now() - startedAt) / 3600_000).toFixed(2),
|
||||
rateReqMin: +rate.toFixed(1),
|
||||
window: { ...w, successPct: w.sent ? +((w.ok / w.sent) * 100).toFixed(2) : null, gateAttemptPct: w.attemptsTotal ? +((w.gateAttempts / w.attemptsTotal) * 100).toFixed(2) : null },
|
||||
totals: { ...totals, successPct: totals.sent ? +((totals.ok / totals.sent) * 100).toFixed(2) : null, gateAttemptPct: totals.attemptsTotal ? +((totals.gateAttempts / totals.attemptsTotal) * 100).toFixed(2) : null },
|
||||
pathSplit: { fetch: totals.fetch, browser: totals.browser },
|
||||
topFps,
|
||||
decisions: decisions.slice(-20),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 主流程:并行 worker + 每分钟聚合 + 控制器 + 每 5min 快照;到时终局收尾
|
||||
// ============================================================================
|
||||
|
||||
const startedAt = Date.now();
|
||||
const endAt = startedAt + HOURS * 3600_000;
|
||||
fs.writeFileSync(JSONL, "");
|
||||
fs.writeFileSync(REQ_JSONL, "");
|
||||
console.log(`开始:label=${LABEL} hours=${HOURS} rate0=${RATE0} 区间=[${RATE_MIN},${RATE_MAX}] workers=${WORKERS} 出口=json(auto 双路径)`);
|
||||
|
||||
for (let i = 0; i < WORKERS; i++) void worker(endAt);
|
||||
|
||||
/** # 成员变量 每分钟定时器:滚动窗口推进 + 控制器决策 + 聚合行落盘 **/
|
||||
const minuteTimer = setInterval(() => {
|
||||
const row = { ts: new Date().toISOString(), rate: +rate.toFixed(1), ...cur };
|
||||
try { fs.appendFileSync(JSONL, JSON.stringify(row) + "\n"); } catch {}
|
||||
window.push({ ts: row.ts, c: cur });
|
||||
if (window.length > WINDOW_MIN) window.shift();
|
||||
cur = newCounter();
|
||||
controlTick();
|
||||
}, 60_000);
|
||||
|
||||
/** # 成员变量 每 5 分钟快照与控制台摘要 **/
|
||||
const snapTimer = setInterval(() => {
|
||||
const snap = snapshot();
|
||||
fs.writeFileSync(LIVE, JSON.stringify(snap, null, 2));
|
||||
const t = snap.totals as any; const w = snap.window as any;
|
||||
console.log(`[${snap.at}] ${(snap.hoursElapsed as number).toFixed(1)}h rate=${snap.rateReqMin}/min | 窗口 成功率=${w.successPct}% 门壳尝试=${w.gateAttemptPct}% sent=${w.sent} (ok=${w.ok} gate=${w.gate} captcha=${w.captcha} poolEmpty=${w.poolEmpty} httpErr=${w.httpErr} netErr=${w.netErr}) | 全程 成功率=${t.successPct}% 路径 fetch=${snap.pathSplit.fetch}/browser=${snap.pathSplit.browser}`);
|
||||
}, 5 * 60_000);
|
||||
|
||||
const endTimer = setTimeout(() => {
|
||||
clearInterval(minuteTimer); clearInterval(snapTimer);
|
||||
const snap = snapshot();
|
||||
fs.writeFileSync(`${DATA}${LABEL}-final.json`, JSON.stringify(snap, null, 2));
|
||||
console.log("测试结束,final 已写:" + `${DATA}${LABEL}-final.json`);
|
||||
process.exit(0);
|
||||
}, HOURS * 3600_000);
|
||||
endTimer.unref?.();
|
||||
Reference in New Issue
Block a user