服务器负载分析

文档版本 2026-05-29 · 背景:1300 条 ble_token_renew 来自单个访客、在主人离线的 4 小时内——正是这一点促使我们去寻找其他多余 HTTP 与 WebSocket 流量的来源。

已在 6.61–6.63 中修复

第 1 项——没有退避的 HTTP 重试 FIX

Files: HostService.kt:1424-1432, Vault.kt:213

private val pendingKeysRunnable = object : Runnable {
    override fun run() {
        if (Prefs.isHostRegistered && Prefs.getPendingHostKeys().isNotEmpty()) {
            Thread { syncPendingHostKeys() }.start()
        }
        pendingKeysHandler.postDelayed(this, 60_000L)
    }
}

syncPendingHostKeys 取出全部待处理的主机密钥并调用 Api.keyCreate逐个处理。若 HTTP 调用失败,密钥仍处于待处理状态,60 秒后循环重来。

失控的场景

修复:按密钥做指数退避

В Prefs 按 localId 存储:

每次运行 syncPendingHostKeys:

val waitMs = (30_000L * (1L shl attemptCount)).coerceAtMost(30 * 60_000L)
if (now - attemptAt < waitMs) return@forEach  // skip
// otherwise try
val ok = try { Api.keyCreate(...); true } catch (e) { false }
if (ok) { clear attempt_at/count } else { attempt_count++; attempt_at = now }

Progression: 30 秒 → 1 分钟 → 2 分钟 → 4 分钟 → 8 分钟 → 16 分钟 → 30 分钟(封顶)。在持续失败的情况下,就是每 30 分钟一次请求,而不是每分钟一次。 降低到三十分之一。

同样适用于 Vault.retryPendingBundles() ——同样的套路。

第 2 项——视频快照并非我们的负载 SKIP

snap_url 是摄像机、录像机或 HTTP 源的外部地址。客户端通过 Coil 或 ExoPlayer 直接访问该地址,我们的服务器只是传递加密的 data_cipher 载荷——地址就藏在捆绑包里。画面不经过我们。

若某位主人把 snap_int=1 设成给 10 位访客用,他的录像机每秒就会收到 10 次请求。 那是他自己录像机的负载,与我们的服务器无关。

可选:把界面滑块的下限设为三秒。这是界面问题,不是基础设施问题。

第 3 项——ble_renew 调度器 SKIP

已被 6.61 的修复约束住:

若有十个 BLE 对象,用户会连发十条 WebSocket 消息,服务器连着跑十次 case 'ble_token_renew' 分支。小事一桩。

第 4 项——递增 overridesVersion FIX

File: GuestConn.kt ——7 处以上。

AppState.overridesVersion = MutableState<Int>,一个 Compose 信号,含义是“数据变了,重新读取 remember”。它用在 ActionCard:

val ovrVersion = AppState.overridesVersion.value
val (overrideLabel, avatarPath) = remember(action.key, ovrVersion) {
    Prefs.getOverride(action.key)   // SharedPreferences IO
}
val snapCfg = remember(action.key, ovrVersion) {
    if (action.source == ActionSource.HOST_OWN) Prefs.getSnapshot(action.numberId)
    else Prefs.getIncomingSnapshot(...)
}
val tpl = remember(action.key, ovrVersion) { loadTpl() }  // more IO

每次递增都会让每张卡片从 SharedPreferences中重新读取。一个有 20 张卡片的用户就意味着 80 次 SharedPreferences 读取 (每次递增)。

递增都在哪里

LineContext需要吗?
L193重新接收捆绑包之后(没有 K_obj)yes
L355numbers_update 中 parseNumbers 之后redundant 当号码并未变化时
L411加载头像之后yes
L558, L595其他处理器中 parseNumbers 之后redundant 当数据完全相同时

这在哪里间接给服务器加压

WebSocket 抖动时,每个 guest_ok → 处理器重新解析号码 → 递增 overridesVersion → 80 次 SharedPreferences 读取 × 20 张卡片 × guest_ok 的频率。

这属于本地 CPU 与 I/O, 并非服务器的直接负载,但是:

修复:递增前先比对

递增前,把新的 JSON 列表 numbers 与旧的比较——用哈希,或比较其中的 GuestNumberInfo)。相同 → 不递增。

private var lastNumbersHash: Int = 0

private fun maybeUpdateNumbers(arr: JSONArray?) {
    val parsed = parseNumbers(arr)
    val newHash = parsed.hashCode()  // or a hash of the JSON serialisation
    if (newHash == lastNumbersHash) return  // nothing changed
    lastNumbersHash = newHash
    numbers = parsed
    publish()
    AppState.overridesVersion.value = AppState.overridesVersion.value + 1
}

numbers_update 来得很规律,却很少真的变化——主人不会每分钟都改对象。这项优化去掉了 约 90% 的多余重绘浪潮.

结论:该修什么

第 1 项(没有退避的 HTTP 重试) ——直接的 HTTP 负载。按密钥做一个简单退避,就能在糟糕网络下削掉 97% 的请求。
第 4 项(递增 overridesVersion) ——间接的,经由电量与 CPU 消耗。递增前比对可去掉 90% 的重组浪潮。
第 2 项与第 3 项 不在议程上:视频流来自外部录像机或 CDN,不经过我们;续期调度器已被 6.61 的修复约束住。