Follow 接口压测 QPS 卡在 90 的瓶颈排查复盘

Follow 接口压测 QPS 卡在 90 的瓶颈排查复盘

1. 背景

在对微博系统的 follow 接口进行压力测试时,发现一个比较反直觉的现象:

  • follow 接口 QPS 到 90 左右后基本上不再上升;
  • 应用服务器 CPU 使用率只有 20% 左右;
  • MySQL 所在机器 CPU 使用率也只有 50% 左右;
  • 继续增加并发后,QPS 提升不明显,接口延迟开始上升。

从资源使用率看,应用和数据库似乎都没有“打满”,但系统吞吐却已经进入瓶颈区间。

本文记录这次 follow 压测瓶颈的完整排查过程,包括最初的假设、排除过程、关键证据,以及最终确定瓶颈在 notifications 表同步写入上的原因。

2. 压测场景

本次关注的是 follow 接口:

PUT /api/v1/users/{userId}/follow
Authorization: Bearer <accessToken>

对应的核心业务流程大致如下:

用户发起关注请求
  ├─ 校验不能关注自己
  ├─ 按稳定顺序锁定当前用户和目标用户
  ├─ 校验目标用户状态
  ├─ 插入 follows 关注关系
  ├─ 更新当前用户 following_count
  ├─ 更新目标用户 fan_count
  ├─ 写用户资料变更 outbox
  ├─ 创建关注通知
  ├─ 写 timeline follow backfill outbox
  ├─ 判断是否大 V并更新缓存
  └─ 返回 follow 响应

代码中 follow 主流程位于:

src/main/java/com/autmaple/weibo/follow/service/FollowService.java

其中核心同步事务是:

@Transactional
public FollowResponse follow(Long currentUserId, Long targetUserId) {
    ...
}

3. 初始现象

压测时观察到:

QPS ≈ 90
应用 CPU ≈ 20%
MySQL CPU ≈ 50%

直觉上,如果 CPU 没有打满,系统似乎还应该能继续提升吞吐。但实际 QPS 却几乎不再上涨。

这说明瓶颈很可能不是 CPU 型瓶颈,而是等待型瓶颈,例如:

  • 数据库连接池等待;
  • MySQL 行锁等待;
  • commit / fsync 等待;
  • 磁盘 I/O 等待;
  • Kafka / Redis / 下游组件等待;
  • 压测脚本自身限速。

4. 第一阶段:排查压测脚本是否限制了 QPS

最开始怀疑是 k6 脚本的 think time 限制了请求产生速率。

在部分 k6 脚本中,存在类似配置:

const THINK_TIME_SECONDS = parseNonNegativeNumber(__ENV.THINK_TIME_SECONDS, 1);

如果每个 VU 每次请求后都 sleep 1 秒,那么 QPS 上限近似为:

QPS ≈ VU / (请求耗时 + think time)

例如 100 VU、think time 1 秒、请求耗时 100ms 时:

QPS ≈ 100 / 1.1 ≈ 90.9

这与 QPS 卡在 90 左右非常接近。

但本次实际使用的是 recent-poster-follow-benchmark.k6.js,它的 think time 默认已经是 0:

const THINK_TIME_SECONDS = parseNonNegativeNumber(
  __ENV.WEIBO_RECENT_POSTER_THINK_TIME_SECONDS,
  0
);

即使显式加上:

WEIBO_RECENT_POSTER_THINK_TIME_SECONDS=0

QPS 仍然没有变化。

因此可以排除:

压测脚本 think time 限制 QPS

5. 第二阶段:怀疑数据库锁等待

由于 follow 接口会对当前用户和目标用户加锁:

lockUsersInStableOrder(currentUserId, targetUserId);

底层 SQL 是:

@Select("SELECT * FROM users WHERE id = #{userId} FOR UPDATE")
User selectByIdForUpdate(@Param("userId") Long userId);

follow 成功后还会更新两个计数字段:

userRepository.incrementFollowingCount(currentUserId);
userRepository.incrementFanCount(target.getId());

因此,第二个怀疑方向是:

users 表行锁竞争 / fan_count 热点更新

尤其是在大量用户关注少量目标用户时,目标用户的 fan_count 更新可能形成热点行锁。

如果是这个问题,常见现象是:

  • CPU 不高;
  • QPS 不上升;
  • 接口延迟升高;
  • MySQL 中出现 row lock wait;
  • Hikari 连接池 active 接近上限,pending 增加。

不过这还只是推测,需要进一步看 MySQL 当前到底在执行什么 SQL。

6. 第三阶段:查看 SHOW PROCESSLIST

压测过程中执行:

SHOW PROCESSLIST;

得到的关键现象是,大量连接都处于:

Query, update, INSERT INTO notifications ...

部分连接处于:

waiting for handler commit, commit

简化后的典型输出如下:

Id     User  DB     Command  Time  State                       Info
15424  root  weibo  Query    6     update                      INSERT INTO notifications ...
15426  root  weibo  Query    6     update                      INSERT INTO notifications ...
15439  root  weibo  Query    6     update                      INSERT INTO notifications ...
15501  root  weibo  Query    0     waiting for handler commit  commit
15517  root  weibo  Query    0     waiting for handler commit  commit
15601  root  weibo  Query    11    update                      INSERT INTO notifications ...

这个结果非常关键。

如果瓶颈是 users 行锁,通常会看到大量连接卡在:

SELECT ... FOR UPDATE
UPDATE users ...
Waiting for row lock

但实际大量连接集中在:

INSERT INTO notifications
commit

因此瓶颈方向开始从 users 行锁,转向:

notifications 表写入 / commit 等待

7. follow 为什么会同步写 notifications?

FollowService.follow() 中,新增关注成功后会同步创建关注通知:

notificationService.createFollowNotification(currentUserId, targetUserId);

NotificationService.createFollowNotification() 是强制参与外层事务的:

@Transactional(propagation = Propagation.MANDATORY)
public void createFollowNotification(Long actorId, Long recipientId) {
    createNotification(CreateNotificationCommand.builder()
            .actorId(actorId)
            .recipientId(recipientId)
            .type(NotificationTypeEnum.FOLLOW)
            .targetType(NotificationTargetTypeEnum.USER)
            .targetId(actorId)
            .build());
}

也就是说,follow 请求必须等待关注通知写入 notifications 表完成后,整个事务才能提交,HTTP 请求才能返回。

当前链路实际上是:

follow HTTP 请求
  ├─ insert follows
  ├─ update users counters
  ├─ insert user profile outbox
  ├─ INSERT notifications        ← 同步阻塞点
  ├─ insert timeline outbox
  └─ commit

更重要的是,用户行锁是在事务前半段就拿到的。如果 notifications 插入很慢,会延长整个 follow 事务的持锁时间,从而进一步放大并发等待。

8. 第四阶段:查看 performance_schema 表 I/O 等待

为了进一步确认 notifications 表是否真的慢,执行:

SELECT
    object_schema,
    object_name,
    index_name,
    count_star,
    sum_timer_wait
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE object_schema = 'weibo'
  AND object_name = 'notifications'
ORDER BY sum_timer_wait DESC;

执行结果:

weibo,notifications,,219851,112137458454293840
weibo,notifications,uk_notifications_dedup,439704,149128403458860
weibo,notifications,idx_notifications_recipient_read_created,222821,8033491369320
weibo,notifications,PRIMARY,0,0
weibo,notifications,idx_notifications_recipient_created,0,0
weibo,notifications,idx_notifications_actor_created,0,0
weibo,notifications,idx_notifications_post,0,0
weibo,notifications,idx_notifications_comment,0,0

performance_schemasum_timer_wait 通常单位是皮秒:

1 秒 = 1,000,000,000,000 ps

其中最关键的一行是:

index_name = NULL
count_star = 219851
sum_timer_wait = 112137458454293840

换算后:

总等待时间 ≈ 112137 秒
平均每次 ≈ 112137 / 219851 ≈ 0.51 秒

也就是说:

notifications 表基础行 I/O 平均等待约 510ms / 次

对比唯一索引:

uk_notifications_dedup:
149128403458860 ps / 439704 ≈ 0.34ms / 次

这说明:

不是 uk_notifications_dedup 这个索引查询本身慢,
而是 notifications 表基础写入 / commit 等待非常重。

9. 为什么 CPU 不高但 QPS 上不去?

这次问题的关键点在于:

瓶颈不是 CPU 计算,而是数据库写入/提交等待。

MySQL CPU 只有 50%,并不代表数据库没有瓶颈。

常见的等待型瓶颈包括:

  • redo log flush;
  • binlog flush;
  • group commit 等待;
  • 磁盘 fsync 延迟;
  • InnoDB handler commit;
  • 表数据页写入;
  • 脏页刷盘;
  • 高并发小事务提交排队。

这些等待不会表现为 CPU 打满,而会表现为:

应用线程等待数据库响应
MySQL 线程等待存储引擎提交
数据库连接长期被占用
HTTP 请求延迟升高
QPS 到达平台期

这也解释了为什么:

应用 CPU 20%
MySQL CPU 50%
QPS 却只有 90 左右

10. notifications 表为什么写入成本高?

notifications 表结构中有多个索引:

PRIMARY KEY (id),
KEY idx_notifications_recipient_read_created (recipient_id, read_flag, created_at, id),
KEY idx_notifications_recipient_created (recipient_id, created_at, id),
KEY idx_notifications_actor_created (actor_id, created_at),
KEY idx_notifications_post (post_id),
KEY idx_notifications_comment (comment_id)

后续还新增了唯一去重索引:

UNIQUE KEY uk_notifications_dedup (
  recipient_id,
  actor_id,
  type,
  target_type,
  target_id
)

每插入一条通知,MySQL 都需要维护:

1 个主键索引
多个二级索引
1 个唯一去重索引
事务日志
binlog
commit

在高并发 follow 压测中,每个有效 follow 都要同步写一条 notification,这会把 follow 接口吞吐绑定到 notifications 表写入能力上。

11. 最终结论

结合以下证据:

  1. WEIBO_RECENT_POSTER_THINK_TIME_SECONDS=0 后 QPS 无变化,排除脚本 sleep 限速;
  2. SHOW PROCESSLIST 中大量连接卡在 INSERT INTO notifications
  3. 出现 waiting for handler commit
  4. performance_schema 显示 notifications 表基础行 I/O 平均等待约 510ms;
  5. 应用和 MySQL CPU 都未打满,符合等待型瓶颈特征;

可以确定:

follow QPS 卡在 90 左右的主要瓶颈,是 FollowService 在同步事务内创建关注通知,
导致大量请求阻塞在 notifications 表插入和 commit 阶段。

更准确地说:

同步关注通知写入将 follow HTTP 主链路与 notifications 表最终写入吞吐强耦合,
notifications 表写入/提交等待拖长 follow 事务,
进一步占用数据库连接和用户行锁,
最终导致 QPS 无法继续提升。

12. 解决方案

12.1 不推荐只做索引优化

虽然 notifications 表索引较多,写入成本确实存在,但这次 performance_schema 数据显示:

主要等待在基础行写入 / commit,
不是某个单独索引查询。

因此,删除一两个索引可能有帮助,但不是根本解决方案。

根本问题是:

follow 请求必须同步等待 notifications 写入完成。

只要这个同步关系存在,follow QPS 就仍然会受通知表写入吞吐限制。

12.2 推荐方案:关注通知异步化

关注通知是 follow 成功后的派生副作用,不应该阻塞关注主流程。

推荐将当前链路:

follow HTTP 请求
  ├─ insert follows
  ├─ update users counters
  ├─ INSERT notifications
  └─ commit

改为:

follow HTTP 请求
  ├─ insert follows
  ├─ update users counters
  ├─ insert notification outbox
  └─ commit

Debezium / Kafka
  └─ notification consumer
       └─ INSERT notifications

也就是:

FollowService 不再直接写 notifications,
而是在 follow 事务内写 notification outbox event,
由 Kafka consumer 异步创建最终通知。

这样,通知系统慢时,影响的是:

通知延迟
Kafka lag
consumer backlog

而不是:

follow HTTP QPS
follow HTTP latency
用户关注主链路

13. 为什么这个方案适合当前项目?

项目中已经有 outbox CDC 基础设施:

  • domain_event_outbox
  • Debezium Outbox Router
  • Kafka topic
  • notification event listener
  • DLT / replay 机制
  • notification interaction event 处理链路

互动通知已经类似地通过 outbox 异步处理:

PostInteractionService
  └─ NotificationInteractionOutboxService
       └─ domain_event_outbox
            └─ Kafka
                 └─ NotificationInteractionEventListener
                      └─ NotificationService
                           └─ notifications

因此 follow 通知可以复用已有链路,新增类似:

USER_FOLLOWED event

让 consumer 将其映射为原来的 FOLLOW 通知语义:

actorId = 关注者
recipientId = 被关注者
type = FOLLOW
targetType = USER
targetId = 关注者

14. 异步化后的正确性要求

14.1 通知最终一致

follow 成功后,通知不一定立即出现在通知列表中。

这属于预期行为:

关注关系是强一致主数据
通知是最终一致派生数据

14.2 重复消费必须幂等

Kafka / CDC 是至少一次投递,consumer 可能重复消费。

因此异步创建通知必须保证:

同一个 follower 对同一个 followee 的 FOLLOW 通知最多只有一条

当前 notifications 表已有唯一去重索引:

UNIQUE KEY uk_notifications_dedup (
  recipient_id,
  actor_id,
  type,
  target_type,
  target_id
)

可以继续依赖这个唯一键和 duplicate key handling 来保证幂等。

14.3 notification consumer 失败不能影响 follow 关系

异步化后,如果 notification consumer 失败:

follow 关系已经提交
通知事件进入 retry / DLT / replay

不应该回滚已经成功的关注关系。

15. 后续验证指标

改造完成后,需要重新压测并对比以下指标。

follow HTTP 侧

  • QPS 是否明显提升;
  • P95 / P99 是否下降;
  • Hikari active / pending 是否下降;
  • SHOW PROCESSLIST 中是否不再大量出现 INSERT INTO notifications
  • follow 日志中的 elapsedMs 是否降低。

notification 异步侧

  • notification topic lag;
  • notification consumer QPS;
  • consumer 处理耗时;
  • DLT 数量;
  • 最终 notifications 写入是否能在压测结束后回落;
  • 通知列表中 FOLLOW 通知是否最终可见。

MySQL 侧

  • notifications 表写入是否仍慢;
  • 慢是否只影响异步 consumer;
  • waiting for handler commit 是否减少;
  • Hikari pending 是否消失;
  • redo/binlog/disk I/O 是否仍是潜在瓶颈。

16. 总结

这次问题的关键教训是:

CPU 没打满,不代表系统没有瓶颈。

对于高并发写接口,QPS 上不去往往是等待型瓶颈:

  • 行锁等待;
  • 连接池等待;
  • redo/binlog flush;
  • commit 等待;
  • 磁盘 I/O;
  • 同步副作用写入。

本次 follow 压测中,最终确认的瓶颈是:

同步 FOLLOW 通知写入 notifications 表。

解决方向不是简单增加机器 CPU,也不是只优化索引,而是调整链路设计:

主链路只处理关注关系和必要 outbox,
通知作为派生副作用异步创建。

最终目标是让系统退化方式从:

通知表写入慢 → follow 接口慢 → QPS 上不去

变成:

通知表写入慢 → notification lag 增长 → follow 接口仍可正常完成

这也是事件驱动和 outbox CDC 在高并发业务系统中的核心价值:把非强一致派生工作从用户请求主链路中解耦出来。

使用 Hugo 构建
主题 StackJimmy 设计