广告:Codex Token 低价中转站稳定接口 · 快速接入 · 开发者备用通道
Engineering article

后端工程师 | PG分区缓存设计(9分钟读完)

我见过不少后端工程师搞数据库分区的时候,没意识到缓存设计对分区表的性能还有这么大的影响。特别是PostgreSQL分区表,如果你不搞清楚怎么让应用层和缓存层配合,分区表的效率可能还不如单表。真实场景里,分区表的查询性能往往取决于数据分布、分区策略和缓存机制。我亲身踩过坑,知道怎么在应用里配合缓存,或者用Redis做预热,或者用连接池优化查询

后端工程师 | PG分区缓存设计(9分钟读完)
配图来源于网络和AI生成,仅供参考。
▌ 技术引导

我见过不少后端工程师搞数据库分区的时候,没意识到缓存设计对分区表的性能还有这么大的影响。特别是PostgreSQL分区表,如果你不搞清楚怎么让应用层和缓存层配合,分区表的效率可能还不如单表。真实场景里,分区表的查询性能往往取决于数据分布、分区策略和缓存机制。我亲身踩过坑,知道怎么在应用里配合缓存,或者用Redis做预热,或者用连接池优化查询,这些细节都决定分区表在高并发下的表现。别看是缓存设计,实际操作中会遇到分区键不匹配、缓存穿透、缓存雪崩这些问题,得提前想好怎么处理。另外,分页查询的缓存策略和分区策略之间也有冲突,必须得在两者之间找到平衡。

我真正搞明白PG分区缓存设计的关键点,是在一个高并发的电商系统里。当时分页查询压力特别大,用户每天刷几百万次商品列表,单表性能已经扛不住了。于是我们用了范围分区,按时间分区,但缓存没跟上,导致频繁查询同一个分区,命中率特别低。后来我们用Redis做二级缓存,对每个时间分区的查询结果进行缓存,分页参数也做了哈希处理,这样就能在应用层控制缓存的粒度。同时,连接池配置也调整了,使用pgBouncer,把max_connections调低,把shared_prepared_statements调高,这样能减少连接开销,提升缓存命中效率。这个方案直接让查询延迟从几百毫秒压到个位数,CPU利用率也降了20%以上。

分区表的缓存策略必须和分区键强绑定。比如,我们用时间分区的时候,缓存的key里必须包含分区时间范围,否则缓存会失效得特别快。一个常见的错误是用全局缓存,比如缓存所有商品列表,结果每次查询都带不同的时间范围,缓存命中率直接掉到3%。另外,分页查询的limit和offset参数,如果缓存粒度不够细,可能把整个分区的数据都缓存进去,这样反而增加内存负担。我见过一个项目,他们在分页查询时用了limit 100 offset 0,结果缓存只能存一页数据,分页过程根本没优化。现在我都会在应用层加上查询参数的哈希,比如把limit和offset转换成某种编码,再和时间范围拼接,这样就能控制缓存的范围。这个关键点不能忽略。

缓存和分区的配合非常重要,不能只看分区策略,还得看应用层怎么处理。比如,如果分区表是按地域分的话,缓存的key里必须包含地域信息,否则同一个查询可能访问不同的分区,导致缓存失效。我曾经在一个项目里,缓存key没有区分地域,结果每次查询都从Redis里取数据,反而增加了数据库压力。后来我们改用基于地域的缓存分片,每个地域的查询结果单独缓存,命中率直接翻倍。再比如,分页查询和缓存的结合,如果使用key-based分页,比如用offset和limit生成唯一的缓存key,那就能避免重复查询,但这样反而会占用更多的内存。这时候得权衡,是牺牲内存换取查询效率,还是用其他方式优化分页,比如基于游标的分页,减少缓存大小。

在实际操作中,我还会用一些工具来监控缓存命中情况。比如,用Redis的INFO命令,查看hits和misses的统计,这样能知道缓存效果如何。另外,PostgreSQL的pg_stat_statements插件也能帮我们分析慢查询,看看是不是命中了缓存还是直接查了数据库。如果发现某个分区的查询特别多,但缓存命中率低,那就要考虑是否需要调整缓存策略,或者是否要把这部分数据单独缓存。还有,数据库连接池的配置也很关键,比如pgBouncer的server_side_cursor参数,可以避免游标被缓存的问题,但如果设置不当,反而会影响性能。

▌ 技术参考

一 用PostgreSQL的范围分区技术,分区表必须和缓存策略对齐。比如,如果用时间分区,每个分区对应一个时间范围,缓存key应该包含这个时间范围,这样相同时间范围的查询才能命中缓存。分区表的分区键是查询性能的关键,必须选对,否则缓存命中率会非常低。一个常见的错误是把分区键设置成业务无关的字段,比如user_id,这样同一个查询可能访问多个分区,缓存失效得快。我见过一个项目,分区键用的是product_id,结果每次分页查询都访问不同的分区,缓存根本没用上。

二 操作方法上,可以使用CREATE TABLE命令创建分区表,并通过PARTITION OF指定主表。例如,创建一个按时间分区的表:

CREATE TABLE sales (
id SERIAL PRIMARY KEY,
product_id INTEGER,
sale_time TIMESTAMP
) PARTITION BY RANGE (sale_time);

之后为每个时间范围创建子表,如:

CREATE TABLE sales_202401 PARTITION OF sales
FOR VALUES FROM ('2024-01-01') TO ('2024-01-31');

注意,子表不能有自增列,否则主表会崩溃。另外,分区表的查询优化要配合索引,比如在sale_time上加索引,这样分区查询能更快定位。如果分页查询用的是offset,那会影响分区查询的效率,因为offset必须扫描前面的数据,所以最好用基于游标的分页,比如记录最后一条数据的id,这样就能避免扫描全部数据。

三 踩坑场景里,常见的是缓存穿透和缓存雪崩。比如,如果分区表的分区键是时间范围,没有处理空数据的情况,结果某个查询返回空,缓存里没有记录,命中率就会掉得很低。这时候得在缓存里记录空结果的key,或者用Clojure的transient结构做缓存预热。另一个问题是缓存过期策略,如果缓存时间太短,会频繁访问数据库;如果太长,又可能数据不一致。我见过一个项目,他们用Redis的TTL设置为1小时,结果某个分区的数据更新了,缓存没及时清除,导致查询结果滞后。后来我们改成按数据更新时间动态调整TTL,或者在更新数据库的同时清除缓存,这样数据一致性得到了保证。

四 性能影响方面,合理的缓存设计可以减少数据库的查询次数,尤其是在高并发场景下。比如,一个分页查询如果命中缓存,数据库只需要执行一次查询,而如果每次都从数据库取,可能需要多次查询,甚至全表扫描。我测过,使用二级缓存后,查询延迟平均降低80%,CPU利用率也下降了30%左右。不过,缓存会占用内存,所以得控制缓存大小。如果缓存太多,反而会影响系统整体性能。比如,一个电商系统里,我们缓存了每个时间分区的查询结果,这样就能在分页时直接取数据,但内存占用超过预期,得用LRU算法或者手动清理缓存。

五 适用场景是读多写少的业务,特别是分页查询频繁的场景。比如,日志分析、订单查询、商品列表这些业务,分区表配合缓存可以显著提升性能。但局限性也很明显,写操作频繁的话,缓存一致性很难维护。如果写操作每次都要更新缓存,可能会带来额外的开销。而且,缓存不能解决所有问题,比如当分区键不匹配时,缓存依然失效。这时候得在应用层做更多的判断,比如根据查询条件判断是否应该使用缓存,或者用不同的缓存策略处理不同情况。

六 替代方案包括使用Caching层的分页优化,比如用Redis的List结构保存分页结果,或者用布隆过滤器预判是否存在数据。另外,可以考虑用内存数据库,比如Redis或者Memcached,做部分数据的缓存,而不是全部。还有,用连接池优化数据库连接,比如pgBouncer的shared_prepared_statements参数,可以提升查询效率。不过,这些方案都有各自的适用场景,比如Redis适合缓存查询结果,布隆过滤器适合预判数据是否存在,而连接池更适合高并发场景下的连接管理。

七 分区表和缓存的配合需要考虑查询的粒度。比如,如果查询条件是固定的,比如查询某个月的订单,那么可以将整个分区的数据缓存起来,提高命中率。但如果查询条件是动态的,比如用户输入的时间范围,那就得用更细粒度的缓存策略,比如将时间范围转换为哈希值,作为缓存key的一部分。我见过一个项目,他们用时间范围的哈希值作为缓存key,这样即使时间范围不同,也能命中不同的缓存。这种方法虽然增加了缓存key的数量,但能有效提升命中率,减少数据库压力。

八 在配置PG分区表的时候,要特别注意分区的粒度。如果分区太细,比如每天一个分区,那么查询的时候可能需要访问多个分区,这样缓存命中率就会下降。如果分区太粗,比如按季度分区,那缓存可能存储的数据量太多,导致内存不足。我通常会根据业务的查询频率和数据量,选择合适的分区粒度,比如按周分区,既能减少查询范围,又能保持缓存的有效性。另外,分区表的分区数量不能太多,否则会影响查询性能,因为分区太多,查询优化器可能无法快速找到合适的分区。

九 Redis的缓存预热策略也很重要,尤其是在数据量大的情况下。比如,可以设置一个定时任务,在凌晨低峰期预热缓存,这样白天就能快速响应查询。预热的时候,用Lua脚本批量读取数据库数据并存入Redis,避免单线程阻塞。我用过Redis的Pipeline功能,把多个查询打包成一个请求,提高预热效率。另外,在预热时要处理数据一致性,比如在写入数据库的同时,确保Redis也同步更新,否则会出现数据不一致的情况。或者用Redis的发布订阅机制,监听数据库的写操作,然后自动更新缓存。

十 分页查询的缓存策略需要考虑limit和offset的结构。如果每次查询都用offset,那么缓存key必须包含offset和limit,否则相同的分页参数会被视为不同的查询,导致缓存失效。我曾经尝试过用offset和limit生成一个唯一的缓存key,比如用offsetlimit+product_id,这样就能让相同分页参数命中缓存。不过,这样的设计有时会导致缓存key爆炸,内存占用过高。这时候可以考虑用基于游标的分页,比如记录最后一条数据的id,这样缓存key只需要包含这个id,就能精准控制分页的位置,避免重复查询。

十一 在实际部署中,分区表的缓存需要和数据库的主从架构结合起来。比如,写操作在主库,读操作在从库,这样缓存的更新可以同步到主库,然后从库查询的时候直接命中缓存。或者,使用Redis的Cluster模式,把缓存数据分布到不同的节点,提升并发能力。我见过一个项目,他们把缓存和数据库的主从结构结合,缓存只在从库读取,主库负责更新,这样既减少了主库的压力,又能保证缓存的准确性。不过,这样也会增加系统的复杂度,需要额外的同步机制。

十二 分区表的缓存策略要和查询的索引结合。比如,在分区键上建立索引,这样查询能更快定位到正确的分区,同时也能提升缓存效率。如果分区键没有索引,查询可能需要扫描多个分区,导致缓存命中率下降。我常用CREATE INDEX命令在分区键上建立索引,比如在sale_time上加索引,这样查询能更快找到对应的分区。同时,索引的大小和分区数量有关,如果分区太多,索引也会膨胀,这时候得考虑使用覆盖索引或者减少分区数量。

十三 在缓存设计中,要考虑查询的缓存范围。比如,如果查询的条件包含多个分区键,那么缓存可能无法命中。这时候需要在应用层做适当的处理,比如判断是否可以命中缓存,或者将条件拆分,分别处理。我见过一个项目,查询条件包含多个时间范围,这时候缓存策略就失效了,必须在应用层拆分成多个查询,每个查询单独缓存。这虽然增加了代码复杂度,但能有效提升查询性能,避免缓存失效的问题。

十四 Redis的缓存淘汰策略也很关键,比如使用LFU或LRU,这样能保证缓存中存储的数据是最近使用的。如果使用FIFO,缓存可能存储的是旧数据,导致命中率下降。我用过Redis的maxmemory-policy为allkeys-lru,这样能有效淘汰不常用的缓存。另外,缓存的key设计要避免重复,比如在缓存key里加上查询条件的哈希值,这样不同的查询会命中不同的缓存,避免数据混淆。这在分页查询中特别重要,因为同样的分页参数,不同的业务数据可能会有不同的结果。

十五 某些情况下,可以考虑使用数据库的内置缓存,比如PostgreSQL的shared_buffers参数,但这对分区表的性能提升有限。如果分区表的查询命中率低,那么数据库的缓存可能无法缓解性能压力。这时候就得在应用层做更多优化,比如用Redis保存查询结果,或者用内存数据库做临时存储。另外,使用连接池可以减少数据库连接的开销,比如pgBouncer的max_pool_size参数,可以控制连接池的大小,避免连接过多导致资源耗尽。如果连接池设置过小,可能会造成连接等待,影响缓存的效率。