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_schema 中 sum_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. 最终结论
结合以下证据:
WEIBO_RECENT_POSTER_THINK_TIME_SECONDS=0后 QPS 无变化,排除脚本 sleep 限速;SHOW PROCESSLIST中大量连接卡在INSERT INTO notifications;- 出现
waiting for handler commit; performance_schema显示notifications表基础行 I/O 平均等待约 510ms;- 应用和 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 在高并发业务系统中的核心价值:把非强一致派生工作从用户请求主链路中解耦出来。