一、依赖与客户端配置
- 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:2181 或 zk1: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 请求延迟与错误率。