ata
发布于 2023-05-09 / 246 阅读
0
0

重写一个接口,降低 2/3 的后端负载……

创业业务的后端自推翻重写后,随着访问量逐渐上升,一直存在比较明显的性能问题,即访问人数较多时 PostgreSQL 数据库会吃掉大部分 CPU,引发微服务超时/熔断。因发生时间不定,一直没有没有明确的切入点,且有其它需求排期,这个问题一直处在“挂起”的状态。一个月前开始逐步实现缓存,使后端负载降低了一些。但是偶尔还会出现负载爆炸的情况,并且近期越来越频繁。应该是用户增长消耗掉了这部分空余性能。

昨天看网关日志时,发现了大量的慢请求,均来自同一个微服务接口 - batchHasLike。这是一个提供给 DataLoader 的接口,在设计上可以一定程度提高性能(DataLoader 用来将短时间内的多个请求合并,再发送给对应接口)。这个接口兼顾了帖子和评论的点赞情况,是一个非常高频的接口,基本上每次列取帖子/评论都会用到。

排查后发现:这个接口的缓存逻辑不完善,旧数据很难命中缓存;并且 SQL 查询复杂度为 N(传入 N 个成员,会执行 N 次 SQL 查询)。而 DataLoader 在高峰期通常会一次提交几十~上百个数据,这无疑是致命性的。(鬼知道我当初是在什么精神状态写下的这些代码,果然往事不堪回首)

分析后得出了以下结论:

  • 接口负责的内容可以分为三部分:帖子、评论和子评论(下文简称 target),可以将传入的数据分类,然后分别做请求。

  • 每个用户对于每个 target 的数据是独立的,可以将 targetId 作为 key 缓存,如 likePost:{postId}。这样在 target 被删除时,也可以一次性清理缓存。

查阅资料后,决定在 SQL 中使用 FOR..IN 语句,如下所示 ⬇️

SELECT * FROM "like" WHERE (creator_id, post_id) IN ((1, 1), (2, 2));

在实现的过程中,遇到了新的问题:接口需要返回的数组长度应该与请求的长度一致,而 Redis 缓存返回的值和 PostgreSQL 的值都有可能为空。如果要保证这一点,需要多次遍历各种数组。想了一想,使用对象也许就能避免这个问题,牺牲空间换速度(也许)。

  • 给不同 target 建立一个 Record<string, { flag: boolean, creatorId: number }> 类型的对象,targetId 作为 key。

  • 在一开始分类时把每个 key 设一个初始值 false

  • 请求完数据库,遍历一遍结果,更新对应 key 的 flag

  • 对于无缓存数据,异步写入 Redis,设定一定时间后过期。在用户点赞/取消点赞/删除 target 时,更新或删除这部分缓存。

这样,成功把无缓存情况下的 SQL 查询次数降低到了常数级别(<= 3)。上线后效果立竿见影啊……

业务系统的优化任重道远。要么在设计之初就做完善,减少后期 refactor 的频率;要么快速开发上线,后期慢慢重构。


评论