3个爽歪歪面试必问坑,官方文档太长抓不住重点,老手教你避坑
官方文档翻了几十页还是云里雾里,面试被问懵?别慌。 “爽歪歪”这词听着像零食,但在后端开发圈,它专指那些表面逻辑通顺、实则埋雷的并发或事务场景。 HR 和面试官最爱拿这类“爽歪歪”案例当面试必问题,就为了看你有没有真在一线踩过坑。
很多新人抱怨官方文档太长,抓不住重点,其实是因为文档讲的是“理想状态”,而面试考的是“现实故障”。 今天不念经,直接上干货,拆解 3 个最经典的“爽歪歪”坑点,全是血泪换来的实战经验。
1. 坑的现象:为什么你的数据对不上?
想象一下这个场景:
高并发下,用户 A 下单扣库存,用户 B 同时下单扣库存。
你写了 SELECT count(*) FROM stock WHERE product_id = 1;
判断大于 0,然后 UPDATE stock SET count = count - 1 ...
逻辑完美,测试环境跑通了,心里美滋滋,觉得自己写的代码“爽歪歪”。
结果上线后,库存变成了负数。 客服炸锅,财务炸锅,你炸锅。 这就是典型的“爽歪歪”陷阱:你以为的原子操作,在并发下其实是两个独立动作。
核心痛点:
- 官方文档里
SELECT和UPDATE是分开讲的,没告诉你中间会插入别的线程。 - 本地单线程测试永远复现不了并发问题,导致你以为代码没问题。
- 面试时,面试官问:“怎么保证扣库存不超卖?”你答“加锁”,面试官追问:“加什么锁?锁粒度多大?”你卡壳。
Stack Overflow 上有个高赞回答一针见血:
"Concurrency bugs are the most expensive bugs you can write. They are hard to reproduce, hard to debug, and hard to explain to the business." (并发 bug 是你写过的最昂贵的 bug。难复现、难调试、难向业务解释。)
这就是为什么面试必问并发,因为这是区分“背题侠”和“真干活”的分水岭。
2. 根本原因:ACID 里的 A 和 I 被谁偷了?
数据库事务有 ACID 特性,其中 A(Atomicity,原子性) 和 I(Isolation,隔离性) 在这里成了关键。
根本原因一:隔离级别不够
MySQL InnoDB 默认隔离级别是 Repeatable Read(可重复读)。
但这不等于“完全隔离”。在可重复读级别下,SELECT 不加锁是快照读,看到的是某个时间点的快照。
而 UPDATE 是当前读,会加行锁。
问题出在:SELECT 和 UPDATE 之间,快照是旧的,当前数据已经变了。
根本原因二:缺乏乐观锁或悲观锁机制 你只是“先查后改”,没有告诉数据库:“如果这条数据在我查完之后被别人改过,就报错或重试”。 这就好比你去 ATM 取钱,先插卡查询余额(SELECT),然后按下取款键(UPDATE)。 如果在你查询和取款之间,有人在另一个柜台把你钱转走了,ATM 机没做校验,直接扣款,就超支了。
面试考点拆解:
- Q: 怎么解决库存超卖?
- A: 用乐观锁(版本号)或悲观锁(
SELECT ... FOR UPDATE)。 - Q: 为什么不用悲观锁?
- A: 性能差,锁住后其他线程全部阻塞,高并发下吞吐量暴跌。
- Q: 乐观锁怎么实现?
- A: 加一个
version字段,UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?。如果影响行数为 0,说明冲突,重试。
3. 正确写法对比:代码说话
这里给两段代码,一段是“爽歪歪”的错误写法,一段是“稳如老狗”的正确写法。
错误写法:裸奔的并发
-- 场景:扣减库存
-- 线程1 和 线程2 同时执行-- 1. 查询库存
SELECT count FROM stock WHERE product_id = 1001;
-- 假设返回 count = 1-- 2. 判断并更新
IF count > 0 THENUPDATE stock SET count = count - 1 WHERE product_id = 1001;
END IF;
问题解析:
- 线程1 和 线程2 同时执行
SELECT,都拿到count = 1。 - 线程1 执行
UPDATE,count变成 0。 - 线程2 执行
UPDATE,count变成 -1。 - 结果:超卖!
正确写法:乐观锁兜底
-- 场景:扣减库存(带版本号)-- 1. 查询库存及版本号
SELECT count, version FROM stock WHERE product_id = 1001;
-- 假设返回 count = 1, version = 5-- 2. 带条件更新
UPDATE stock
SET count = count - 1, version = version + 1
WHERE product_id = 1001 AND version = 5;-- 3. 检查影响行数
IF affected_rows == 1 THEN-- 成功
ELSE-- 失败,重试或报错
END IF;
代码逐行讲解:
SELECT count, version:不仅拿库存,还要拿版本号。版本号是乐观锁的灵魂。UPDATE ... AND version = 5:这是关键!数据库会检查:当前行的版本号还是 5 吗?- 如果线程1 先更新成功,版本号变成 6。
- 线程2 再执行
UPDATE,条件version = 5不成立,影响行数为 0。 - 线程2 捕获到失败,进行重试(重新
SELECT拿到新版本号)或直接返回“库存不足”。
affected_rows:通过影响行数判断是否成功,这是乐观锁的标准姿势。
进阶技巧:为什么不用 SELECT ... FOR UPDATE?
- 悲观锁会锁住整行,其他线程只能等待。
- 在秒杀场景下,大量请求排队,数据库连接池被打满,服务直接挂掉。
- 乐观锁无锁,并发高时性能更好,但重试机制要设计好,避免死循环。
4. 复现与修复代码:实战演练
光说不练假把式,这里给一个 Java + MyBatis 的简化示例,模拟“爽歪歪”场景。
错误代码(千万别这么写)
public void deductStockWrong(Long productId) {// 1. 查询Stock stock = stockMapper.selectById(productId);if (stock.getCount() > 0) {// 2. 更新stock.setCount(stock.getCount() - 1);stockMapper.updateById(stock);}
}
坑点:
selectById和updateById不在同一个事务原子操作中(即使有事务,也是读-写分离)。- 高并发下,
stock.getCount()是内存中的旧值,更新时会覆盖别人的修改。
正确代码(乐观锁 + 重试)
public boolean deductStockCorrect(Long productId) {int maxRetry = 3; // 最大重试次数for (int i = 0; i < maxRetry; i++) {// 1. 查询当前状态(包含版本号)Stock stock = stockMapper.selectById(productId);if (stock == null || stock.getCount() <= 0) {return false; // 库存不足}// 2. 构造更新条件int updated = stockMapper.updateStockWithVersion(productId, stock.getCount() - 1, stock.getVersion());// 3. 判断更新结果if (updated > 0) {return true; // 扣减成功}// 4. 更新失败,说明版本冲突,继续循环重试// 这里可以加个短暂 sleep,避免死循环}return false; // 重试多次仍失败
}
对应 Mapper XML:
<update id="updateStockWithVersion">UPDATE stockSET count = #{newCount},version = version + 1WHERE product_id = #{productId}AND version = #{oldVersion}
</update>
关键点:
version字段:数据库表里必须有version列,初始值为 0。updateStockWithVersion:SQL 里必须带上AND version = #{oldVersion}。- 重试机制:失败后不要直接抛异常,要重试。重试次数不宜过多,3-5 次足够。
5. 规避建议与面试话术
规避建议
永远不要相信“先查后改”是安全的
- 除非是单线程环境,否则任何“读-改-写”模式都有并发风险。
- 要么用数据库锁(悲观锁),要么用版本号(乐观锁),要么用中间件(Redis 原子操作)。
Redis 扣库存是更好的选择
- 对于秒杀场景,数据库扛不住。
- 用 Redis 的
DECR命令,原子性由 Redis 保证。 - 扣减成功后,再异步落库。
- 注意:Redis 和数据库的一致性需要补偿机制,比如对账脚本。
监控与告警
- 监控库存字段是否出现负数。
- 监控乐观锁重试率。如果重试率过高,说明并发冲突严重,可能需要调整锁粒度或引入队列削峰。
面试话术(直接背下来)
面试官: “怎么防止库存超卖?”
你:
“我在项目中遇到过这个问题,当时用的是 MySQL 乐观锁。
具体做法是在库存表加一个 version 字段。
扣库存时,先 SELECT 拿到当前 count 和 version,
然后 UPDATE 时带上 WHERE version = ? 条件。
如果影响行数为 0,说明被其他线程抢先修改了,我会进行重试。
为了减少数据库压力,高并发场景下我会用 Redis 的 DECR 做前置拦截,
只有 Redis 扣减成功,才去操作数据库,这样能扛住更高的 QPS。”
面试官追问: “Redis 和数据库不一致怎么办?”
你: “我会做定时对账任务,比如每 5 分钟比对一次 Redis 和数据库的库存。 如果不一致,以数据库为准,修正 Redis。 同时,扣库存失败会记录日志,方便排查。 另外,前端下单时会做幂等性处理,防止用户重复点击。”
结尾互动
这些坑,我当年全踩过。 尤其是乐观锁重试次数没设好,导致 CPU 飙高,被运维追着骂,那滋味,真“爽歪歪”。
你在项目里踩过这个坑吗?评论区聊聊,你用的什么方案?乐观锁还是 Redis?有没有遇到过更离谱的并发 bug?
别藏着掖着,多交流才能少踩坑。 点赞收藏,面试前拿出来看看,保你面试必问题不再慌。