缓存与性能工程
专栏:new-api 源码拆解 · 第 10 / 12 篇:::info 学习目标 完成本篇后你能够:画出 new-api 的缓存体系全景并说明每级缓存的失效策略;解释 Lua 原子预扣与突变 fence 两个并发设计的动机;配置多机部署的同步参数。 前置:第 3、8 篇完成。预计时长:60 分钟。 :::
:::note 本章术语速查(新手建议先读)
- Lua 脚本:Redis 支持的一段小程序,把”读-判断-写”三步合成原子操作,中间不会被插队。
- 原子操作:要么完整执行、要么不执行,执行中不会被其他请求打断。
- 全量重建:缓存不逐条更新,而是定期从数据库重新整体构建——简单且不易出错。
- 批量落库:把高频写入先在内存累计,每 5 秒合并写一次数据库。
- 最终一致:数据短暂不同步但最终会一致——计数类数据可接受,扣费类不行。
- 过载保护:系统太忙时直接拒绝新请求(503),保住已有请求不雪崩。 :::
网关的每个请求都要:查 token、查用户、选渠道、扣额度、写日志——全是热路径读写,直接打 DB 既慢又贵。new-api 的性能工程就是围绕这些操作铺开的一套缓存与批量策略,分三级:进程内内存 → Redis → DB。
缓存体系全景
开关关系:REDIS_CONN_STRING 配置 Redis 后强制开启 MemoryCacheEnabled(main.go
设计一:渠道缓存的全量重建 + 读写锁
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 篇):系统监控数据供过载保护;日志库独立部署属于本篇的部署话题。
常见踩坑
- 多机只共享 DB 不开 Redis——额度预扣退化 DB 条件更新、限流退化为单节点内存,性能骤降;
- SYNC_FREQUENCY 太长——改了倍率/渠道其他节点迟迟不生效;
- 批量更新期间强一致读——计数类数据是最终一致,别拿它做实时扣费判断(扣费走 Lua 原子路径);
- 崩溃丢 5 秒计数——接受它;真要强一致就关批量更新并承受写压力。
随堂练习(带验收标准)
- 单机(无 Redis)跑一个压测基线(如
hey -c 50 -n 500);再开 Redis 对比。验收:能说出哪类操作耗时差异最大并解释; - 开启
BATCH_UPDATE_ENABLED,压测期间连续查 DB 用户额度。验收:观察到额度 5 秒一跳的批量落库特征; - 并发实验:两个进程同时对同一令牌预扣,用 Lua 版与”读-改-写”版各跑一遍。验收:后者出现超额扣减(负余额),前者不会——亲手验证 Lua 的必要性;
- 读
model/channel_cache.go的InitChannelCache与channelSyncLock,回答:重建期间读请求用的是旧索引还是新索引?为什么?