先说点题外话。我第一次看到 rea 这个名字时,第一反应是“它是不是拼错了某单词”。翻了代码注释才知道,这是我们组内部对 Resource-Efficient Architecture 的缩写,直译过来就是“资源高效架构”。这个项目源于一次不算光彩的线上事故:晚高峰流量一来,我们负责的商品搜索建议服务延迟从 80ms 一路涨到 900ms,机器从 8 台加到 16 台仍然压不住,最后只能限流保命。事后复盘,最关键的问题不是机器不够多,而是每一台机器手里的 CPU、内存、线程、连接都没有用在刀刃上。
这篇文章就是那次事故之后,我们折腾了大半年才沉淀出来的一套资源高效方案。它会讲清楚 rea 到底是什么、为什么能省钱省机器、参数怎么算出来、上线踩过哪些坑,也会给一段可以直接参考的代码和巡检清单。适合后端开发、架构师、以及所有被“加机器”救过命的运维同学看。
1. 从一次事故说起:为什么需要资源高效架构
1.1 事故现场与复盘过程
那个业务是商品搜索建议,用户在搜索框每输入一个字符,系统就要返回一批候选词。高峰期 QPS 能到几千,特点是流量抖得厉害,可能一瞬间翻倍,也可能三分钟后又掉回去。
当天晚高峰的监控曲线我记得很清楚:入口流量比上周同期涨了 30%,但 CPU 使用率却涨了差不多 80%,P99 延迟从 80ms 直接冲到 900ms。数据库连接池被打满,线程大量阻塞在获取连接的环节上,慢查询日志里冒出来几条原本不该慢的 SQL。一查发现是数据库的统计信息过期,优化器选错了索引,走了全表扫描。
当时第一反应就是扩容,从 8 台加到 16 台,结果只把容量压力往后推了半小时,流量再抖一下又打满了。后来停下来仔细看线程栈,发现大量线程同一时间卡在同一个地方:连接池等待。也就是说,问题不在机器数量,而在单机资源分配结构上。机器越多,连接等待和线程切换带来的浪费也被放得越大。
1.2 rea 要解决的三个核心问题
那次事故之后,我们组决定做一个专门的架构优化项目,内部代号就叫 rea。规划时给它定了三个目标:
第一个是降低同等流量下的资源占用。相同 QPS 下,CPU、内存、线程、连接数要明显下降,这是最直接的省钱点。
第二个是让系统具备应对突发流量的弹性能力。不是靠盲目加机器,而是先把单机资源用到位、把流量挡在核心链路之外,让扩缩容变成一种主动行为。
第三个是让资源消耗变得可量化。每个接口在什么 QPS 下消耗多少连接、多少线程、多少内存,要能估算、能监控、能回归。没有这一步,所谓优化就是拍脑袋。
这三个目标后来变成了 rea 的四大设计原则,也是整篇文章的主线。
2. 整体设计思路:资源高效不等于简单压配置
2.1 四个设计原则
第一,按需供给。实例规格要和接口的真实水位匹配,而不是按照“最坏情况”长期部署。高峰期动态扩容,低峰期缩容,弹性能力建立在监控数据而不是经验值上。
第二,池化复用。数据库连接、HTTP 连接、线程、大对象,能复用的坚决不复用 new。连接池、线程池、对象池是资源高效的第一道关卡。很多系统 CPU 高、GC 频繁,根因不是算法慢,而是每秒创建销毁太多线程和中间对象。
第三,分级回退。压力高的时候,非核心逻辑要能自动降级,把省出来的资源让给核心链路。比如搜索建议服务里的“个性化推荐”模块,属于锦上添花,大促时不跑也没有关系。
第四,量化感知。所有资源预算必须有数字。每个接口的 RT、QPS、依赖调用次数、缓存命中率,都要对应到具体的连接数和线程数估算。没有数字就没有控制力。
2.2 架构分层与各层资源策略
rea 把服务拆成三层来看:接入层、逻辑层、存储层。
接入层负责限流、鉴权、负载均衡这类轻量操作,核心要求是薄。所有重量级逻辑都可以往后放,不在这一层创建大对象、不发起远程调用。
逻辑层是资源优化的主战场。这里运行核心业务计算,线程池参数、缓存设计、对象复用全部要在这一层落地。一般我们会给每个核心接口单独规划线程池,避免一个慢接口把整个进程的线程都占光。
存储层包括数据库连接池、Redis 连接池和任何外部依赖的调用通道。连接池参数、超时时间、重试策略都要在这一层统一管理。存储层的资源消耗往往是最容易被低估的,因为数据库端也有自己的连接上限和线程模型。
各层的优化可以这样归纳:
| 层 | 主要资源 | 高效策略 | 关键指标 |
|---|---|---|---|
| 接入层 | CPU、socket | 轻薄处理、不建大对象 | 单请求处理耗时 |
| 逻辑层 | 线程、内存 | 独立线程池、缓存、对象复用 | 线程池活跃度、GC 频率 |
| 存储层 | 连接、网络 IO | 连接池参数精准化、超时管理 | 连接获取等待、慢查询数 |
2.3 和传统“扩容保平安”思路的区别
很多团队遇到性能问题是“先加机器,再想原因”,rea 的思路正好反过来。可以看成两套哲学:
| 维度 | 传统思路 | rea 思路 |
|---|---|---|
| 容量规划 | 按峰值预留且长期保持 | 按实际水位动态伸缩 |
| 扩容方式 | 先加机器,后查根因 | 先榨干单机,不足再扩 |
| 资源利用率 | 平时 40% 以下 | 目标 70% 以上 |
| 故障风险 | 扩容时机不可控 | 有预算、有回退方案 |
| 成本增长 | 随流量线性上涨 | 尽量做到亚线性 |
传统思路在系统性慢 SQL、连接打满这类场景下尤其被动,因为流量每涨一点,浪费的资源也同步放大。rea 讲究的是“先把浪费减掉,再谈弹性”,顺序不能反。
3. 核心环节实现:连接池、缓存、线程池与实际代码
3.1 连接池大小是算出来的,不是拍脑袋定的
连接池参数是 rea 里见效最快、也最容易翻车的一环。很多人会问“最大连接数设多少合适”,我一般不建议用 20、50 这种经验值,而是按 Little's Law 来估算。
对于一个稳定系统,需要的并发连接数约等于 QPS 乘以平均响应时间:
并发连接数 = QPS × 平均响应时间举例:一个接口 QPS 是 500,数据库查询平均耗时 20ms,那么理论并发连接数就是 500 × 0.02 = 10。考虑到流量波动和慢查询余量,池子设到 15~20 就足够,不需要 50。
我们当时的搜索建议服务,数据库连接池从 50 调到 20,P99 延迟不升反降。原因很简单:连接数减少之后,数据库端上下文切换和锁竞争少了,每一条 SQL 反而跑得更快。
HikariCP 的配置可以这么写:
HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://your-db:3306/search_suggest"); config.setUsername("your-user"); config.setPassword("your-password"); config.setMaximumPoolSize(20); config.setMinimumIdle(5); config.setConnectionTimeout(3000); config.setIdleTimeout(600000); config.setMaxLifetime(1800000);这里有两个参数我想特别说。connectionTimeout 我们设的是 3 秒,而不是默认的 30 秒。如果 3 秒拿不到连接,说明系统已经拥塞,继续等只会让请求堆积,不如快速失败让上游重试或降级。maxLifetime 设成 30 分钟,小于数据库侧的 wait_timeout,避免断开后的连接被继续当成可用连接。
另外强调一句:连接池不是越大越好。每个连接都会占用数据库端的内存、线程和文件描述符,池子过大会把数据库连接数直接打爆。有的系统在数据库侧 max_connections 上限不高,应用一扩容就超过它,最后数据库挂了,所有应用一起雪崩。
3.2 两级缓存设计:本地缓存与 Redis 的配合
资源高效的另一大杠杆是缓存,但缓存不是越多越好,内存本身也是资源。我们采用的是经典两级缓存:一级是进程内的 Caffeine,二级是 Redis。
为什么不用单级 Redis?因为每次远程读取 Redis 都有一次网络开销和序列化成本。在极高 QPS 下,Redis 的响应时间再短,也不如本地内存快。本地缓存命中后只要几十纳秒就能拿到数据。但本地缓存容量有限,且各个实例各自为政,所以热点之外的数据还是交给 Redis。
Caffeine 的配置大致是这样:
Cache<String, SkuInfo> localCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofSeconds(60)) .recordStats() .build();容量这一项要按可接受的内存开销来算。假设一个 key 约 1KB,100 万条基础数据就是 1GB 内存。服务实例堆内存一般就 2~4GB,留给缓存太大会挤占业务和 GC 空间。所以我们在不同实例上限制本地缓存条目数在 5 万到 20 万之间,超过这个量就让 Redis 来兜底。
淘汰策略上,我们一开始用的 LRU,后来发现搜索结果类数据带有明显热度倾斜,热门词可能占 80% 的流量。LRU 对“偶发访问”不够敏感,反而更容易把真正热的数据淘汰掉。后来换成了 Caffeine 默认的 W-TinyLFU,它能更好地保留高频 key,缓存命中率从 72% 提到了 88%。
还要做一道防穿透的保险。对于不存在的 key,如果每次请求都直接穿透到数据库,流量一大就会把数据库打挂。常见做法是布隆过滤器,代码实现也不复杂:
BloomFilter<String> bloom = BloomFilter.create( Funnels.stringFunnel(Charsets.UTF_8), 100_000_000, // 预计数据量 0.01 // 误判率 );判断 key 不存在时,直接返回一个空结果并短暂缓存,避免在同一窗口内反复打库。误判率 0.01 意味着最多有 1% 的“不存在请求”会穿到数据库,量级已经足够低。
3.3 线程模型与请求预算:别让一个慢接口拖垮整个进程
线程池是另一个高频踩坑点。服务刚上线时我们用全局默认线程池,结果一个慢接口堵住队列,整个服务的所有请求全部排队,P99 一路走高。
后来把所有接口按链路分成独立线程池,每个线程池设置单独的队列容量和拒绝策略。给一个参考配置:
ThreadPoolExecutor searchPool = new ThreadPoolExecutor( 10, // 核心线程数 20, // 最大线程数 30L, TimeUnit.SECONDS, // 非核心线程空闲回收时间 new ArrayBlockingQueue<>(200), new NamedThreadFactory("search-core"), new ThreadPoolExecutor.CallerRunsPolicy() );核心线程数 10、最大 20 这个数字,按 IO 密集型线程数公式粗略估算:N = CPU 核心数 × (1 + 等待时间 / 计算时间)。当时服务部署在 4 核容器上,单次请求计算约 10ms,等待外部依赖约 30ms,所以 N = 4 × (1 + 3) ≈ 16,取中间值 10~20 验证压测后确认。
队列容量设 200 而不是无限,是为了给系统一个明确的“背压信号”。如果队列无限增长,实际就是内存无限增长,最终必然 OOM。队列满之后,CallerRunsPolicy 会让提交任务的线程自己来执行任务,等于把压力反向传导给上游。
请求预算的概念也很重要。每次核心链路的调用,我们会提前设定超时时间。比如搜索主逻辑允许 150ms,其中缓存读取 20ms,数据库查询 80ms,外部推荐接口 50ms。任何一个调用超预算,就快速失败并触发降级,不允许无限等待。没有这种预算控制,一个外部服务变慢就能把整条链路的线程资源全部拖死。
3.4 资源审计:把整个服务的资源账本打开
前面说的连接池、缓存、线程池全是“控制”,但控制的前提是“测量”。rea 里面我们做了一个简易的资源审计脚本,用于发布前和巡检时摸清服务家底:
#!/bin/bash # 每 5 秒采集一次关键资源指标,用于发布前后的资源基线对比 PID=$(jps -l | grep search-suggest | awk '{print $1}') while true; do date +"%Y-%m-%d %H:%M:%S" # CPU 与负载 top -bn1 | head -5 | tail -2 # 内存 free -m | head -2 # 当前 TCP 连接数 ss -s | head -1 # GC 统计:YoungGC次数、FullGC次数、耗时 jstat -gcutil "$PID" 1000 3 sleep 5 done这个脚本是初版,后来接了监控看板,指标粒度细化到每个线程池活跃数、连接池等待时间、缓存命中率。但脚本的思路到今天也还有效:发布前跑一次,记录 CPU、内存、连接、GC 四个维度的基线;发布后再跑一次,两边一对比,有没有资源劣化立刻就能看出来。很多性能问题不是在压测时暴露的,而是巡检时拿新旧数据对比才发现的。
4. 上线过程中的坑与排查实录
4.1 连接池调小后超时反而增加
连接池从 50 调到 20 之后,有一段时间我们观察到的超时率不降反升。当时第一反应是“参数调小了”,差点直接改回去。后来查数据库侧的慢查询日志,发现根源是一条 SQL 因为索引统计信息过期,走了全表扫描,单次查询耗时从 20ms 涨到 2 秒。等于每个连接被占住的时间拉长了 100 倍,池子再大也扛不住。
这件事的教训有两层。调连接池参数之前,必须先确保没有未治理的慢 SQL,否则你调的不是参数,是给本来就快饿死的连接池又递了一把刀。另外,连接池调优是“先修根因再改参数”的顺序,改完参数之后一定要盯一段时间的连接等待时间指标。
4.2 缓存穿透把数据库打崩的完整修复过程
有次发布新版本,代码里忘了处理不存在的 key,结果用户输入一堆奇怪词,缓存里全部没有,请求直接穿透到数据库。当时数据库连接数瞬间打满,幸好限流拦住了大流量。那之后我们强制要求所有缓存查询路径都要过布隆过滤器,同时把“空值缓存”补上,即使数据库中确实没有数据,也要缓存一个空对象几秒钟。
修复的逻辑并不复杂,但很多人容易忽略一个细节:布隆过滤器也需要更新。每当写入新的 key 时,除了写 Redis,还要同步往布隆过滤器里加。如果漏了这一步,过滤器会一直认为某个新商品不存在,缓存永远不命中。
单飞(single-flight)也是缓存穿透场景里的救火神器。同一时刻同一个 key 只允许一个线程去数据库加载,其他线程等它加载完直接拿结果:
ConcurrentHashMap<String, CompletableFuture<SkuInfo>> futures = new ConcurrentHashMap<>(); public SkuInfo getSku(String skuId) { SkuInfo sku = cache.getIfPresent(skuId); if (sku != null) return sku; CompletableFuture<SkuInfo> future = futures.computeIfAbsent(skuId, id -> CompletableFuture.supplyAsync(() -> loadFromDb(id))); try { return future.get(2, TimeUnit.SECONDS); } catch (Exception e) { futures.remove(skuId); return loadFromDb(skuId); } }这个方案对热点失效很有效,但要注意缓存加载完成后要清理 future 条目,否则 map 会越积越大。
4.3 G1 老年代频繁回收的问题
资源审计脚本上线后,我们发现服务的 Full GC 次数偏高。查 jstat 数据,看到年轻代晋升率很高,对象大量进入老年代。分析 dump 之后发现,问题是搜索逻辑里有个循环在拼接候选字符串,每次循环都会创建新的对象,高峰期每秒产生大量中间对象。
这种问题不是靠调 GC 参数能根治的。我们把拼接逻辑改成 StringBuilder 复用、把请求上下文做对象池化,之后年轻代回收次数明显下降,Full GC 基本消失。所以排查 GC 问题的时候,优先看代码里的对象分配速率,而不是一上来就换垃圾回收器。换 G1 是对的,但 G1 也不是万能药。
4.4 日常巡检清单:用哪些工具、看哪些指标
分享一份我们后来固定下来的巡检清单,对同类服务有直接参考价值:
| 检查项 | 工具/命令 | 正常范围 | 危险信号 |
|---|---|---|---|
| 连接池等待时间 | 监控看板/HikariCP 指标 | 平均 < 5ms | 持续 > 100ms |
| 线程活跃度 | JConsole/Arthas | 核心线程 50% 以下 | 长期 100% |
| GC 情况 | jstat -gcutil | YoungGC 正常,FullGC 极少 | FullGC 频繁 |
| 缓存命中率 | Caffeine metrics/Redis INFO | 本地缓存 > 85% | 命中率持续走低 |
| 数据库慢查询 | 数据库慢查询日志 | 单次 < 100ms | 出现秒级查询 |
这套清单不需要每天都跑,但发布前后、流量突增时按它过一遍,能省下很多排查时间。经验是:监控指标不用多,先把连接、线程、GC、缓存命中率这四个维度做透,大部分资源问题都能提前露出苗头。
5. 量化收益与方案的适用范围
5.1 优化前后的资源对比
把 rea 完整落地之后,我们对核心服务做了一次量化对比。优化前实例数 16 台,优化后降到 7 台;CPU 平均使用率从 75% 降到 35%;内存使用从每实例 3GB 降到 2.2GB,总内存少了接近一半。P99 延迟从 450ms 降到 120ms,QPS 反而还涨了 20% 左右,因为连接池释放了数据库端的压力。
这里需要说明一下,数字是当时那个业务的特例,不代表任何系统都能有同样幅度的提升。但方向是通的:先把单机资源算清楚、用到位,再谈横向扩容,成本下降通常在 30% 以上。对中小团队来说,省下机器就是省下真金白银,而且这种优化让系统在流量突增时更从容。
5.2 哪些系统不要照搬 rea 的做法
不是所有服务都适合做资源高效架构。低流量的内部管理后台,QPS 个位数,连接池和线程池怎么配都无所谓,先把代码可维护性做好更重要。纯批处理任务型系统,资源模型是吞吐型而不是延迟型,量化方式和在线接口完全不同,需要单独设计。
还有一种情况是写多读少、没有明显峰值的系统,这类系统的主要瓶颈往往是磁盘 IO 和数据库写入容量,线程池和缓存优化空间很小。做优化之前先搞清楚系统的瓶颈在哪一层,否则就是在错误的维度上用力。
5.3 隐性收益:团队对成本和容量的感知
rea 这个项目最大的隐性收益,不是省了几台机器,而是团队形成了成本意识和容量感知。现在我们每次架构评审都会问三个问题:这个接口预计 QPS 多少?单次调用消耗多少资源?容量不足时是先降级还是先扩容?这些问题过去很少有人提,现在变成了默认动作。
更重要的是,我们把“资源预算”写进了压测流程。每次发版之前,先做一轮容量测试,把模块的支撑 QPS 和资源上限写进发布记录。这样上线后如果指标异常,大家第一时间翻的是预算表,而不是临场猜。这套流程弥补了经验主义容易漏掉的盲区,比任何工具都管用。
6. 关于 rea 之后的工程体会
如果有人问我,什么样的团队值得去折腾 rea 这套东西。我的回答是:如果你们还在靠“加机器”救火,先别急着搞全链路资源预算,先把连接池和线程池的参数算清楚,把慢 SQL 修干净。这两件事做完,通常已经能省下 30% 的资源。全链路资源预算、动态扩缩容那些是后话,基础参数没有治理之前,上层设计再漂亮也是空中楼阁。
我到现在还记得事故复盘那次,A 同学盯着监控屏幕说了一句:“我们以为瓶颈是机器不够,其实是每一台机器都在等一个连接。”后来整个 rea 的落地方向,都是从这句话展开的。资源高效不是一句口号,而是每个参数背后都有据可依、每个资源消耗都有账可查。希望这篇记录能帮你少走几步弯路,把省下来的机器钱用在更有价值的事情上。