fix: 发布 0.6.8,修复落库卡死(在线终端 0 根因)、本地自学习库、组播噪音过滤(debug15)

- access_log.remote 45→255 并在启动时自动迁移存量库;代理连接 remote 恢复只存 IP(长域名曾让每分钟落库全部失败,在线终端 0、统计停更)
- 本地自学习库(debug15-1):host_domain_day 实测的 IP→应用 沉淀为长期记忆,每小时重建热加载,DNS 缓存过期 / DoH / 裸 IP 流量按记忆归类
- 历史重算落库改 upsert + 加锁串行化,IntegrityError 纳入重试(审计 P1-1:并发重算撞主键 1062 整笔回滚)
- 组播 / 广播流量(SSDP、mDNS)不再计入统计(debug15-2:单设备月发几百 MB SSDP 全落「其它」)
This commit is contained in:
Su Yijun committed 2026-10-25 12:02:08 +08:00
1 parent f2d4f4145a
commit 9add509a1e
12 files changed
+323 -35

No files matched your search

+10
View File
@@ -1,5 +1,15 @@
# Changelog
## [0.6.8] - 2026-10-25
修复落库卡死(在线终端 0 的根因)、本地自学习库、组播噪音过滤(debug15)
- **修复落库全部卡死**(在线终端 0、统计停更的根因):sing-box 代理连接的目标有时是域名,被写进了 `access_log.remote`(VARCHAR(45),本意只存 IP),超过 45 字符的长域名让**每分钟的落库全部失败**——流量、终端在线状态、访问日志从失败时刻起全部停更,缓冲 2 小时后数据开始丢弃。三处修复:① 列加宽到 255,启动时自动迁移存量库(不再需要手工 ALTER);② 代理连接的 `remote` 恢复"只存 IP"的语义,目标是域名时留空(域名本来就在 `dest` 列);③ 之前执行过手工 ALTER 的库也兼容。
- **本地自学习库**(debug15 第 1 条):本网络实测的「IP → 应用」沉淀成长期记忆(来自 31 天的 host_domain_day,每小时从数据库重建、热加载,规模见 /api/system 的 `local`)——之后同一个 IP 即使 DNS 缓存过期、终端改用 DoH、或流量只走裸 IP,也能按记忆归类。公开库给不了这层数据:它只属于你自己的网络;且每次历史重算都会自动纠正它,与识别库升级形成闭环。这是"自建本地库"对 Panabit 形成差异化的部分(它靠协议特征,这里多了自学习)。
- **历史重算的落库改为 upsert + 串行化**(审计报告 P1-1):自动任务和手动按钮同时触发时,两个重算会写同一批行撞主键(1062)整笔回滚——现在目标行用 ON CONFLICT 累加、源行用负增量 upsert,且整个重算加锁串行,IntegrityError 也纳入重试。
- **组播 / 广播流量不再计数**(debug15 第 2 条):SSDP(239.255.255.250)、mDNS(224.0.0.251)等设备发现协议的流量不再计入终端和应用统计——单台设备一个月能发几百 MB 的 SSDP,此前全部落进「其它」(第二站点实测 473MB)。
- 其它和未识别的收敛路径(debug15 第 2 条):列宽修复恢复落库 → 本地自学习库 + 每日零点自动重算逐日消化历史 → AdGuard / MosDNS 映射持续积累。手工归类未识别域名大头(媒体刮削、自建隧道等)仍然有效。
## [0.6.7] - 2026-10-25
修复 0.6.5 / 0.6.6 的历史重算和识别库自动登记(debug13、debug14 复查)
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.6.7"
__version__ = "0.6.8"
+6 -2
View File
@@ -180,9 +180,10 @@ class Classifier:
def classify(self, remote_ip: str, proto: int, port: int, qname: str | None, cname: str | None) -> str:
from app.classify.lists import lists as extra_lists
from app.classify.local import local
from app.classify.sources import sources as extra
ver = (extra_lists.version, extra.version)
if ver != self._ver: # 第三方库重新加载过:之前的结果作废
ver = (extra_lists.version, extra.version, local.version)
if ver != self._ver: # 第三方库 / 本地自学习库重新加载过:之前的结果作废
self._cache.clear()
self._ver = ver
self._sync_list_apps()
@@ -217,6 +218,9 @@ class Classifier:
hit = asndb.lookup(remote_ip)
if hit:
app = self.asn_apps.get(hit[0])
if app is None:
# 本地自学习库(debug15):本网络实测过的 IP→应用 长期记忆,DNS 缓存过期 / DoH / 裸 IP 都能归类
app = local.ip_app(remote_ip)
if app is None or app.startswith(WEAK_PREFIXES):
# 分类级列表(Hagezi > UT1):只认得出类别;也用来细化 v2fly 的地区兜底条目
cat = extra_lists.category(qname) or extra_lists.category(cname)
+60
View File
@@ -0,0 +1,60 @@
"""本地自学习库(debug15):把本网络实测的「IP → 应用」长期记忆接入分类管线。
host_domain_day(31 天)里以 IP 形式记录的行,当初是靠 DNS / SNI / ASN / 端口认出来的——这些证据
过后会消失(DNS 缓存过期、终端改用 DoH、SNI 只在连接开头出现),但"这个 IP 是什么应用"的结论是
实测来的,比任何公开库都准,因为它只属于你自己的网络。本模块把结论沉淀成 IP → 应用 的映射,
每小时从数据库重建一次、热加载:之后同一个 IP 就算完全拿不到域名,也能按记忆归类。
这是"自建本地库"的第一层(debug15);它随每一次重算自动纠正,与识别库升级、历史重算形成闭环。
"""
import ipaddress
import threading
from collections import defaultdict
from sqlalchemy import select
from app.models import HostDomainDay
MAX_IPS = 200_000
class LocalDB:
def __init__(self):
self.lock = threading.Lock()
self.version = 0
self.ips: dict[str, str] = {} # IP -> 应用 ID(按历史字节数取最多的应用)
def load(self, db):
"""从 host_domain_day 聚合:每个 IP 取历史字节数最多的应用;调用方提交事务"""
votes: dict = defaultdict(dict)
rows = db.execute(
select(HostDomainDay.domain, HostDomainDay.app, HostDomainDay.bytes)
).all()
for dom, app, b in rows:
try:
ipaddress.ip_address(dom)
except ValueError:
continue
per = votes[dom]
per[app] = per.get(app, 0) + int(b or 0)
# 只留最大的 MAX_IPS 个 IP(按总字节),避免极端环境下无限膨胀
top = sorted(votes.items(), key=lambda kv: -sum(kv[1].values()))[:MAX_IPS]
ips = {}
for dom, per in top:
ips[dom] = max(per, key=per.get)
with self.lock:
self.ips = ips
self.version += 1
def ip_app(self, ip: str) -> str | None:
with self.lock:
return self.ips.get(ip)
def reset(self):
"""测试用:清空内存记忆"""
with self.lock:
self.ips = {}
self.version += 1
local = LocalDB()
+14 -5
View File
@@ -141,18 +141,27 @@ def init_db(retries: int = 20, delay: float = 3.0):
def add_missing_columns():
"""升级时补上新版本新增的列(create_all 只建新表,不改已有的表)。新增列一律可为空,不需要回填。"""
"""升级时补上新版本新增的列(create_all 只建新表,不改已有的表)。新增列一律可为空,不需要回填。
同时把代码里加宽过的列在存量库上加宽(0.6.8:access_log.remote 45→255;只加宽不缩窄,SQLite 不校验长度跳过)。"""
insp = inspect(engine)
with engine.begin() as conn:
for table in Base.metadata.sorted_tables:
if not insp.has_table(table.name):
continue
have = {c["name"] for c in insp.get_columns(table.name)}
have = {c["name"]: c for c in insp.get_columns(table.name)}
for col in table.columns:
if col.name not in have and col.nullable:
if col.name not in have:
if col.nullable:
ctype = col.type.compile(dialect=engine.dialect)
conn.exec_driver_sql(f"ALTER TABLE `{table.name}` ADD COLUMN `{col.name}` {ctype} NULL")
print(f"[db] 已为 {table.name} 添加列 {col.name}", flush=True)
continue
length = getattr(col.type, "length", None)
db_len = have[col.name].get("length")
if insp.dialect.name == "mysql" and length and db_len and length > int(db_len):
ctype = col.type.compile(dialect=engine.dialect)
conn.exec_driver_sql(f"ALTER TABLE {table.name} ADD COLUMN {col.name} {ctype} NULL")
print(f"[db] 已为 {table.name} 添加列 {col.name}", flush=True)
conn.exec_driver_sql(f"ALTER TABLE `{table.name}` MODIFY `{col.name}` {ctype}")
print(f"[db] 已把 {table.name}.{col.name} 从 {db_len} 加宽到 {length}", flush=True)
def add_ready_hook(fn):
+10 -1
View File
@@ -246,7 +246,8 @@ class Engine:
return
proto = 17 if network == "udp" else 6
qname = host if host and not _is_ip(host) else None
remote = dst or host or "0.0.0.0"
# remote 只存 IP(写 access_log.remote):目标是域名时没有 IP 可存,留空;0.6.8 前把域名塞进来曾撑爆列宽
remote = dst if dst and _is_ip(dst) else (host if host and _is_ip(host) else "")
node, group, rule, names = route
with self.lock:
app = self.classifier.classify(dst or "0.0.0.0", proto, dport, qname, None)
@@ -313,6 +314,14 @@ class Engine:
if not src or not dst or nbytes <= 0:
self.stats["flows_dropped"] += 1
return None
# 组播 / 广播是设备发现协议(SSDP、mDNS……)的噪音:一台设备一个月能发几百 MB,全落进「其它」,不计
try:
if ipaddress.ip_address(dst).is_multicast or ipaddress.ip_address(src).is_multicast \
or src == "255.255.255.255" or dst == "255.255.255.255":
self.stats["multicast_dropped"] = self.stats.get("multicast_dropped", 0) + 1
return None
except ValueError:
pass
src_lan, dst_lan = self.is_lan(src), self.is_lan(dst)
mac = None
if src_lan and not dst_lan:
+13 -1
View File
@@ -47,7 +47,8 @@ def _on_db_ready():
"""数据库连接成功后(启动时或安装向导保存后)执行。各步互不依赖:一步出错只记日志,不挡住后面的
(0.6.0 / 0.6.1 的主域名回填出错时,后面的外部数据源配置、自定义规则都没加载,外部数据源显示“未启用”)"""
ensure_admin() # 建管理员失败要报出来(安装向导靠这个提示)
for name, fn in (("load_state", engine.load_state), ("apply_settings", _apply_saved)):
for name, fn in (("load_state", engine.load_state), ("apply_settings", _apply_saved),
("local_db", _refresh_local)):
try:
fn()
except Exception as e: # noqa: BLE001
@@ -55,6 +56,16 @@ def _on_db_ready():
print("[startup] 数据库已连接", flush=True)
def _refresh_local():
"""本地自学习库(debug15):从 host_domain_day 重建 IP→应用 长期记忆(启动 + 每小时),热加载"""
if not database.is_configured():
return
from app.classify.local import local
with database.session() as db:
local.load(db)
db.commit()
def _auto_update_on() -> bool:
"""识别规则页的「自动更新」开关(默认开启):管 v2fly、ASN 库和补充库的定期更新"""
if not database.is_configured():
@@ -118,6 +129,7 @@ async def lifespan(app: FastAPI):
sched.add_job(_safe(integrations.singbox.poll, "singbox"), "interval", seconds=5, max_instances=1, coalesce=True)
sched.add_job(_safe(integrations.mosdns.poll, "mosdns"), "interval", seconds=5, max_instances=1, coalesce=True)
sched.add_job(_safe(integrations.adguard.poll, "adguard"), "interval", seconds=5, max_instances=1, coalesce=True)
sched.add_job(_safe(_refresh_local, "local-db"), "interval", hours=1, max_instances=1, coalesce=True)
# 识别库更新 / 过了零点后的自动重算:指纹没变时开销只有一次设置查询。启动后等 15 分钟再跑第一次,
# 第三方库、ASN、国内列表是在后台线程加载的,不能用没加载完的识别器去算历史
sched.add_job(_safe(_auto_reclassify, "auto-reclass"), "interval", hours=1, max_instances=1, coalesce=True,
+1 -1
View File
@@ -134,7 +134,7 @@ class AccessLog(Base):
host = Column(String(64), nullable=False)
host_ip = Column(String(45))
dest = Column(String(255), nullable=False) # 域名;没有域名时是远端 IP
remote = Column(String(45)) # 最近一次的远端 IP
remote = Column(String(255)) # 最近一次的远端 IP;0.6.8 从 45 加宽:代理连接曾把域名写进来导致落库卡死
app = Column(String(48), nullable=False)
port = Column(Integer)
proto = Column(Integer)
+29 -24
View File
@@ -18,17 +18,20 @@ app 标记同步改掉(保证重复执行幂等)。host_domain_day 只覆盖
识别规则页的手动「重算历史分类」直接调 reclassify()。
"""
import ipaddress
import threading
import time
from collections import defaultdict
from sqlalchemy import select
from sqlalchemy.exc import OperationalError
from sqlalchemy.exc import IntegrityError, OperationalError
from app import timeutil
from app.classify import GENERIC_APPS, UNIDENTIFIED_CATEGORIES, WEAK_PREFIXES
from app.database import upsert_add
from app.models import DnsCache, HostDomainDay, Setting, TrafficDay
FP_KEY = "reclass_fp"
_reclass_lock = threading.Lock() # 自动任务和手动按钮可能同时触发:串行化,避免两轮重算搬同一批字节
# 只说明协议、地区或“是个网站”的笼统分类:可以被升级成具体应用,但重算不会把行改成它们
VAGUE_APPS = GENERIC_APPS | {"cn_net", "cn_web", "unknown"}
@@ -47,22 +50,24 @@ def _is_ip(s: str) -> bool:
return False
def _deadlock(e: Exception) -> bool:
"""MySQL 死锁(1213)或锁等待超时(1205):落库任务每分钟在写同一批表,撞上重试即可"""
def _retryable(e: Exception) -> bool:
"""MySQL 死锁(1213)/ 锁等待超时(1205)/ 主键冲突(1062,并发重算撞车):落库任务每分钟在写同一批表,
自动任务和手动按钮也可能同时跑,撞上重试即可"""
s = str(e)
return "(1213" in s or "(1205" in s
return "(1213" in s or "(1205" in s or "(1062" in s
def reclassify(engine, db, before: int | None = None, attempts: int = 3) -> dict:
"""重算 before(默认今天零点)以前的历史分类"""
for i in range(attempts):
try:
return _reclassify_once(engine, db, before)
except OperationalError as e:
db.rollback()
if i == attempts - 1 or not _deadlock(e):
raise
time.sleep(2 + i * 2)
with _reclass_lock:
for i in range(attempts):
try:
return _reclassify_once(engine, db, before)
except (OperationalError, IntegrityError) as e:
db.rollback()
if i == attempts - 1 or not _retryable(e):
raise
time.sleep(2 + i * 2)
def _dns_domain_map(db) -> dict[str, str]:
@@ -139,20 +144,20 @@ def _reclassify_once(engine, db, before: int | None) -> dict:
s_[1] += down
s_[2] += flows
for key, (up, down, flows) in srcs.items():
src = db.get(TrafficDay, {"ts": key[0], "host": key[1], "app": key[2]})
if src is None:
# 负增量也走 upsert:并发重算串行化了(见 reclassify 的锁),但落库任务可能同时碰同一行
if db.get(TrafficDay, {"ts": key[0], "host": key[1], "app": key[2]}) is None:
continue
src.up = max(src.up - up, 0)
src.down = max(src.down - down, 0)
src.flows = max(src.flows - flows, 0)
upsert_add(db, TrafficDay.__table__,
[{"ts": key[0], "host": key[1], "app": key[2], "up": -up, "down": -down, "flows": -flows}],
["ts", "host", "app"], ["up", "down", "flows"])
src = db.get(TrafficDay, {"ts": key[0], "host": key[1], "app": key[2]})
if src is not None and (src.up < 0 or src.down < 0 or src.flows < 0):
src.up, src.down, src.flows = max(src.up, 0), max(src.down, 0), max(src.flows, 0)
for key, (up, down, flows) in dsts.items():
dst = db.get(TrafficDay, {"ts": key[0], "host": key[1], "app": key[2]})
if dst is None:
db.add(TrafficDay(ts=key[0], host=key[1], app=key[2], up=up, down=down, flows=flows))
else:
dst.up += up
dst.down += down
dst.flows += flows
# upsert:落库任务 / 并发的重算可能刚写入了同一行(0.6.7 及以前用 db.get + add,撞主键整笔回滚)
upsert_add(db, TrafficDay.__table__,
[{"ts": key[0], "host": key[1], "app": key[2], "up": up, "down": down, "flows": flows}],
["ts", "host", "app"], ["up", "down", "flows"])
return {"scanned": len(rows), "rows_moved": len(dsts), "bytes_moved": sum(v[0] + v[1] for v in dsts.values()),
"flows_moved": sum(v[2] for v in dsts.values()), "seconds": round(time.time() - t0, 1)}
+7
View File
@@ -177,6 +177,12 @@ def _integrations(db):
"proxy_auto": integrations.singbox.host_ip()}
def local_status():
"""本地自学习库(debug15):本网络实测的 IP→应用 记忆规模"""
from app.classify.local import local
return {"ips": len(local.ips), "updated": local.version}
def _db_info():
eng = database.engine
if eng is None:
@@ -245,6 +251,7 @@ def system():
"singbox": integrations.singbox.status(),
"mosdns": integrations.mosdns.status(),
"adguard": integrations.adguard.status(),
"local": local_status(),
"asn": asndb.status(),
"proxied_skipped": st["proxied_skipped"], "dns_mosdns": st["dns_mosdns"],
"generic_apps": sorted(GENERIC_APPS),
+66
View File
@@ -0,0 +1,66 @@
"""debug15:本地自学习库(IP→应用 长期记忆)、组播噪音过滤、代理连接 remote 只存 IP。"""
import time
from app.classify import Classifier
from app.classify.local import local
from app.engine import Engine
from app.models import HostDomainDay
DAY = 1792900000
HOST = "IP:192.168.8.40"
def _engine():
e = Engine()
e.set_lan("192.168.8.0/24")
return e
def test_local_library_feeds_classifier():
import app.database as database
database.connect_from_saved(force=True)
s = database.SessionLocal()
try:
# 其它测试可能在共享库里留过同域名的行:先清掉,保证聚合结果确定
s.query(HostDomainDay).filter(HostDomainDay.domain.in_(["203.0.113.9", "www.example.com"])).delete(synchronize_session=False)
s.query(HostDomainDay).filter(HostDomainDay.host == HOST).delete(synchronize_session=False)
s.add(HostDomainDay(ts=DAY, host=HOST, domain="203.0.113.9", app="douyin", base=None, bytes=500))
s.add(HostDomainDay(ts=DAY, host=HOST, domain="www.example.com", app="web", base=None, bytes=900))
s.commit()
local.load(s)
s.commit()
assert local.ip_app("203.0.113.9") == "douyin" # IP 行进记忆
assert local.ip_app("198.51.100.7") is None
c = Classifier()
assert c.classify("203.0.113.9", 6, 443, None, None) == "douyin" # 分类管线用上记忆(无域名 / DoH 场景)
assert c.classify("198.51.100.7", 6, 443, None, None) == "https" # 没记忆的照旧走通用
finally:
s.query(HostDomainDay).filter(HostDomainDay.host == HOST).delete(synchronize_session=False)
s.query(HostDomainDay).filter(HostDomainDay.domain.in_(["203.0.113.9", "www.example.com"])).delete(synchronize_session=False)
s.commit()
s.close()
local.reset() # 单例记忆会污染同进程的其它用例(203.0.113.9 是测试标准地址)
def test_multicast_and_broadcast_dropped():
e = _engine()
now = time.time()
for dst in ("239.255.255.250", "255.255.255.255", "224.0.0.251", "ff02::fb"):
r = e.add_flow(dict(src="192.168.8.20", dst=dst, sport=5000, dport=1900, proto=17, bytes=1000), now)
assert r is None, dst
r = e.add_flow(dict(src="192.168.8.20", dst="93.184.216.34", sport=5000, dport=443, proto=6, bytes=1000), now)
assert r is not None
def test_proxy_remote_stores_ip_only():
e = _engine()
e.set_proxy({"192.168.8.249"}, True)
now = time.time()
# 目标是域名(无 IP):remote 留空,不把长域名写进 access_log.remote
e.add_proxy_conn("192.168.8.20", 50000, "very-long-domain-name.example-cdn-cluster.some-provider.net",
"", 443, "tcp", 100, 1000, now)
# 目标是 IP:remote 存 IP
e.add_proxy_conn("192.168.8.20", 50001, "api.example.com", "93.184.216.34", 443, "tcp", 100, 1000, now + 1)
remotes = {k[1] for k in e.sessions}
assert "" in remotes and "93.184.216.34" in remotes
assert not any(r and "example-cdn-cluster" in r for r in remotes)
+106
View File
@@ -0,0 +1,106 @@
# ROS-DPI 审计报告(2026-10-25)
审计范围:0.6.5 / 0.6.6 / 0.6.7 三次发布的相关代码(reclass.py、lists.py、integrations.py、main.py、admin.py、classify/__init__.py、oplog.py、Settings.vue、Rules.vue、style.css),以及线上实例(192.168.88.240,对外 rosdpi.suyijun.dpdns.org:9443)的运行状态。**只读审计,未改动任何代码。**
## 一、总体结论
0.6.7 的方向和实现是对的:历史重算「只动今天以前、只升级不降级」解决了反复搬移和降级;死锁重试解决了和落库任务撞锁;投票归属比纯名字猜测可靠得多;oplog 已脱敏 AdGuard 密码。测试 97 个全绿,CI 构建成功。
线上实例运行正常:AdGuard 源已生效(应答 4322 条且持续增长)、「未识别」已降到 2.6%、历史重算成功搬移约 1.6GB。**「其它」大类今天仍占 45.5% 属于设计内现象**(见 P1-2),不是故障。
以下按优先级列出发现的问题。
## 二、P1 — 建议尽快处理
### 1. 并发重算互相撞主键,整个重算静默回滚(0.6.7 仍存在)
- 现象:容器日志里有 `[error] POST /api/rules/reclassify: IntegrityError (1062, "Duplicate entry '1792771200-00:00:00:00:00:00-microsoft' for key 'traffic_day.PRIMARY'")`,对应你在 0.6.6/0.6.7 上的多次点击——至少一次点击实际失败了(整笔回滚,页面上只有一条报错)。
- 根因:`reclass.py` 目标行写入是 `db.get(TrafficDay, {...})` 查不到就 `db.add()` 纯 INSERT(reclass.py L149-155)。重算可以并发触发:自动任务每小时一次(main.py L122-123,启动后 15 分钟首跑)+ 手动按钮 + 连续点击(oplog 里 18:06 一分钟内就有三次)。两个重算同时跑:A 查不到目标行(未提交)→ add;B 同样查不到 → add;后提交的撞主键。注意和落库任务无关——落库只写今天的行,重算只碰今天以前的行。
- 重试救不了它:`reclassify()` 的重试只捕死锁(1213/1205,reclass.py `_deadlock()`),IntegrityError 直接抛出。
- 建议(约 10 行改动):
1. 目标行写入改用现成的 `database.upsert_add()`(ON CONFLICT 累加,database.py L85),天然冲突安全;
2. 或者给重算加模块级 `threading.Lock` 串行化(自动任务和手动按钮互斥);
3. 顺手把 IntegrityError 也纳入重试清单。
### 2. 预期管理:「其它」今天占 45.5% 是设计行为,明天才会归位
- 0.6.7 起重算只处理**今天以前**的数据(`reclass.py` 的 `before` 过滤,避免和每分钟落库抢写同一行)。
- 你今天的大头(爱快下游的国内 CDN 流量)全在「今天」的桶里:点了重算它也不动,要等**明天零点后的自动重算**才会用上期间积累的 AdGuard 映射。
- 观察方法:明天早上看全网概览的「其它」占比,应明显低于今天;之后逐日收敛。识别规则页的识别库指纹里含日期(`fingerprint()` 的 `day{...}`),过零点必然触发一轮。
### 3. 重算是同步 HTTP 接口,数据量大时会超时
- 18:04 那轮全量重算耗时 80.7 秒(oplog 实录),是在数据库里 4600 行的规模。行数随终端和域名数增长,前端 fetch 默认超时后按钮会报错,但后端其实还在跑。
- 建议:改成后台任务 + 识别规则页显示"上次重算结果"(oplog 里已有数据),或至少前端把该请求的超时调到 5 分钟。
## 三、P2 — 低风险观察项
4. **AdGuard 轮询吞吐上限**:每 5 秒拉最新 500 条(≈100 QPS 封顶),未用 `older_than` 分页。内网规模足够;若下级终端很多且出现 DNS 风暴,会有漏读缝隙(漏读的映射下次查询会补上,影响有限)。
5. **投票的小目录滞留**:`VOTE_MIN=5`,可识别域名不足 5 条的新目录永远停在 pending,每个更新周期重复下载一次。无害,量也小。
6. **GitHub API 限流**:自动发现用未认证 API(60 次/小时/IP)。当前 bm7 每周一次 + hagezi/ut1 每天各一次 ≈ 3 次/天,充足;以后若加来源留意。
7. **首跑指纹的浪费**:指纹含 `apps{数量}` 和 v2fly latest,启动后 15 分钟首跑时 v2fly 可能还没完成第一次检查 → 首跑后指纹立刻又变 → 1 小时后多做一次重算。无害(第二次搬移量为 0),介意的话让首跑跳过指纹写入。
8. **VM 时区是 EDT**:应用内部统一用 TZ=Asia/Shanghai(统计日桶正确),但系统日志、`timedatectl` 显示美国东部时间,排查问题时容易看岔。建议:`timedatectl set-timezone Asia/Shanghai`。
9. **识别率口径注意**:「国内网络 / 国内网站」在 `UNIDENTIFIED_CATEGORIES` 之外、按已识别计。AdGuard 把部分「国内网络」拆成具体应用后,个别未认出的会落进「未识别」,识别率数字可能先降后升——看趋势别看单点。
## 四、运行状态快照(审计时点)
| 项 | 状态 |
|---|---|
| 线上版本 | 0.6.7(你已自行部署,CI 构建成功) |
| 后端测试 | 97 个全绿 |
| AdGuard 源 | ok,应答 4322 条且持续增长(data 格式修复生效) |
| MosDNS 源 | ok,应答 2226 条 |
| 历史重算 | 成功过 4 轮:合计搬移约 1.6GB;失败 1 次(1062,见 P1-1) |
| 未识别占比 | 2.6%(此前 9.4%) |
| 其它占比 | 45.5%(今天的数据,见 P1-2) |
| VM 环境 | 无 daemon.json 残留(存储驱动默认 overlay2 ✓);磁盘 98G 可用 90G;时区 EDT(见 P2-8) |
| Arcane | VM 上的项目在 `/opt/ros-dpi`(命令行部署),Arcane 面板里看不到,属已知状态 |
## 五、建议的处理顺序
1. **观察 24–48 小时**(零成本):AdGuard 映射积累 + 每日零点自动重算,「其它」应逐日下降;若 48 小时后「其它」里国内网络仍 >20%,回来找我,那时值得再看一次 DNS 链路(爱快下游是否全部走 192.168.100.117、有没有设备用 DoH 绕过)。
2. **修 P1-1**(10 行改动):upsert_add 或加锁,避免下一次点击又静默失败。
3. 可选:P1-3 重算改后台任务、P2-8 时区。
## 六、第二站点审计:rosdpi.suyijun.top:8443(2026-10-25 补充)
这一台监控的是**另一个站点**:LAN 192.168.8.0/24,RouterOS x86(7.23beta5 development)@192.168.8.254,MosDNS @192.168.8.248:9099(已接、应答 2101 条),MySQL @192.168.8.251,版本 0.6.7。AdGuard 未配置。
**「其它+未识别」≈ 18%(月视图:unknown 14.5% + other 3.8%),构成如下:**
1. **unknown 14.5% 的主体是几个联通骨干(AS4837)的纯 IP 大流量**:116.169.179.186(2.7GB)、101.205.174.229(1.1GB)、101.206.244.19(741MB)、116.169.56.161(728MB)等——DNS 应答里没有它们,属于"查询发生在实例启动前 / 客户端 DoH / 上游缓存未过审计"三种情况之一。这台实例的 DNS 可见性其实很好(pair 匹配 353MB 是最大来源,none 只有 8.7MB),所以**点一次「重算历史分类」让 dns_cache 反查生效**,再等每日自动重算积累,即可逐步收敛。
2. **unknown 域名里的大头(可用现有功能手工处理,无需改代码)**:
- `assets.fanart.tv` 380MB —— Kodi/Jellyfin 的媒体封面刮削源,建议在 识别规则 → 未识别域名 一键归类到「网络电视」;
- `sui / dmit / doh.pubdefault.suyijun.top` 合计约 350MB —— 你自建的 VPS / DoH 隧道,建议建一个自定义应用(如「自建服务」)归「流量代理」或「常用协议」;
- `jz.wacaijizhang.com` 10MB —— 随手记,可归「常用网站」。
3. **意外发现(建议列入开发计划)**:SSDP 组播 `239.255.255.250`(udp/1900 设备发现)一个月被计了 **473MB**,全部落进「其它」。一方面组播/广播地址(224.0.0.0/4、ff02::/16、255.255.255.255)建议在采集侧过滤或单独归类;另一方面 473MB 的 SSDP 流量本身不正常,内网大概率有一台设备在疯狂发组播,值得抓一下是哪台(交换机端口流量或临时 firewall 计数)。
4. **这台站点没有接 AdGuard 的必要**(除非 8.x 网段也存在二级路由下挂 AdGuard 的结构;目前的未知流量来自联通 CDN,不是 DNS 链路问题)。
5. **顺带提醒**:这台路由器跑的是 7.23beta5(development 通道)。beta 版的 sniff-tzsp / traffic-flow 行为可能有变化,若以后出现嗅探异常(SNI/DNS 应答数骤降),优先考虑换回 stable 再排查。
## 七、P0(2026-10-25 上午发现,正在发生):主实例落库全部卡死,「在线终端 0」只是表象
**现象**:rosdpi.suyijun.dpdns.org:9443 在线终端显示 0,与实际不符;应用统计/趋势/最近访问全部停更约 40 分钟(审计时点)。
**因果链(已逐环证实)**:
1. sing-box 代理连接的目标有时是**域名**而不是 IP:`engine.py:249` `remote = dst or host or "0.0.0.0"` 把域名塞进了 `AccessLog.remote`,而该列是 `String(45)`(models.py,本意只存 IP,45 正好是 IPv6 最长文本长度)。
2. 某个超过 45 字符的长域名进来自后,**每分钟的落库(flush)全部失败**:`DataError (1406, "Data too long for column 'remote' at row 6")`,容器日志里每分钟一条。
3. flush 失败 → traffic_day / host_domain_day / hosts 表(含 last_seen)全部停更 → 「在线终端」按 `now - last_seen < 阈值` 判定(stats.py L109)→ 全部离线、显示 0。实时流量图走平、但「今天流量」等内存侧数字还在动,所以页面看起来"半正常"。
4. 失败的数据在内存 `_pending` 里每分钟重试,`PENDING_MAX_AGE = 7200 秒`(engine.py L31):**首次失败约 40 分钟前,缓冲的数据约 80 分钟后开始被丢弃**——不处理的话,故障期间的全部流量数据会丢。
**立即处理(一条 SQL,不改代码,保住缓冲数据)**:
```bash
docker exec -it ros-dpi-mysql mysql -urosdpi -p<MYSQL_PASSWORD> rosdpi \
-e "ALTER TABLE access_log MODIFY remote VARCHAR(255);"
```
列加宽后,下一分钟的 flush 重试会自动成功,缓冲的流量、last_seen、在线终端全部恢复。**务必在首次失败后 2 小时内执行**(超过后缓冲开始按滑动窗口丢弃)。
**代码侧修复(下次发版,建议 0.6.8)**:
1. `models.py`:`remote = Column(String(255))`(与 dest 一致)。注意 `add_missing_columns()` 只加列不改宽,已有库需要上面那条 ALTER 或在迁移逻辑里补宽度变更。
2. `engine.py add_proxy_conn`:`remote` 的语义是"远端 IP"——目标是域名时应留空(或存解析后的 IP),而不是把域名塞进去;至少加长度防御(截断到列宽)。
3. 排查同类隐患:`grep "String(45)\|String(64)" models.py`,凡是可能收到域名/长字符串的列都过一遍(host String(64) 存 "IP:x.x.x.x" 或 MAC 目前安全;dest 已是 255)。
**这个 bug 为什么现在才暴露**:sing-box 外部数据源是昨晚 17:33 才配置的(oplog 实录),代理连接的域名目标从此进入 access_log;今天早上出现第一个超长域名后落库开始失败。属于 0.6.5 接入 sing-box 后的遗留隐患,与 0.6.7 的重算改动无关。