最近我开发了一个接口,这个接口需要从 Redis 上读取一系列规则,判断请求是否满足这些规则。服务使用 Java 开发,垃圾回收器选择了 G1。由于请求量很大,且用户对性能要求较高,查询延迟(TP999)必须尽可能低。
每次请求最多会读取 5 条规则,也有可能读取不到规则。每条规则的形式是一个 Map,且比较大,每个 Map 大约 1 到 2 KB。规则数量非常庞大,并且会有更新操作,因此将所有规则全部做成本地缓存是不现实的。我们估算,大部分请求会读取 2 到 3 条规则,只有极少部分请求会读取 5 条规则(我们通过布隆过滤器来快速判断并拦截那些会读取 5 条规则的请求,从而减少查询时间)。
为了尽量减少查询延迟,我们考虑过一种方案,即提前将请求可能用到的所有规则(最多 5 条)通过 mget
一次性加载到内存中。这样,无论请求最终读取多少条规则,都能以较低的延迟完成查询,虽然加载 5 条规则的时间略长一点。但这种做法的缺点是,无论请求最终需要多少条规则,都会预先加载 5 条规则,这些规则的生命周期仅限于一次请求。如果请求量很大,频繁的内存分配可能会导致 GC 增加,甚至触发 G1 的 MixedGC,从而引发 STW(Stop-the-World)。
如果不提前加载规则,每次请求最多需要读取 5 条规则时,就会调用 5 次 Redis 请求,导致查询延迟增大,TP999 的表现就不好了。
这个问题困扰了我一段时间,不过现在我已经想通了。我的结论是,“提前加载所有需要的规则”。因为 GC 问题可以通过水平扩容来解决,而 TP999 的问题,只有通过提前加载所需的规则才能根本解决。
使用 Java 这种有垃圾回收机制的语言,追求单机的极致性能其实是一个伪命题。虚拟机和垃圾回收器的存在意味着,不管怎么优化,也不可能达到顶级稳定的性能。Java 的优势在于减少了开发的复杂度,避免了过多的性能调优工作。对于单机性能,我们只需要确保性能不至于太差,而在流量压力大的情况下,可以通过水平扩容来解决。如果为了 GC 优化而不提前加载规则,那么每个请求的 TP999 延迟很难通过外部手段来解决,也无法通过水平扩容来解决。
因此,我认为,业务开发中应该进行性能优化,但优化要适度。因为业务代码可能会在半年内被重构或删除,而根据用户体验法则,响应时间超过 150ms 会让用户明显感到不适。如果真的需要追求极致性能,应该优先考虑选择高性能的语言和框架,而不是在现有的技术栈上做过度的调优。