创业业务的后端自推翻重写后,随着访问量逐渐上升,一直存在比较明显的性能问题,即访问人数较多时 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 的频率;要么快速开发上线,后期慢慢重构。