尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

乐观锁与悲观锁详解:并发控制原理、实现与应用指南

乐观锁与悲观锁详解:并发控制原理、实现与应用指南 面试时被问到“悲观锁和乐观锁怎么实现它们的区别是什么”很多人第一反应是背定义“悲观锁假设一定会冲突所以先加锁乐观锁假设不一定冲突所以先操作再判断。”但面试官并不想听你背这个定义。他更想通过这道题确认你是否有真正处理过并发问题的经验。比如扣库存线程并发时为什么会有超卖用乐观锁扣库存为什么更新行数为 0什么场景用synchronized什么场景要上select ... for update这篇文章不打算只讲概念。我会从实现原理、Java 代码、数据库 SQL、面试场景题和工程落地几个角度把悲观锁和乐观锁讲透。如果你是 Java 后端开发正在准备跳槽、涨薪、刷八股文或者刚接触并发编程建议收藏后再看。先说一个判断悲观锁和乐观锁不是两种具体技术而是两种并发控制策略。你可以在 JVM 层用synchronized实现悲观锁也可以在数据库层用version字段实现乐观锁。只有搞清楚策略和实现的关系面试追问时才能答得稳。1. 悲观锁与乐观锁面试官到底想考什么先看一个真实场景。你有两张表product商品和orders订单。用户下单时要执行两步查库存判断库存是否足够如果足够扣减库存生成订单。如果不加任何并发控制两个用户同时下单都查到剩余库存为 1然后都通过判断都去执行扣减库存最终会变成 -1。这就是典型的超卖问题。解决超卖的核心不是把 SQL 写得多么花哨而是回答一个问题当多个线程同时修改同一个共享数据时你怎么保证数据一致悲观锁和乐观锁就是解决这个问题的两个不同思路。面试官问这道题真正想看你三个能力是否理解并发冲突的本质是否能根据业务场景选择合适的并发控制策略是否知道在 Java、数据库和分布式环境下分别用什么工具落地。如果你能按这个层次回答面试印象会完全不一样。2. 悲观锁的核心原理与 Java 实现悲观锁的核心思想非常直接我认为操作数据时一定会发生冲突所以先加锁锁住之后别人不能改等操作完成再释放锁。这种思路偏保守适合写操作多、并发冲突严重的场景。比如金融转账、库存扣减一旦出问题代价很大宁愿多等一会儿也不愿意算错账。2.1 Java 层面的悲观锁synchronized 和 ReentrantLock在 JVM 层最常用的悲观锁是synchronized和ReentrantLock。先看synchronized的经典写法// 文件路径src/main/java/com/example/concurrency/PessimisticLockDemo.java public class PessimisticLockDemo { private int stock 10; public synchronized boolean deductStock(int count) { if (stock count) { return false; } // 模拟耗时操作 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } stock - count; return true; } public static void main(String[] args) throws InterruptedException { PessimisticLockDemo demo new PessimisticLockDemo(); Runnable task () - { boolean result demo.deductStock(1); if (result) { System.out.println(Thread.currentThread().getName() 扣减成功); } else { System.out.println(Thread.currentThread().getName() 扣减失败); } }; for (int i 0; i 20; i) { new Thread(task, 线程- i).start(); } Thread.sleep(2000); System.out.println(最终库存 demo.stock); } }synchronized加到方法上等价于锁住当前对象。执行deductStock方法的线程必须先拿到对象锁拿不到锁的线程只能排队等待。再对比ReentrantLock的写法// 文件路径src/main/java/com/example/concurrency/ReentrantLockDemo.java import java.util.concurrent.locks.ReentrantLock; public class ReentrantLockDemo { private int stock 10; private final ReentrantLock lock new ReentrantLock(); public boolean deductStock(int count) { lock.lock(); try { if (stock count) { return false; } Thread.sleep(50); stock - count; return true; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { lock.unlock(); } } }ReentrantLock更灵活支持公平锁、非公平锁支持尝试获取锁tryLock()并且lock()和unlock()之间必须用finally保证释放否则锁可能永远不释放。这两个都是典型的悲观锁实现线程在读取和修改共享变量前先获取独占锁保证同一时刻只有一个线程可以操作。2.2 数据库层面的悲观锁select ... for updateJVM 锁只对单机进程内线程有效。如果服务部署了多个节点或者多个客户端连接同一个数据库就必须依靠数据库锁来保证一致性。MySQL InnoDB 提供了select ... for update可以在事务内对查询到的行加排他锁-- 开启事务 START TRANSACTION; -- 锁定 id 1 的商品行 SELECT stock FROM product WHERE id 1 FOR UPDATE; -- 业务判断 stock 0 -- 如果大于 0执行扣减 UPDATE product SET stock stock - 1 WHERE id 1; COMMIT;关键点必须放在事务里commit或rollback后才释放锁for update会对命中的行加锁其他事务想改这一行会被阻塞如果查询条件没有走索引可能升级为表锁并发性能差。这种方案的优点是一致性很强缺点也很明显锁竞争激烈时吞吐量低还有可能因为锁等待形成死锁或超时。小结论悲观锁适合并发冲突多、要求强一致性的场景。JVM 层用synchronized和ReentrantLock数据库层用select ... for update。前提是确认锁范围、锁粒度别把行锁变成表锁。3. 乐观锁的核心原理与 Java 实现乐观锁的思路相反我假设操作数据时一般不会冲突所以在操作前不加锁先正常读写在写回数据时检查数据是否被其他人改过如果被改过就重试或失败。核心是“检测冲突”而不是“预防冲突”。3.1 乐观锁的重要基础CASJava 并发包里的乐观锁很多底层都基于 CASCompare And Swap。CAS 是一条 CPU 原子指令执行过程是比较内存中的值是否等于预期值如果等于就更新为新值如果不相等说明有其他线程改过了本次操作失败。java.util.concurrent.atomic包下的原子类例如AtomicInteger就是 CAS 的典型应用// 文件路径src/main/java/com/example/concurrency/AtomicDemo.java import java.util.concurrent.atomic.AtomicInteger; public class AtomicDemo { private static final AtomicInteger STOCK new AtomicInteger(10); public static boolean deduct(int count) { while (true) { int current STOCK.get(); if (current count) { return false; } // 比较并交换只有当前值仍等于 current 时才更新 if (STOCK.compareAndSet(current, current - count)) { return true; } // 如果 CAS 失败说明其他线程先改了循环重试 } } public static void main(String[] args) throws InterruptedException { Runnable task () - { if (deduct(1)) { System.out.println(Thread.currentThread().getName() 扣减成功); } else { System.out.println(Thread.currentThread().getName() 扣减失败); } }; for (int i 0; i 20; i) { new Thread(task, 线程- i).start(); } Thread.sleep(2000); System.out.println(最终库存 STOCK.get()); } }compareAndSet(current, current - count)的含义是如果当前值没变化就更新为扣减后的值如果中途被改过就不更新并返回false。这里要注意一个经典问题ABA 问题。假设线程 A 读到值为 1此时线程 B 把值改成 2又改回 1。线程 A 执行 CAS 时发现当前值还是 1就认为数据没被修改过于是扣减成功。但实际上数据已经被改过两次可能已经产生了不正确的中间状态。解决 ABA 问题的常用方法是带版本号或时间戳。Java 里可以用AtomicStampedReference// 文件路径src/main/java/com/example/concurrency/AtomicStampedReferenceDemo.java import java.util.concurrent.atomic.AtomicStampedReference; public class AtomicStampedReferenceDemo { // 初始库存为 10初始版本号为 0 private static final AtomicStampedReferenceInteger STOCK new AtomicStampedReference(10, 0); public static boolean deduct(int count) { while (true) { int[] stampHolder new int[1]; int current STOCK.get(stampHolder); int currentStamp stampHolder[0]; if (current count) { return false; } // 比较值和版本号两个都匹配才更新 boolean success STOCK.compareAndSet( current, current - count, currentStamp, currentStamp 1 ); if (success) { return true; } } } }AtomicStampedReference额外保存了一个版本号字段每次更新都让版本号加一CAS 时同时比较值和版本号就可以规避 ABA 问题。3.2 数据库层面的乐观锁版本号机制在数据库层面最常用的乐观锁实现是版本号机制。表结构大致如下CREATE TABLE product ( id BIGINT PRIMARY KEY, stock INT NOT NULL, version INT NOT NULL DEFAULT 0 );更新库存时先查询出version更新时带上版本号作为条件UPDATE product SET stock stock - 1, version version 1 WHERE id 1 AND version 0 AND stock 0;下面完整模拟一次乐观锁扣库存的 Java 代码。这里使用简单的 JDBC 写法方便你直接理解逻辑// 文件路径src/main/java/com/example/concurrency/OptimisticLockDbDemo.java import java.sql.*; public class OptimisticLockDbDemo { public static boolean deductStock(long productId, int count) throws SQLException { String url jdbc:mysql://localhost:3306/test; String username root; String password 123456; try (Connection conn DriverManager.getConnection(url, username, password)) { conn.setAutoCommit(false); // 1. 查询库存和版本号 String selectSql SELECT stock, version FROM product WHERE id ?; PreparedStatement selectStmt conn.prepareStatement(selectSql); selectStmt.setLong(1, productId); ResultSet rs selectStmt.executeQuery(); if (!rs.next()) { conn.rollback(); return false; } int stock rs.getInt(stock); int version rs.getInt(version); if (stock count) { conn.rollback(); return false; } // 2. 通过版本号条件更新 String updateSql UPDATE product SET stock stock - ?, version version 1 WHERE id ? AND version ? AND stock ?; PreparedStatement updateStmt conn.prepareStatement(updateSql); updateStmt.setInt(1, count); updateStmt.setLong(2, productId); updateStmt.setInt(3, version); updateStmt.setInt(4, count); int rows updateStmt.executeUpdate(); conn.commit(); return rows 0; } } public static void main(String[] args) throws SQLException { boolean success deductStock(1, 1); System.out.println(扣减结果 success); } }这段代码的关键逻辑先读库存和版本号判断库存是否充足更新时强制带上version 旧版本号条件如果影响行数为 1说明没有其他线程抢先修改如果影响行数为 0说明版本号已经变了本次更新失败。注意我把判断条件写成了stock ?而不是只靠版本号。这是因为即使版本号没变也可能出现库存被其他操作修改的情况。把库存条件也放进WHERE可以再兜底一层防止超卖。这种方案的好处是读操作不阻塞并发读性能高。坏处是写操作失败后需要重试如果并发特别高很多线程会不停地重复执行“查询-更新-失败”的流程反而增加数据库压力。小结论乐观锁适合读多写少、冲突概率低的场景。Java 里的AtomicInteger数据库里的版本号机制都是乐观锁的落地方式。使用乐观锁时要特别关注 CAS 自旋开销和 ABA 问题。4. 悲观锁与乐观锁的区别对比用一个表格把关键区别说清楚对比维度悲观锁乐观锁核心思路先加锁再操作防止冲突先操作写回时检测冲突是否阻塞会阻塞其他线程/事务不会阻塞通过失败重试处理锁粒度JVM 锁粒度可以是对象、类数据库锁是行/表通常依赖版本号或 CAS没有显式锁适用场景写多读少、冲突严重、强一致性要求高读多写少、并发冲突概率低性能锁竞争激烈时吞吐量下降冲突少时吞吐量高冲突多时重试开销大实现方式synchronized、ReentrantLock、select ... for updateAtomicInteger、AtomicStampedReference、数据库版本号常见问题死锁、锁等待超时、锁升级ABA、CAS 自旋 CPU 开销、重试逻辑典型例子转账扣款、库存扣减点赞数、浏览数、任务状态更新在此基础上还有几个容易被忽略的细节悲观锁不一定慢乐观锁不一定快。如果并发冲突率很高乐观锁会在 CAS 上频繁自旋浪费 CPU反而不如悲观锁稳定。数据库乐观锁不是“完全无锁”。它只是没有在查询阶段加锁但update执行时数据库本身还是要加行锁去更新那行数据。Java 里的乐观锁和数据库乐观锁不是一回事。前者依靠 CPU CAS 指令后者依靠 SQL 条件更新和事务。面试时要把两者分开讲不要混为一谈。5. 面试场景题实战从扣库存到下单面试官常会追问“如果让你设计一个下单扣库存接口你会用乐观锁还是悲观锁”比较稳妥的回答不是直接说“用乐观锁”或“用悲观锁”而是先分析场景再给出方案。下面用一个完整场景演示两种方案的实现和取舍。假设你现在负责一个秒杀系统的库存扣减。要求是库存不能超卖高并发下尽量不阻塞。5.1 方案一数据库乐观锁扣库存流程设计如下查询商品库存和版本号判断库存是否足够执行带版本号条件的更新 SQL如果影响行数为 1扣减成功如果影响行数为 0说明冲突可以重试或返回“系统繁忙”。关键 SQLUPDATE product SET stock stock - 1, version version 1 WHERE id ? AND version ? AND stock 1;这个方案适合并发冲突不算特别高的场景。优点是实现简单不阻塞读操作缺点是如果秒杀瞬时流量非常高大量线程都在执行“查询 更新”大部分更新会失败会产生无效请求。5.2 方案二数据库悲观锁扣库存把扣库存包裹在一个事务里先用for update锁住商品行再更新START TRANSACTION; SELECT stock FROM product WHERE id 1 FOR UPDATE; -- 业务判断 stock 0 UPDATE product SET stock stock - 1 WHERE id 1; COMMIT;这个方案能保证同一时刻只有一个线程在扣库存不会超卖。缺点是持有锁的时间越长其他线程等待越久。如果还把创建订单等耗时操作放在锁内吞吐量会直线下降。实际项目中一种折中做法是下单主流程使用乐观锁扣减库存快速失败、快速返回对一致性要求更高的支付和转账场景使用悲观锁或分布式锁把“库存预占”和“订单确认”拆成不同状态减少单次事务持有锁的时间。下面给一个典型的服务方法代码手动实现带重试的乐观锁扣库存// 文件路径src/main/java/com/example/concurrency/StockService.java import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.transaction.annotation.Transactional; public class StockService { private final JdbcTemplate jdbcTemplate; public StockService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } /** * 最多重试 3 次避免无限重试 */ public boolean deductWithRetry(long productId, int count) { int maxRetry 3; for (int i 0; i maxRetry; i) { boolean success tryDeduct(productId, count); if (success) { return true; } } return false; } Transactional public boolean tryDeduct(long productId, int count) { // 查询当前版本号 Integer version jdbcTemplate.queryForObject( SELECT version FROM product WHERE id ?, Integer.class, productId ); // 带版本号更新 int rows jdbcTemplate.update( UPDATE product SET stock stock - ?, version version 1 WHERE id ? AND version ? AND stock ?, count, productId, version, count ); return rows 0; } }这段代码里有两个关键点使用了Transactional保证查询版本号和更新在同一个事务内重试次数必须有限制否则高并发下可能造成连接池被打满。另外要强调不要把创建订单这种耗时操作和扣库存放在同一个大事务里。常见做法是先扣库存成功再异步创建订单如果订单创建失败再回流库存。这样才能缩短锁和事务的持有时间。小结论面试场景题不需要你把代码写得多么复杂而是要展现场景分析能力和工程权衡。对面试官来说能说出“扣库存的核心是条件更新 失败重试”和“要控制事务边界”比背一堆 API 更有价值。6. 系统设计视角如何选择并发控制策略很多开发者在实际项目中选错锁不是因为不懂概念而是没有形成一套判断标准。下面分享一下我自己的判断顺序。6.1 先看冲突概率如果业务是典型的读多写少比如文章阅读数、商品浏览量多数操作只是读取写操作之间很少碰到那乐观锁很合适。用 CAS 或版本号简单高效。如果是高频写操作比如秒杀库存、银行账户余额变动每次写操作都极可能和其他写操作冲突那悲观锁更可靠。直接排队执行反而比乐观锁反复重试更省资源。6.2 再看一致性要求对强一致性要求很高的场景比如转账、订单支付、账务流水不能接受“最终一致性”中间态。建议使用悲观锁或数据库行锁确保同一时间只有一个事务能够修改。如果允许短暂的不一致或者可以通过幂等重试补偿那乐观锁更灵活。例如更新用户积分、任务状态流转失败后重试一次通常没有问题。6.3 还要看锁的范围是在 JVM 内还是跨进程很多人的订单服务一开始是单机部署用synchronized就能解决同步问题。后来服务拆成多个实例部署了 Nginx 负载均衡原来的 JVM 锁立刻失效。此时你需要分布式锁或者改用数据库乐观锁/悲观锁。这里要提醒一个常见的坑不能用synchronized充当多节点环境下的互斥锁。每个 JVM 只有自己进程内的线程在竞争锁其他节点上的请求仍然可以同时操作同一行数据。分布式场景的解决方案通常有基于 Redis 的分布式锁SET NX EX基于 ZooKeeper 的临时顺序节点数据库乐观锁版本号数据库悲观锁for update。本文不展开分布式锁但面试时如果你能主动提到“单机锁和分布式锁的边界”一定会给面试官留下好印象。7. 常见面试追问与解答面试官在问完“悲观锁和乐观锁怎么实现”之后通常会接着追问几个问题。提前准备好能极大提高通过率。7.1 CAS 自旋会不会导致 CPU 占用过高会。如果线程竞争非常激烈CAS 一直失败循环重试就会一直消耗 CPU。解决方案失败后让线程Thread.yield()或sleep短暂让出 CPU引入退避策略比如等待指数递增的时间后再重试高冲突场景直接用悲观锁不要死磕 CAS。7.2 ABA 问题到底是什么怎么解决ABA 问题的本质是“值没有变但内容已经被改过”。解决思路是引入版本号或时间戳。Java 里用AtomicStampedReference数据库里用version字段。补充一个细节ABA 问题在很多场景下其实影响不大比如扣库存即使库存从 1 变成 2 又变回 1中间状态通常不会导致错误。但如果是在无锁链表中操作指针ABA 会导致严重的内存问题。所以回答时不要一概而论要说明“问题严重程度取决于具体业务”。7.3 乐观锁一定会失败吗不是。乐观锁只是在更新时发现版本号不匹配才失败。如果更新条件中的版本号没有变化就能一次性成功。它的失败率取决于冲突概率。面试官更想听的是你怎么处理失败常见策略是失败重试、快速返回提示“操作频繁”、或者切换到其他扣减渠道。7.4 悲观锁一定安全吗不一定。悲观锁只是降低了并发冲突风险不代表不会出问题。for update如果没走索引可能锁全表导致极大性能问题事务中锁的顺序不一致会产生死锁如果忘记提交事务或释放连接会把锁一直占着最终造成连接池耗尽。所以在回答“安全”这个问题时也要主动说清使用边界。7.5 synchronized 和 ReentrantLock 有什么区别这是并发编程里另一个常见追问维度synchronizedReentrantLock使用方式自动加锁、自动释放手动加锁必须手动释放可中断不支持支持lockInterruptibly()公平性非公平支持公平和非公平锁升级JDK 1.6 后有锁升级优化基于 AQS 实现条件变量wait/notify支持多个Condition获取锁超时不支持支持tryLock(timeout)面试时可以答简单场景用synchronized代码更简洁复杂场景需要可中断、可超时、公平锁时使用ReentrantLock。8. 常见问题与排查思路在实际开发中遇到几类高频率并发问题可以按下面的表格排查。问题现象可能原因排查方式解决方案库存出现超卖更新 SQL 缺少条件判断锁失效查看 SQL 执行计划确认是否走了索引检查事务隔离级别在更新语句中加入stock count条件或使用for update数据库连接池被打满乐观锁失败后重试次数过多事务内持有锁时间太长查看数据库活跃连接数、超时日志限制重试次数把耗时操作移出事务死锁多个事务加锁顺序不一致锁范围过大查看 MySQL 死锁日志找到事务加锁顺序统一加锁顺序减少锁粒度用tryLock超时处理高并发下 CPU 飙升CAS 自旋频繁大量线程循环重试JVM 线程 dump查看热点线程栈使用退避策略改为悲观锁或分布式锁单机锁无法锁住多节点请求架构已变成多实例仍在用synchronized确认服务部署节点数、负载均衡方式引入分布式锁或数据库乐观锁锁等待超时一个事务持锁时间过长查看慢 SQL、事务提交耗时拆分大事务只锁必要的数据行每个问题的排查重点都有区别。比如超卖问题优先看 SQL 条件而不是看代码逻辑死锁问题优先看数据库死锁日志而不是乱调代码。9. 最佳实践与工程建议结合线上经验整理几条能直接用到项目里的建议。9.1 更新语句一定要加条件约束不要只用版本号作为条件。更新库存时把stock count也放进WHERE相当于多一层安全兜底。即使版本号逻辑写错库存条件也能挡住大部分超卖。9.2 控制事务的边界事务越小锁持有的时间越短并发能力越强。不要在事务里做远程调用、消息发送、复杂计算。如果必须做把事务拆成两段先扣库存提交事务再异步发消息。9.3 乐观锁必须有重试上限重试没用上限高并发下每个失败线程都会再次发起“查询 更新”数据库压力会成倍放大。建议最多重试 2 到 3 次超过就直接返回失败。9.4 注意索引对行锁的影响select ... for update时如果查询条件没有走唯一索引或主键索引InnoDB 可能锁住多行甚至升级为表锁。上线前一定要用EXPLAIN查看执行计划。9.5 需要记录版本号和操作日志使用数据库乐观锁时建议在表中添加version字段并在更新日志中记录请求前后版本号方便排查重复提交流程。9.6 不要把锁机制和业务逻辑混淆锁只是保证并发安全的手段不是业务规则。下单接口除了锁还要有幂等校验、库存预占、超时回滚等措施。把锁和业务状态机搭配使用才是一个合格的工程方案。10. 总结与面试回答模板最后针对这道经典面试题给出一个可以直接参考的回答思路。可以这样回答悲观锁和乐观锁是两种并发控制策略。悲观锁假设并发冲突一定发生所以先加锁、后操作JVM 里可以用synchronized和ReentrantLock数据库里可以用select ... for update。乐观锁假设冲突概率低所以先操作、更新时再判断是否被修改过Java 里可以用AtomicInteger等 CAS 原子类数据库里可以用版本号机制例如update ... set version version 1 where id ? and version ?。选择时主要看冲突概率和一致性要求。如果写多冲突严重选悲观锁读多写少选乐观锁。实际项目中我通常会用带版本号和库存条件的乐观锁扣库存并且限制重试次数避免高并发下数据库压力过大。这段回答兼顾了定义、实现、区别、场景和工程经验基本能让面试官满意。不过还要提醒一句八股文只能帮你过面试真正能涨工资的是你在项目里解决过一个真实的并发问题。建议今天就把悲观锁和乐观锁分别写一个最小可运行示例跑一遍多线程场景观察库存变化、失败次数和数据库锁等待时间。当你亲手看到“超卖”这个 bug 被修复时这道题才算真正消化了。下次面试再被问到“悲观锁和乐观锁”希望你不只是背定义而是能把这个答案清晰、有层次地讲出来。
返回列表