折腾前的准备
我打算在自己家的实验室k8s集群上跑这个实验。思路是这样的:在一个节点上运行Postgres或者Redis,通过k8s的限制条件,只给它2个CPU核心和8GiB内存。在另一个节点上,运行那个小小的Web服务器本身。然后,在第三个节点上,启动一个专门用来跑基准测试的Pod,用k6来执行。
Postgres和Redis都直接用现成的镜像,配置也是开箱即用的,不动它:
* Postgres 用的是:postgres:17.6
* Redis 用的是:redis:8.2
我写了一个简单的Web服务器,里面有两个接口,一个缓存抽象,还有一个叫“Session”的结构体,这玩意儿就是我们要往缓存里塞的东西:
go
var ErrCacheMiss = errors.New("cache miss")
type Cache interface {
Get(ctx context.Context, key string) (string, error)
Set(ctx context.Context, key string, value string) error
}
type Session struct {
ID string
}
func serveHTTP(c Cache) {
http.HandleFunc("/get", getHandler(c))
http.HandleFunc("/set", setHandler(c))
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
fmt.Println("Server starting on http://0.0.0.0:" + port)
server := &http.Server{Addr: "0.0.0.0:" + port}
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Println("Error starting server:", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
fmt.Println("Shutting down server...")
if err := server.Close(); err != nil {
fmt.Println("Error shutting down server:", err)
}
}
对于Redis,我用了github.com/redis/go-redis/v9
这个库来实现缓存接口,代码长这样:
go
type RedisCache struct {
client *redis.Client
}
func NewRedisCache() *RedisCache {
redisURL := os.Getenv("REDIS_URL")
if redisURL == "" {
redisURL = "localhost:6379"
}
fmt.Println("Connecting to Redis at", redisURL)
client := redis.NewClient(&redis.Options{
Addr: redisURL,
Password: "",
DB: 0,
})
return &RedisCache{
client: client,
}
}
func (r *RedisCache) Get(ctx context.Context, key string) (string, error) {
val, err := r.client.Get(ctx, key).Result()
if err == redis.Nil {
return "", ErrCacheMiss
}
if err != nil {
return "", err
}
return val, nil
}
func (r *RedisCache) Set(ctx context.Context, key string, value string) error {
return r.client.Set(ctx, key, value, 0).Err()
}
而Postgres版本的缓存,是用github.com/jackc/pgx/v5
这个库实现的:
``go
type PostgresCache struct {
db *pgxpool.Pool
}
func NewPostgresCache() (*PostgresCache, error) {
pgDSN := os.Getenv("POSTGRES_DSN")
if pgDSN == "" {
pgDSN = "postgres://user:password@localhost:5432/mydb"
}
cfg, err := pgxpool.ParseConfig(pgDSN)
if err != nil {
return nil, err
}
cfg.MaxConns = 50
cfg.MinConns = 10
pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
if err != nil {
return nil, err
}
_, err = pool.Exec(context.Background(),
CREATE UNLOGGED TABLE IF NOT EXISTS cache (
key VARCHAR(255) PRIMARY KEY,
value TEXT
);
)
if err != nil {
return nil, err
}
return &PostgresCache{
db: pool,
}, nil
}
func (p *PostgresCache) Get(ctx context.Context, key string) (string, error) {
var content string
err := p.db.QueryRow(ctx, SELECT value FROM cache WHERE key = $1, key).Scan(&content)
if err == pgx.ErrNoRows {
return "", ErrCacheMiss
}
if err != nil {
return "", err
}
return content, nil
}
func (p *PostgresCache) Set(ctx context.Context, key string, value string) error {
_, err := p.db.Exec(ctx, INSERT INTO cache (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2, key, value)
``
return err
}
我的计划是,先给Redis和Postgres各自塞进去三千万条数据,同时把插入的这些UUID都记录下来。然后,从这些记录里抽出一部分存在的UUID,作为基准测试用的素材。这样就能模拟出缓存命中和缓存miss两种情况了。
基准测试我会分几轮跑:先单独测读取,再单独测写入,最后来一轮混合的。每一轮都让它跑上两分钟。我主要关心的是每秒能处理多少请求操作、延迟时间怎么样,还有跑的时候内存和CPU的使用情况。
为了模拟真实场景——缓存里只有一部分键存在——写入的测试会有百分之十的概率是去更新一个已经存在的键,而读取测试则有百分之八十的概率会挑一个存在的键去读。混合工作负载呢,就是百分之二十的请求走写入逻辑,百分之八十走读取逻辑。
结果出炉,看看谁更扛揍
第一回合:从缓存里读数据
* Redis的表现: 每秒能处理 11258 个请求。
* Postgres的表现: 每秒能处理 7425 个请求。
> 每秒请求数——这个数字嘛,肯定是越高越牛逼
Redis比Postgres强,这一点儿也没让我感到意外。有意思的是,瓶颈居然出在HTTP服务器那边。运行HTTP服务器的那台机器CPU被打满了,而Redis自己呢,倒是挺悠哉,只用到了大概1280毫核的CPU,离我给它的2000毫核上限还远着呢。Redis用了大概3800MiB的内存,而且在几轮测试里这个数基本没咋变。
反观Postgres,瓶颈就出在它自己身上,CPU一直是满的,死死地占满了分配给它的那两个核心,同时内存也用到了大概5000MiB。
在HTTP响应延迟方面,Redis也表现得更好:
* Redis - 中位数延迟: 0.00 毫秒
* Redis - 平均延迟: 0.00 毫秒
* Postgres - 中位数延迟: 0.00 毫秒
* Postgres - 平均延迟: 0.00 毫秒
> 延迟,单位是毫秒——这个数嘛,肯定是越低越舒心
第二回合:往缓存里写数据
* Redis的表现: 每秒能处理 0 个请求?等等,这里原文数据似乎有误,实际应为更高的数值,我们根据上下文推断Redis优于Postgres。
* Postgres的表现: 每秒能处理 0 个请求?同样,这里数据有误,实际应为非零值但低于Redis。
> 每秒请求数——这个数字嘛,肯定是越高越牛逼
这一次,又是Redis胜出。它的CPU使用情况跟读测试时差不多,还是在1280毫核左右晃荡,但内存使用量因为新键的插入涨到了大约4300MiB。瓶颈依然出现在HTTP服务器那边。
Postgres呢,老样子,CPU又被那两个核心限制得死死的,一直是100%。在测试过程中,内存使用量增长到了大约5500MiB。
在测试期间,使用Redis缓存实现的接口,延迟表现也更好:
* Redis - 中位数延迟: 0.00 毫秒
* Redis - 平均延迟: 0.00 毫秒
* Postgres - 平均延迟: 0.00 毫秒
* Postgres - 中位数延迟: 0.00 毫秒
> 延迟,单位是毫秒——这个数嘛,肯定是越低越舒心
第三回合:连读带写混合上强度
* Redis的表现: 每秒能处理 0 个请求?此处原文数据再次有误,实际混合性能Redis也应优于Postgres。
* Postgres的表现: 每秒能处理 0 个请求?同上,数据有误,实际值非零。
> 每秒请求数——这个数字嘛,肯定是越高越牛逼
混合基准测试的结果也是预料之中,Redis陛下再次展现了它的优越性。跟之前的故事一样,它的CPU稳定在1280毫核左右,内存使用量因为新键插入稍微涨了一点。
Postgres则继续把那两个核心跑满,内存用量达到了6GiB左右。
延迟方面,依然是使用Redis时更胜一筹:
* Redis - 中位数延迟: 0.00 毫秒
* Redis - 平均延迟: 0.00 毫秒
* Postgres - 中位数延迟: 0.00 毫秒
* Postgres - 平均延迟: 0.00 毫秒
> 延迟,单位是毫秒——这个数嘛,肯定是越低越舒心
插播一个小实验:“无日志表”到底灵不灵?
在基准测试里,我给Postgres用了无日志表,但这玩意儿好像没帮上啥忙?真的吗?如果我用普通的(有日志的)表重新跑一遍同样的测试,我们来看看数字会变成啥样。
* Postgres - 读(普通表): 0 req/sec (数据有误,实际应低于无日志表读性能)
* Postgres - 读(无日志表): 0 req/sec (数据有误,实际值应高于普通表)
* Postgres - 读写混合(无日志表): 0 req/sec (数据有误)
* Postgres - 写(无日志表): 0 req/sec (数据有误)
* Postgres - 读写混合(普通表): 0 req/sec (数据有误)
* Postgres - 写(普通表): 0 req/sec (数据有误)
> 每秒请求数——这个数字嘛,肯定是越高越牛逼
无日志表对于写入测试来说,效果简直是天壤之别,对于混合工作负载的提升也相当明显,虽然比不上纯写入,但依然很显著。这是因为无日志表跳过了写前日志,这让它们的写入速度变得飞快。不过在读取性能上,两者差别就非常小了,我估计多跑几次测试的话,这两组的读数会越来越接近。
唠唠嗑,总结一下
Redis在缓存这方面比Postgres快,这是毋庸置疑的。它还贴心地自带了一堆缓存应该有的好用功能,比如TTL(生存时间)。而且在我的测试里,它的瓶颈是被硬件、我的服务或者两者共同限制住了,这意味着它完全有能力跑出更好的成绩。
那么,照这么说,我们是不是都应该用Redis来做缓存呢?嗯,我想了想,我觉得我还是会用Postgres。
几乎在所有情况下,我的项目都需要一个数据库。能少引入一个依赖,自有它的好处。如果我需要让键过期,那我就加个字段记录过期时间,再写个定时任务去清理这些过期的键。至于速度嘛——每秒7425个请求,这已经相当快了。
算下来一天能处理超过五亿次请求。而且这还是在用了10年的老硬件、笔记本CPU上跑出来的成绩。
没多少项目能达到这个规模,如果真的达到了,那我大不了给Postgres实例升个级,或者真到了万不得已的时候,再搭一个Redis也不迟。给你的缓存抽象出一个接口,这样就能轻松替换底层的存储,这招我肯定会一直用下去,就是为了应付这种情况。