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 个特性
- 互斥性:同一时刻只有一个客户端能持有锁(基本要求)
- 防死锁:持有锁的客户端宕机了,锁也要能释放(不能永远锁死)
- 防误删:只能释放自己加的锁,不能删掉别人的锁
- 可重入(加分项):同一个线程可以重复获取同一把锁
下面按"演进过程"讲,每个版本解决上一版的问题—
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>
设计要点总结:
- 锁的粒度:锁
lock:seckill:{productId}而不是全局锁——抢商品 A 的人不用等抢商品 B 的人(类比前端按需 import 而不是全量打包) - 快速失败:
tryLock(3s)拿不到就返回,避免线程堆积拖垮服务 - 双重保险:分布式锁管"并发效率",兜底 SQL 管"绝对正确"
第四部分:
-
synchronized 和 ReentrantLock 区别?
前者是 JVM 关键字自动释放;后者是 API 手动 lock/unlock,支持 tryLock 超时、公平锁、可中断。 -
分布式锁为什么要设置过期时间? 防客户端宕机导致死锁。
-
为什么解锁要用 Lua 脚本? "判断锁归属 + 删除"必须原子执行,否则可能误删别人的锁。
-
Redisson 看门狗原理? 默认锁 30s,后台线程每 10s 续期一次;JVM 宕机看门狗消失,锁到期自动释放。
-
Redisson 锁可重入怎么实现? Redis Hash 结构存储
线程ID: 重入次数。 -
Redis 主从切换丢锁怎么办? RedLock 多节点方案,或接受小概率丢失 + 数据库兜底。
-
怎么防止库存超卖? 分布式锁(效率)+
UPDATE ... WHERE stock > 0兜底(正确性)。

浙公网安备 33010602011771号