one-api/new-api是一款开源的OpenAI 接口管理 & 分发系统,支持OpenAI、Anthropic、Gemini等多种模型。
这套系统,从使用者的角度来看,用起来倒还好,UI很简洁;从开发者的角度来看,作者挺不容易的要用Go来处理各种奇奇怪怪的序列化问题,一旦请求数量过多并发就无法提升,除此之外也有很多奇奇怪怪的地方啦……
我的OpenAI接口转发站 「头顶冒火」 就是在这个基础之上搭建的。只不过经过了我的大量修改,比如加个数据看板这种前端的功能
以及默默的看不见的后台优化。经过我的一番调教之后,目前在Hetzner 2C 2G的机器上已经能达到几百的QPS了。当然实际使用,还得看真正的API能够提供多少TPM和RPM。
在高并发的情况下,本来数据库的压力就比较大,如果还开启了日志记录功能,那么数据库就会被大量的日志写入直接撑爆;另外,即使在非高负载的情况下,在日志条目比较多的时候,查看每一页日志的请求速度也会很慢。
优化并发最简单、并且大概率好用的办法之一是用更高配置的机器。但是一般来说,提升到一定配置之后,再提升基本没帮助,也就是陷入边际效应递减。而且对于我这种穷人,自然只能靠变异优化了。
本文将介绍如何使用 ClickHouse替换原有的日志系统,避免在高并发的时候清空恶化并降低性能。至于其他方面的优化,等以后再说~
one-api的数据库
one-api默认支持三种数据库,SQLite,MySQL和PostgreSQL。
- SQLite:嵌入式的数据库,只有一个文件。在高并发下,即使允许多线程读写,其性能也不会好到哪里去。
- MySQL:知名的RDBMS
- PostgreSQL:PostgreSQL应该有更丰富的功能和特性。
如果你实在懒得自己优化,直接换成 PostgreSQL+高配置的机器吧,PostgreSQL的性能应该会好过MySQL一些。
one-api的日志系统:写入
One-api的普通数据和日志默认情况下均记录在同一个数据库之中,其Go Struct Model 如下
type Log struct { Id int `json:"id"` UserId int `json:"user_id" gorm:"index"` CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_type"` Type int `json:"type" gorm:"index:idx_created_at_type"` Content string `json:"content"` Username string `json:"username" gorm:"index:index_username_model_name,priority:2;default:''"` TokenName string `json:"token_name" gorm:"index;default:''"` ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"` Quota int `json:"quota" gorm:"default:0"` PromptTokens int `json:"prompt_tokens" gorm:"default:0"` CompletionTokens int `json:"completion_tokens" gorm:"default:0"` ChannelId int `json:"channel" gorm:"index"` }
默认配置下,每一次请求,都会调用 RecordConsumeLog
写入日志,其代码非常简单
log := &Log{ UserId: userId, Username: GetUsernameById(userId), CreatedAt: helper.GetTimestamp(), Type: LogTypeConsume, Content: content, PromptTokens: promptTokens, CompletionTokens: completionTokens, TokenName: tokenName, ModelName: modelName, Quota: int(quota), ChannelId: channelId, } err := LOG_DB.Create(log).Error
逐条写入日志
在请求数量比较多的情况下,这相当于是一条一条的执行 insert 语句。这种情况下主要有以下缺点:
- 网络延迟:每次操作都有往返的RTT、通信开销
- 事务开销:每次操作都要开启和关闭事务
- 负载:频繁插入会导致CPU和IO负载增加
即使你很有钱,用到了每个月几百几千美元配置和价格奇高的Managed database,1和2的问题还是无法避免
insert_many优化
使用 insert_many,积攒一定的数据之后一起插入,这是一种非常简单的优化思路。这种办法也有些缺点:
- 要等数据积攒到一定程度,会有一定的延迟
- 积攒数据到时候可能会有内存消耗过多的风险
- 可能会有更高的失败概率,一次性插入数据过多,可能会有
max_allowed_packet
之类的问题
优点很明显,一次性写入数据可比一条一条写要快上很多倍了。
one-api自带了一个类似的功能,环境变量BATCH_UPDATE_ENABLED
和 BATCH_UPDATE_INTERVAL
。不过截止到我写本文的时候,写日志的这部分功能不包含在batchUpdate
内…而是在一个go routine内执行的…
更要命的是,这个batchUpdate
从设计上来看,只能聚合数值类型的数据😂
func addNewRecord(type_ int, id int, value int64) { batchUpdateLocks[type_].Lock() defer batchUpdateLocks[type_].Unlock() if _, ok := batchUpdateStores[type_][id]; !ok { batchUpdateStores[type_][id] = value } else { batchUpdateStores[type_][id] += value } }
改造一下当然可行,攒够1000条或到一定时间间隔一起写入数据库,难度不高,ChatGPT可以帮忙。
one-api的日志系统:读取
假如日志很少,那么读取日志并不会造成什么大问题。
但是如果数据量很大,并且还用户点击了下一页,甚至是最后一页进行了分页操作,那么 OFFSET
和LIMIT
会显著影响数据库的性能。
MySQL 在执行 OFFSET
时,必须从头开始扫描并丢弃前 OFFSET
条记录,然后返回 LIMIT
条记录,导致较大的时间开销,尤其是 OFFSET
数值较大时。
优化方法应该是有一些的,比如通过主键而不是OFFSET
,具体可以问问ChatGPT,我不是很愿意进行这个方向的研究😂
ClickHouse介绍
ClickHouse是yandex开发的一个开源列式数据库管理系统,专门为高性能的分析查询而设计,在处理大量数据时有出色的表现。
ClickHouse采用列式存储,不像MySQL等是行式存储,特别适合存储日志。在 WebP Cloud Services 中我们的统计信息来源便是 ClickHouse
更重要的是,ClickHouse的查询语句和MySQL、PostgreSQL等几乎没什么差别,基本上改改就能用,学习曲线很平缓。
ClickHouse创建数据库表
根据上面的Go Struct和已有的数据结构,改改基本上就可以了。为了保持一致,created_at
就用数字时间戳了,虽然使用 datetime
是更好的办法。
CREATE TABLE logs ( user_id Nullable(Int64), created_at Int64 DEFAULT toUnixTimestamp(now()), type Nullable(Int64), content Nullable(String), username String DEFAULT '', token_name String DEFAULT '', model_name String DEFAULT '', quota Int64 DEFAULT 0, prompt_tokens Int64 DEFAULT 0, completion_tokens Int64 DEFAULT 0, ip Nullable(String), user_agent Nullable(String) ) ENGINE = MergeTree() ORDER BY (created_at, username, token_name, model_name);
连接到ClickHouse
使用官方的 github.com/ClickHouse/clickhouse-go
var ch, err = clickhouse.Open(&clickhouse.Options{ Addr: []string{"clickhouse:9000"}, Auth: clickhouse.Auth{ Database: "openai", Username: "default", Password: "", }, })
写入数据
query := `INSERT INTO logs ( user_id, created_at, type, content, username, token_name, model_name,quota, prompt_tokens, completion_tokens, ip, user_agent)` ch.Exec(ctx, query, 1, timestamp, ...)
批量写入数据
Clickhouse最厉害的地方之一是可以批量写入数据,官网上是这么说的:
Generally, we recommend inserting data in fairly large batches of at least 1,000 rows at a time, and ideally between 10,000 to 100,000 rows.
你看他们多自信,让你攒够了再写,一次写几万没问题
创建batch
在Go里批量写入,要先创建batch
var batch, err = ch.PrepareBatch(ctx, `INSERT INTO logs ( user_id, created_at, type, content, username, token_name, model_name, quota, prompt_tokens, completion_tokens, ip, user_agent) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
追加数据
然后往这个 batch里疯狂append
数据,也就是在原来的RecordConsumeLog
中追加数据到这个batch
batch.Append(userId, helper.GetTimestamp(), LogTypeConsume, content, username, tokenName, modelName, quota, promptTokens, completionTokens, ip, ua, )
提交
时间差不多了,或者数据足够了,提交
batch.Send()
定时器
定时器可以用ticker
func ClickHouseTicker() { var ticker = time.NewTicker(time.Second * 60) for { select { case <-ticker.C: FlushLog() } } }
在 main里 go ClickHouseTicker()
即可
清空batch
写完数据之后,然后清空batch,不能越写越多
batch=createBatch()
程序退出清理工作
最后,要捕获 SIGINT和SIGTERM信号,不能重启程序的丢日志啊,这个时候就要用到IIFE+goroutine了
sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT) go func() { for { sig := <-sigChan switch sig { case syscall.SIGINT, syscall.SIGTERM: logger.SysLog("Cleaning up...") model.FlushLog() model.ConsolidateConsumeQuota() time.Sleep(time.Second * 2) syscall.Exit(0) } } }()
读数据
查询语法基本和MySQL一样,ch.Query(ctx, query, params…)
就行,order by,offset,limit,count等都有
删除日志
日志总得删掉,要不然磁盘早晚会被撑爆。用类似如下语句就行,最后的 max_execution_time
可选,避免日志真的太多删除也很费时间
DELETE FROM logs WHERE type = 2 AND created_at < timestamp SETTINGS max_execution_time=3600
数据分析
通过 Grafana + Clickhouse作为数据源,可以直接画图表。之前 QPS 的数据是写入到 influxdb的,一旦超过7天查询就很慢,现在我单独把本应写到 influxdb的数据写到了一个名为 stats 的ClickHouse表,只保存关键的信息,如请求时间,模型名称等。
比如看看大家用得最多的模型,最多的竟然是mini,然后是4o;但是消费是反过来的,因为4o的价格比较贵,mini比较便宜
查询性能能提升
简单的执行三个查询,一共有354万的数据。主要测一下大量翻页,可以看出即使最差的select *
的情况下,ClickHouse大概也有4-5倍的性能提升。
查询 | MySQL | ClickHouse | 比率 |
select count(*) from openai.logs; | 2.73 | 0.002 | 1365.00 |
select user_id from openai.logs limit 3 offset 3000000; | 4.19 | 0.022 | 190.45 |
select * from openai.logs limit 3 offset 3000000; | 12.16 | 2.778 | 4.38 |
mysql> select count(*) from openai.logs; +----------+ | count(*) | +----------+ | 3540771 | +----------+ 1 row in set (2.73 sec) mysql> select user_id from openai.logs limit 3 offset 3000000; .... 3 rows in set (4.19 sec) mysql> select * from openai.logs limit 3 offset 3000000; .... | 3 rows in set (12.16 sec) c387e15ec5c6 :) select count(*) from openai.logs; SELECT count(*) FROM openai.logs Query id: 9382c843-927d-471f-8f6c-85a049d58294 ┌─count()─┐ 1. │ 3540771 │ -- 3.54 million └─────────┘ 1 row in set. Elapsed: 0.002 sec. c387e15ec5c6 :) select user_id from openai.logs limit 3 offset 3000000; SELECT user_id FROM openai.logs LIMIT 3000000, 3 Query id: 66d0ea52-6b30-4a73-b313-fc45bdcbaaf2 .... 3 rows in set. Elapsed: 0.022 sec. Processed 3.29 million rows, 29.64 MB (146.90 million rows/s., 1.32 GB/s.) Peak memory usage: 4.26 MiB. c387e15ec5c6 :) select * from openai.logs limit 3 offset 3000000; SELECT * FROM openai.logs LIMIT 3000000, 3 Query id: 0faf62f4-5203-434a-8b3d-d1bcf032a8d0 .... 3 rows in set. Elapsed: 2.778 sec. Processed 3.01 million rows, 9.26 GB (1.08 million rows/s., 3.33 GB/s.) Peak memory usage: 258.79 MiB.
最后
那当然是要自我宣传一下啦!
🌟🌟🌟欢迎注册并使用 头顶冒火 OpenAI 接口转发站,稳定、快速、高并发,注册即赠送 $0.5 测试额度 🌟🌟🌟