订票系统高并发实战:基于 ZooKeeper 的分布式锁、选座与幂等回滚(Java/Curator)

一、依赖与客户端配置

  • Maven 依赖
xml 复制代码
<dependency>
  <groupId>org.apache.curator</groupId>
  <artifactId>curator-framework</artifactId>
  <version>5.3.0</version>
</dependency>
<dependency>
  <groupId>org.apache.curator</groupId>
  <artifactId>curator-recipes</artifactId>
  <version>5.3.0</version>
</dependency>
  • 作用说明
    • curator-framework:封装 ZooKeeper 连接、重试、路径管理等底层细节。
    • curator-recipes :提供分布式协调"配方",如 InterProcessMutex 分布式互斥锁、读写锁、信号量等,极大简化业务实现。
  • Spring 配置与启动
java 复制代码
@Bean(initMethod = "start", destroyMethod = "close")
public CuratorFramework curatorFramework(
        @Value("${zookeeper.connect-string}") String connectString) {
    return CuratorFrameworkFactory.newClient(
        connectString, new ExponentialBackoffRetry(1000, 3));
}
  • 关键点
    • initMethod="start" :容器初始化时自动调用 client.start() 建立会话。
    • ExponentialBackoffRetry(1000, 3) :连接/操作失败按"指数退避 "重试,初始间隔 1s 、最多 3 次,提升网络抖动下的稳定性。
    • 连接串示例:localhost:2181zk1:2181,zk2:2181,zk3:2181
  • 适用说明
    • 使用 Curator 的 InterProcessMutex 可获得"可重入公平自动释放(会话失效自动释放)"的分布式锁,是票务扣减/选座等场景的推荐做法。

二、数据模型与路径设计

  • 路径规划
    • /shows/{showId}:场次元数据(时间、总票数、状态)。
    • /shows/{showId}/seats/{seatId} :座位状态(可用/锁定/已售),数据体可存放 ownerOrderId、expireAt、version
    • /locks/booking/{showId}:场次级串行锁,避免同一场次并发扣减导致超卖。
    • /orders/{orderId}:订单持久节点(状态、座位列表、过期时间)。
    • /users/{userId}/orders:用户订单索引,便于查询与清理。
  • 节点与一致性
    • 座位/订单节点使用小体量数据Stat.version ,配合条件更新实现乐观锁。
    • 锁节点使用 EPHEMERAL/EPHEMERAL_SEQUENTIAL,确保会话失效自动释放,避免死锁。
    • 读多写少场景可就近读;强一致读可先 sync() 再读或走 Leader 读(Curator 内部已封装重试与一致性策略)。

三、核心流程 Java 代码逐段解释

  • 场次级锁 + 选座 + 条件更新(核心骨架)
java 复制代码
// 路径常量
private static final String SEAT_PATH = "/shows/%s/seats/%s";
private static final String BOOKING_LOCK = "/locks/booking/%s";

// 尝试锁定并"占位"某个座位(锁定持有者为当前订单,设置15分钟过期)
public boolean tryLockAndHoldSeat(String showId, String seatId, String orderId, int holdSec) throws Exception {
    // 1) 场次级分布式互斥锁:同一时间只有一个实例能扣减该场次座位
    String lockPath = String.format(BOOKING_LOCK, showId);
    InterProcessMutex mutex = new InterProcessMutex(client, lockPath);

    // 2) 只尝试获取锁一段时间,避免无限等待(典型值:5-10秒)
    if (!mutex.acquire(5, TimeUnit.SECONDS)) {
        return false; // 拿不到锁快速失败,给前端友好提示
    }

    try {
        // 3) 读取座位节点当前数据与版本号(Stat.version)
        String seatPath = String.format(SEAT_PATH, showId, seatId);
        Stat stat = new Stat();
        byte[] data = client.getData().storingStatIn(stat).forPath(seatPath);

        // 4) 反序列化座位状态(不存在则视为"可用")
        Map<String, Object> seat = data == null || data.length == 0
            ? Map.of("status", "AVAILABLE")
            : mapper.readValue(data, Map.class);

        // 5) 业务校验:只有"可用"座位才能被锁定
        if (!"AVAILABLE".equals(seat.get("status"))) {
            return false; // 已被锁定/已售
        }

        // 6) 准备更新:写入"锁定"状态、持有者订单、过期时间
        Map<String, Object> update = new HashMap<>(seat);
        update.put("status", "LOCKED");
        update.put("ownerOrderId", orderId);
        update.put("expireAt", System.currentTimeMillis() + holdSec * 1000);
        byte[] newBytes = mapper.writeValueAsBytes(update);

        // 7) 关键:基于版本的"条件更新"(乐观锁)
        //    仅当版本号未变时写入成功;若期间被他人修改,则抛出 BadVersionException
        client.setData().withVersion(stat.getVersion()).forPath(seatPath, newBytes);
        return true; // 锁定占位成功
    } finally {
        // 8) 释放场次级锁(finally 确保一定释放)
        mutex.release();
    }
}
  • 代码要点

    • 场次级锁粒度:减少热点争用;若秒杀极端并发,可按"票档/区域"再分桶加锁。
    • 条件更新 :通过 withVersion(stat.getVersion()) 实现 CAS,避免并发写覆盖。
    • 租约机制 :设置 expireAt (如 15分钟),配合定时任务回收"支付超时"的锁定座位,避免占座不付。
    • 失败路径:拿不到锁或座位不可用,立即返回失败,由上游做"换座/排队/降级"处理。
  • 确认占座与回滚

java 复制代码
// 支付成功:将座位状态改为"已售/已支付"
public void confirmSeat(String showId, String seatId, String orderId) throws Exception {
    String seatPath = String.format(SEAT_PATH, showId, seatId);
    Stat stat = new Stat();
    byte[] data = client.getData().storingStatIn(stat).forPath(seatPath);
    if (data == null) return;

    Map<String, Object> seat = mapper.readValue(data, Map.class);
    // 只有"锁定且持有者为当前订单"的座位才能确认售出
    if (orderId.equals(seat.get("ownerOrderId"))) {
        Map<String, Object> sold = new HashMap<>(seat);
        sold.put("status", "SOLD");
        // 仍用版本号做条件更新,确保幂等
        client.setData().withVersion(stat.getVersion())
              .forPath(seatPath, mapper.writeValueAsBytes(sold));
    }
}

// 支付超时/取消:释放座位回"可用"
public void releaseSeat(String showId, String seatId, String orderId) throws Exception {
    String seatPath = String.format(SEAT_PATH, showId, seatId);
    Stat stat = new Stat();
    byte[] data = client.getData().storingStatIn(stat).forPath(seatPath);
    if (data == null) return;

    Map<String, Object> seat = mapper.readValue(data, Map.class);
    // 只有"锁定且持有者为当前订单"的座位才能释放
    if (orderId.equals(seat.get("ownerOrderId"))) {
        Map<String, Object> free = new HashMap<>(seat);
        free.put("status", "AVAILABLE");
        free.remove("ownerOrderId"); free.remove("expireAt");
        client.setData().withVersion(stat.getVersion())
              .forPath(seatPath, mapper.writeValueAsBytes(free));
    }
}
  • 代码要点

    • 幂等保护 :两次条件更新都校验 ownerOrderId + version,避免重复确认/重复释放。
    • 异常场景:若节点已被他人修改(版本不匹配),本次更新失败,可记录日志并告警,由补偿任务兜底。
  • 订单创建与索引(简化骨架)

java 复制代码
// 创建订单(持久节点,订单号全局唯一)
public void createOrder(String orderId, String showId, List<String> seatIds, long expireAt) throws Exception {
    String orderPath = "/orders/" + orderId;
    Map<String, Object> order = Map.of(
        "showId", showId,
        "seatIds", seatIds,
        "status", "PENDING",
        "expireAt", expireAt
    );
    client.create().creatingParentsIfNeeded()
          .forPath(orderPath, mapper.writeValueAsBytes(order));

    // 用户订单索引(便于查询与清理)
    String userIdx = "/users/" + getCurrentUserId() + "/orders/" + orderId;
    client.create().creatingParentsIfNeeded()
          .forPath(userIdx, new byte[0]);
}
  • 代码要点
    • creatingParentsIfNeeded():自动创建缺失的父路径,避免"父节点不存在"异常。
    • 订单状态机:PENDING → PAID → USED/CANCELLED;支付超时由定时任务回滚座位与索引。

四、并发控制与兜底策略

  • 锁粒度与惊群控制
    • 常规用场次级锁 即可;秒杀/热门场次可按"票档/区域"再分桶加锁,降低争用。
    • 只监听"前序座位"的删除事件,避免大量客户端同时被唤醒(减少"羊群效应")。
  • 缓存与异步
    • 场次与座位状态可放入 Redis 作为热读层,ZooKeeper 负责强一致写与协调
    • 下单成功后发 MQ 异步扣减库存、写订单明细、清理缓存,提升吞吐与解耦。
  • 幂等与可恢复
    • 订单号、座位号、版本号三重幂等键;所有写操作均带重试 + 版本校验
    • 定时任务补偿"支付超时 ""网络异常未释放锁 ""ZK 会话过期"等异常场景。
  • 监控与容量
    • 监控 ZK 连接数、Watch 数量、请求延迟、Leader/Follower 状态
    • 控制 Znode 数据体量(建议 < 1MB ),避免把 ZK 当数据库;大对象放 DB/对象存储,ZK 仅存元数据与锁。

🔥 关注公众号【云技纵横】,目前正在更新分布式缓存进阶技巧和干货


五、常见坑位与规避

  • 只监听一次 Watch 导致"漏单":在事件回调中重新注册;网络抖动/会话过期要重建监听与临时节点。
  • 锁不设置超时:务必使用 acquire(timeout)锁租约,避免进程崩溃导致锁长期不释放。
  • 仅依赖 ZK 存大对象:ZK 适合小数据 + 高频协调 ,订单明细、支付流水等放 MySQL/ES,ZK 只做状态与锁。
  • 强一致读误用:读多写少可就近读;强一致读先 sync() 再读或走 Leader 读,避免读到旧视图。

六、运行与验证要点

  • 启动顺序:先起 ZooKeeper 集群(3/5/7 台),再起业务服务;观察连接日志与会话建立情况。
  • 用例覆盖:
    • 并发抢同一张票,验证不超卖
    • 支付超时,验证自动回滚
    • 服务宕机/断链,验证锁自动释放会话重建
    • 网络分区,验证多数派写入成功少数派不可用的容错性。
  • 观测指标:锁等待时长、锁争用次数、座位状态变更成功率、订单回滚次数、ZK 请求延迟与错误率。
相关推荐
disgare1 天前
关于分布式系统 RPC 中高可用功能的实现
java·分布式
小马爱打代码1 天前
Kafka 偏移量(Offset):消费者如何记住消费位置?
分布式·kafka
a努力。1 天前
中国电网Java面试被问:分布式缓存的缓存穿透解决方案
java·开发语言·分布式·缓存·postgresql·面试·linq
sheji34161 天前
【开题答辩全过程】以 基于Hadoop教育平台的设计与实现为例,包含答辩的问题和答案
大数据·hadoop·分布式
2301_807288631 天前
MPRPC项目(第11天,zookeeper)
分布式·zookeeper·debian
Light601 天前
构建数据要素新纪元:领码SPARK平台驱动的可验证、可交易、可监管数据要素工程体系
分布式·数据治理·数据要素·数据质量·dcmm·领码spark·数据产品化
长路 ㅤ   1 天前
Curator源码解析:LeaderLatch实现
zookeeper·curator源码·leaderlatch·分布式选主·临时顺序节点
齐 飞1 天前
Spring Cloud Alibaba快速入门-分布式事务Seata(下)
分布式·spring cloud·微服务
机器觉醒时代1 天前
定义下一代机器人训练?智元 SOP:VLA 模型真实世界分布式在线后训练的关键突破
分布式·机器人·ai大模型·人形机器人