Postgres缓存实战

222 阅读8分钟

Postgres缓存实战

参考链接

前言:

在初创公司中简化技术栈、减少组件、加快开发速度、降低风险并提供更多功能特性的方法之一就是 “一切皆用 Postgres” 。Postgres 能够取代许多后端技术,包括 Kafka、RabbitMQ、ElasticSearch,Mongo和 Redis ,至少到数百万用户时都毫无问题。

  • 使用 Postgres 替代 Redis 作为缓存,使用 UNLOGGED Table 并用 TEXT 类型存储 JSON 数据,并使用存储过程来添加并强制执行过期时间,正如 Redis 所做的那样。
  • 使用 Postgres 作为消息队列,采用 SKIP LOCKED来代替Kafka(如果你只需要消息队列的能力)。
  • 使用加装了 TimescaleDB扩展的 Postgres 作为数据仓库。
  • 使用 PostgreSQL 的 JSONB 类型来存储、索引、搜索 JSON 文档,从而替代 MongoDB。
  • 使用加装 pg_cron 扩展的 Postgres 作为定时任务守护程序,在特定时间执行特定任务,例如发送邮件,或向消息队列中添加事件。
  • 使用 Postgres + PostGIS 执行 地理空间查询
  • 使用 Postgres 进行全文搜索,加装 ParadeDB 替代 ElasticSearch。
  • 使用 Postgres 在数据库中生成JSON,免去服务器端代码编写,直接供 API 使用。
  • 使用 GraphQL适配器,也可以让 PostgreSQL 提供 GraphQL 服务。

我已明言,一切皆可Postgres

PostgreSQL 在9.1 中推出了一种特殊的表:UNLOGGED TABLE,使用UNLOGGED TABLE 最大的特点是涉及到表的更新,删除等操作不会记录WAL 日志,这样可以大大的提高性能。

Postgres的可玩性太多,本文我们主要来探讨Postgres使用UNLOGGED TABLE实现实现缓存服务功能。

传统的缓存服务特性

  • 过期-能够设置缓存数据的过期时间,使缓存不会存储过时的信息
  • 内存回收-在缓存已满时删除不常用的
  • 缓存失效-在数据更改时覆盖数据
  • 性能-使用缓存的主要原因是避免数据库查询缓慢。与缓存相比,SQL数据库的写入速度通常也较慢
  • 非持久性-缓存服务通常具有有限的或没有持久性的
  • 键值存储

如果我们要使用Postgres来实现缓存服务,那么也必定要具备以上传统缓存服务的特性。并且使用Potgres作为缓存服务时,相比其他第三方组件时也有如下优点:

  • 熟悉的界面-使用SQL和常见的PostgreSQL客户端库,使其更容易集成到应用程序中
  • 成本-无需设置和维护其他服务。这降低了运营成本。您也不需要Redis/Memcached/。。。维护专家

UNLOGGED TABLE 特性

  1. UNLOGGED TABLE 不会记录WAL日志
  2. UNLOGGED TABLE 在集群环境下,只会在主节点上有数据,不会复制到备节点;即不支持分布式缓存(写入速度快,备库无数据,只有结构。)
  3. 当数据库crash后,数据库重启时自动清空unlogged table的数据。
  4. 正常关闭数据库,再启动时,unlogged table有数据。
  5. temporary tables are cached in process private memory, governed by the temp_buffers parameter, while unlogged tables are cached in shared_buffers

UNLGGED TABLE 缓存应用实战

创建缓存表

使用UNLOGGED TABLE作为缓存表

CREATE UNLOGGED TABLE cache (
    id serial PRIMARY KEY,
    key text UNIQUE NOT NULL,
    value jsonb,
    inserted_at timestamp);
​
CREATE INDEX idx_cache_key ON cache (key);

和普通表创建唯一不同的就是多了UNLOGGED关键字;这里使用jsonb类型来存储数据,根据适合自己的场景你也可以使用textvarchar或者hstore等类型。这里的inserted_at字段主要的作用是用户缓存的过期校验。最后我们也创建了一个索引来提高查询的性能。

缓存管理(过期、失效、回收)

如上所述,我们期望缓存服务的一个特性是能够使记录过期。要在PostgreSQL中做到这一点,我们可以创建一个定期删除旧行的存储过程:

CREATE OR REPLACE PROCEDURE expire_rows (retention_period INTERVAL) AS
$$
BEGIN
    DELETE FROM cache
    WHERE inserted_at < NOW() - retention_period;
​
    COMMIT;
END;
$$ LANGUAGE plpgsql;
​
CALL expire_rows('60 minutes'); -- 删除插入时间距离当前时间超过1小时的记录

我们需要定期的去调用这个存储过程,使得过期记录及时被删除掉。我们可以使用Postgres的pg_cron扩展,需要在操作系统层面安装这个插件(参考链接),然后再数据库中创建这个扩展。Docker安装

安装完成之后(CREATE EXTENSION pg_cron;),可以使用如下命令定期调用存储过程:

-- Create a schedule to run the procedure every hour
SELECT cron.schedule('0 * * * *', $$CALL expire_rows('1 hour');$$);
​
-- List all scheduled jobs
SELECT * FROM cron.job;

如果你没有安装这个扩展,你也可以选择创建一个触发器,在每次插入数据的时候检测过期记录并删除。(不推荐)

CREATE OR REPLACE FUNCTION expire_rows_func (retention_hours integer) RETURNS void AS
$$
BEGIN
    DELETE FROM cache
    WHERE inserted_at < NOW() - (retention_hours || ' hours')::interval;
END;
$$ LANGUAGE plpgsql;
​
CREATE OR REPLACE FUNCTION expire_rows_func_trigger() RETURNS trigger AS
$$
BEGIN
    PERFORM expire_rows_func (1);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
​
CREATE TRIGGER cache_cleanup_trigger
    AFTER INSERT ON cache
    FOR EACH ROW
    EXECUTE FUNCTION expire_rows_func_trigger();

当然,实际的过期/清除时间取决于你的数据和用例。

这是过期,但内存回收(删除旧数据为新记录腾出空间)怎么办?

由于到过期应该会降低大小,我认为内存回收是可选的,但我们也可以实现这一点——我们可以添加last_read时间戳列,该列将在每次读取时更新。然后,我们可以每隔一段时间运行一次存储过程来清理最近未使用的行,从而为我们提供一个LRU缓存。您可以决定是否值得在每次读取时更新行。

这样,我们创建了一个简单的缓存,它具有快速写入、快速读取、键值存储、比传统缓存服务更好的持久性、缓存过期、清理和失效,而无需部署另一个(昂贵的)服务。

性能

到目前为止,我主要只提到使用PostgreSQL作为缓存有多好,但显然也有缺点。其中之一是性能,这将比专门构建的优化缓存服务(略)差。更糟糕的是,这取决于您的数据和使用模式(读重或写重操作、数据大小、查询类型等)。将UNLOGGED表与Memcached或Redis进行基准测试和比较超出了本文的范围,但如果您想测试性能(并且应该),您可以从生成一些数据开始:

INSERT INTO cache (key, value, inserted_at)
VALUES
    ('key1', '{"field1": "value1", "field2": "value2"}', NOW() - INTERVAL '1 hour'),
    ('key2', '{"field1": "value3", "field2": "value4"}', NOW() - INTERVAL '2 hours'),
    ('key3', '{"field1": "value5", "field2": "value6"}', NOW() - INTERVAL '3 hours'),
    ('key4', '{"field1": "value7", "field2": "value8"}', NOW() - INTERVAL '4 hours'),
    ('key5', '{"field1": "value9", "field2": "value10"}', NOW() - INTERVAL '5 hours');
​
-- Insert more data
INSERT INTO cache (key, value, inserted_at)
SELECT 'key' || s,
       ('{"field1": "value' || s || '"}')::jsonb,
       NOW() - (s || ' hours')::interval
FROM generate_series(1, 25) AS s;

然后分析插入和查询语句的性能:

EXPLAIN ANALYZE SELECT * FROM cache WHERE key = 'key1';
​
EXPLAIN ANALYZE INSERT INTO cache (key, value, inserted_at)
VALUES ('new_key', '{"field1": "new_value1", "field2": "new_value2"}', NOW());

我还建议阅读这篇关于UNLOGGED表的好文章,它说明了有关该功能的更多细节,包括一些性能比较。

结合SpringBoot使用

在SpringBoot JPA 中UNLOGGED TABLE的使用和普通表的使用基本一致,唯一需要注意的是使用UNLOGGED TABLE时,需要自己手动建表,不能依赖JPA的自动建表;因为自动建表的类型是普通表而非UNLOGGED TABLE,其他的用法均和普通表一致。

个人观点

在我看来,大多数时候,你不需要额外的服务或特殊的数据库。有一个原因是,为什么多数的新的高级数据库是在好的旧SQL数据库之上实现的。可以看PostgreSQL派生数据库的列表。更不用说像Timescale和其他许多数据库了,它们实际上只是PostgreSQL,上面点缀了一些额外的功能。虽然专门构建、优化的解决方案有其用武之地,但最好考虑为每一件小事运行额外服务的利弊,如缓存、调度程序、矢量数据库等。也许,只是也许,使用一个工具处理多件事,节省成本和开销超过了额外服务提供的少数好处。