
陌陌怎么加好友背后的并发陷阱与性能优化实战
刚入职那会儿,我盯着屏幕上满屏的 NullPointerException 和 Connection Reset 报错,心里只有一个念头:这代码明明能跑,为啥一上量就崩?很多新手都卡在这一步,语法背得滚瓜烂熟,LeetCode 题刷了几百道,但真到了项目里,面对陌陌怎么加好友这种高并发场景,立马就懵了。你写的逻辑是对的,但服务器扛不住,用户等不及,这就是典型的“学会语法却不知怎么搭项目”。
今天不聊虚的,直接拆解我在某头部社交 App 后端开发中踩过的深坑。我们要解决的核心问题,就是如何在陌陌怎么加好友这个高频操作里,既保证数据一致性,又实现极致的性能优化。别以为加好友只是简单的 INSERT 一下,里面的门道够你喝一壶的。
坑的现象:好友列表加载慢到怀疑人生
先说现象。测试环境里,用户 A 加用户 B,毫秒级响应,丝滑流畅。一旦切到生产环境,或者模拟千级并发压测,问题就来了。
用户发起“加好友”请求后,前端转圈超过 3 秒,直接超时。后端日志里全是 SocketTimeoutException 和 Lock wait timeout exceeded。更诡异的是,数据库连接池打满了,CPU 飙升到 90%,但 QPS 却只有几百。这时候,运维小哥第一反应是“加机器”,但加了也没用,因为瓶颈不在算力,而在锁竞争和无效 IO。
很多新人第一反应是:是不是索引没建对?是不是 SQL 写错了?其实都不是。这是一个典型的状态机竞争问题。在陌陌怎么加好友的业务逻辑里,通常涉及三种状态:未请求、待通过、已是好友。如果在高并发下,两个请求同时判断状态为 未请求,然后同时尝试插入 pending_request 表,数据库的行锁就会瞬间打架。
更糟糕的是,很多团队为了“稳妥”,在事务里加了 SELECT ... FOR UPDATE 去锁住目标用户的主记录。想象一下,一个热门网红账号,每秒被几百人点击“加好友”,你的数据库主键行就被锁住了几百次,其他业务查询全部阻塞。这就是为什么你加了索引、加了机器,性能依然糟糕的原因。
根本原因:同步阻塞与状态不一致
要解决这个问题,必须看清底层机制。核心痛点在于读后写(Read-Write)模式下的竞态条件。
传统的实现逻辑通常是这样的:
查询当前关系状态。
如果状态是 未请求,则插入一条 pending 记录。
更新状态为 pending。
发送通知。
在这个流程中,步骤 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);
}
问题解析:
竞态条件:步骤 2 和 3 之间没有原子性保护,高并发下数据可能错乱。
同步阻塞:步骤 4 和 5 是远程 RPC 调用,网络抖动会导致主线程挂起,Tomcat 线程池迅速耗尽。
缓存不一致:步骤 6 在最后执行,如果步骤 5 失败,事务回滚,但缓存可能已经被删除或之前的操作产生了脏数据。
正确写法:乐观锁 + 异步消息 + 最终一致性
// 语言: Java (Spring Boot + RabbitMQ)
// 场景: 用户A 添加 用户B
public 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());
}
优势解析:
原子性保障:利用数据库唯一索引(UNIQUE KEY)天然具备的原子性,替代了繁琐的 SELECT FOR UPDATE。并发插入时,只有一个能成功,其他直接抛异常,无需加分布式锁,性能提升数个数量级。
吞吐量暴涨:主流程只做一次 DB 插入和一次 Redis 删除,耗时从 200ms+ 降低到 5ms 以内。后续的通知、风控、推荐全部异步化,互不干扰。
最终一致性:通过 MQ 保证消息不丢失(配合 ACK 机制),即使消息服务宕机,消息也会堆积在队列中,恢复后继续处理,保证了业务的最终一致。
复现与修复代码:从 Demo 到生产
光看代码不够,我们来看一个具体的复现场景和修复细节。
假设我们有一个简单的 Go 语言服务(因为 Go 在高性能后端中很常见),我们来模拟一下从“错误”到“正确”的修复过程。
场景复现:Redis 缓存穿透
在“陌陌怎么加好友”的场景中,如果用户 B 是一个不存在的 ID(或者已注销),每次请求都会穿透 Redis,打到数据库。
错误代码 (Go):
// 语言: Go
func AddFriend(userID, targetID int64) error {
// 1. 查 Redis
key := 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 不存在时,dbErr 是 sql.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
}
关键修复点:
空值缓存:将查不到的结果也缓存起来,TTL 设短一点(30s),平衡了穿透风险和实时性。
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 唯一索引?在性能优化过程中,你们遇到过最头疼的坑是什么?欢迎在评论区聊聊,我们一起避坑。