登录
  • 人们都希望被别人需要 却往往事与愿违
  • 简单是可靠的先决条件。Simplicity is prerequisite for reliability.@Edsger Dijkstra (图灵奖得主)

one-api/new-api性能优化:使用 ClickHouse 作为日志系统

编程 Benny小土豆 2397次浏览 6859字 1个评论
文章目录[显示]

one-api/new-api是一款开源的OpenAI 接口管理 & 分发系统,支持OpenAI、Anthropic、Gemini等多种模型。

这套系统,从使用者的角度来看,用起来倒还好,UI很简洁;从开发者的角度来看,作者挺不容易的要用Go来处理各种奇奇怪怪的序列化问题,一旦请求数量过多并发就无法提升,除此之外也有很多奇奇怪怪的地方啦……


我的OpenAI接口转发站 「头顶冒火」 就是在这个基础之上搭建的。只不过经过了我的大量修改,比如加个数据看板这种前端的功能

one-api/new-api性能优化:使用 ClickHouse 作为日志系统

以及默默的看不见的后台优化。经过我的一番调教之后,目前在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 语句。这种情况下主要有以下缺点:

  1. 网络延迟:每次操作都有往返的RTT、通信开销
  2. 事务开销:每次操作都要开启和关闭事务
  3. 负载:频繁插入会导致CPU和IO负载增加

即使你很有钱,用到了每个月几百几千美元配置和价格奇高的Managed database,1和2的问题还是无法避免

insert_many优化

使用 insert_many,积攒一定的数据之后一起插入,这是一种非常简单的优化思路。这种办法也有些缺点:

  1. 要等数据积攒到一定程度,会有一定的延迟
  2. 积攒数据到时候可能会有内存消耗过多的风险
  3. 可能会有更高的失败概率,一次性插入数据过多,可能会有 max_allowed_packet 之类的问题

优点很明显,一次性写入数据可比一条一条写要快上很多倍了。

one-api自带了一个类似的功能,环境变量BATCH_UPDATE_ENABLEDBATCH_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的日志系统:读取

假如日志很少,那么读取日志并不会造成什么大问题。

但是如果数据量很大,并且还用户点击了下一页,甚至是最后一页进行了分页操作,那么 OFFSETLIMIT 会显著影响数据库的性能。

MySQL 在执行 OFFSET 时,必须从头开始扫描并丢弃前 OFFSET 条记录,然后返回 LIMIT 条记录,导致较大的时间开销,尤其是 OFFSET 数值较大时。

优化方法应该是有一些的,比如通过主键而不是OFFSET ,具体可以问问ChatGPT,我不是很愿意进行这个方向的研究😂

ClickHouse介绍

ClickHouse是yandex开发的一个开源列式数据库管理系统,专门为高性能的分析查询而设计,在处理大量数据时有出色的表现。

ClickHouse采用列式存储,不像MySQL等是行式存储,特别适合存储日志。在 WebP Cloud Services 中我们的统计信息来源便是 ClickHouse

one-api/new-api性能优化:使用 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比较便宜

one-api/new-api性能优化:使用 ClickHouse 作为日志系统

查询性能能提升

简单的执行三个查询,一共有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 测试额度 🌟🌟🌟


文章版权归原作者所有丨本站默认采用CC-BY-NC-SA 4.0协议进行授权|
转载必须包含本声明,并以超链接形式注明原作者和本文原始地址:
https://dmesg.app/one-api-clickhouse.html
喜欢 (3)
分享:-)
关于作者:
If you have any further questions, feel free to contact me in English or Chinese.
发表我的评论
取消评论

                     

去你妹的实名制!

  • 昵称 (必填)
  • 邮箱 (必填,不要邮件提醒可以随便写)
  • 网址 (选填)
(1)个小伙伴在吐槽
  1. 在 xai 眼里, 皆为屎上雕花, 顶层设计缺陷, 下面再修改都没啥卵用 2核2G 起码要2万QPS 才行, 区区几百...
    xai2024-09-22 08:24 回复