锁一篇搞定:乐观锁、悲观锁、CAS、数据库锁与分布式锁【从零开始的 Web 后端学习】

锁一篇搞定:乐观锁、悲观锁、CAS、数据库锁与分布式锁【从零开始的 Web 后端学习】

面向零基础,咱们从"两个请求同时改一条数据"开始,把 Java 锁、CAS、数据库锁和 Redis 分布式锁一次串起来。

文中的 Java 示例只使用标准 JDK;数据库与 Redis 示例是可落地的代码骨架,未在本文环境中执行,实际结果请以你的 JDK、数据库、驱动和 Redis 版本为准。全文不虚构性能数字。

上面这张桌面图先把本文的五个关键词放在一起:乐观锁、悲观锁、CAS、数据库锁和分布式锁。后面咱们会把它们分别放回正确的运行边界里。


一、ok,咱们先想一个问题:为什么需要锁?

商品库存只剩 1 件,用户 A 和用户 B 几乎同时点击"购买"。后端代码看起来很正常:

java 复制代码
if (stock > 0) {
    stock = stock - 1;
    save(stock);
}

但两个线程可能这样交叉执行:

text 复制代码
线程 A:读取 stock = 1
线程 B:读取 stock = 1
线程 A:判断 1 > 0,准备写回 0
线程 B:判断 1 > 0,准备写回 0
线程 A:写回 0,购买成功
线程 B:写回 0,购买也成功

最后库存显示为 0,订单却可能有两笔。不是代码不会减一,而是"读取、判断、写回"这几个动作被两个执行者交叉执行了。

再换一个例子:两个人同时编辑同一篇文章。

  • A 看到标题是"Java 入门";
  • B 也看到标题是"Java 入门";
  • A 改成"Java 并发入门"并保存;
  • B 改成"Java 面试入门"并保存。

如果没有冲突检测,B 的保存可能覆盖 A 的修改。咱们真正想要的通常不是"最后一个保存的人赢",而是:发现数据已经变过,就让后来的操作重新确认。

所以锁要解决的核心问题是:

多个执行者同时访问同一份共享数据时,怎样避免数据被错误地交叉修改?

这里的共享数据可以是:

  • Java 堆里的对象、集合、计数器;
  • 数据库里的行、页、表;
  • Redis 里的库存、订单状态、业务标记;
  • 多台服务共同使用的某个业务资源。

很多人学锁会懵,不是名词真的多,而是把几个不同层次的东西搅在一起了:

  • synchronized 和 ReentrantLock:主要解决同一个 JVM 里的线程并发;
  • CAS:一种"比较并交换"的原子更新手段;
  • 数据库锁:由数据库事务和存储引擎协调并发;
  • 分布式锁:让多台机器上的多个进程围绕同一资源协调;
  • 乐观锁、悲观锁:是处理冲突时的两种思路,不等于某一个固定 API。

咱们先清场,再逐个击破。


二、先清场:这些锁到底在什么地方工作?

2.1 A vs B:乐观锁和悲观锁

对比项 悲观锁 乐观锁
核心想法 先假设会冲突,先把资源锁住 先假设冲突少,提交时再检查
常见动作 加锁、等待、持有、释放 读取版本,提交时比较版本
冲突发生时 后来的执行者等待或失败 后来的执行者更新失败、重试或提示
是否一定阻塞别人 通常会,具体看锁模式 通常不会在读取阶段阻塞
常见实现 synchronized、ReentrantLock、SELECT ... FOR UPDATE 版本号、CAS、条件更新
适合感觉 临界区短、冲突代价高、需要串行 读多写少、冲突可恢复

2.2 A vs B:CAS 和互斥锁

对比项 CAS 互斥锁
基本动作 比较旧值,匹配才写新值 先获取锁,再执行临界区
线程获取不到资源时 常见做法是失败后重试或返回失败 可以等待、超时或放弃
典型 API compareAndSet(old, new) lock() / unlock()
适合范围 小粒度、单变量或少量状态 多步骤组成的临界区
主要风险 ABA、自旋消耗、重试风暴 死锁、忘记释放、锁粒度过大

2.3 A vs B:本地锁、数据库锁、分布式锁

对比项 Java 本地锁 数据库锁 分布式锁
保护范围 当前 JVM 进程 数据库事务中的数据 多台机器共同可见的资源
其他机器能否感知 不能 能通过数据库访问感知 能通过 Redis、ZooKeeper、etcd 等感知
常见实现 synchronized、ReentrantLock 行锁、锁定读、FOR UPDATE Redis SET NX PX、租约
主要成本 线程排队 事务、连接、锁等待 网络往返、租约和故障处理
典型使用 单实例缓存刷新 库存扣减、订单状态落库 多实例定时任务、幂等处理

2.4 A vs B:数据库悲观锁和数据库乐观锁

对比项 SELECT ... FOR UPDATE 版本号/条件更新
读取时 读取并锁住符合条件的记录 普通读取,不主动占用写锁
提交时 在同一事务内更新 WHERE id = ? AND version = ?
冲突处理 后来的事务等待或按超时策略处理 受影响行数为 0,说明版本已经变化
事务要求 必须放在事务中,并注意提交时机 更新语句承担冲突检测,但仍要结合事务设计
常见问题 长事务导致锁等待 忘记检查受影响行数,误以为更新成功

清场之后,先记住这一句话:

乐观/悲观是处理冲突的策略;CAS 是原子更新手段;Java 锁、数据库锁、分布式锁是不同运行边界下的实现工具。


三、一句话定义:锁到底是什么?

锁是一种并发协调机制:它把对共享资源的访问规则明确下来,让多个执行者按照互斥、可见、可控的方式读写数据,从而避免竞态条件和状态破坏。

这里有三个关键词:

  1. 互斥:同一时刻,特定临界区不要被多个线程同时执行;
  2. 可见:一个线程写入的结果,其他线程能在规定的内存模型或存储规则下看到;
  3. 可控:冲突发生时,要有等待、失败、重试、回滚等处理方式。

锁不是"加上就万事大吉"。锁住哪个对象、锁多久、异常时是否释放、跨不跨机器,这些才是实战里的关键。


四、先看全貌:一次库存扣减会经过哪些锁?

text 复制代码
用户请求 A                         用户请求 B
    │                                 │
    ▼                                 ▼
┌──────────────┐                 ┌──────────────┐
│ Web 线程 A   │                 │ Web 线程 B   │
└──────┬───────┘                 └──────┬───────┘
       │                                  │
       ├── 同一个 JVM? ──────────────────┤
       │                                  │
       ▼                                  ▼
  synchronized / ReentrantLock       synchronized / ReentrantLock
       │                                  │
       └──────────────┬───────────────────┘
                      ▼
             数据库事务边界
                      │
          ┌───────────┴───────────┐
          ▼                       ▼
  SELECT ... FOR UPDATE     version 条件更新
          │                       │
          └───────────┬───────────┘
                      ▼
                 COMMIT / ROLLBACK
                      │
                      ▼
              多实例场景再考虑
             Redis / etcd 分布式协调

这张图解决的是"锁到底在哪一层生效"的问题:线程锁只在当前进程里协调,数据库锁由数据库事务协调,分布式锁才面向多个进程或多个实例。

注意:这几层不是必须全部叠加。实际项目要根据共享资源的位置选择合适的协调方式。


五、线程安全基础:什么是竞态条件?

5.1 共享资源和临界区

如果多个线程都能访问某个数据,这个数据就是共享资源。访问共享资源的那段代码,就是临界区。

java 复制代码
int stock = 1;

// 读取、判断、写回是一个完整业务动作
if (stock > 0) {
    stock = stock - 1;
}

问题在于,stock = stock - 1 并不是一个不可分割的机器动作。它至少包含:

text 复制代码
读取 stock
计算 stock - 1
把结果写回 stock

两个线程交错执行,就可能出现丢失更新。

5.2 volatile 解决不了 i++

volatile 主要解决可见性和一定的有序性问题,但它不把复合操作变成原子操作。

java 复制代码
private volatile int count;

public void increment() {
    count++; // 仍然可能丢失更新
}

count++ 可以拆成:

text 复制代码
读取 count
count + 1
写回 count

两个线程都读到 10,最后都写回 11,结果就少加了一次。

5.3 为什么要把边界讲清楚?

因为"线程安全"不是一句魔法咒语:

  • 只在同一个 JVM 里共享对象,可以考虑 Java 本地锁;
  • 多个服务实例都访问数据库,要考虑数据库事务和数据库锁;
  • 多个实例要争抢同一个任务,可以考虑分布式协调;
  • 一个字段的原子递增,可能只需要原子类或数据库条件更新。

六、悲观锁:先假设会冲突,先把资源占住

6.1 悲观锁的思路

悲观锁的出发点是:我认为冲突可能发生,所以在进入临界区之前先占用资源。

生活中的例子就是卫生间门锁:一个人进去之后,其他人先等待,直到里面的人出来并释放门锁。

text 复制代码
获取锁
  │
  ├── 获取成功 → 执行临界区 → 释放锁
  │
  └── 获取失败 → 等待、超时或放弃

6.2 synchronized 示例

下面这个示例是一个标准 JDK 示例,保存为 SynchronizedCounterDemo.java 即可尝试编译运行。本文未执行。

java 复制代码
public class SynchronizedCounterDemo {

    private static class Counter {
        private int value;

        public synchronized void increment() {
            value++;
        }

        public synchronized int getValue() {
            return value;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread[] threads = new Thread[4];

        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 10_000; j++) {
                    counter.increment();
                }
            }, "worker-" + i);
            threads[i].start();
        }

        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println("counter = " + counter.getValue());
    }
}

这里的 synchronized 保护的是同一个 Counter 对象实例。两个不同对象之间没有自动协调关系。

6.3 ReentrantLock 示例

ReentrantLock 更像一把需要手动操作的锁:

java 复制代码
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockDemo {

    private static class SafeCounter {
        private final ReentrantLock lock = new ReentrantLock();
        private int value;

        public void increment() {
            lock.lock();
            try {
                value++;
            } finally {
                lock.unlock();
            }
        }

        public int getValue() {
            lock.lock();
            try {
                return value;
            } finally {
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        SafeCounter counter = new SafeCounter();
        Thread first = new Thread(counter::increment);
        Thread second = new Thread(counter::increment);

        first.start();
        second.start();
        first.join();
        second.join();

        System.out.println(counter.getValue());
    }
}

finally 不是装饰,而是必须。只要忘了释放,异常路径就可能让后续线程一直等待。

6.4 tryLock:不想无限等待怎么办?

java 复制代码
if (lock.tryLock()) {
    try {
        // 执行临界区
    } finally {
        lock.unlock();
    }
} else {
    // 获取失败:返回稍后重试、进入队列或降级处理
}

如果使用带超时的 tryLock,还要处理等待超时和线程中断。实际业务不能只写"拿不到就一直等"。

6.5 悲观锁的优点和代价

优点:

  • 多步骤临界区更直观;
  • 冲突多时,可以减少反复重试;
  • 业务代码容易表达"同一时间只能一个人处理"。

代价:

  • 线程可能等待;
  • 锁范围太大,会降低并发度;
  • 多把锁顺序不一致时,可能形成死锁;
  • 长时间持锁会放大故障影响。

七、乐观锁:先做事,提交时再核对

7.1 乐观锁的思路

乐观锁的出发点是:冲突不是每次都会发生,那就先读取、先计算,提交时检查数据有没有被别人改过。

最常见的做法是增加一个 version 版本号:

text 复制代码
第一次读取:id = 1,stock = 10,version = 7

请求 A:带 version = 7 更新,成功后 version = 8
请求 B:仍然带 version = 7 更新,条件不成立,更新 0 行

这张图解决的是"悲观锁为什么让后来的请求等待,乐观锁为什么在提交时发现版本冲突"的理解问题。

7.2 数据库版本号 SQL

表结构可以这样设计:

sql 复制代码
CREATE TABLE product_stock (
    id BIGINT PRIMARY KEY,
    product_name VARCHAR(100) NOT NULL,
    stock INT NOT NULL,
    version BIGINT NOT NULL DEFAULT 0
);

读取数据:

sql 复制代码
SELECT id, product_name, stock, version
FROM product_stock
WHERE id = 1;

带版本条件更新:

sql 复制代码
UPDATE product_stock
SET stock = stock - 1,
    version = version + 1
WHERE id = 1
  AND stock > 0
  AND version = 7;

这里最重要的是检查影响行数:

  • 返回 1:本次条件匹配并完成更新;
  • 返回 0:可能是版本冲突、库存不足、记录不存在或其他条件不满足。

SQL 执行成功,不等于业务更新成功。0 行必须进入冲突处理分支。

7.3 乐观锁更新的 Java 伪代码

java 复制代码
int affectedRows = productMapper.decreaseStock(
        productId,
        expectedVersion
);

if (affectedRows != 1) {
    // 重新查询、提示重试、有限次数重试,或进入补偿流程
    throw new ConcurrentUpdateException("库存版本已变化");
}

重试也要有边界:

  • 重试次数有限;
  • 每次重试重新读取最新版本;
  • 必要时加入退避;
  • 高冲突时进入队列或直接提示用户。

无限重试会把一次冲突放大成重试风暴。

7.4 乐观锁不是"不加任何保护"

乐观锁仍然需要保护,只是保护方式不是先占住资源,而是提交时进行条件检查。

下面两条 SQL 的含义完全不同:

sql 复制代码
-- 有冲突检测
UPDATE product_stock
SET stock = stock - 1, version = version + 1
WHERE id = ? AND version = ?;
sql 复制代码
-- 没有版本条件,可能覆盖别人的更新
UPDATE product_stock
SET stock = stock - 1, version = version + 1
WHERE id = ?;

第二条即使修改了 version,也没有用旧版本判断"我读到的数据是不是最新的"。


八、CAS:Compare-And-Set 到底是什么?

8.1 CAS 的三个参数

CAS 可以理解成:

text 复制代码
如果当前值 == 预期值:
    原子地改成新值
    返回 true
否则:
    不修改
    返回 false

Java 的 AtomicInteger 提供了这个能力:

java 复制代码
import java.util.concurrent.atomic.AtomicInteger;

public class AtomicIntegerDemo {

    public static void main(String[] args) {
        AtomicInteger stock = new AtomicInteger(1);

        boolean success = stock.compareAndSet(1, 0);

        System.out.println("success = " + success);
        System.out.println("stock = " + stock.get());
    }
}

注意:compareAndSet 返回 false 后,程序要自己决定是重读、重试还是返回失败。

8.2 CAS 循环

java 复制代码
import java.util.concurrent.atomic.AtomicInteger;

public class CasLoopDemo {

    private static final AtomicInteger counter = new AtomicInteger();

    public static void increment() {
        while (true) {
            int oldValue = counter.get();
            int newValue = oldValue + 1;

            if (counter.compareAndSet(oldValue, newValue)) {
                return;
            }
        }
    }

    public static void main(String[] args) {
        increment();
        increment();
        System.out.println(counter.get());
    }
}

CAS 循环的特点是:失败后不一定阻塞等待,而是重新读取并尝试。但竞争激烈时,线程可能反复失败并消耗 CPU。

8.3 ABA 问题

假设线程 A 读取到值 A,线程 B 把它改成 B,之后又改回 A。线程 A 再比较时发现"还是 A",却不知道中间已经发生过变化,这就是 ABA 问题。

如果业务关心"中间有没有变化",就不能只比较值,还要比较版本或时间戳。Java 中可以了解 AtomicStampedReference 这类带版本戳的工具。

8.4 CAS 不等于永远更快

CAS 适合小粒度状态更新,但不是所有业务的替代品:

  • 多个字段要一起修改时,单个 CAS 不够表达整体一致性;
  • 业务逻辑很长时,反复自旋的成本可能变高;
  • 需要等待、超时、条件队列时,显式锁更直观;
  • 跨 JVM、跨机器时,内存 CAS 保护不了远端资源。

九、数据库悲观锁:SELECT ... FOR UPDATE

9.1 数据库锁的协调者是谁?

Java 本地锁由 JVM 协调;数据库锁由数据库和存储引擎协调。只要多个请求通过同一个数据库访问同一条记录,数据库才有机会让它们排队。

以常见的 MySQL InnoDB 语法为例:

sql 复制代码
START TRANSACTION;

SELECT id, stock
FROM product_stock
WHERE id = 1
FOR UPDATE;

-- 应用层检查 stock 是否足够
UPDATE product_stock
SET stock = stock - 1
WHERE id = 1;

COMMIT;

另一个事务如果也对同一条记录执行锁定读,通常会等待前一个事务提交或回滚。精确的锁范围仍然取决于数据库、存储引擎、索引、隔离级别和查询条件。

9.2 FOR UPDATE 必须和事务绑定

下面这种写法容易让人误会:

text 复制代码
连接 1:SELECT ... FOR UPDATE
连接 1:语句结束
连接 2:修改同一条记录

如果锁没有被一个持续的事务持有,后续业务动作就没有得到预期保护。使用 JDBC 时通常要显式关闭自动提交,在同一个连接上完成锁定读、业务更新和提交。

9.3 JDBC 事务骨架

java 复制代码
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class JdbcPessimisticLockDemo {

    public static void decreaseStock(Connection connection,
                                     long productId) throws SQLException {
        boolean oldAutoCommit = connection.getAutoCommit();
        connection.setAutoCommit(false);

        try {
            int stock;
            String selectSql = """
                    SELECT stock
                    FROM product_stock
                    WHERE id = ?
                    FOR UPDATE
                    """;

            try (PreparedStatement ps = connection.prepareStatement(selectSql)) {
                ps.setLong(1, productId);
                try (ResultSet rs = ps.executeQuery()) {
                    if (!rs.next()) {
                        throw new SQLException("商品不存在");
                    }
                    stock = rs.getInt("stock");
                }
            }

            if (stock <= 0) {
                throw new SQLException("库存不足");
            }

            String updateSql = """
                    UPDATE product_stock
                    SET stock = stock - 1
                    WHERE id = ?
                    """;

            try (PreparedStatement ps = connection.prepareStatement(updateSql)) {
                ps.setLong(1, productId);
                int affectedRows = ps.executeUpdate();
                if (affectedRows != 1) {
                    throw new SQLException("库存更新失败");
                }
            }

            connection.commit();
        } catch (Exception exception) {
            connection.rollback();
            throw exception;
        } finally {
            // 连接池场景中,归还连接前恢复连接状态
            connection.setAutoCommit(oldAutoCommit);
        }
    }
}

这段代码只展示事务边界和锁定读骨架。实际项目还要配置 JDBC 驱动、连接池、数据库地址,并根据数据库文档核对语法。本文未执行。

9.4 数据库悲观锁的几个注意点

  • 事务不能长时间持有锁;
  • 查询条件要有合适索引,避免扫描和锁住过宽范围;
  • 多个事务加锁顺序尽量一致;
  • 锁等待超时和死锁是两类问题,处理方式不完全相同;
  • 普通 SELECT 的一致性读和 FOR UPDATE 锁定读不是一回事;
  • 不同数据库对间隙锁、范围锁和隔离级别的实现可能不同。

十、数据库乐观锁:版本号和条件更新

10.1 最小可用方案

sql 复制代码
SELECT id, stock, version
FROM product_stock
WHERE id = ?;

应用层拿到 stock = 10、version = 7 后,执行:

sql 复制代码
UPDATE product_stock
SET stock = 9,
    version = 8
WHERE id = ?
  AND version = 7
  AND stock >= 1;

如果返回 1 行,说明这次更新成功;如果返回 0 行,需要判断是库存不足、版本冲突还是记录不存在。

10.2 更适合扣减的原子条件更新

如果业务只是"库存减一",可以把判断和扣减合并到 SQL 条件中:

sql 复制代码
UPDATE product_stock
SET stock = stock - 1,
    version = version + 1
WHERE id = ?
  AND stock >= 1
  AND version = ?;

这种写法的关键不是 SQL 看起来短,而是:

  1. 条件中带旧版本;
  2. 成功时版本递增;
  3. 应用层检查 executeUpdate() 的返回值;
  4. 失败后进入明确的业务分支。

10.3 时间戳能不能当版本号?

有些系统会用更新时间判断冲突,但时间戳会受到字段精度、数据库类型、时钟和并发写入的影响。新手示例优先使用单调递增的整数版本号;如果使用时间戳,必须核对数据库字段精度和驱动行为。

10.4 ORM 的 @Version 是什么?

很多 ORM 会用 @Version 封装版本条件更新。它的本质仍然是:

sql 复制代码
WHERE id = ? AND version = ?

框架具体如何报告冲突,要以对应版本的官方文档和实际配置为准。不要把"有 @Version 注解"理解成所有外部副作用也自动受保护。


十一、分布式锁:为什么 synchronized 管不了两台机器?

11.1 本地锁的边界

假设有两个服务实例:

text 复制代码
机器 A / JVM 1                     机器 B / JVM 2
synchronized(lockObject)           synchronized(lockObject)
        │                                  │
        └──── 两个完全不同的 JVM ─────────┘

即使变量名相同、代码完全相同,它们也不是同一个内存对象。因此 JVM 本地锁无法协调两个进程。

这时才会考虑 Redis、ZooKeeper、etcd 等外部协调组件。

11.2 Redis 获取锁的基本命令

redis 复制代码
SET lock:order:1001 unique-token NX PX 30000

含义:

  • lock:order:1001:锁的业务 key;
  • unique-token:当前持有者的唯一标识;
  • NX:只有 key 不存在时才设置;
  • PX 30000:设置毫秒级过期时间。

这里的 token 不能省略。因为释放时必须确认"这个锁是不是我自己的"。

11.3 为什么不能直接 DEL?

一个危险时间线:

text 复制代码
客户端 A 获取锁,token = A
A 执行时间过长,锁过期
客户端 B 获取同一个 key,token = B
客户端 A 终于执行完,直接 DEL lock:key
结果:A 把 B 的锁删除了

正确做法是"比较 token 相同后再删除",而且比较和删除要是一个原子操作。可以使用 Lua 脚本表达:

lua 复制代码
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

这张图专门解决 Redis 锁释放时最容易犯的错误:不能无条件删除,必须确认当前 token 仍然属于自己。

11.4 Redis 锁不是万能钥匙

分布式锁还要面对:

  • 业务执行时间超过租约;
  • 进程暂停、网络延迟或客户端失联;
  • 锁已经过期,但旧持有者仍继续写资源;
  • 续租线程自身失败;
  • 锁服务发生网络分区;
  • 业务方把 Redis 锁误当成数据库事务。

对严格正确性场景,可以引入 fencing token:每次成功获取资源时获得递增令牌,资源端在写入时校验令牌,拒绝旧令牌的写入。TTL 解决"锁最终会释放",fencing token 解决"旧持有者还能不能写"。两者不是一回事。


十二、锁粒度、锁范围和锁顺序

12.1 锁粒度

锁粒度可以简单理解成"锁住多大范围":

  • 锁一个对象;
  • 锁一个用户;
  • 锁一条订单;
  • 锁一行数据库记录;
  • 锁一张表;
  • 锁整个业务模块。

锁得太细,代码管理复杂;锁得太粗,并发度下降。实际选择要看共享资源的边界。

12.2 锁范围

锁范围不仅是代码块大小,也包括锁持有的时间:

java 复制代码
lock.lock();
try {
    // 只把必要的共享状态操作放进来
    updateSharedState();
} finally {
    lock.unlock();
}

// 发送通知、调用远程服务等非必要工作尽量不要占着锁做

不要拿着数据库行锁去等待用户输入,也不要在事务里长时间调用不稳定的外部服务。

12.3 锁顺序和死锁

死锁通常可以抽象成:

text 复制代码
线程 A 持有锁 1,等待锁 2
线程 B 持有锁 2,等待锁 1

避免思路:

  1. 所有线程按照统一顺序获取多把锁;
  2. 尽量减少同时持有的锁数量;
  3. 使用超时获取,避免无限等待;
  4. 缩短临界区和事务时间;
  5. 对数据库死锁异常做有限重试,但先修复锁顺序问题。

十三、综合实战:库存扣减怎么选?

13.1 单 JVM、低复杂度场景

如果库存只在一个 JVM 内存对象里,业务也不需要跨实例协调,可以使用 synchronized 或 ReentrantLock:

java 复制代码
public class InventoryService {

    private final Object lock = new Object();
    private int stock = 10;

    public boolean buy() {
        synchronized (lock) {
            if (stock <= 0) {
                return false;
            }
            stock--;
            return true;
        }
    }
}

但一旦部署多个实例,每个实例都有自己的 stock 和自己的锁,这个方案就不再保护共享数据库库存。

13.2 数据库为最终事实来源

如果库存最终落在数据库,常见选择有两种:

悲观锁:

sql 复制代码
START TRANSACTION;
SELECT stock FROM product_stock WHERE id = ? FOR UPDATE;
-- 检查库存
UPDATE product_stock SET stock = stock - 1 WHERE id = ?;
COMMIT;

乐观锁:

sql 复制代码
UPDATE product_stock
SET stock = stock - 1,
    version = version + 1
WHERE id = ?
  AND stock > 0
  AND version = ?;

如果更新影响行数为 0,应用层可以重新读取并有限重试,也可以直接返回"库存已变化,请重试"。

13.3 多实例抢同一个定时任务

这类场景首先要明确"谁是共享资源":不是某个 Java 对象,而是"这个任务今天只能执行一次"。可以用分布式锁做入口协调,但任务内部仍要有:

  • 幂等键;
  • 数据库事务;
  • 状态机;
  • 失败补偿;
  • 超时和重复执行处理。

分布式锁只是减少重复进入的机会,不能替代整个业务的一致性设计。


十四、到底怎么选?给你一张决策表

业务问题 优先考虑 还要补什么
一个 JVM 内多个线程修改同一对象 synchronized / ReentrantLock 明确锁对象、释放路径和超时策略
单个数值的原子更新 AtomicInteger / CAS 处理 CAS 失败、自旋和 ABA
数据库行需要先读后改 SELECT ... FOR UPDATE 显式事务、索引、锁顺序、超时
数据库读多写少、冲突可重试 版本号条件更新 检查影响行数、有限重试、用户提示
多实例只允许一个执行者 Redis / etcd / ZooKeeper 协调 token、租约、幂等、fencing 或补偿
多个资源要一起提交 数据库事务 不要把事务、锁和隔离级别混为一谈

一个简单口诀:

text 复制代码
先看共享资源在哪儿:
Java 内存      → JVM 锁 / CAS
数据库记录    → 数据库事务 + 悲观锁或乐观锁
多实例共同任务 → 分布式协调 + 幂等 + 失效处理

十五、❌ 坑:代码运行时容易出问题的地方

  1. ReentrantLock 只在正常分支解锁,异常分支忘记放进 finally;
  2. JDBC 修改了 autoCommit,归还连接池前没有恢复连接状态;
  3. SELECT ... FOR UPDATE 没有放在持续事务里;
  4. 乐观锁 UPDATE 返回 0 行,却仍然向用户返回"成功";
  5. 版本号没有出现在 WHERE 条件中;
  6. CAS 失败后无限自旋,没有退出条件;
  7. 多条 SQL 以不同顺序加锁,触发死锁;
  8. 查询条件没有合适索引,导致扫描和锁范围扩大;
  9. Redis 锁没有设置过期时间;
  10. Redis 释放锁时直接 DEL,误删其他客户端刚获得的锁;
  11. 锁的租约太短,业务还没结束锁已经过期;
  12. 把远程调用、用户等待和大批量计算全部放在持锁区;
  13. 把数据库锁当成缓存、文件和消息系统的全局保护;
  14. 连接池复用连接时遗留了隔离级别、只读属性或事务状态。

十六、★ 误区:代码能跑,但理解是错的

  1. "乐观锁就是不加锁。" 乐观锁依靠版本或条件检查检测冲突。
  2. "悲观锁一定慢,乐观锁一定快。" 结果取决于冲突率、临界区、重试成本、数据库和网络等因素。
  3. "CAS 比 synchronized 高级。" CAS 是原子更新原语,不是所有业务临界区的替代品。
  4. "CAS 一定不会阻塞。" CAS 循环可能长时间自旋,线程也可能长期得不到进展。
  5. "加了 volatile 就线程安全。" volatile++ 仍然是复合操作。
  6. "数据库事务就是锁。" 事务描述操作边界,锁是并发协调手段,隔离级别描述并发可见性规则。
  7. "SELECT ... FOR UPDATE 到处都一样。" 数据库、存储引擎、索引和隔离级别都会影响精确语义。
  8. "拿到 Redis 锁后,数据库更新就绝对安全。" 租约过期和旧持有者继续写仍然需要版本校验或 fencing。
  9. "Redis SETNX 成功后直接 DEL 就完整了。" 还需要唯一 token、过期策略和原子校验释放。
  10. "锁住缓存就等于锁住数据库。" 缓存和数据库是不同资源,需要一致性、幂等和补偿策略。
  11. "锁的范围越大越安全。" 范围越大,等待和死锁的影响面也可能越大。
  12. "锁等待超时就是死锁。" 死锁通常是循环等待,超时则是等待超过阈值,诊断方式不同。

十七、📌 易忘点、❌ 坑、★ 误区、✅ 结论

📌 易忘点

  • 乐观锁的关键是提交时的版本/条件检查;
  • 悲观锁的关键是进入临界区之前先占用资源;
  • CAS 比较的是共享值,数据库版本锁比较的是数据库记录条件;
  • FOR UPDATE 要和事务边界一起理解;
  • Redis 解锁要校验 token,并且校验和删除要原子完成;
  • JVM 锁只覆盖当前进程,跨实例要换成数据库或分布式协调方案。

❌ 坑

  • 忘记解锁;
  • 忽略影响行数;
  • 无限重试;
  • 长事务;
  • 直接 DEL 分布式锁;
  • 连接池连接状态污染。

★ 误区

  • 把乐观锁理解成完全没有保护;
  • 把 CAS 当成所有锁的替代品;
  • 把事务、锁和隔离级别当成同一个概念;
  • 认为分布式锁拿到后业务就天然一致。

✅ 结论

锁的选择不是背 API,而是先回答三个问题:

  1. 共享资源到底在哪里?Java 内存、数据库,还是多实例共同访问的外部资源?
  2. 冲突发生时希望等待,还是希望失败后重试?
  3. 锁释放、事务提交、客户端崩溃和网络异常发生后,业务还能不能自洽?

十八、我自己是这么记的

我会把锁记成三句话:

  1. 悲观锁先占位,乐观锁后验货。
  2. CAS 管一个小状态,事务管一组数据库动作。
  3. 本地锁管一个 JVM,分布式锁管多个进程,但最终正确性还要靠版本、幂等、事务和补偿。

再遇到"这里该不该加锁"的问题,我不会先搜索某个 API,而是先画出:谁在并发访问、共享资源在哪儿、冲突怎么发现、失败怎么收场。


十九、下篇预告

下一篇咱们可以继续拆:

  • MySQL 事务隔离级别和 MVCC;
  • Spring @Transactional 的事务边界;
  • Redis 分布式锁的续期、fencing token 和幂等设计;
  • 订单、库存和支付状态机如何组合起来。

如果这篇文章帮你把乐观锁和悲观锁分清楚了,欢迎收藏,后面写并发业务时直接回来查。代码示例按本文说明复制即可尝试,实际运行前记得补齐本机依赖和配置。


参考资料

以下链接属于官方 API 或官方手册,用于核对命令和类的语义;本文没有把外部资料中的性能数字写成结论。

  1. Java SE API:ReentrantLock ------ JDK 官方 API。
  2. Java SE API:AtomicInteger ------ JDK 官方 API。
  3. MySQL:Locking Reads ------ MySQL 官方手册,核对 FOR UPDATE 等锁定读说明。
  4. Redis:SET command ------ Redis 官方命令文档,核对 NX、PX 等参数。
  5. Redis:EVAL command ------ Redis 官方命令文档,核对 Lua 脚本调用方式。

配图说明

图片 用在哪 解决什么理解问题
文章桌面图 文首 一眼看到本文的五个关键词:乐观锁、悲观锁、CAS、数据库锁、分布式锁
锁层次全景图 "先看全貌"一节 说清锁分别在线程、CAS、数据库、分布式协调哪一层生效
乐观锁/悲观锁对照图 乐观锁一节 对比"先等待"和"提交时发现版本冲突再重试"
Redis 安全释放图 分布式锁一节 说明释放锁必须先校验 token,不能无条件删除

图片源文件与本文放在一起,路径统一在 images/locks-guide/ 下。

相关推荐
软件技术NINI15 分钟前
基于SpringBoot和Vue的博物馆预约平台管理系统的设计与实现
java·毕业设计·毕设
m0_5648768435 分钟前
CMake学习
学习
IT_陈寒1 小时前
Java开发实战:从入门到精通
前端·人工智能·后端
只愿云淡风清1 小时前
正则表达式学习笔记 02:基础语法
笔记·学习·正则表达式
jktantan1 小时前
注解式脱敏:用 Jackson 的 ContextualSerializer 做字段级策略(一)
spring boot·后端·算法·spring cloud·系统架构
wangqiaowq4 小时前
Ontology 学习 二
学习
独泪了无痕10 小时前
探索Pinyin4j-Java中的拼音转换利器
java·后端
码事漫谈10 小时前
从 Coze 居然要下线了说起
后端
该学习了咕咕11 小时前
顶刊论文学习笔记
学习