Files
dudu/server/internal/store/redis.go
T
wangjia 7b693b0f63
ci / server (push) Failing after 11s
ci / design-tokens (push) Failing after 11s
feat(12B): latencyprobe 延迟验收工具 + 会话建立耗时观测 + 滑动窗口只查不记修复
- cmd/latencyprobe:复刻桌面端行为(单连接串行、100ms/帧实时推流)实测
  网关全链路 first_partial / release_to_commit P50/P95,口径对齐 dictation.rs
- gummy/gateway 增加会话建立耗时日志(dial / task-started / start 总耗时),
  作为 first_partial 延迟分解的常驻观测点
- 修复 slideScript:val=0 的只查请求(AudioWindowExhausted)不再落 0 值成员,
  与注释「只查不记」一致,避免污染窗口 ZSET 并空耗 seq

12B 实测结论(gummy 真实 provider,本机网关):
- first_partial P50≈940ms:其中 DashScope 需收到 ~0.8s 音频内容才出首个
  partial(直连同样),网关暖路径开销仅 ~130ms、冷启 dial ~600ms
- release_to_commit:短会话 ~170ms 达标;8s 会话 350~540ms 超标(final
  flush 随内容量增长);偶发 DashScope 抖动可到秒级
- stop 后周期 usage 帧常先于尾部 final 到达,桌面端会以 partial 文本提前
  注入丢失 final 修正(iOS 已在 21A 修过同类问题,桌面端待修)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 19:03:27 +08:00

123 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"time"
"github.com/redis/go-redis/v9"
)
// Redis Key 设计见 doc/backend-architecture.html 第七章。
func KeyQuotaBalance(uid string) string { return "quota:" + uid + ":balance" }
func KeyQuotaTrial(uid string, day string) string {
return "quota:" + uid + ":trial:" + day
}
func KeyAuthQr(state string) string { return "authqr:" + state }
func KeyJwtBlock(jti string) string { return "jwt:block:" + jti }
func KeyRateCnt(did string) string { return "rate:" + did + ":asr:cnt" }
func KeyRateSecs(did string) string { return "rate:" + did + ":asr:secs" }
func KeyRateFb(uid, day string) string { return "rate:" + uid + ":fb:" + day }
func KeyActiveSession(did string) string { return "asr:active:" + did }
func OpenRedis(addr string, db int) *redis.Client {
return redis.NewClient(&redis.Options{Addr: addr, DB: db})
}
// Day 返回服务端时区(Asia/Shanghai)的自然日,作为试用与反馈限频的键。
var cst = time.FixedZone("CST", 8*3600)
func Day(t time.Time) string { return t.In(cst).Format("2006-01-02") }
// ─── 设备 30 分钟滑动窗口限制(ZSET,member 唯一、score 为时间戳秒)───────────────
// slideWindow 原子地:清理过期成员 → 检查阈值 → 通过则记录本次。
// cnt 窗口按"次"记 1secs 窗口按本次秒数记。
var slideScript = redis.NewScript(`
local key, now, window, limit, val = KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local sum = 0
if ARGV[5] == 'count' then
sum = redis.call('ZCARD', key)
else
local members = redis.call('ZRANGE', key, 0, -1)
for _, m in ipairs(members) do
local v = string.match(m, ':(%d+)$')
if v then sum = sum + tonumber(v) end
end
end
if sum + val > limit then return 0 end
-- val=0 为"只查不记"AudioWindowExhausted 的 start 预检):不落 0 值成员,
-- 避免污染窗口 ZSET 并空耗 seq;count 模式 val 恒为 1,不受影响。
if val > 0 then
local seq = redis.call('INCR', key .. ':seq')
redis.call('EXPIRE', key .. ':seq', window + 60) -- 17Cseq 计数器与 ZSET 同寿命,避免按设备永久泄漏
redis.call('ZADD', key, now, now .. '-' .. seq .. ':' .. val)
redis.call('EXPIRE', key, window + 60)
end
return 1
`)
// AllowSession 设备维度新会话准入:30 分钟内 ≤30 次。
func AllowSession(ctx context.Context, rdb *redis.Client, deviceID string, now time.Time) (bool, error) {
ok, err := slideScript.Run(ctx, rdb, []string{KeyRateCnt(deviceID)},
now.Unix(), 30*60, 30, 1, "count").Int()
return ok == 1, err
}
// AudioWindowExhausted 会话 start 时检查时长窗口是否已满(只查不记;本次秒数在结束时
// 经 RecordAudioSeconds 记录——音频已实际消耗,结束时无条件记账)。
func AudioWindowExhausted(ctx context.Context, rdb *redis.Client, deviceID string, now time.Time) (bool, error) {
ok, err := slideScript.Run(ctx, rdb, []string{KeyRateSecs(deviceID)},
now.Unix(), 30*60, 30*60, 0, "sum").Int()
return ok == 0, err
}
// RecordAudioSeconds 会话结束记录本次识别秒数(无条件,limit 取大数)。
func RecordAudioSeconds(ctx context.Context, rdb *redis.Client, deviceID string, seconds int, now time.Time) error {
return slideScript.Run(ctx, rdb, []string{KeyRateSecs(deviceID)},
now.Unix(), 30*60, 1<<30, seconds, "sum").Err()
}
const deviceSlotTTL = 4 * time.Minute
// AcquireDeviceSlot 单设备同时仅 1 路识别会话(SET NX + TTL 兜底防泄漏)。
//
// 续期契约(17D):槽位 TTL 仅 deviceSlotTTL4min)作为崩溃/泄漏兜底,并非会话上限。
// 长会话期间持有者必须周期性调用 RefreshDeviceSlot 续期(间隔需 < TTL),
// 否则 TTL 到期后槽位被释放、并发设备可抢占。网关 usageLoop 每个 tick2s
// 应顺带调用 RefreshDeviceSlot 续命;会话正常/异常结束时由 ReleaseDeviceSlot 主动释放。
func AcquireDeviceSlot(ctx context.Context, rdb *redis.Client, deviceID, sessionID string) (bool, error) {
return rdb.SetNX(ctx, KeyActiveSession(deviceID), sessionID, deviceSlotTTL).Result()
}
// RefreshDeviceSlot 仅当槽位仍由本会话持有时续期 TTL(17D)。
// 返回 (true,nil) 表示续期成功;(false,nil) 表示槽位已不属于自己(被抢占/已释放),
// 调用方应据此判定会话是否仍合法持有槽位。供 gateway usageLoop 每 tick 调用。
func RefreshDeviceSlot(ctx context.Context, rdb *redis.Client, deviceID, sessionID string) (bool, error) {
// 持有者校验 + EXPIRE 原子化,避免续到别人刚抢占的槽位。
script := redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('EXPIRE', KEYS[1], ARGV[2]) end
return 0`)
n, err := script.Run(ctx, rdb, []string{KeyActiveSession(deviceID)}, sessionID, int(deviceSlotTTL.Seconds())).Int()
return n == 1, err
}
func ReleaseDeviceSlot(ctx context.Context, rdb *redis.Client, deviceID, sessionID string) error {
// 仅当持有者是自己时释放
script := redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end
return 0`)
return script.Run(ctx, rdb, []string{KeyActiveSession(deviceID)}, sessionID).Err()
}
// IncrDailyCounter 自然日计数器(反馈限频等),返回自增后的值。
func IncrDailyCounter(ctx context.Context, rdb *redis.Client, key string) (int64, error) {
pipe := rdb.TxPipeline()
incr := pipe.Incr(ctx, key)
pipe.Expire(ctx, key, 48*time.Hour)
if _, err := pipe.Exec(ctx); err != nil {
return 0, err
}
return incr.Val(), nil
}