嘿,朋友。看到这一串标题,你是不是觉得头都大了?银行、死锁、悲观锁、淘宝……这些听起来像是两个世界的东西。但作为一个在代码世界里摸爬滚打多年的“老程序员”,我可以很负责任地告诉你:这一切的底层逻辑,其实都是同一件事——如何优雅地处理“等待”。
想象一下,你早上去银行办业务。门口有一台叫号机,你取了一个号,坐在椅子上等。这时候,如果你前面那个人一直不办完,或者后面来了个插队的,整个系统就乱套了。在计算机里,这就是“锁竞争”和“死锁”。
今天,咱们不聊那些枯燥的定义,我就用讲故事的方式,带你把这四种看起来毫不相关、实则血脉相连的技术,掰开揉碎了讲清楚。我会尽量像咱们平时喝咖啡聊天一样,让你听得进去,而且能真的用得上。
第一部分:银行排队机里的“死锁”陷阱
咱们先回到那个银行的场景。
1.1 什么是死锁?用银行举例最直观
死锁(Deadlock)这个词,听起来很高端,其实它就是一种“僵持”状态。在操作系统或数据库里,它通常发生在两个或多个线程(或者说人)互相持有对方需要的资源,并且谁都不肯先放手,结果大家都卡住了,谁也没法干活。
让我们构建一个经典的银行死锁模型:
假设银行里只有两个柜台(资源 A 和资源 B),只有两个业务员(线程 1 和线程 2)。
- 业务员 1 先拿起了资源 A(比如正在处理一张汇款单),然后他需要资源 B(比如核对印章)才能完成业务。于是,他手握着 A,眼睛盯着 B。
- 业务员 2 同时也先拿起了资源 B(正在审核一张贷款申请),然后他需要资源 A(比如查询账户余额)才能完成业务。于是,他手握着 B,眼睛盯着 A。
这时候,神奇(也很悲剧)的一幕发生了:
- 业务员 1 说:“我必须等业务员 2 把 B 还给我,我才能用。”
- 业务员 2 说:“我也必须等业务员 1 把 A 还给我,我才能用。”
结果: 两个人就这么大眼瞪小眼,谁也不松手,也不干活,一直坐到银行下班。这就是死锁。在计算机里,这意味着你的程序会“卡死”,CPU 占用率可能很低,但任务永远完成不了。
1.2 银行叫号机如何避免这种死锁?
你可能会问,银行叫号机不就是个发号的机器吗,跟死锁有啥关系?
关系大了!叫号机的核心就是一个资源调度算法。它通过“串行化”来从根本上规避死锁。
死锁产生的四个必要条件(这也是乔尔·阿尔索格提出的著名定理):
- 互斥条件:资源一次只能被一个进程使用。
- 占有并等待:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有。
- 不可剥夺:进程已获得的资源在未使用完之前,不能被强行剥夺。
- 循环等待:存在一种进程资源的循环等待链。
银行叫号机是怎么破局的?它破坏了第 4 条“循环等待”。
在叫号系统中,资源(柜台)的分配是有严格顺序的。比如,系统规定:
- 先取号的人,必须等前一个号办完,才能开始叫下一个。
- 或者更复杂一点,分为“个人业务”和“对公业务”两个队列,互不干扰,但在每个队列内部,是严格 FIFO(先进先出)的。
这意味着,资源分配形成了一条单向链,不存在“A 等 B,B 等 A”的环。只要打破了循环等待,死锁就不可能发生。
实战技巧: 如果你在设计一个资源调度系统,记住这个原则——给资源编号,要求线程必须按照编号顺序加锁。比如,资源 A 编号 1,资源 B 编号 2。如果线程需要同时持有 A 和 B,必须先申请 1,再申请 2。这样,无论多少线程同时抢,都不会形成环路。
第二部分:从悲观锁到超时回退——一场真实的“抢票”实战
讲完了理论,咱们来点实战的。很多程序员在面试中被问到:“高并发场景下,如何防止超卖?”
这时候,悲观锁和超时回退就是你的两大法宝。
2.1 悲观锁:我猜我会被打脸
悲观锁(Pessimistic Lock) 的核心思想是:每次去拿数据的时候,都假设别人会来修改它,所以每次拿数据都会上锁。
这就像你去图书馆借一本热门书。悲观锁的做法是:你走进图书馆,发现书在架子上,但你不敢直接拿,你先去借书处登记,把这本书“锁”在你的名字下,然后才去把它取走。在你借出期间,别人想借这本书,系统直接告诉他:“抱歉,已借出。”
代码示例(Java + Spring Data JPA 的悲观锁):
@Entity
public class Ticket {
@Id
private Long id;
private Integer stock; // 库存
}
public interface TicketRepository extends JpaRepository<Ticket, Long> {
// 使用悲观锁,查询时直接加 FOR UPDATE
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT t FROM Ticket t WHERE t.id = :id")
Ticket findByIdForUpdate(@Param("id") Long id);
}
@Service
public class TicketService {
@Autowired
private TicketRepository ticketRepository;
public boolean deductStock(Long ticketId) {
// 1. 获取悲观锁,其他事务必须等待这里释放锁
Ticket ticket = ticketRepository.findByIdForUpdate(ticketId);
// 2. 检查库存
if (ticket.getStock() > 0) {
ticket.setStock(ticket.getStock() - 1);
ticketRepository.save(ticket);
return true;
}
return false;
}
}
悲观锁的优缺点:
- 优点:安全。保证了一致性,不会出现超卖。
- 缺点:性能差。所有请求都排队等锁,吞吐量上不去。在高并发抢票场景下,这可能让数据库连接池直接爆掉。
2.2 超时回退:给锁加个“有效期”
悲观锁虽然安全,但有个大问题:如果持有锁的事务一直不提交怎么办? 比如,某个用户网路卡了,锁了数据但半天没操作完,后面的所有请求都得傻等,直到数据库超时或者连接断开。
这时候,超时回退(Timeout & Backoff) 就派上用场了。
超时回退 的核心思想是:不要无限等待,设置一个合理的超时时间,如果拿不到锁,就放弃本次操作,稍后再试,或者返回给用户“繁忙”提示。
实战场景:Redis 分布式锁 + 自动过期
在电商系统中,我们常用 Redis 的 SET 命令加 EX 参数来实现带超时的分布式锁。
import redis.clients.jedis.Jedis;
import java.util.Collections;
public class DistributedLock {
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
// 锁的key
private static final String LOCK_KEY_PREFIX = "lock:ticket:";
// 持有时长,单位毫秒
private static final long EXPIRE_TIME = 5000;
public static boolean tryLock(Jedis jedis, String lockKey, String requestId) {
String result = jedis.set(LOCK_KEY_PREFIX + lockKey, requestId,
SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, EXPIRE_TIME);
return LOCK_SUCCESS.equals(result);
}
public static boolean releaseLock(Jedis jedis, String lockKey, String requestId) {
// 使用 Lua 脚本保证删除的原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
return LOCK_SUCCESS.equals(jedis.eval(script, Collections.singletonList(LOCK_KEY_PREFIX + lockKey),
Collections.singletonList(requestId)));
}
}
使用示例:
public void purchaseTicket(String userId) {
String lockKey = "1001"; // 假设抢购ID为1001的票
String requestId = UUID.randomUUID().toString();
Jedis jedis = getJedis(); // 获取Redis连接
try {
// 1. 尝试加锁,设置超时
if (tryLock(jedis, lockKey, requestId)) {
try {
// 2. 执行业务逻辑:检查库存、扣减库存等
boolean success = deductStockFromDB(lockKey);
if (success) {
System.out.println(userId + " 抢购成功!");
} else {
System.out.println(userId + " 库存不足!");
}
} finally {
// 3. 释放锁
releaseLock(jedis, lockKey, requestId);
}
} else {
// 4. 超时回退:如果拿不到锁,可以选择排队重试,或者直接拒绝
System.out.println(userId + " 系统繁忙,请稍后重试!");
// 或者:Thread.sleep(100); purchaseTicket(userId); // 简单的重试
}
} catch (Exception e) {
e.printStackTrace();
} finally {
jedis.close();
}
}
为什么这是“超时回退”?
因为我们在加锁时设置了 EXPIRE_TIME = 5000 毫秒。如果持有锁的代码执行超过 5 秒还没释放,Redis 会自动删除这个 key,锁就过期了。后面的请求就可以竞争到这个锁。这避免了“一个慢事务卡死整个系统”的悲剧。
注意点: 释放锁时必须用 Lua 脚本保证原子性,防止把别人的锁给删了(因为锁有过期时间,A 线程的锁可能已经过期,B 线程拿到了锁,如果 A 线程此时释放,会删掉 B 的锁)。
第三部分:程序员必须掌握的 4 种锁排序技巧
前面我们提到了“打破循环等待”可以防止死锁,而锁排序(Lock Ordering) 正是实现这一点的最佳实践。无论你用的是数据库锁、Redis 锁还是内存中的对象锁,这四种技巧都能让你的代码更健壮。
技巧 1:全局唯一顺序排序(Global Ordering)
核心思想: 给所有需要加锁的资源分配一个全局唯一的 ID 或顺序号。所有线程在获取多个锁时,必须严格按照 ID 从小到大的顺序获取。
例子: 假设你有两个资源:用户 A(ID=1)和用户 B(ID=2)。
- 线程 1 需要操作 A 和 B。
- 线程 2 也需要操作 A 和 B。
如果线程 1 先锁 A,再锁 B;而线程 2 先锁 B,再锁 A,就可能死锁。 解决方法: 规定所有线程必须先锁 ID 小的,再锁 ID 大的。
- 线程 1:锁 A(1) -> 锁 B(2)
- 线程 2:锁 A(1) -> 锁 B(2)
这样就形成了严格的偏序关系,不可能出现循环等待。
代码示意:
public void transferMoney(Long fromUserId, Long toUserId, BigDecimal amount) {
// 确保锁的顺序一致:总是先锁 ID 小的
Long firstLock = Math.min(fromUserId, toUserId);
Long secondLock = Math.max(fromUserId, toUserId);
// 伪代码:获取锁
lock(firstLock);
lock(secondLock);
try {
// 执行转账逻辑
deduct(fromUserId, amount);
add(toUserId, amount);
} finally {
unlock(secondLock);
unlock(firstLock);
}
}
技巧 2:分层加锁(Layered Locking)
核心思想: 将系统资源划分为不同的层次(如:数据库层、缓存层、应用层),规定只能从高层向低层加锁,或者反之,禁止跨层循环依赖。
例子: 在一个电商系统中,下单可能需要:
- 扣减库存(数据库)
- 更新优惠券状态(缓存)
你可以规定:先获取数据库锁,再获取缓存锁。如果某个流程需要反向操作,必须释放所有锁,重新按顺序获取。
技巧 3:锁粗化与锁消除(Lock Coarsening & Elimination)
核心思想:
- 锁粗化:如果一段代码连续对同一对象加锁、解锁多次,可以将这些操作合并成一次加锁,减少锁的开销,同时也减少了因频繁加解锁导致的死锁风险。
- 锁消除:JIT 编译器在运行时如果发现某些代码上的锁根本不会发生竞争,会自动去掉这些锁。
例子(Java):
// 锁粗化示例:避免循环内频繁加解锁
public String concatStrings(List<String> strings) {
StringBuilder sb = new StringBuilder();
// 不推荐:每次循环都加锁(虽然StringBuffer内部有锁,但这里举例说明思想)
// for (String s : strings) { synchronized(sb) { sb.append(s); } }
// 推荐:只在需要的时候加锁,或者使用线程安全的容器避免外部加锁
for (String s : strings) {
sb.append(s); // 如果sb是普通StringBuilder,这里没有锁,因为单线程操作
}
return sb.toString();
}
在实际开发中,尽量使用 ConcurrentHashMap 等并发容器,它们内部已经做好了细粒度的锁管理,你不需要自己手动加锁,从而避免了死锁风险。
技巧 4:尝试锁 + 退避算法(TryLock with Backoff)
核心思想: 不要死等锁,而是尝试获取锁。如果获取不到,就退后一会儿再试,而不是立即重试(立即重试可能导致“ livelock”,即两个线程互相让步,永远拿不到锁)。
例子:
public boolean tryAcquireWithBackoff(String lockKey) {
int maxRetries = 5;
int delay = 100; // 初始延迟 100ms
for (int i = 0; i < maxRetries; i++) {
if (tryLock(lockKey)) {
return true;
}
// 指数退避:每次延迟翻倍
try {
Thread.sleep(delay);
delay *= 2;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return false;
}
这种技巧在 Redis 的 SETNX 配合重试逻辑中非常常见。
第四部分:淘宝订单超时自动释放锁机制解析
最后,我们来聊聊最贴近生活的例子——淘宝订单。
你有没有经历过:下单后,如果不立即支付,订单会在 15 分钟或 30 分钟后自动取消?这就是超时自动释放锁的典型应用。
4.1 为什么需要超时释放?
在淘宝的库存系统中,当你点击“提交订单”时,系统会:
- 扣减库存。
- 生成一个待支付订单。
- 给用户 15-30 分钟的支付时间。
在这 15 分钟内,这件商品的库存是被“锁定”的。如果用户支付成功,订单状态变为已支付;如果超时未支付,库存必须释放,以便其他人可以购买。
如果没有超时机制,会发生什么?
- 用户 A 下单后,锁住了库存,然后去吃饭了。
- 用户 B 也想买,发现库存不足。
- 用户 A 吃完饭回来,可能又不想买了,但库存已经被锁了几天,直到他手动取消或系统超时。
- 后果:库存被“占坑”,造成虚假缺货,损失交易机会。
4.2 技术实现:四种常见方案
方案一:数据库轮询(简单但低效)
每隔一段时间,扫描数据库中 status = 'PENDING_PAYMENT' 且 create_time < (now - 30 minutes) 的订单,将其取消并释放库存。
- 优点:实现简单。
- 缺点:数据库压力大,尤其是订单量大时,轮询性能极差。
方案二:Redis + 过期监听(推荐)
将订单信息存入 Redis,并设置 key 的过期时间为 30 分钟。同时注册一个 RedisTemplate 的过期监听器。
- 优点:利用 Redis 自身的过期机制,无需轮询,性能高。
- 缺点:Redis 过期时间不精确(最多比设定时间晚一点),且需要处理过期事件的可靠性问题(比如消息丢失)。
代码示例(Spring Boot + Redis):
”`java @Configuration public class RedisConfig extends CachingConfigurerSupport {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 设置 key 序列化
template.setKeySerializer(new StringRedisSerializer());
// 设置 value 序列化
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
// 设置过期监听
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
container.addMessageListener((message, pattern) -> {
