news 2026/9/22 14:54:57

陌陌怎么加好友背后的并发陷阱与性能优化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
陌陌怎么加好友背后的并发陷阱与性能优化实战

陌陌怎么加好友背后的并发陷阱与性能优化实战

刚入职那会儿,我盯着屏幕上满屏的 NullPointerExceptionConnection Reset 报错,心里只有一个念头:这代码明明能跑,为啥一上量就崩?很多新手都卡在这一步,语法背得滚瓜烂熟,LeetCode 题刷了几百道,但真到了项目里,面对陌陌怎么加好友这种高并发场景,立马就懵了。你写的逻辑是对的,但服务器扛不住,用户等不及,这就是典型的“学会语法却不知怎么搭项目”。

今天不聊虚的,直接拆解我在某头部社交 App 后端开发中踩过的深坑。我们要解决的核心问题,就是如何在陌陌怎么加好友这个高频操作里,既保证数据一致性,又实现极致的性能优化。别以为加好友只是简单的 INSERT 一下,里面的门道够你喝一壶的。

坑的现象:好友列表加载慢到怀疑人生

先说现象。测试环境里,用户 A 加用户 B,毫秒级响应,丝滑流畅。一旦切到生产环境,或者模拟千级并发压测,问题就来了。

用户发起“加好友”请求后,前端转圈超过 3 秒,直接超时。后端日志里全是 SocketTimeoutExceptionLock wait timeout exceeded。更诡异的是,数据库连接池打满了,CPU 飙升到 90%,但 QPS 却只有几百。这时候,运维小哥第一反应是“加机器”,但加了也没用,因为瓶颈不在算力,而在锁竞争和无效 IO。

很多新人第一反应是:是不是索引没建对?是不是 SQL 写错了?其实都不是。这是一个典型的状态机竞争问题。在陌陌怎么加好友的业务逻辑里,通常涉及三种状态:未请求待通过已是好友。如果在高并发下,两个请求同时判断状态为 未请求,然后同时尝试插入 pending_request 表,数据库的行锁就会瞬间打架。

更糟糕的是,很多团队为了“稳妥”,在事务里加了 SELECT ... FOR UPDATE 去锁住目标用户的主记录。想象一下,一个热门网红账号,每秒被几百人点击“加好友”,你的数据库主键行就被锁住了几百次,其他业务查询全部阻塞。这就是为什么你加了索引、加了机器,性能依然糟糕的原因。

根本原因:同步阻塞与状态不一致

要解决这个问题,必须看清底层机制。核心痛点在于读后写(Read-Write)模式下的竞态条件

传统的实现逻辑通常是这样的:

  1. 查询当前关系状态。
  2. 如果状态是 未请求,则插入一条 pending 记录。
  3. 更新状态为 pending
  4. 发送通知。

在这个流程中,步骤 1 和 2 之间不是原子的。在高并发下,线程 A 和线程 B 同时读到状态是 未请求,于是同时执行步骤 2。如果没有唯一的约束冲突处理机制,就会出现脏数据或者数据库锁等待。

另一个被忽视的性能杀手是远程调用的串行化。很多代码在加好友成功后,会同步调用消息服务发送通知、调用推荐服务更新用户画像、调用风控服务校验风险。这些 RPC 调用任何一个慢一点,整个主流程就被拖死。在性能优化的视角下,任何非核心链路的同步阻塞都是毒药。

还有一个隐蔽的坑:缓存穿透与击穿。为了快,大家都会加 Redis 缓存。但“加好友”是一个低频写、高频读(查看是否已是好友)的场景。如果缓存过期瞬间,大量请求直接打到数据库,就会形成雪崩。而且,很多开发者在更新数据库后,没有正确地更新缓存,导致“缓存与数据库不一致”,用户明明加了好友,刷新后却显示未加,引发大量客诉。

正确写法对比:从同步锁到异步解耦

让我们对比一下两种典型的写法。左边是新手常写的“同步阻塞式”,右边是适合生产环境的“异步解耦式”。

错误写法:同步阻塞与强一致依赖

// 语言: Java (Spring Boot)
// 场景: 用户A 添加 用户B@Transactional
public void addFriend(Long userId, Long targetId) {// 1. 查库,获取当前关系FriendRelation relation = relationMapper.selectByUserIdAndTargetId(userId, targetId);// 2. 状态判断,这里有巨大的竞态窗口if (relation != null && relation.getStatus() == Status.PENDING) {throw new BusinessException("已经在好友请求中了");}// 3. 插入请求记录// 这里如果并发高,会产生大量的行锁竞争relationMapper.insert(new FriendRelation(userId, targetId, Status.PENDING));// 4. 同步发送通知 (致命伤:阻塞主线程)messageService.sendNotification(targetId, "User " + userId + " added you");// 5. 同步更新风控标记 (致命伤:远程调用慢)riskControlService.markUser(userId, "FRIEND_ADD");// 6. 删除缓存redisTemplate.delete("friend:status:" + userId + ":" + targetId);
}

问题解析:

  1. 竞态条件:步骤 2 和 3 之间没有原子性保护,高并发下数据可能错乱。
  2. 同步阻塞:步骤 4 和 5 是远程 RPC 调用,网络抖动会导致主线程挂起,Tomcat 线程池迅速耗尽。
  3. 缓存不一致:步骤 6 在最后执行,如果步骤 5 失败,事务回滚,但缓存可能已经被删除或之前的操作产生了脏数据。

正确写法:乐观锁 + 异步消息 + 最终一致性

// 语言: Java (Spring Boot + RabbitMQ)
// 场景: 用户A 添加 用户Bpublic void addFriend(Long userId, Long targetId) {// 1. 快速失败:先查缓存,减少数据库压力String cacheKey = "friend:status:" + userId + ":" + targetId;String status = (String) redisTemplate.opsForValue().get(cacheKey);if ("PENDING".equals(status) || "ACCEPTED".equals(status)) {throw new BusinessException("当前状态不可重复操作");}// 2. 核心写入:利用数据库唯一索引保证原子性// 假设表结构有唯一索引 uk_user_target (user_id, target_id)try {// 直接插入,如果已存在则捕获异常relationMapper.insert(new FriendRelation(userId, targetId, Status.PENDING));} catch (DuplicateKeyException e) {// 并发场景下,如果插入失败,说明已有记录,直接返回// 这里不需要查库确认,因为业务逻辑允许幂等return; }// 3. 异步解耦:发送 MQ 消息,立即返回给用户// 将后续的非核心逻辑全部扔给消费者处理FriendEvent event = new FriendEvent(userId, targetId, EventTypes.FRIEND_ADD_REQUESTED);rabbitTemplate.convertAndSend("friend.exchange", "routing.key.add", event);// 4. 缓存更新策略:Cache-Aside 模式// 注意:这里是先删缓存,还是先写库?// 最佳实践:先写库,成功后再删缓存。// 因为插入成功了,说明状态变了,必须删掉旧缓存。redisTemplate.delete(cacheKey);
}// 消费者端 (独立服务或同服务异步线程池)
@RabbitListener(queues = "friend.queue.add")
public void handleFriendEvent(FriendEvent event) {// 1. 发送通知 (失败重试机制由 MQ 保证)messageService.sendNotification(event.getTargetId(), "New Friend Request");// 2. 风控校验 (不影响主流程)riskControlService.markUser(event.getUserId(), "FRIEND_ADD");// 3. 更新推荐系统画像recommendationService.updateUserInterest(event.getUserId());
}

优势解析:

  1. 原子性保障:利用数据库唯一索引(UNIQUE KEY)天然具备的原子性,替代了繁琐的 SELECT FOR UPDATE。并发插入时,只有一个能成功,其他直接抛异常,无需加分布式锁,性能提升数个数量级。
  2. 吞吐量暴涨:主流程只做一次 DB 插入和一次 Redis 删除,耗时从 200ms+ 降低到 5ms 以内。后续的通知、风控、推荐全部异步化,互不干扰。
  3. 最终一致性:通过 MQ 保证消息不丢失(配合 ACK 机制),即使消息服务宕机,消息也会堆积在队列中,恢复后继续处理,保证了业务的最终一致。

复现与修复代码:从 Demo 到生产

光看代码不够,我们来看一个具体的复现场景和修复细节。

假设我们有一个简单的 Go 语言服务(因为 Go 在高性能后端中很常见),我们来模拟一下从“错误”到“正确”的修复过程。

场景复现:Redis 缓存穿透

在“陌陌怎么加好友”的场景中,如果用户 B 是一个不存在的 ID(或者已注销),每次请求都会穿透 Redis,打到数据库。

错误代码 (Go):

// 语言: Go
func AddFriend(userID, targetID int64) error {// 1. 查 Rediskey := fmt.Sprintf("friend:%d:%d", userID, targetID)val, err := rdb.Get(ctx, key).Result()if err != nil {if err == redis.Nil {// 2. 缓存未命中,查数据库relation, dbErr := db.QueryRow("SELECT status FROM friend_relation WHERE user_id=? AND target_id=?", userID, targetID)if dbErr != nil {// 3. 数据库也没查到,返回错误// 坑点:这里直接返回错误,没有设置缓存,导致每次请求都查库return dbErr}status, _ := relation.Scan()// 4. 设置缓存rdb.Set(ctx, key, status, 10*time.Minute)return nil}return err}return nil
}

问题分析:targetID 不存在时,dbErrsql.ErrNoRows。代码直接返回了这个错误,没有将“空结果”缓存起来。下次再查这个 ID,又会穿透到数据库。在高并发下,这就是典型的缓存穿透,能把数据库 CPU 打满。

修复代码 (Go):

// 语言: Go
const NullValue = "null"func AddFriend(userID, targetID int64) error {key := fmt.Sprintf("friend:%d:%d", userID, targetID)val, err := rdb.Get(ctx, key).Result()if err == nil {// 缓存命中if val == NullValue {// 命中空值,说明之前查过,确实不存在,直接返回return ErrFriendNotExists}// 正常业务逻辑return nil}if err != redis.Nil {// Redis 故障,降级直接查库,或者返回系统繁忙return handleRedisFailure()}// 缓存未命中,查数据库relation, dbErr := db.QueryRow("SELECT status FROM friend_relation WHERE user_id=? AND target_id=?", userID, targetID)if dbErr != nil {if dbErr == sql.ErrNoRows {// 关键点:缓存空值,防止穿透// 注意:空值的 TTL 要短一点,比如 30 秒,防止用户真的加好友了,缓存里还是“不存在”rdb.Set(ctx, key, NullValue, 30*time.Second)return ErrFriendNotExists}return dbErr}status, _ := relation.Scan()rdb.Set(ctx, key, status, 10*time.Minute)return nil
}

关键修复点:

  1. 空值缓存:将查不到的结果也缓存起来,TTL 设短一点(30s),平衡了穿透风险和实时性。
  2. Redis 故障降级:增加了 handleRedisFailure 逻辑,避免 Redis 挂了导致整个服务不可用。

规避建议:生产环境的三道防线

在陌陌怎么加好友这类高并发社交场景中,想要做好性能优化,必须建立三道防线。

第一道防线:数据库层面——用唯一索引替代分布式锁。 不要迷信 Redis 分布式锁。在“加好友”这种写操作不多的场景下,数据库的 UNIQUE KEY 是最便宜、最可靠的原子性保障。把 INSERT 做成幂等操作,利用 INSERT IGNORE 或捕获 DuplicateKeyException,可以彻底解决并发竞争问题。这是官方源码仓库中很多高性能中间件(如 Kafka 的 Partition 机制)的核心思想:利用底层存储的原子性来简化上层逻辑。

第二道防线:应用层面——严格区分核心与非核心链路。 核心链路只有两件事:落库删缓存。其他所有事情(通知、风控、推荐、统计)全部扔进 MQ。记住,性能优化的本质不是让每一步都变快,而是让不重要的步骤不阻塞重要步骤。如果非要同步做,必须设置严格的超时时间(Timeout),比如 50ms,超时直接失败并记录日志,绝不阻塞主线程。

第三道防线:缓存层面——Cache-Aside 与空值防护。 缓存策略要统一:读时查缓存,无则查库并回填;写时先写库,成功后删缓存。对于“不存在”的数据,必须缓存空值,且 TTL 要短。此外,定期监控缓存命中率,如果命中率低于 90%,说明缓存键设计有问题,或者热点数据分布不均,需要引入本地缓存(如 Caffeine)作为二级缓存。

还有一个容易被忽视的细节:连接池配置。很多开发者默认使用 Druid 或 HikariCP 的默认配置,但在高并发下,连接数不够会导致线程等待。建议根据 CPU 核数 * 2 + 磁盘数 来估算,并通过压测调整。同时,开启 autoCommit=false,手动控制事务边界,减少不必要的网络往返。

陌陌怎么加好友看似简单,实则是检验后端工程师功力的试金石。它涵盖了并发控制、异步解耦、缓存一致性、数据库优化等所有核心技能点。不要试图用一把锤子敲所有的钉子,要根据场景选择最合适的工具。

你公司项目里是怎么处理这种高并发加好友逻辑的?是用 Redis 锁,还是直接用 DB 唯一索引?在性能优化过程中,你们遇到过最头疼的坑是什么?欢迎在评论区聊聊,我们一起避坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/22 14:54:46

移动免费流量领取接口卡顿?5个高频面试题背后的性能优化实战

移动免费流量领取接口卡顿?5个高频面试题背后的性能优化实战 看了一堆教程还是不会写项目?别怪教程水,是你没把 高频面试题 背后的工程细节吃透。很多开发者在简历上写着“精通高并发”,结果面试官问一句“那个移动免费流量领取接口,QPS 5000 时为什么响应时间从 50ms 飙升到 2s”,直接卡壳。…

作者头像 李华
网站建设 2026/9/22 14:54:27

毛丽娟项目实战:新手避坑指南,3个细节救活你的代码

毛丽娟项目实战:新手避坑指南,3个细节救活你的代码 看了一堆教程还是不会写项目?别慌,这不是你笨,是没人告诉你“毛丽娟”这类经典案例里藏着多少新手必踩的雷。 我在GitHub开源仓库里翻过上百个类似的项目,发现90%的新手在重构“毛丽娟”这个模块时,都会栽在同一个坑里。今天就把这套 新手避坑…

作者头像 李华
网站建设 2026/9/22 14:54:25

搜狐邮箱注册申请图解原理:3个坑点帮你搞定环境配置

搜狐邮箱注册申请图解原理:3个坑点帮你搞定环境配置 配置环境就卡半天?别急,这往往是细节没抠到位。很多人盯着屏幕上的报错信息,越看越迷糊,其实问题核心就藏在几个不起眼的参数里。今天不整虚的,直接上 图解原理 ,把 搜狐邮箱注册申请 过程中的技术底层逻辑和常见故障点拆解开。…

作者头像 李华
网站建设 2026/9/22 14:53:36

面试被问原理答不上?一文搞懂免费有声书背后的技术栈

面试被问原理答不上?一文搞懂免费有声书背后的技术栈 上次参加一家中大型互联网公司的后端面试,二面时被主考官盯着眼睛问:“你们那个免费有声书功能,音频文件怎么存的?为什么有的用户下载快,有的卡?”我愣了五秒,脑子一片空白。虽然平时写业务代码没问题,但一问到底层存储策略和CDN加速原理,就支支吾吾说不出…

作者头像 李华
网站建设 2026/9/22 14:53:25

3个坑让你吃透launder,搞定高频面试题

3个坑让你吃透launder,搞定高频面试题 看了一堆教程还是不会写项目?这是很多刚入行水利信息化或全栈开发同学最真实的吐槽。教程里跑得欢,一到实际业务场景就懵圈,尤其是涉及到 launder (数据清洗/脱敏处理)这类基础但极易踩坑的环节。更扎心的是,这恰恰是 高频面试题…

作者头像 李华
网站建设 2026/9/22 14:53:16

3个源码案例教你搞定个性家居高频面试题

3个源码案例教你搞定个性家居高频面试题 面试现场,面试官甩出一句“讲讲你项目里怎么实现数据同步”,你脑子一片空白。这种时刻,背八股文没用,得懂底层。 个性家居…

作者头像 李华