😱 前言:一张票引发的血案

在单机应用中,防止超卖很简单,加个 synchronized 关键字就行。
但在微服务集群下,synchronized 只能锁住当前机器的线程,锁不住由于负载均衡分发到其他机器的请求。

超卖场景还原:
库存剩 1 张。

  1. 服务器 A 的线程读库存:stock = 1
  2. 服务器 B 的线程同时读库存:stock = 1
  3. A 卖出:stock = 0
  4. B 卖出:stock = 0
    结果:卖出了 2 张票,实际上只有 1 张。 这在铁路系统里意味着有人要站票或者上不了车,属于 P0 级事故。

我们需要一把**“分布式锁”**。


🔒 一、 为什么选 Redisson?

Redis 自带的 setnx 虽然能实现锁,但有巨大缺陷:

  1. 死锁风险:如果服务宕机,锁没释放怎么办?(需要加过期时间)
  2. 过期时间难定:业务执行了 10s,锁 5s 就过期了,导致锁失效(锁误删)。
  3. 不可重入:复杂的业务逻辑调用链无法多次拿锁。

Redisson 是 Redis 的 Java 驻内存数据网格,它完美解决了上述问题,特别是它的**“看门狗(Watch Dog)”**机制。

Redisson 锁流程图 (Mermaid):

Redisson 内部机制

1. tryLock()

加锁成功

开启后台线程

每 10s 续期

业务结束

停止看门狗

加锁失败

客户端请求

Redis Master

执行业务逻辑

🐶 看门狗 (Watch Dog)

重置锁过期时间 (默认 30s)

unlock() 释放锁

自旋等待 / 放弃


💻 二、 实战:Redisson 锁住高铁票

1. 引入依赖
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.23.0</version>
</dependency>

2. 抢票核心逻辑 (OrderService)

我们不仅要加锁,还要锁得细粒度。如果锁整个 G1024 车次,那吞吐量太低。我们应该锁具体座次车厢

@Service
public class TicketService {

    @Autowired
    private RedissonClient redissonClient;
    
    @Autowired
    private StringRedisTemplate redisTemplate;

    public boolean buyTicket(String trainNumber, String seatType) {
        // 关键点:锁的粒度。这里锁住特定车次的特定席别
        String lockKey = "lock:ticket:" + trainNumber + ":" + seatType;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            // 1. 尝试获取锁
            // waitTime: 等待获取锁的时间,leaseTime: -1 表示开启看门狗自动续期
            boolean isLocked = lock.tryLock(5, -1, TimeUnit.SECONDS);
            
            if (isLocked) {
                // 2. 双重检查 (Double Check) - 防止拿到锁之前库存被扣光
                // 这里不仅要查 Redis,最稳妥是查数据库或预加载的缓存
                int stock = getStockFromCache(trainNumber, seatType);
                
                if (stock > 0) {
                    // 3. 扣减库存 (操作数据库 + 更新缓存)
                    decreaseStock(trainNumber, seatType);
                    createOrder();
                    return true;
                } else {
                    return false; // 没票了
                }
            } else {
                return false; // 系统繁忙 (获取锁失败)
            }
        } catch (InterruptedException e) {
            return false;
        } finally {
            // 4. 释放锁 (必须放在 finally 中)
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}


🔄 三、 难点攻克:余票缓存一致性

抢票时,用户疯狂刷新查看余票。如果每次都查数据库,数据库必死。我们必须查 Redis。
但这就引出了经典问题:数据库扣减了库存,Redis 里的缓存还没更新,怎么办?

在 12306 这种场景下,我们通常采用 Cache-Aside Pattern (旁路缓存模式) 的变种,并配合 Lua 脚本

方案 A:先更库,再删缓存 (延时双删)

这是通用方案,但在极端高并发下依然有脏数据风险。

方案 B:Redis 预扣减 (12306 推荐)

真正的余票其实是以 Redis 为准 的。

  1. 初始化:将数据库库存预热到 Redis。
  2. 扣减:直接在 Redis 中扣减 (decr)。
  3. 异步同步:通过 MQ 异步将扣减结果同步回 MySQL,做最终持久化。

Redis Lua 脚本实现 (保证原子性):

-- keys[1]: 库存 key
-- argv[1]: 扣减数量
local stock = tonumber(redis.call('get', KEYS[1]))
if (stock == nil) then
    return -1
end
if (stock >= tonumber(ARGV[1])) then
    redis.call('decrby', KEYS[1], tonumber(ARGV[1]))
    return 1
else
    return 0
end

Java 调用:

// 这样就不需要 Redisson 锁住“读”操作,只需要锁住“写”操作
// 或者完全依赖 Redis 单线程特性,连分布式锁都可以省去(针对纯扣减逻辑)
Long result = redisTemplate.execute(script, Collections.singletonList(key), "1");
if (result == 1) {
    // Redis 扣减成功,发送 MQ 消息去异步更新 MySQL
    sendToMQ(orderInfo);
}


🚀 四、 性能优化:分段锁 (Segment Lock)

如果 G1024 次列车只有一把锁,那么全中国想买这趟车的人都要排队。
我们可以借鉴 ConcurrentHashMap 的思想,将库存分段

假设二等座有 1000 张票:

  • Key1: stock:G1024:second:part1 (0-100)
  • Key2: stock:G1024:second:part2 (101-200)

用户请求进来时,随机路由到一个分段库存 Key 上。

  • 如果 Key1 有票,直接扣。
  • 如果 Key1 没票,尝试去 Key2 扣。

这样,并发度瞬间提升了 10 倍!


🎯 总结

开发一个简易版的 12306,核心就在于对 “共享资源”(库存)的争抢控制。

  1. Redisson 看门狗:解决了锁过期导致的并发安全问题。
  2. Redis 预扣减 + MQ:解决了数据库的性能瓶颈和缓存一致性问题。
  3. 分段锁:解决了热点商品的单点瓶颈。

Next Step:
思考一下,12306 还有一个极其复杂的逻辑:区间票
比如 北京 -> 上海 的车,中间经停 南京。如果我买 北京 -> 南京,那么 北京 -> 上海 的全程票库存也要减 1。
这涉及到 Bitmap (位图) 技术。下一篇,我们挑战用 Redis Bitmap 实现区间库存管理!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐