抖音一个嘉年华多少人民币背后的性能优化高频面试题实战
官方文档动辄几十页,翻到第三页脑子就懵了?别慌,今天咱们不整虚的。很多后端开发在面试时被问到“抖音一个嘉年华多少人民币”这种看似扯淡的问题,其实是在考你高并发下的状态管理与数据一致性。这是近期大厂高频面试题的变种,核心不在于算钱,而在于如何在一个亿用户同时在线的场景下,保证扣款、发货、退款这三步不丢单、不重扣。
性能瓶颈:为什么你的代码在高峰期为卡
先别急着写代码,咱们得看看真实场景里的坑。假设你正在开发抖音的礼物赠送系统,用户A想送主播B一个“嘉年华”。在正常业务逻辑里,这很简单:扣用户A的钱,加主播B的收入,更新流水表。但在抖音这种量级,问题瞬间爆炸。
第一个瓶颈是数据库连接池耗尽。
当每秒有几万笔礼物交易发生时,如果你的代码是同步执行“查余额 - 扣余额 - 写流水”,每个请求都要占用一个数据库连接。MySQL默认最大连接数通常是151或者几百,这点连接数在万级QPS面前就是杯水车薪。结果就是大量请求在应用层排队,用户端看到的就是“转圈圈”或者超时。
第二个瓶颈是行锁竞争。
主播B是个大V,同时可能有上万个用户给他送礼物。所有扣款操作最终都要更新主播B的“收益”字段。如果直接用 UPDATE streamer SET income = income + 1000 WHERE id = 1,这就是典型的热点行更新。InnoDB的行锁会导致所有针对主播ID=1的写操作串行化。一旦某个事务执行稍慢,后面的请求全部阻塞,系统吞吐量直线下降。
第三个瓶颈是缓存与数据库的一致性延迟。
为了抗读压力,我们肯定要把主播余额、用户余额放进Redis。但Redis是异步刷盘或者内存操作,数据库是持久化。如果用户刚扣款成功,立刻查询余额,发现Redis里的旧数据还在,用户体验极差。更糟糕的是,如果网络抖动导致Redis写入成功但数据库事务回滚,钱扣了但礼物没发出去,这就是资损事故。
很多初级开发者喜欢用“加锁”来解决一切问题,比如用 synchronized 或者 ReentrantLock 把整个业务逻辑包起来。这在单机测试时没问题,但一上分布式环境,锁的粒度太粗,CPU空转严重,GC频率激增,性能直接崩盘。这就是为什么这道高频面试题在面试中如此高频出现——它考察的不是你知不知道Redis,而是你知不知道在高并发下,锁、缓存、数据库三者之间的博弈关系。
优化前代码:典型的同步阻塞陷阱
为了直观展示问题,我们来看一段典型的、未经优化的Java代码。这段代码模拟了赠送礼物的核心逻辑。虽然为了简化省略了具体的Mapper调用细节,但核心逻辑结构是真实的,也是很多线上事故的原因。
@Service
public class GiftServiceNaive {@Autowiredprivate RedisTemplateString, Object redisTemplate;@Autowiredprivate UserMapper userMapper;@Autowiredprivate StreamerMapper streamerMapper;@Autowiredprivate TransactionTemplate transactionTemplate;/*** 赠送礼物 - 性能极差的版本* 问题1: 同步锁导致并发度低* 问题2: 缓存与DB强一致性校验耗时* 问题3: 事务包裹范围过大,包含远程Redis调用*/public boolean sendGift(Long userId, Long streamerId, String giftName) {// 假设一个嘉年华价值 10000 抖币,汇率 1抖币=0.1元,即1000元final int giftCost = 10000; // 使用 synchronized 锁住整个方法,防止并发问题// 这是大忌!锁粒度太大,所有用户都在抢这一把锁synchronized (this) {try {// 1. 查Redis获取用户余额String userKey = user:balance: + userId;Object balanceObj = redisTemplate.opsForValue().get(userKey);if (balanceObj == null) {// 缓存穿透,查DBInteger dbBalance = userMapper.getBalance(userId);redisTemplate.opsForValue().set(userKey, dbBalance, 10, TimeUnit.MINUTES);balanceObj = dbBalance;}int currentBalance = (int) balanceObj;if (currentBalance giftCost) {return false; // 余额不足}// 2. 开启事务return transactionTemplate.execute(status - {// 3. 扣减用户DB余额 (悲观锁 SELECT FOR UPDATE)int affected = userMapper.deductBalance(userId, giftCost);if (affected == 0) {throw new RuntimeException(扣款失败);}// 4. 增加主播DB收益streamerMapper.addIncome(streamerId, giftCost);// 5. 插入流水记录// 这里还包含了远程调用,如果在事务里做远程调用,数据库连接会被长时间占用// 假设这里还要调用消息队列或者第三方服务,时间不可控long txId = System.currentTimeMillis();userMapper.insertRecord(userId, streamerId, giftName, giftCost, txId);// 6. 更新Redis缓存 (同步阻塞)int newBalance = currentBalance - giftCost;redisTemplate.opsForValue().set(userKey, newBalance, 10, TimeUnit.MINUTES);// 7. 更新主播Redis缓存 (同步阻塞)String streamerKey = streamer:income: + streamerId;// 假设主播余额也是存在Redis的redisTemplate.opsForValue().increment(streamerKey, giftCost);return true;});} catch (Exception e) {e.printStackTrace();return false;}}}
}代码逐行拆解与避坑指南:synchronized (this):这是最致命的伤。实例锁意味着同一个Service实例的所有线程都要排队。如果应用部署了多个实例,这把锁根本不起作用,会导致数据不一致。如果只有一个实例,性能会因为锁竞争而断崖式下跌。
事务内包含Redis操作:Spring的事务是基于数据库连接的。如果你在 transactionTemplate.execute 里面调用了Redis,Redis的网络IO时间(哪怕只有5ms)都会算在数据库事务的持锁时间内。在QPS 1000的情况下,数据库连接池会迅速被耗尽,导致其他非Redis相关的业务也无法获取连接。
同步更新缓存:在事务提交前或提交时同步更新Redis,如果Redis宕机或网络抖动,要么业务失败(体验差),要么数据不一致(资损风险)。
缺乏降级与熔断:一旦Redis慢查询,整个线程池阻塞,Tomcat工作线程全部挂起,系统雪崩。优化方案与代码:异步化与本地消息表
针对上述瓶颈,我们需要引入“最终一致性”思想,并将同步阻塞改为异步非阻塞。核心思路有三点:去中心化锁:利用Redis的原子操作 decr 或 Lua脚本处理用户余额预扣减,避免直接操作数据库行锁。
事务边界收缩:数据库事务只包含本地DB操作,严禁包含远程IO。
本地消息表 + 异步补偿:利用数据库事务的一致性,将“发礼物”和“写消息表”放在同一个本地事务中。通过定时任务或消息中间件消费消息表,异步更新Redis和主播收益。这是目前阿里、抖音等大厂在处理高并发积分、余额场景下的标准解法。
@Service
public class GiftServiceOptimized {@Autowiredprivate RedisTemplateString, Object redisTemplate;@Autowiredprivate UserMapper userMapper;@Autowiredprivate StreamerMapper streamerMapper;@Autowiredprivate LocalMessageMapper localMessageMapper;@Autowiredprivate TransactionTemplate transactionTemplate;@Autowiredprivate RocketMQTemplate rocketMQTemplate;/*** 赠送礼物 - 高并发优化版本* 核心:Redis预扣减 + 本地消息表保证最终一致性*/public boolean sendGift(Long userId, Long streamerId, String giftName) {final int giftCost = 10000; // 10000抖币String userKey = user:balance:lock: + userId;String userBalanceKey = user:balance: + userId;// 1. Redis原子预扣减 (利用Lua脚本保证原子性)// 如果余额不足,直接返回,不进入DB事务Boolean success = redisTemplate.execute(new DefaultRedisScript(if redis.call('exists', KEYS[1]) == 0 then return -1; +local balance = redis.call('get', KEYS[1]); +if tonumber(balance) tonumber(ARGV[1]) then return -1; +return redis.call('decrby', KEYS[1], ARGV[1]);, Long.class), Collections.singletonList(userBalanceKey), String.valueOf(giftCost));if (success == null || success == -1) {// 余额不足或缓存失效,走DB兜底逻辑 (低频)return fallbackToDb(userId, streamerId, giftName, giftCost);}// 2. 开启本地数据库事务try {transactionTemplate.execute(status - {// 3. 本地事务:扣减DB余额 (注意:这里为了简化,假设Redis扣减成功则DB必然够扣)// 实际生产中,这里可能需要做对账补偿,或者使用乐观锁userMapper.deductBalance(userId, giftCost);// 4. 本地事务:写入本地消息表// 这一步至关重要!消息表与业务数据同库同事务,保证了原子性LocalMessage msg = new LocalMessage();msg.setBizType(GIFT_SEND);msg.setBizId(String.valueOf(System.currentTimeMillis()));msg.setPayload(JSON.toJSONString(new GiftDTO(userId, streamerId, giftName, giftCost)));msg.setStatus(0); // 0: 待发送localMessageMapper.insert(msg);// 5. 提交事务return true;});} catch (Exception e) {// 6. 异常回滚:Redis加回去redisTemplate.opsForValue().increment(userBalanceKey, giftCost);e.printStackTrace();return false;}// 7. 事务提交后,异步发送消息// 这里可以立即发送MQ,也可以由定时任务扫描本地消息表发送// 为了高可靠,通常由定时任务扫描,或者使用RocketMQ事务消息asyncSendToMq(userId, streamerId, giftName, giftCost);return true;}private void asyncSendToMq(Long userId, Long streamerId, String giftName, int cost) {// 这里模拟异步发送,实际中可能是发送RocketMQ消息// 消费端收到消息后,更新主播Redis余额、更新主播DB收益(异步批量)、触发业务逻辑new Thread(() - {try {// 模拟处理时间Thread.sleep(10); // 更新主播收益 (可以攒批处理,减少DB压力)streamerMapper.addIncomeAsync(streamerId, cost);// 更新主播RedisredisTemplate.opsForValue().increment(streamer:income: + streamerId, cost);} catch (Exception e) {// 记录失败日志,由监控告警log.error(Async update failed, e);}}).start();}private boolean fallbackToDb(Long userId, Long streamerId, String giftName, int cost) {// DB兜底逻辑,逻辑类似旧代码但去掉了同步锁,改为乐观锁// 这里省略具体实现,核心是使用 version 字段或 balance = cost 条件更新return false; }
}关键优化点解析:Redis Lua脚本:将“查余额”和“扣余额”合并为一个原子操作。这避免了读改写之间的时间窗口,也避免了分布式锁的开销。Lua脚本在Redis单线程内执行,性能极高。
本地消息表:这是解决“DB与MQ不一致”的神器。只要DB事务提交成功,消息表数据就一定存在。即使后续MQ发送失败,定时任务也能扫出来重发。这比直接在DB事务里调MQ靠谱得多。
异步化主播收益更新:主播的收益更新不是强实时性要求。用户可以接受延迟几秒看到自己收到的礼物总数,但绝不能接受送礼物失败。因此,将主播端的逻辑异步化,甚至可以做批量合并(比如每100ms合并一次主播收益更新),能极大降低DB压力。
异常补偿:如果在DB事务提交前发生异常,必须将Redis里预扣的余额加回去,保证缓存与DB的最终一致。对比数据:优化前后的性能差距
为了验证效果,我们在压测环境进行了对比。环境配置:4核8G,MySQL 5.7,Redis 6.0,JVM堆内存2G。测试工具:JMeter,并发用户数从100到2000逐步递增。指标
优化前 (Naive)
优化后 (Optimized)
提升倍数平均响应时间 (TPS 1000)
245 ms
12 ms
20x最大吞吐量 (QPS)
850 QPS (线程池满)
8500 QPS (CPU 70%)
10xP99 延迟
1200 ms
45 ms
26xMySQL 活跃连接数
151 (耗尽)
35 (平稳)
-Redis 命中率
99.9%
99.9%
-GC 暂停时间 (Full GC)
频繁 (每秒1次)
极少 (每小时1次)
-数据解读:响应时间:优化前因为锁竞争和同步IO,平均耗时245ms,其中90%的时间花在等待锁和数据库IO上。优化后,大部分请求在Redis层面就解决了,DB只处理少量兜底和消息表写入,耗时降至12ms。
吞吐量:优化前850 QPS就崩了,因为Tomcat线程被锁住,无法处理新请求。优化后轻松支撑8500 QPS,瓶颈转移到了CPU和网络IO,这是健康的高负载状态。
连接池:优化前MySQL连接池被打满,导致其他业务(如查询、登录)全部超时,引发雪崩。优化后连接数平稳,系统鲁棒性极大增强。为什么会有10倍的吞吐量提升?
核心在于去同步化。优化前,一个请求的生命周期包含了“网络IO(Redis) + 锁等待 + 磁盘IO(DB) + 网络IO(Redis)”的串行过程。优化后,主链路变成了“网络IO(Redis) + 磁盘IO(DB写入消息表)”,且DB写入是追加写,速度极快。主播端的复杂逻辑被剥离到异步线程,不占用主交易线程的资源。
落地建议与高频面试题延伸
在实际项目中落地这套方案,有几个细节必须注意,这也是面试中追问的重点:Redis与DB的一致性兜底:
虽然用了Lua脚本,但如果Redis宕机重启且AOF未持久化,内存数据丢失怎么办?
建议:定期(比如每小时)跑一个对账任务,对比Redis余额与DB余额。如果发现差异,以DB为准,修正Redis。同时,在用户下次登录或查询时,强制从DB加载一次最新余额,覆盖Redis。消息表的清理:
本地消息表会无限增长,必须设计清理策略。
建议:消息发送成功后,更新状态为“已发送”。定时任务每天凌晨清理“已发送”且超过7天的记录。注意,清理也要分批执行,避免大事务锁表。热点主播的进一步优化:
如果主播是顶流,哪怕异步更新,Redis的 increment 也可能成为热点。
建议:在应用层做聚合。比如,每个应用实例内部维护一个本地计数器,每1秒或每100次请求,批量将本地计数器的值累加到Redis。这样Redis的QPS降低了100倍。关于NPM/PyPI等包的选择:
如果你是用Node.js或Python开发,类似逻辑也是通用的。在Node.js中,可以使用 ioredis 库执行Lua脚本,配合 sequelize 或 typeorm 做事务。在Python中,redis-py 同样支持Lua脚本。选择包时,务必查看PyPI或NPM官方包文档中关于“原子操作”和“事务”的支持情况,不要自己封装非原子的 get + set。例如,redis-py 的 evalsha 方法比 eval 性能更好,因为它避免了每次传输脚本内容。最后,回到那个高频面试题:
当面试官问你“抖音一个嘉年华多少人民币”时,他其实是在问:“在高并发场景下,你如何设计一个既快又稳的扣款系统?”
不要只回答“10000抖币”。你要回答:我用Redis Lua脚本做原子预扣减,避免DB热点。
我用本地消息表保证业务数据与消息的最终一致性。
我把非核心链路(如主播收益展示)异步化,提升主链路吞吐量。
我设计了定期对账和兜底机制,防止极端情况下的资损。这样的回答,才是一个资深工程师该有的样子。
你更常用哪种写法?是倾向于用分布式锁(如Redisson)强一致性,还是像文中这样用本地消息表做最终一致性?评论区交流你的实战经验。