Java高并发与分布式锁详解

Java 高并发与分布式锁详解

全文用「库存秒杀」这一条主线贯穿,每个概念都配可运行的代码。


第一部分:高并发基础

1.1 什么是"高并发"

高并发 = 同一时刻有大量请求同时打到你的系统。衡量指标:

指标 含义 类比
QPS 每秒查询数 类似前端的"每秒 API 调用次数"
TPS 每秒事务数(一个事务可能含多次查询) 一次完整下单流程
响应时间 RT 一个请求从进来到返回的耗时 前端 Performance API 里的 duration
并发数 系统同时处理的请求数 同时在飞的 fetch 请求数

前端视角类比:浏览器同时只能发 6 个 HTTP/1.1 连接,超出的请求要排队。Java 服务端也一样——Tomcat 默认只有 200 个线程处理请求,线程是稀缺资源,这就是高并发问题的根源。

1.2 并发的核心问题:共享资源竞争

Java 后端和前端的本质区别:

  • 前端 JS 是单线程的,你永远不用担心两行代码"同时执行"改同一个变量。
  • Java 后端是多线程的,Tomcat 给每个请求分配一个线程,几百个线程可能同一瞬间执行同一行代码。

经典问题场景——库存扣减:

// 错误的扣减代码(看看你能不能看出问题)
public void deduct(Long productId) {
    Product p = productMapper.selectById(productId); // ① 查库存
    if (p.getStock() > 0) {                          // ② 判断
        p.setStock(p.getStock() - 1);                // ③ 扣减
        productMapper.updateById(p);                 // ④ 写回
    }
}

问题在哪? 假设库存只剩 1,两个线程同时执行:

线程A: ①查到 stock=1  →  ②判断通过  →  (还没来得及扣)
线程B: ①查到 stock=1  →  ②判断通过  →  ③扣成0  →  ④写回
线程A:                    ③也扣成0  →  ④写回
结果:卖出去 2 件,库存却是 0 → 超卖!

这就是竞态条件(Race Condition)。前端几乎遇不到,后端天天要防。


1.3 单机锁:JVM 内的解决方案

方案一:synchronized(最常用)

// 方式1:修饰方法,锁的是 this(当前对象)
public synchronized void deduct(Long productId) {
    Product p = productMapper.selectById(productId);
    if (p.getStock() > 0) {
        p.setStock(p.getStock() - 1);
        productMapper.updateById(p);
    }
}

// 方式2:同步代码块,粒度更细(推荐)
private final Object lock = new Object();

public void deduct(Long productId) {
    synchronized (lock) {   // 只有拿到 lock 这把锁的线程才能进来
        Product p = productMapper.selectById(productId);
        if (p.getStock() > 0) {
            p.setStock(p.getStock() - 1);
            productMapper.updateById(p);
        }
    }   // 出代码块自动释放锁,即使抛异常也会释放
}

类比前端:synchronized 有点像给函数加了"排队执行"——同一时刻只有一个调用能进入临界区。

注意一个巨坑:在 Spring 里 @Service 是单例的,synchronized(this) 看似能锁住所有请求。但只要你的服务部署了 2 台机器(生产环境必然如此),每台机器各有一把锁,互不认识,锁就失效了。这就是引出分布式锁的原因。

方案二:ReentrantLock(更灵活的手动锁)

private final ReentrantLock lock = new ReentrantLock();

public void deduct(Long productId) {
    lock.lock();           // 手动加锁
    try {
        Product p = productMapper.selectById(productId);
        if (p.getStock() > 0) {
            p.setStock(p.getStock() - 1);
            productMapper.updateById(p);
        }
    } finally {
        lock.unlock();     // 必须在 finally 里释放,否则异常时死锁!
    }
}

ReentrantLock 比 synchronized 多出的能力:

// 1. 尝试加锁,拿不到就不等(防止线程无限阻塞)
if (lock.tryLock(3, TimeUnit.SECONDS)) {   // 最多等 3 秒
    try { /* 业务 */ } finally { lock.unlock(); }
} else {
    throw new RuntimeException("系统繁忙,请稍后重试");
}

// 2. 公平锁:按排队顺序获得锁(默认非公平,性能更高)
new ReentrantLock(true);

必须 finally 里 unlock——这就像前端 try/finally 里关 loading,忘了就永远卡住(这里是永远死锁)。

方案三:CAS 无锁编程(Atomic 原子类)

CAS(Compare-And-Swap)是一种"乐观"思路:不加锁,直接改,改之前检查一下值有没有被别人动过。

// AtomicInteger 内部就是 CAS
private AtomicInteger stock = new AtomicInteger(100);

public void deduct() {
    while (true) {
        int current = stock.get();                    // 读到当前值
        if (current <= 0) throw new RuntimeException("售罄");
        // compareAndSet:仅当值还是 current 时才更新为 current-1
        // 如果期间被别人改了,返回 false,循环重试
        if (stock.compareAndSet(current, current - 1)) {
            return; // 扣减成功
        }
        // 失败则自旋重试
    }
}

类比前端:CAS 很像 Git 的冲突解决——你 push 前会检查远端 commit 是不是你 pull 时的那个;不是就拒绝,你 rebase 后重试。数据库里的"乐观锁 version 字段"也是同一思想。


1.4 单机锁为什么不够 → 分布式锁登场

生产环境的服务一定是多实例部署的(负载均衡、容灾)。此时:

  • synchronized / ReentrantLock:只能锁 单个 JVM 进程内 的线程
  • 实例 A 的锁,实例 B 完全感知不到

分布式锁 = 把"锁"从 JVM 内存里搬到一个所有实例都能访问的公共地方(Redis / ZooKeeper / 数据库),谁抢到谁执行。


第二部分:分布式锁

2.1 分布式锁必须满足的 4 个特性

  1. 互斥性:同一时刻只有一个客户端能持有锁(基本要求)
  2. 防死锁:持有锁的客户端宕机了,锁也要能释放(不能永远锁死)
  3. 防误删:只能释放自己加的锁,不能删掉别人的锁
  4. 可重入(加分项):同一个线程可以重复获取同一把锁

下面按"演进过程"讲,每个版本解决上一版的问题—


2.2 基于 Redis 实现(生产主流)

V1:setnx —— 会死锁的版本

// SETNX key value:key 不存在才能设置成功(set if not exists)
Boolean locked = stringRedisTemplate.opsForValue()
        .setIfAbsent("lock:product:" + productId, "1");
if (Boolean.TRUE.equals(locked)) {
    try {
        // 抢到锁,执行业务
        deduct(productId);
    } finally {
        stringRedisTemplate.delete("lock:product:" + productId);
    }
}

问题:如果服务在 deduct() 执行中宕机,delete 永远执行不到 → 锁永远不释放 → 死锁。

V2:加过期时间 —— 仍有坑

// 关键:加锁和设过期时间必须是原子操作!
// 错误写法:先 setnx 再 expire,两条命令之间宕机照样死锁
Boolean locked = stringRedisTemplate.opsForValue()
        .setIfAbsent("lock:product:" + productId, "1", 30, TimeUnit.SECONDS);
// 底层是 SET key value NX EX 30,一条命令,原子执行

新问题:锁有 30 秒过期时间。如果业务执行了 35 秒:

线程A: 拿到锁(30s过期) → 业务执行到第30秒,锁自动过期
线程B: 立刻拿到锁,开始执行
线程A: 第35秒业务做完,执行 delete → 删掉了线程B的锁!  ← 误删
线程C: 发现没锁了,又进来 → 并发失效

V3:唯一标识 + Lua 原子解锁 —— 防误删

public void deductWithLock(Long productId) {
    String lockKey = "lock:product:" + productId;
    // 每个请求生成唯一标识,标记"这把锁是谁的"
    String lockValue = UUID.randomUUID().toString();

    Boolean locked = stringRedisTemplate.opsForValue()
            .setIfAbsent(lockKey, lockValue, 30, TimeUnit.SECONDS);
    if (!Boolean.TRUE.equals(locked)) {
        throw new RuntimeException("操作太频繁,请稍后再试");
    }

    try {
        deduct(productId);  // 真正的业务逻辑
    } finally {
        // 解锁必须原子地完成"判断是自己的锁 + 删除",用 Lua 脚本
        String luaScript =
                "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                "    return redis.call('del', KEYS[1]) " +
                "else " +
                "    return 0 " +
                "end";
        stringRedisTemplate.execute(
                new DefaultRedisScript<>(luaScript, Long.class),
                Collections.singletonList(lockKey),
                lockValue);
    }
}

为什么要 Lua 脚本? 因为"GET 判断 + DEL 删除"是两步操作,中间锁可能过期被别人抢走。Lua 脚本在 Redis 里是原子执行的(Redis 单线程执行命令,脚本执行期间不会插入其他命令)。

还剩一个问题:业务 35 秒才执行完,锁 30 秒就过期了 → 别人还是能进来。怎么办?答案:锁续期(看门狗)。

V4:Redisson —— 生产级终极方案(直接用这个)

上面这些坑(原子加锁、防误删、锁续期、可重入)全部自己写太累,Redisson 是成熟的 Redis 客户端,把分布式锁封装好了。

引入依赖:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.27.2</version>
</dependency>

配置(application.yml):

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379

使用(简单到不像分布式锁):

@Service
public class SeckillService {

    @Autowired
    private RedissonClient redissonClient;

    public void deduct(Long productId) {
        RLock lock = redissonClient.getLock("lock:product:" + productId);
        try {
            // 尝试加锁:最多等待 10 秒,锁持有 30 秒后自动释放
            boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);
            if (!locked) {
                throw new RuntimeException("系统繁忙,请稍后重试");
            }
            // 真正的业务逻辑
            doDeduct(productId);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            // 只释放自己持有的锁(Redisson 内部就是 V3 的 Lua 脚本逻辑)
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    private void doDeduct(Long productId) {
        Product p = productMapper.selectById(productId);
        if (p.getStock() <= 0) throw new RuntimeException("已售罄");
        p.setStock(p.getStock() - 1);
        productMapper.updateById(p);
    }
}

Redisson 的看门狗(Watchdog)机制——锁续期:

// 不传过期时间时,Redisson 默认锁 30 秒
lock.lock();
// 但会启动一个后台定时线程:每 10 秒(过期时间的 1/3)检查一次
// 只要你的业务还在执行,就自动把锁续期回 30 秒
// 类比前端:WebSocket 心跳保活,只要连接活着就持续发心跳

看门狗工作流程:

t=0s   加锁成功,锁 30 秒后过期
t=10s  看门狗:业务还没完?续期 → 锁重新 30 秒后过期
t=20s  看门狗:还没完?再续期 → 又重置为 30 秒
t=35s  业务执行完,unlock(),看门狗停止
——如果服务宕机:看门狗线程随 JVM 一起死,锁 30 秒后自动过期,不会死锁 ✓

可重入:Redisson 的锁底层是 Redis Hash 结构,记录 线程标识: 加锁次数,同一线程再次 lock 次数 +1,unlock 次数 -1,减到 0 才真正释放——语义和 ReentrantLock 完全一致。

补充:Redis 集群下的 RedLock(了解即可)

单个 Redis 节点主从切换时,锁可能丢失(锁写进 master 还没同步到 slave,master 宕机)。Redis 作者提出 RedLock:向多个独立 Redis 节点(通常 5 个)同时申请锁,超过半数成功才算成功。业界对此有争议(Martin Kleppmann 的著名质疑文章),大多数公司用"Redis 单节点/主从 + Redisson + 数据库兜底"就够了,不必过度设计。


2.3 基于数据库实现(无 Redis 时的备选)

悲观锁:SELECT ... FOR UPDATE

@Transactional
public void deduct(Long productId) {
    // 加行锁:当前事务提交前,其他事务无法读取/修改这一行
    Product p = productMapper.selectByIdForUpdate(productId);
    // SELECT * FROM product WHERE id = ? FOR UPDATE
    if (p.getStock() <= 0) throw new RuntimeException("已售罄");
    p.setStock(p.getStock() - 1);
    productMapper.updateById(p);
}   // 事务提交,行锁释放

特点:实现最简单、绝对安全,但行锁会阻塞其他请求,并发高时性能差。

乐观锁:version 字段(CAS 思想)

-- 表加一个 version 字段
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = #{id} AND version = #{version} AND stock > 0
public void deduct(Long productId) {
    Product p = productMapper.selectById(productId);
    int rows = productMapper.deductWithVersion(
            productId, p.getVersion());   // 返回受影响行数
    if (rows == 0) {
        // 更新失败 = version 被别人改了 或 库存不足,重试或报错
        throw new RuntimeException("扣减失败,请重试");
    }
}

特点:无锁、并发性能好,适合"读多写少"场景。注意更新失败时要有重试策略。

防超卖的兜底 SQL(强烈建议任何方案都加上)

不管用什么锁,这条 SQL 是最后一道防线,成本极低:

-- 把"判断 + 扣减"合并成一条原子 SQL,数据库层面保证不超卖
UPDATE product SET stock = stock - 1 WHERE id = #{id} AND stock > 0
-- 返回 0 行受影响 = 没库存了

2.4 基于 ZooKeeper 实现(了解原理)

思路:利用 ZK 的临时顺序节点。

每个客户端在 /lock 下创建临时顺序节点:
/lock/seq-00000001  ← 客户端A(序号最小,获得锁)
/lock/seq-00000002  ← 客户端B
/lock/seq-00000003  ← 客户端C

规则:序号最小的节点持有锁,其他节点只监听"自己前一个"节点
A 宕机 → 临时节点自动删除(防死锁✓)→ B 收到通知获得锁

优点:临时节点天然防死锁,可靠性比 Redis 高。缺点:性能比 Redis 低一个数量级。实际项目一般用 Curator 框架的 InterProcessMutex,不用手写。


2.5 三种方案对比(面试高频)

维度 Redis (Redisson) ZooKeeper 数据库
性能 高(万级 QPS) 中(千级) 低
可靠性 中(主从切换可能丢锁) 高 高
实现复杂度 低(Redisson 开箱即用) 中(需 ZK 集群) 最低
锁续期 看门狗自动续期 会话保持 事务期间持有
适用场景 高并发业务锁 强一致性场景 低并发/无中间件

实际项目建议:90% 的场景用 Redisson + 兜底 SQL 就是最佳实践。


第三部分:完整实战案例(Spring Boot 秒杀扣库存)

把上面所有知识串起来,一个生产可用的完整结构:

@RestController
@RequestMapping("/seckill")
public class SeckillController {

    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private ProductMapper productMapper;

    @PostMapping("/buy/{productId}")
    public Result buy(@PathVariable Long productId) {
        String lockKey = "lock:seckill:" + productId;
        RLock lock = redissonClient.getLock(lockKey);
        try {
            // ① 分布式锁:最多等 3 秒,拿不到快速失败(比无限排队体验好)
            if (!lock.tryLock(3, TimeUnit.SECONDS)) {
                return Result.fail("抢购人数过多,请稍后再试");
            }
            // ② 兜底 SQL:原子扣减,即使锁失效也不会超卖
            int rows = productMapper.deductStock(productId);
            if (rows == 0) {
                return Result.fail("手慢了,已售罄");
            }
            // ③ 创建订单(略)
            return Result.ok("抢购成功");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return Result.fail("系统异常");
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}
<!-- ProductMapper.xml -->
<update id="deductStock">
    UPDATE product SET stock = stock - 1
    WHERE id = #{id} AND stock > 0
</update>

设计要点总结:

  1. 锁的粒度:锁 lock:seckill:{productId} 而不是全局锁——抢商品 A 的人不用等抢商品 B 的人(类比前端按需 import 而不是全量打包)
  2. 快速失败:tryLock(3s) 拿不到就返回,避免线程堆积拖垮服务
  3. 双重保险:分布式锁管"并发效率",兜底 SQL 管"绝对正确"

第四部分:

  1. synchronized 和 ReentrantLock 区别?
    前者是 JVM 关键字自动释放;后者是 API 手动 lock/unlock,支持 tryLock 超时、公平锁、可中断。

  2. 分布式锁为什么要设置过期时间? 防客户端宕机导致死锁。

  3. 为什么解锁要用 Lua 脚本? "判断锁归属 + 删除"必须原子执行,否则可能误删别人的锁。

  4. Redisson 看门狗原理? 默认锁 30s,后台线程每 10s 续期一次;JVM 宕机看门狗消失,锁到期自动释放。

  5. Redisson 锁可重入怎么实现? Redis Hash 结构存储 线程ID: 重入次数。

  6. Redis 主从切换丢锁怎么办? RedLock 多节点方案,或接受小概率丢失 + 数据库兜底。

  7. 怎么防止库存超卖? 分布式锁(效率)+ UPDATE ... WHERE stock > 0 兜底(正确性)。

posted @ 2026-09-27 12:35  -鹿-  阅读(5)  评论(0)    收藏  举报