缓存与性能工程

专栏:new-api 源码拆解 · 第 10 / 12 篇
new-api缓存性能

:::info 学习目标 完成本篇后你能够:画出 new-api 的缓存体系全景并说明每级缓存的失效策略;解释 Lua 原子预扣与突变 fence 两个并发设计的动机;配置多机部署的同步参数。 前置:第 3、8 篇完成。预计时长:60 分钟。 :::

:::note 本章术语速查(新手建议先读)

  • Lua 脚本:Redis 支持的一段小程序,把”读-判断-写”三步合成原子操作,中间不会被插队。
  • 原子操作:要么完整执行、要么不执行,执行中不会被其他请求打断。
  • 全量重建:缓存不逐条更新,而是定期从数据库重新整体构建——简单且不易出错。
  • 批量落库:把高频写入先在内存累计,每 5 秒合并写一次数据库。
  • 最终一致:数据短暂不同步但最终会一致——计数类数据可接受,扣费类不行。
  • 过载保护:系统太忙时直接拒绝新请求(503),保住已有请求不雪崩。 :::

网关的每个请求都要:查 token、查用户、选渠道、扣额度、写日志——全是热路径读写,直接打 DB 既慢又贵。new-api 的性能工程就是围绕这些操作铺开的一套缓存与批量策略,分三级:进程内内存 → Redis → DB

缓存体系全景

图表(newapi-cache-performance.md)

开关关系:REDIS_CONN_STRING 配置 Redis 后强制开启 MemoryCacheEnabled(main.go

);未启用 Redis 的一切操作降级为内存或 DB 条件更新。

设计一:渠道缓存的全量重建 + 读写锁

InitChannelCache 把启用渠道按 group→model→priority 全量组织成三级索引(group2model2channels),外加 channelsIDM(全量 id→channel 映射)。SyncChannelCache 周期(SYNC_FREQUENCY 默认 60s)重建:

// model/channel_cache.go:24-92(节选)
var group2model2channels map[string]map[string][]int // 启用渠道
var channelsIDM map[int]*Channel                     // 全部渠道(含禁用)
...
func InitChannelCache() {
    if !common.MemoryCacheEnabled { ... return }
    ...
    for _, channel := range channels {
        if channel.Status != common.ChannelStatusEnabled {
            continue // 跳过禁用渠道
        }
        groups := strings.Split(channel.Group, ",")
        for _, group := range groups {
            models := strings.Split(channel.Models, ",")
            for _, model := range models {
                ...newGroup2model2channels[group][model] = append(..., channel.Id)
            }
        }
    }
    // 按优先级排序
    for _, model2channels := range newGroup2model2channels { ... }

不做增量更新而做全量重建——渠道数在千级、重建毫秒级,channelSyncLock 读写锁保证重建期间读请求走旧索引。简单性完胜增量一致性,这是”数据量小 + 读极多”场景的标准答案。

设计二:额度预扣的 Lua 原子化

预扣额度是”读-判断-写”三步,纯 Redis 命令有竞态。Lua 脚本把校验与扣减原子化,还带缓存身份校验

// model/quota_reserve.go:20-31(节选)
const userQuotaReserveScript = `
if tonumber(redis.call('HGET', KEYS[1], 'Id') or '0') ~= tonumber(ARGV[2])
  or tonumber(redis.call('HGET', KEYS[1], 'CacheSchema') or '0') ~= tonumber(ARGV[3])
  or redis.call('HEXISTS', KEYS[1], 'Quota') == 0 then
  return -1                                   -- 缓存属于别的实体/旧 schema → 弃用
end
local quota = tonumber(redis.call('HGET', KEYS[1], 'Quota'))
if quota == nil or quota < tonumber(ARGV[1]) then
  return 0                                    -- 余额不足
end
redis.call('HINCRBY', KEYS[1], 'Quota', -tonumber(ARGV[1]))
return 1`

Redis 不可用时降级为 DB 条件更新 UPDATE ... SET quota = quota - ? WHERE quota >= ?——条件写天然防透支。配合第 3 篇的 token 缓存突变 fence(元数据变更先抬 fence 再删缓存),计数与元数据两类的并发安全各自有专门机制

设计三:批量落库——5 秒聚合一次写

高频计数(用户额度、令牌额度、已用额度、渠道用量、请求计数共 5 类)走批量更新:

// model/utils.go:16-63(节选)
const (
    BatchUpdateTypeUserQuota = iota
    BatchUpdateTypeTokenQuota
    BatchUpdateTypeUsedQuota
    BatchUpdateTypeChannelUsedQuota
    BatchUpdateTypeRequestCount
    BatchUpdateTypeCount
)
var batchUpdateStores []map[int]int
var batchUpdateLocks []sync.Mutex

func InitBatchUpdater() {
    gopool.Go(func() {
        for {
            time.Sleep(time.Duration(common.BatchUpdateInterval) * time.Second) // 默认 5s
            batchUpdate()
        }
    })
}

内存 map 累加、定期刷库,用户三表合并为一条 UPDATE。代价:崩溃可能丢最多 5 秒的计数——对计数类数据是正确取舍。优雅关机时 SaveQuotaDataCache 兜底落库。

设计四:过载保护

common/system_monitor.go 每 5 秒采样 CPU/内存/磁盘,middleware.SystemPerformanceCheck 在 relay 路由入口检查——超阈值快速返回 503。宁可快速失败,不让雪崩拖垮;进程内协程统一走 bytedance gopool,防 goroutine 失控。观测数据与保护逻辑共用一套采集(第 9 篇)。

多机部署参数

共享:SQL_DSN + REDIS_CONN_STRING(数据面共享)
独立:NODE_TYPE=slave(从节点不做 option 迁移)、NODE_NAME
同步:SYNC_FREQUENCY 控制配置/渠道缓存刷新节拍
批量:BATCH_UPDATE_ENABLED 各节点独立落库(计数最终一致)

与其他模块的联系

  • ← 鉴权(第 3 篇):token/user 缓存与 fence 的消费方;额度预扣的原子保障;
  • ← 选路(第 7 篇):渠道缓存索引是选路算法的输入;熔断写回渠道状态、下轮同步生效;
  • ← 计费(第 8 篇):预扣 Lua、批量落库全为其服务;
  • ← 日志(第 9 篇):系统监控数据供过载保护;日志库独立部署属于本篇的部署话题。

常见踩坑

  1. 多机只共享 DB 不开 Redis——额度预扣退化 DB 条件更新、限流退化为单节点内存,性能骤降;
  2. SYNC_FREQUENCY 太长——改了倍率/渠道其他节点迟迟不生效;
  3. 批量更新期间强一致读——计数类数据是最终一致,别拿它做实时扣费判断(扣费走 Lua 原子路径);
  4. 崩溃丢 5 秒计数——接受它;真要强一致就关批量更新并承受写压力。

随堂练习(带验收标准)

  1. 单机(无 Redis)跑一个压测基线(如 hey -c 50 -n 500);再开 Redis 对比。验收:能说出哪类操作耗时差异最大并解释;
  2. 开启 BATCH_UPDATE_ENABLED,压测期间连续查 DB 用户额度。验收:观察到额度 5 秒一跳的批量落库特征;
  3. 并发实验:两个进程同时对同一令牌预扣,用 Lua 版与”读-改-写”版各跑一遍。验收:后者出现超额扣减(负余额),前者不会——亲手验证 Lua 的必要性;
  4. model/channel_cache.goInitChannelCachechannelSyncLock,回答:重建期间读请求用的是旧索引还是新索引?为什么?

← 返回文章列表