【架构实战】性能调优方法论:系统化提升性能

【架构实战】性能调优方法论:系统化提升性能

"性能优化最怕的不是找不到瓶颈,而是找到了瓶颈却用错了方法,越改越慢。"

说这话的是我前同事阿Ken,某社交平台的技术负责人。2019年,他们平台经历了一次惊心动魄的性能事故:核心接口 P99 响应时间从正常的 200ms 飙升到 30 秒,用户大规模投诉,CEO 在群里连发了三个"?"。团队加班加点"优化"了两天,加了三倍服务器,结果 P99 不降反升到 45 秒。最后请来了外部专家,看了 20 分钟,一行代码没改,P99 回到了 150ms。

怎么回事?专家关掉了团队刚刚加上去的一个"性能优化"功能------一个自作主张的数据预加载服务。这个预加载本来是为了减少数据库查询,结果因为没有控制好数据量和并发,反而导致了大量内存溢出和 GC 停顿,把系统彻底拖垮了。

这个故事几乎包含了性能优化中所有经典的坑:盲目加机器、不明所以地加功能、头痛医脚。性能优化是一个系统性工程,需要方法论,而不是靠直觉和运气。在这篇文章中,我将从性能分析的基本流程出发,系统性地讲解性能瓶颈的定位方法,然后深入 JVM 调优、SQL 优化和缓存优化三个核心领域,分享大量真实踩坑案例,帮助你建立系统化的性能优化能力。

一、性能分析的基本流程

1.1 性能优化的第一步:测量

性能优化有一个铁律:先测量,后优化。不以数据为依据的优化都是耍流氓。

很多团队的性能优化之所以失败,是因为上来就凭感觉"这个肯定慢"、"那个肯定有问题",然后花大量时间优化了一个根本不是瓶颈的地方。正确的做法是:用工具采集性能数据,找到真正的热点,然后按影响程度排序,优先解决收益最大的问题。

我常用的性能测量工具有以下几个层次:

系统层面 :top、vmstat、iostat、sar、perf 用于分析 CPU、内存、I/O 的使用情况

应用层面 : Arthas(Java)、py-spy(Python)、node-clinic(Node.js)用于分析应用内部的调用链路

数据库层面 :EXPLAIN、慢查询日志、Performance Schema(MySQL)、AWR 报告(Oracle)

全链路层面:SkyWalking、Zipkin、Jaeger 用于分布式追踪

1.2 性能指标的认知

性能指标有很多,但最核心的只有三个:RT(Response Time,响应时间)、TPS(Throughput,吞吐量)和错误率。 其他所有指标都是这三个指标的衍生或细分。

在讨论性能时,必须明确你关注的是哪个百分位。平均值(P50)很容易被掩盖------一个系统 P50=50ms,P99=5000ms,它的平均值可能只有 80ms,看起来很健康,但实际有 1% 的用户经历了 5 秒的等待。在用户量大的系统中,1% 的用户可能就是几万甚至几十万人,绝对数量并不少。

所以我建议:关注 P50、P95、P99 三个指标,特别是 P99。 P99 是 SLA 承诺的基础,也是用户体验的分水岭。

bash 复制代码
# 使用 ab 或 wrk 测试接口性能
# Apache Bench 示例
ab -n 10000 -c 500 -p post_data.json -T application/json \
   -H "Authorization: Bearer $TOKEN" \
   http://api.example.com/v1/feed

# 输出示例关键指标解读:
# Requests per second: 2345.67    # 吞吐量
# Time per request: 426.789ms      # 平均RT
# 50%  120ms                       # P50
# 90%  350ms                       # P90
# 99%  1200ms                      # P99

# wrk 更适合高并发压测
wrk -t12 -c400 -d30s --latency \
    -H "Authorization: Bearer $TOKEN" \
    http://api.example.com/v1/feed

1.3 性能问题的分析框架

面对一个性能问题,新手往往无从下手。我推荐一个"自顶向下"的五步分析法:

第一步:确认问题。 是所有用户都慢还是部分用户慢?是所有接口都慢还是个别接口慢?是突然变慢还是逐渐变慢?这些信息能快速缩小排查范围。

第二步:区分瓶颈在端还是在服务端。 用浏览器开发工具或 curl 查看本地响应时间,如果本地就很慢,问题在前端或网络;如果本地快但服务端慢,问题在后端。

第三步:区分瓶颈在计算还是 I/O。 如果 CPU 使用率接近 100%,瓶颈在计算侧(需要优化算法、JVM参数或代码逻辑);如果 CPU 使用率低但响应慢,瓶颈在 I/O 侧(网络、磁盘、数据库)。

第四步:定位具体热点。 使用 profiler 工具找到消耗最多时间的代码路径和函数调用。

第五步:验证优化效果。 优化后必须用同样的测试条件重新测量,确认指标提升。

这个框架的关键是:不要跳步。 很多人做到第三步就急于动手了,结果优化了半天发现瓶颈在完全不同的层次。

二、某社交平台性能调优全过程

2.1 问题背景

某中型社交平台(我们叫它"S平台"),2019年月活用户约 800 万。核心 feed 接口(信息流)在高峰期的 P99 响应时间达到了 3 秒,用户刷新一次 feed 要等 3 秒,体验极差,DAU 开始下滑。团队接到的任务是:将 P99 从 3 秒优化到 200ms 以内。

这是一个很有挑战性的目标。3 秒到 200ms,意味着性能需要提升 15 倍。这种量级的优化,靠调几个参数是绝对做不到的,必须从架构层到实现层全面分析和优化。

2.2 初始分析:瓶颈在哪里?

团队首先采集了线上性能数据。AWR 报告显示,CPU 使用率只有 35%,但平均响应时间 800ms,P99 3 秒。这个数据一出来就排除了"CPU 计算瓶颈"的可能性------CPU 有大量空闲,说明系统在等待什么东西。

然后查看了 GC 日志(后面会详细讲),发现 Full GC 频率很高,每次 Full GC 会导致约 2-3 秒的服务停顿。高峰期每 5 分钟就有一次 Full GC,意味着每 5 分钟就有 2-3 秒所有用户请求都在等待。这个发现基本锁定了第一个问题:JVM GC 导致的停顿。

接着查看了慢查询日志,发现一个典型的 N+1 查询问题:

sql 复制代码
-- 用户反馈列表查询,慢得离谱
-- 原始查询:先查100条用户ID
SELECT user_id, content, create_time FROM feed WHERE visible=1 ORDER BY create_time DESC LIMIT 100;

-- 然后对每个user_id单独查询用户信息(N+1问题)
SELECT nickname, avatar FROM user WHERE user_id = 123;
SELECT nickname, avatar FROM user WHERE user_id = 456;
...(重复100次)

这个查询在数据量小的时候不明显,但 S 平台的用户量上来后,100 次数据库查询每次 30ms,总耗时就是 3 秒。这就是 P99=3秒的主要原因之一。

继续深入分析还发现了更多问题:没有使用 Redis 缓存热门 feed 数据,没有分页加载(一次加载了 100 条),部分查询缺少索引(order by create_time 的查询走了全表扫描),等等。

总结一下 S 平台发现的问题清单:

序号 问题 影响程度 预估收益
1 Full GC 频繁停顿(每次2-3秒) 🔴 严重 P99降低1-2秒
2 N+1 查询问题(100条数据100次查询) 🔴 严重 P99降低1-2秒
3 热门feed无缓存,每次都查库 🟡 中等 RT降低30-50%
4 ORDER BY 无索引,全表扫描 🟡 中等 RT降低30-50%
5 一次返回100条,无分页 🟡 中等 RT降低20-30%
6 连接池配置过大,连接等待 🟡 中等 并发能力提升

接下来逐个解决。

三、JVM 调优:从 GC 停顿到丝滑流畅

3.1 理解 GC 的基本原理

Java 的垃圾回收器发展到今天,主流的有几种:Serial GC(单线程,停顿时间长,适合小内存)、Parallel GC(多线程,停顿缩短,适合后台批处理)、CMS(并发标记清除,低停顿但碎片化问题)、G1(分区式垃圾回收,可设定停顿目标)和 ZGC/Shenandoah(低延迟垃圾回收器,停顿 <1ms)。

S 平台原来使用的是 ParNew + CMS 组合,这个组合在 JDK 8 时代很常见。但 CMS 有一个致命问题:Concurrent Mode Failure(并发模式失败)------当 CMS 在并发标记阶段,新生代的对象晋升到老年代,而老年代空间不足以容纳时,JVM 会退化为单线程 Full GC,停顿时间从毫秒级直接跳到几秒甚至十几秒。

3.2 JVM 参数调优的血泪教训

S 平台的 JVM 初始配置是这样的:

bash 复制代码
java -Xms4g -Xmx4g -XX:+UseParNewGC -XX:+UseConcMarkSweepGC \
     -XX:PermSize=256m -XX:MaxPermSize=256m \
     -XX:CMSInitiatingOccupancyFraction=70 \
     -XX:+UseCMSCompactAtFullCollection \
     -XX:CMSFullGCsBeforeCompaction=5 \
     -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/var/log/gc.log

这个配置有几个明显问题:

第一: -XX:CMSInitiatingOccupancyFraction=70 意味着老年代使用率超过 70% 才触发 CMS 收集。但 CMS 并发标记需要时间,如果老年代增长太快,CMS 还没完成标记,新对象就塞满了,触发 Concurrent Mode Failure,JVM 会紧急调用一次单线程的 Full GC,这次 Full GC 可能持续 5-10 秒。

第二: -XX:PermSize-XX:MaxPermSize 是 JDK 7 的元数据区配置,JDK 8 已经改为 Metaspace,且 Metaspace 是动态扩展的,不设上限是可以的,但必须监控其使用量。

第三: 没有设置 -XX:MaxGCPauseMillis,CMS 无法控制停顿时间目标。

正确的做法是先了解应用的实际内存分配模式:

bash 复制代码
# 使用 jstat 观察 GC 情况(生产环境可以周期性采样)
jstat -gcutil <pid> 1000

# 输出示例:
#   S0     S1     E      O      M     YGC     YGCT    FGC    FGCT     GCT
#  12.45   0.00  65.23  78.56  89.23   234    5.678    12    34.567   40.245
# O=78.56 表示老年代使用率78%,此时CMS应该已经在并发标记了
# FGC=12, FGCT=34.567 表示已经发生了12次Full GC,总耗时34.5秒

# 使用 jmap 查看内存中的对象分布
jmap -histo <pid> | head -30
# 输出内存中最大的30个对象,看是否有异常占用

S 平台分析后发现,内存中有大量 byte[] 数组对象占用,这些是缓存未设置合理上限导致的堆外内存泄漏(后面会详细讲)。经过清理后,堆内存使用率稳定在 50% 左右。

3.3 G1 调优实战

考虑到 S 平台的内存为 4GB,目标停顿时间为 200ms,我推荐将 GC 切换为 G1(G1 是 JDK 9+的默认回收器,JDK 8 也可用)。G1 的核心优势是可以通过 -XX:MaxGCPauseMillis 设置停顿目标,JVM 会尽量把 GC 停顿控制在目标时间内。

调优后的 JVM 参数:

bash 复制代码
java -Xms4g -Xmx4g \
     -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=150 \        # 目标停顿150ms
     -XX:G1HeapRegionSize=4m \         # Region大小4MB
     -XX:InitiatingHeapOccupancyPercent=45 \  # 堆占用45%时开始并发标记
     -XX:G1ReservePercent=15 \         # 预留15%内存防止晋升失败
     -XX:ParallelGCThreads=8 \        # 并行GC线程数
     -XX:ConcGCThreads=4 \             # 并发GC线程数
     -XX:+UseStringDeduplication \     # 字符串去重( JDK 8u20+)
     -XX:+ClassUnloadingWithNativeStack \
     -Xlog:gc*=info:file=/var/log/gc.log:time,uptime,level,tags \
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/log/heapdump.hprof

调优后效果:Full GC 频率从每 5 分钟一次降低到几乎消失(每天可能有一次 Minor GC),GC 相关的停顿从 P99 的 2-3 秒降低到了 <100ms。这个收益是巨大的。

3.4 踩坑:G1 调参不当导致更频繁的 GC

G1 虽好,但调不好也会出问题。S 平台隔壁组的另一个服务也切换到了 G1,但他们设置 -XX:MaxGCPauseMillis=50,即要求停顿不超过 50ms。结果 G1 为了满足这个目标,频繁触发 GC------因为 50ms 的目标太激进,G1 只能靠频繁 GC 来保持堆内存不要太高,导致吞吐量严重下降,TPS 从 5000 降到了 2000。

教训是:MaxGCPauseMillis 设置太低会影响吞吐量。 这个参数是"JVM 会尽量达到"的目标,不是硬性保证。正确的做法是先设一个合理的值(比如 150-200ms),观察效果后再微调。

另一个常见问题是 -XX:InitiatingHeapOccupancyPercent 设置不当。如果设得太低(比如 30),G1 会过早开始并发标记,浪费 CPU;如果设得太高(比如 80),CMS 还没来得及并发标记就满了,触发 Full GC。一般建议设在 40-50。

四、SQL 优化:索引与查询的博弈

4.1 N+1 查询问题及其解决方案

回到 S 平台的 N+1 查询问题,这是典型的 ORM 使用不当导致的性能杀手。N+1 查询的本质是:Hibernate/MyBatis 等 ORM 框架默认会为每条主记录单独发起一次关联查询。

解决方案有两个方向:

方案一:JOIN 查询。 将 N+1 改为一次 JOIN 查询:

sql 复制代码
-- 优化后的查询:一次JOIN搞定
SELECT
    f.user_id,
    f.content,
    f.create_time,
    u.nickname,
    u.avatar
FROM feed f
LEFT JOIN user u ON f.user_id = u.user_id
WHERE f.visible = 1
ORDER BY f.create_time DESC
LIMIT 100;

但 JOIN 查询也有坑:当 feed 表和 user 表都是大表时,JOIN 可能导致性能劣化。此时需要确保关联字段有索引。

方案二:批量查询 IN。 先查 feed 表获取 100 个 user_id,再一次 IN 查询获取用户信息:

sql 复制代码
-- 第一次查询:获取 feed
SELECT user_id, content, create_time
FROM feed WHERE visible=1
ORDER BY create_time DESC LIMIT 100;

-- 第二次查询:批量获取用户信息(使用IN)
SELECT user_id, nickname, avatar
FROM user
WHERE user_id IN (123, 456, 789, ...);  -- 最多100个ID

-- 对应 MyBatis 配置:
<select id="getFeeds" resultMap="FeedResultMap">
    SELECT * FROM feed WHERE visible=1 ORDER BY create_time DESC LIMIT 100
</select>

<select id="getUsersByIds" resultType="map">
    SELECT user_id, nickname, avatar
    FROM user
    WHERE user_id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</select>

S 平台最终选择了方案二,配合 Redis 缓存用户信息(用户信息变化不频繁,适合缓存),实际只有少量缓存未命中的用户需要查库,将 100 次查询降低到了最多 10 次。

4.2 索引加错导致的灾难

索引能加速查询,但加错索引不仅无效,还可能拖慢写入性能。这里有一个真实的踩坑案例。

某团队发现一个列表查询很慢,看了 EXPLAIN 之后发现是全表扫描,于是加了一个索引:

sql 复制代码
-- 原查询
SELECT * FROM orders
WHERE user_id = 123
  AND status = 'paid'
  AND create_time > '2024-01-01'
ORDER BY create_time DESC
LIMIT 20;

-- 添加索引(错误的做法)
CREATE INDEX idx_orders_status ON orders(status);

加了这个索引后,查询确实快了,但写入性能开始下降------每次 INSERT 和 UPDATE orders 表时,数据库需要额外维护这个索引。更糟糕的是,这个索引完全没有被用到,因为 user_id = 123 的过滤性最强,应该作为索引的前缀列。

正确的索引应该是:

sql 复制代码
-- 正确的复合索引设计
-- 原则:等值条件在前,范围条件在后;过滤性强的列在前
CREATE INDEX idx_orders_user_status_time
ON orders(user_id, status, create_time DESC);

-- 如果 status 有多个枚举值且分布不均,可以利用索引的覆盖特性
CREATE INDEX idx_orders_user_status_time_covering
ON orders(user_id, status, create_time DESC)
INCLUDE (total_amount, order_no);  -- 覆盖列,避免回表

如何验证索引是否正确生效?使用 EXPLAIN:

sql 复制代码
EXPLAIN SELECT * FROM orders
WHERE user_id = 123
  AND status = 'paid'
  AND create_time > '2024-01-01'
ORDER BY create_time DESC
LIMIT 20;

-- 关键列解读:
-- type: ref 或 range 表示使用了索引,ALL 表示全表扫描
-- key: 显示实际使用的索引名
-- rows: 扫描的行数,越少越好
-- Extra: Using filesort 表示额外的排序操作,需要优化

4.3 索引加错导致全表扫描

这是另一个真实的踩坑故事。团队发现一个联合查询很慢,字段 phone 是 varchar 类型,有索引,但查询时用了全表扫描:

sql 复制代码
-- 查询:使用 phone 作为条件
SELECT * FROM user WHERE phone = 13800138000;

-- EXPLAIN 发现 type=ALL,全表扫描

乍一看是索引失效,深入分析后发现原因啼笑皆非:phone 字段在数据库中存的是字符串(带引号),而查询条件写的是数字。 MySQL 在比较时做了隐式类型转换,把字符串转成数字,这个转换导致索引失效。

sql 复制代码
-- 错误的写法(导致索引失效)
SELECT * FROM user WHERE phone = 13800138000;

-- 正确的写法
SELECT * FROM user WHERE phone = '13800138000';  -- 字符串字面量

-- 或者转换查询条件
SELECT * FROM user WHERE CAST(phone AS UNSIGNED) = 13800138000;  -- 显式转换

类型不匹配导致索引失效是一个极其常见但又容易被忽视的问题。这个 bug 修复后,查询时间从 800ms 降到了 5ms。

五、缓存优化:缓存的艺术与代价

5.1 缓存设计的核心原则

缓存是性能优化中最有效的手段之一,但也是最容易出问题的手段。S 平台Feed 接口优化中,引入 Redis 缓存是最关键的举措之一:

java 复制代码
// 缓存 Feed 数据的典型实现
@Service
public class FeedService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private FeedMapper feedMapper;

    private static final String FEED_KEY_PREFIX = "feed:user:";
    private static final int CACHE_TTL_SECONDS = 300; // 5分钟

    public List<Feed> getFeedList(Long userId, int offset, int limit) {
        String cacheKey = FEED_KEY_PREFIX + userId + ":" + offset + ":" + limit;

        // 先查缓存
        Object cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return (List<Feed>) cached;
        }

        // 缓存未命中,查数据库
        List<Feed> feeds = feedMapper.selectFeedList(userId, offset, limit);

        // 写入缓存
        redisTemplate.opsForValue().set(cacheKey, feeds,
            CACHE_TTL_SECONDS, TimeUnit.SECONDS);

        return feeds;
    }
}

这段代码看起来简单,但藏着几个常见问题。

5.2 缓存雪崩:同一时间全部失效

S 平台上线缓存后第三天,高峰期突然出现大量请求击穿缓存直接打到数据库,系统差点雪崩。事后分析发现:CACHE_TTL_SECONDS = 300,而每天凌晨 0 点有一批定时任务批量刷新了用户的 feed 数据,导致 0 点时刻大量缓存同时过期。随后所有请求都穿透到数据库,数据库瞬间扛不住。

解决方案是缓存过期时间随机化 + 热点数据永不过期

java 复制代码
// 改进:缓存时间加随机偏移,防止同时失效
private int getCacheTTL() {
    // 基础300秒 + 0~120秒随机偏移 = 实际过期时间300~420秒
    return CACHE_TTL_SECONDS + new Random().nextInt(120);
}

// 热点数据使用主动刷新,不依赖过期
public List<Feed> getFeedListWithRefresh(Long userId, int offset, int limit) {
    String cacheKey = FEED_KEY_PREFIX + userId + ":" + offset + ":" + limit;

    List<Feed> feeds = (List<Feed>) redisTemplate.opsForValue().get(cacheKey);

    if (feeds == null) {
        // 缓存未命中,查库并写入缓存
        feeds = feedMapper.selectFeedList(userId, offset, limit);
        redisTemplate.opsForValue().set(cacheKey, feeds, getCacheTTL(), TimeUnit.SECONDS);
    } else {
        // 缓存命中,检查是否需要异步刷新(后台更新缓存,用户先拿到旧数据)
        String refreshLockKey = "feed:refresh:lock:" + userId;
        Boolean acquired = redisTemplate.opsForValue()
            .setIfAbsent(refreshLockKey, "1", 10, TimeUnit.SECONDS);
        if (Boolean.TRUE.equals(acquired)) {
            // 使用异步线程刷新缓存,不阻塞用户请求
            CompletableFuture.runAsync(() -> {
                List<Feed> freshFeeds = feedMapper.selectFeedList(userId, offset, limit);
                redisTemplate.opsForValue().set(cacheKey, freshFeeds, getCacheTTL(), TimeUnit.SECONDS);
            });
        }
    }

    return feeds;
}

5.3 缓存击穿:热点 key 失效的瞬间

缓存雪崩是大量 key 同时失效,缓存击穿是单个热点 key 失效的瞬间,大量请求同时穿透到数据库

S 平台的某个大 V 用户有 500 万粉丝,他的 feed 缓存过期时,500 万粉丝同时刷新,恰好缓存失效,所有请求都打到了数据库。解决方案是使用分布式锁,只允许一个请求查库,其他请求等待缓存恢复

java 复制代码
public List<Feed> getFeedWithLock(Long userId, int offset, int limit) {
    String cacheKey = FEED_KEY_PREFIX + userId + ":" + offset + ":" + limit;
    Object cached = redisTemplate.opsForValue().get(cacheKey);

    if (cached != null) {
        return (List<Feed>) cached;
    }

    // 使用 Redis SETNX 实现分布式锁
    String lockKey = "feed:lock:" + userId;
    String lockValue = UUID.randomUUID().toString();

    // 获取锁,最多等待3秒,锁自动10秒后释放
    Boolean acquired = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, lockValue, 3, TimeUnit.SECONDS);

    if (Boolean.TRUE.equals(acquired)) {
        try {
            // 查库
            List<Feed> feeds = feedMapper.selectFeedList(userId, offset, limit);
            redisTemplate.opsForValue().set(cacheKey, feeds, getCacheTTL(), TimeUnit.SECONDS);
            return feeds;
        } finally {
            // 释放锁(注意:这里用Lua脚本确保原子性)
            String luaScript =
                "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                "return redis.call('del', KEYS[1]) else return 0 end";
            redisTemplate.execute(
                new DefaultRedisScript<>(luaScript, Long.class),
                Collections.singletonList(lockKey),
                lockValue
            );
        }
    } else {
        // 未获取到锁,短暂等待后重试(最多重试3次)
        for (int i = 0; i < 3; i++) {
            try {
                Thread.sleep(100); // 100ms
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            Object retryCached = redisTemplate.opsForValue().get(cacheKey);
            if (retryCached != null) {
                return (List<Feed>) retryCached;
            }
        }
        // 重试失败,直接查库(兜底策略)
        return feedMapper.selectFeedList(userId, offset, limit);
    }
}

5.4 缓存与数据库的双写一致性

这是最复杂也最容易出 bug 的部分。很多团队在"先更新数据库还是先更新缓存"这个问题上反复横跳。

原则一:读请求优先读缓存,写请求先更新数据库再删除缓存。 这是业界推荐的做法,叫"Cache Aside"模式。不要更新缓存,而是删除缓存------因为更新缓存可能造成数据不一致,删除缓存则天然保证下次读取会从数据库加载最新数据。

java 复制代码
// 更新用户信息:正确做法
@Transactional
public void updateUser(User user) {
    // 第一步:更新数据库
    userMapper.updateById(user);

    // 第二步:删除缓存(不是更新!)
    String userCacheKey = "user:" + user.getUserId();
    redisTemplate.delete(userCacheKey);
    // 不要 set,因为可能数据库还没提交完成就被其他请求读到并存入缓存

    // 第三步(可选):延迟双删,防止并发更新导致的不一致
    // 在更新后几百毫秒再删除一次
    CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS)
        .execute(() -> redisTemplate.delete(userCacheKey));
}

关于"先删缓存再更新数据库"还是"先更新数据库再删缓存"的选择:

  • 先删缓存再更新数据库:风险是删除缓存后到更新数据库前,其他请求会读到旧数据并存入缓存(脏数据)。可以通过延迟双删缓解。
  • 先更新数据库再删缓存:风险更小,是主流推荐方案。但也有极端情况:线程 A 查库得到旧值,线程 B 更新数据库并删缓存,线程 A 把旧值写入缓存。这个概率极低,可以通过给缓存设置极短的 TTL 来缓解。

六、系统性优化:多管齐下的综合调优

6.1 连接池配置

S 平台早期数据库连接池配置过大:

yaml 复制代码
# 原始配置(存在问题)
spring:
  datasource:
    hikari:
      maximum-pool-size: 50      # 50个连接太多了
      minimum-idle: 10
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000
      connection-test-query: SELECT 1

连接池过大的问题:每个数据库连接都是操作系统的宝贵资源,连接数过多会导致上下文切换开销增加、内存占用增加,甚至触发数据库的连接数限制。

连接数的计算有一个经验公式:最佳连接数 = ((核心数 * 2) + 磁盘数)。对于一个有 8 核 CPU 的应用,连接池 20-30 就足够了。

yaml 复制代码
# 优化后的配置
spring:
  datasource:
    hikari:
      maximum-pool-size: 25        # 核心*2 + 适量缓冲
      minimum-idle: 5              # 最小保持5个连接
      connection-timeout: 5000     # 5秒超时,尽快发现问题
      idle-timeout: 300000         # 5分钟无活动释放
      max-lifetime: 1200000       # 20分钟强制刷新连接
      leak-detection-threshold: 60000  # 1分钟未归还视为泄漏
      connection-test-query: SELECT 1

6.2 分页加载

S 平台原来一次加载 100 条 feed,实际上用户首屏只需要看前 20 条,一次加载 100 条意味着传输了大量用户根本不会看的数据,浪费带宽和内存。优化后改为分页加载:

java 复制代码
// 前端分页参数
public class FeedRequest {
    private Integer page = 1;      // 页码,默认1
    private Integer pageSize = 20; // 每页大小,默认20
}

// 后端分页查询优化
public PageResult<Feed> getFeedPage(FeedRequest request) {
    int offset = (request.getPage() - 1) * request.getPageSize();
    int limit = request.getPageSize();

    List<Feed> feeds = feedMapper.selectFeedPage(
        request.getUserId(),
        offset,
        limit
    );

    int total = feedMapper.countFeed(request.getUserId()); // 查询总数

    return new PageResult<>(feeds, total, request.getPage(), request.getPageSize());
}

这样首屏加载时间从 800ms 降到了 150ms(一次只加载 20 条),大幅提升了用户体验。

七、优化成果总结

经过上述一系列优化,S 平台的 Feed 接口性能对比如下:

指标 优化前 优化后 提升幅度
P50 响应时间 200ms 30ms 6.7x
P95 响应时间 1500ms 80ms 18.75x
P99 响应时间 3000ms 180ms 16.7x
数据库连接数 50 25 50% 节省
服务器 CPU 使用率 35%(大量等待) 15%(真正计算) 更高效
Full GC 频率 每5分钟一次 每天1-2次 95% 减少
系统吞吐量 2000 TPS 8000 TPS 4x

最终 P99 从 3 秒降到了 180ms,远超最初设定的 200ms 目标。

八、性能优化的八条军规

回顾整个优化过程,我总结了八条性能优化的核心原则,这些原则比任何具体的调参技巧都重要:

第一条:测量优先,不要猜测。 性能瓶颈在哪里,必须用数据说话。很多性能问题看起来的瓶颈和实际的瓶颈完全不在同一个地方。

第二条:找到瓶颈的根源,而不是表面症状。 GC 停顿只是症状,根源可能是内存泄漏或者代码中有大量临时对象创建。治标不治本,问题会反复出现。

第三条:优化收益最大的地方。 性能优化是讲投入产出比的。一个占 1% 运行时间的代码,优化到零耗时,总收益只有 1%。一个占 80% 运行时间的代码,优化 50%,总收益就是 40%。

第四条:不要盲目加机器。 S 平台的案例已经说明,架构和代码层面的问题,加机器不仅无效,反而可能因为增加了复杂度而引入新的问题。先优化代码,再考虑扩容。

第五条:缓存要谨慎,一致性是核心问题。 缓存用得好是性能倍增器,用不好是数据不一致的根源。引入缓存前必须想清楚:缓存过期策略是什么?缓存击穿怎么办?缓存雪崩怎么预防?

第六条:索引不是越多越好。 每个索引都会增加写入的开销(INSERT/UPDATE/DELETE 需要同时更新索引),而且索引会占用额外的磁盘空间。只在真正需要加速查询的地方加索引。

第七条:JVM 参数要基于实际数据调优。 GC 参数没有万能公式,必须通过监控数据(GC 日志、堆内存使用曲线)来动态调整。设一个保守的目标(比如 200ms),观察效果再迭代。

第八条:建立性能基准线,持续监控。 优化完成后,建立性能回归测试和监控体系,防止代码变更或数据量增长导致性能退化。性能问题往往是渐进式恶化的,发现得越早,修复成本越低。

九、结语

性能优化是一场持久战,不是一次性项目。S 平台的这次优化持续了整整两个月,涉及架构、代码、数据库、运维等多个团队。但更重要的是:优化完成后,团队建立了完整的性能监控和回归测试体系,从此性能退化能被第一时间发现和处理。

性能优化的最高境界,不是把一个已经变慢的系统救回来,而是让系统永远不会变慢------通过架构设计的前瞻性、代码实现的规范性和持续监控的敏锐性,让性能问题消弭于萌芽状态。这才是真正的工程能力。

性能优化没有银弹,但有方法论。掌握方法论,才能在面对任何性能问题时都胸有成竹。

本文来自架构实战系列,专注分享真实踩坑经验