Redis是快——但我要把缓存塞进Postgres里

网上有很多书和文章,比如这一篇,都在鼓吹用Postgres搞定一切。我想呢,不如就挑一个实际用例来看看——用Postgres代替Redis做缓存。我平时跟API打交道不少,所以就打算写一个超级简单的HTTP服务器,让它从缓存里读数据返回。我准备从Redis开始,毕竟这玩意儿工作上常见,然后把它换成Postgres,用上“无日志表”,看看会不会有啥不一样。

折腾前的准备
我打算在自己家的实验室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也不迟。给你的缓存抽象出一个接口,这样就能轻松替换底层的存储,这招我肯定会一直用下去,就是为了应付这种情况。