文章目录
配置中心
我们可以把应用配置存储在zk上,应用作为数据订阅方,可以结合消息推拉模式,从zk获取配置信息,并实现动态更新。
存放在zk的配置信息往往具有3个特性:
- 数据量通常比较小
- 数据内容在运行时有频繁动态变化的需求
- 集群中各机器共享,配置一致
常见的配置中心包含以下操作初始化配置客户端连接,创建配置变更监听器、增删该查本地缓存和远程配置等操作,
下面来看看基于Curator的示例实现,假设我们将所有zk配置存储在lion
初始化配置中心
在初始化同时注册监听配置变更更新本地缓存
public class ConfigManager {
private static final ConcurrentHashMap<String, String> configs = new ConcurrentHashMap<>();
private static final CuratorFramework client = CuratorFrameworkFactory.newClient("127.0.0.1:2181", new ExponentialBackoffRetry(1000, 3));
private static final String CHARSET = "UTF-8";
private static final String PREFIX = "/config";
private static Logger logger = Logger.getLogger(ConfigManager.class);
static {
client.start();
try {
addConfigListener();
} catch (Exception e) {
logger.error(e);
System.exit(-1);
}
}
/**
* 注册配置变更监听器
*
* @throws Exception
*/
private static void addConfigListener() throws Exception {
PathChildrenCache childrenCache = new PathChildrenCache(client, PREFIX, true);
PathChildrenCacheListener childrenCacheListener = (client1, event) -> {
ChildData data = event.getData();
switch (event.getType()) {
case CHILD_ADDED:
logger.info("CHILD_ADDED : " + data.getPath() + " data:" + new String(data.getData()));
configs.put(data.getPath().substring(PREFIX.length()+1), new String(data.getData()));
break;
case CHILD_REMOVED:
logger.info("CHILD_REMOVED : " + data.getPath() + " data:" + new String(data.getData()));
configs.remove(data.getPath().substring(PREFIX.length()+1));
break;
case CHILD_UPDATED:
logger.info("CHILD_UPDATED : " + data.getPath() + " data:" + new String(data.getData()));
configs.put(data.getPath().substring(PREFIX.length()+1), new String(data.getData()));
break;
default:
break;
}
};
childrenCache.getListenable().addListener(childrenCacheListener);
childrenCache.start(PathChildrenCache.StartMode.POST_INITIALIZED_EVENT);
}
/**
* 获取完整路径
*
* @param path
* @return
*/
private static String getFullPath(String path) {
return PREFIX + "/" + path;
}
/**
* 判断指定配置路径是否存在
*
* @param path
* @return
* @throws Exception
*/
private static boolean exists(String path) throws Exception {
Stat stat = client.checkExists().forPath(getFullPath(path));
return stat != null;
}
}
读取配置
只读取本地配置,配置更新依赖于监听器
/**
* 读取本地配置
*
* @param key
* @return
*/
public static String get(String key) {
return configs.get(key);
}
创建配置
只创建远程配置,本地配置创建依赖于监听器
/**
* 创建配置
*
* @param path
* @param value
* @throws Exception
*/
public static void create(String path, String value) throws Exception {
if (exists(path)) {
logger.warn("node " + path + " exists!");
return;
}
byte[] bytes = (value == null ? new byte[0] : value.getBytes(CHARSET));
client.setData().withVersion(4).forPath(getFullPath(path), bytes);
}
修改配置
只修改远程配置,本地配置修改依赖于监听器
/**
* 更新配置
* @param path
* @param value
* @throws Exception
*/
public static void update(String path, String value) throws Exception {
if (!exists(path)) {
logger.warn("node " + path + " not exists!");
return;
}
byte[] bytes = (value == null ? new byte[0] : value.getBytes(CHARSET));
client.create().creatingParentsIfNeeded().forPath(getFullPath(path), bytes);
}
删除配置
只删除远程配置,本地配置删除依赖于监听器
/**
* 删除配置
*
* @param path
* @throws Exception
*/
public static void delete(String path) throws Exception {
if (exists(path)) {
client.delete().forPath(getFullPath(path));
} else {
logger.warn("node " + path + " not exists!");
}
}
动态DNS或命名服务
我们可以基于zk实现动态DNS,在应用机器上下线时相应地去绑定和解绑域名。
举个例子,如下配置:
|DDNS
|–www.test1.com
|----ip1:port1
|----ip2:port2
|–www.test2.com
在命名空间DDNS根节点下,绑定了2个域名,分别为www.test1.com和www.test2.com。在www.test1.com下,上线了两台机器,其IP端口分别为ip1:port1,ip2:port2。
如果我们在需求方应用下,需要访问域名www.test1.com,可以解析到其下有两台线上机器,ip1:port1,ip2:port2,而后即可根据ip端口与该服务器建立连接。
这里如果有新机器上线,会在对应应用域名下新建一个ip:port的节点,如果机器下线,会删除对应的节点。这时候,需求方应用需要具备探测功能,可以基于zk的监听器实现,以监听当前活跃的机器,避免出现请求到已下线的机器或无法请求到新上线的机器等异常情况。
同样的,对于RPC服务调用,我们可以建立类似的结构,只需将域名改成zk作为服务注册中心,服务调用方需要调用服务时,根据服务名称找到对应机器,建立连接。
下面根据示例来看看如何实现一个注册中心的基本操作,一个服务注册中心应该提供以下基本支持:
- 注册服务提供节点
- 获取指定服务当前有效的服务节点列表
- 注册监听一级服务名列表,监听如果有新服务,要注册监听耳机服务地址列表
- 注册监听耳机服务地址列表,监听如果有新节点上线则进行注册,有老节点下线则进行移除,并在服务内部没有服务节点时,删除服务
先来看如何注册远程的服务名或服务提供节点,这部分可以理解为有服务提供方节点调用。
/**
* 注册服务提供节点
*
* @param service
* @param address
* @throws Exception
*/
public static void registerProvider(String service, String address) throws Exception {
if(!exists(service)){
// 如果不存在服务名节点,先创建服务名节点
create(service, null, true);
}
// 创建服务提供方节点
create(service + "/" + address, null, false);
}
/**
* 创建配置
*
* @param path
* @param value
* @throws Exception
*/
private static void create(String path, String value, boolean persistent) throws Exception {
if (exists(path)) {
logger.warn("node " + path + " exists!");
return;
}
byte[] bytes = (value == null ? new byte[0] : value.getBytes(CHARSET));
client.create().creatingParentsIfNeeded().withMode(persistent ? CreateMode.PERSISTENT : CreateMode.EPHEMERAL).forPath(getFullPath(path), bytes);
}
在实际创建的时候,通过withMode(CreateMode.EPHEMERAL)
指定创建临时节点,在节点关闭后会自动删除。从上面示例看到,服务名创建的是永久节点,不随机器下线删除,而服务提供方节点创建的则是临时节点,在提供方下线后自动删除。
获取服务列表主要由服务调用方调用,实现极为简单,直接读取本地配置,因为配置动态变更已由监听器完成:
/**
* 获取指定服务的节点列表
*
* @param service
* @return
*/
public static List<String> getProvider(String service) {
return services.get(service);
}
下面来看监听器的实现,包括两级监听,第一级是监听根节点service下的服务名变更,如果新增服务名,则新建一个二级监听器,监听该服务名下的服务节点变更,二级监听器会当监听到节点注册/移除,会更新本地配置,如果指定服务本地配置为空,则移除服务名节点。一级监听器监听到服务名节点移除,会清理相关的配置信息,包括移除对应的二级监听器,两个监听器实现如下:
/**
* 注册服务变更监听器,一级监听器
*
* @throws Exception
*/
private static void initServicesListener() throws Exception {
servicesListener = new PathChildrenCache(client, PREFIX, true);
PathChildrenCacheListener childrenCacheListener = (client, event) -> {
ChildData data = event.getData();
String serviceName;
switch (event.getType()) {
// 新服务注册
case CHILD_ADDED:
logger.info("CHILD_ADDED : " + data.getPath() + " data:" + new String(data.getData()));
serviceName = data.getPath().substring(PREFIX.length() + 1);
services.put(serviceName, new ArrayList<>());
// 创建服务监听
serviceListeners.put(serviceName, initServiceListener(serviceName));
break;
// 原有服务移除
case CHILD_REMOVED:
logger.info("CHILD_REMOVED : " + data.getPath() + " data:" + new String(data.getData()));
serviceName = data.getPath().substring(PREFIX.length() + 1);
services.remove(serviceName);
// 移除服务监听
PathChildrenCache pathChildrenCache = serviceListeners.remove(serviceName);
if (pathChildrenCache != null) {
servicesListener.getListenable().clear();
}
break;
default:
break;
}
};
servicesListener.getListenable().addListener(childrenCacheListener);
servicesListener.start();
}
/**
* 注册服务提供者变更监听器,二级监听器
*
* @param serviceName
* @throws Exception
*/
private static PathChildrenCache initServiceListener(String serviceName) throws Exception {
PathChildrenCache serviceCache = new PathChildrenCache(client, getFullPath(serviceName), true);
PathChildrenCacheListener pathChildrenCacheListener = (client, event) -> {
ChildData data = event.getData();
String address;
switch (event.getType()) {
// 新服务提供节点注册
case CHILD_ADDED:
logger.info("CHILD_ADDED : " + data.getPath() + " data:" + new String(data.getData()));
address = data.getPath().substring(getFullPath(serviceName).length() + 1);
services.get(serviceName).add(address);
// 创建服务节点
break;
// 原有服务提供节点移除
case CHILD_REMOVED:
logger.info("CHILD_REMOVED : " + data.getPath() + " data:" + new String(data.getData()));
services.remove(data.getPath().substring(PREFIX.length() + 1));
// 移除服务节点
address = data.getPath().substring(getFullPath(serviceName).length() + 1);
services.get(serviceName).remove(address);
if(services.get(serviceName).isEmpty()){
// 删除zk服务节点
delete(getFullPath(serviceName));
}
break;
default:
break;
}
};
serviceCache.getListenable().addListener(pathChildrenCacheListener);
serviceCache.start();
return serviceCache;
}
下面看看完整的代码实现示例:
public class RegistryClient {
// 存储服务名->对应服务监听器的映射关系
private static final ConcurrentHashMap<String, PathChildrenCache> serviceListeners = new ConcurrentHashMap<>();
// 存储服务名->机器列表的映射关系
private static final ConcurrentHashMap<String, List<String>> services = new ConcurrentHashMap<>();
private static final String CHARSET = "UTF-8";
private static final String PREFIX = "/service";
private static CuratorFramework client;
private static PathChildrenCache servicesListener;
private static Logger logger = Logger.getLogger(RegistryClient.class);
static {
client = initClient();
try {
initServicesListener();
} catch (Exception e) {
logger.error(e);
System.exit(-1);
}
}
/**
* 获取指定服务的节点列表
*
* @param service
* @return
*/
public static List<String> getProvider(String service) {
return services.get(service);
}
/**
* 注册服务提供节点
*
* @param service
* @param address
* @throws Exception
*/
public static void registerProvider(String service, String address) throws Exception {
if(!exists(service)){
// 如果不存在服务名节点,先创建服务名节点
create(service, null, true);
}
// 创建服务提供方节点
create(service + "/" + address, null, false);
}
/**
* 创建配置
*
* @param path
* @param value
* @throws Exception
*/
private static void create(String path, String value, boolean persistent) throws Exception {
if (exists(path)) {
logger.warn("node " + path + " exists!");
return;
}
byte[] bytes = (value == null ? new byte[0] : value.getBytes(CHARSET));
client.create().creatingParentsIfNeeded().withMode(persistent ? CreateMode.PERSISTENT : CreateMode.EPHEMERAL).forPath(getFullPath(path), bytes);
}
/**
* 删除配置
*
* @param path
* @throws Exception
*/
private static void delete(String path) throws Exception {
if (exists(path)) {
client.delete().forPath(getFullPath(path));
} else {
logger.warn("node " + path + " not exists!");
}
}
/**
* 判断指定配置路径是否存在
*
* @param path
* @return
* @throws Exception
*/
private static boolean exists(String path) throws Exception {
Stat stat = client.checkExists().forPath(getFullPath(path));
return stat != null;
}
/**
* 获取完整路径
*
* @param path
* @return
*/
private static String getFullPath(String path) {
return PREFIX + "/" + path;
}
/**
* 初始化zk客户端
*
* @return
*/
private static CuratorFramework initClient() {
CuratorFramework client = CuratorFrameworkFactory.newClient("127.0.0.1:2181", new ExponentialBackoffRetry(1000, 3));
client.start();
return client;
}
/**
* 注册服务变更监听器,一级监听器
*
* @throws Exception
*/
private static void initServicesListener() throws Exception {
servicesListener = new PathChildrenCache(client, PREFIX, true);
PathChildrenCacheListener childrenCacheListener = (client, event) -> {
ChildData data = event.getData();
String serviceName;
switch (event.getType()) {
// 新服务注册
case CHILD_ADDED:
logger.info("CHILD_ADDED : " + data.getPath() + " data:" + new String(data.getData()));
serviceName = data.getPath().substring(PREFIX.length() + 1);
services.put(serviceName, new ArrayList<>());
// 创建服务监听
serviceListeners.put(serviceName, initServiceListener(serviceName));
break;
// 原有服务移除
case CHILD_REMOVED:
logger.info("CHILD_REMOVED : " + data.getPath() + " data:" + new String(data.getData()));
serviceName = data.getPath().substring(PREFIX.length() + 1);
services.remove(serviceName);
// 移除服务监听
PathChildrenCache pathChildrenCache = serviceListeners.remove(serviceName);
if (pathChildrenCache != null) {
servicesListener.getListenable().clear();
}
break;
default:
break;
}
};
servicesListener.getListenable().addListener(childrenCacheListener);
servicesListener.start();
}
/**
* 注册服务提供者变更监听器,二级监听器
*
* @param serviceName
* @throws Exception
*/
private static PathChildrenCache initServiceListener(String serviceName) throws Exception {
PathChildrenCache serviceCache = new PathChildrenCache(client, getFullPath(serviceName), true);
PathChildrenCacheListener pathChildrenCacheListener = (client, event) -> {
ChildData data = event.getData();
String address;
switch (event.getType()) {
// 新服务提供节点注册
case CHILD_ADDED:
logger.info("CHILD_ADDED : " + data.getPath() + " data:" + new String(data.getData()));
address = data.getPath().substring(getFullPath(serviceName).length() + 1);
services.get(serviceName).add(address);
// 创建服务节点
break;
// 原有服务提供节点移除
case CHILD_REMOVED:
logger.info("CHILD_REMOVED : " + data.getPath() + " data:" + new String(data.getData()));
services.remove(data.getPath().substring(PREFIX.length() + 1));
// 移除服务节点
address = data.getPath().substring(getFullPath(serviceName).length() + 1);
services.get(serviceName).remove(address);
if(services.get(serviceName).isEmpty()){
// 删除zk服务节点
delete(getFullPath(serviceName));
}
break;
default:
break;
}
};
serviceCache.getListenable().addListener(pathChildrenCacheListener);
serviceCache.start();
return serviceCache;
}
}
在这个例子中,已RPC服务的注册中心为例,但基于以上的示例实现,还可以拓展很多业务场景,如对一个分布式集群进行管理对应的各类应用场景,基于发布订阅的功能场景,分布式任务协调/通知等。
Master选主
在分布式系统中,部署运行这众多机器节点,实际场景中往往需要在这些机器中选出一个leader,来同一协调管理整个集群,并且需要具备当集群leader节点宕机后,支持自动选举新的leader,且系统的数据最终一致性需要得到保障。
下面看一个调用示例,假设这个工具类在多个服务器进程中得到初始化,在任意时刻,只有一个成为leader并调用takeLeadership方法。
public class LeaderChooser extends LeaderSelectorListenerAdapter {
private final String name;
private final LeaderSelector leaderSelector;
private final String PATH = "/master";
private final Logger logger = Logger.getLogger(LeaderChooser.class);
public LeaderChooser(CuratorFramework client, String name) {
this.name = name;
leaderSelector = new LeaderSelector(client, PATH, this);
leaderSelector.autoRequeue();
}
public void start() {
leaderSelector.start();
}
/**
* client成为leader后,会调用此方法,如果不想结束领导,这个方法需要一直堵塞
*/
@Override
public void takeLeadership(CuratorFramework client) {
int waitSeconds = (int) (5 * Math.random()) + 1;
logger.info(name + " becomes leader");
try {
Thread.sleep(TimeUnit.SECONDS.toMillis(waitSeconds));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
logger.info(name + " leaves");
}
}
}
如基于以下测试用例调用:
public static void main(String[] args) throws IOException {
CuratorFramework client = CuratorFrameworkFactory.newClient("127.0.0.1:2181", new ExponentialBackoffRetry(1000, 3));
client.start();
for (int i = 0; i < 10; i++) {
new LeaderChooser(client, "chooser" + i).start();
}
System.in.read();
}
运行部分结果显示如下:
2019-01-30 20:41:01 -892 [Curator-LeaderSelector-1] INFO - chooser1 becomes leader
2019-01-30 20:41:06 -5895 [Curator-LeaderSelector-1] INFO - chooser1 leaves
2019-01-30 20:41:06 -5914 [Curator-LeaderSelector-4] INFO - chooser4 becomes leader
2019-01-30 20:41:08 -7917 [Curator-LeaderSelector-4] INFO - chooser4 leaves
2019-01-30 20:41:08 -7929 [Curator-LeaderSelector-7] INFO - chooser7 becomes leader
2019-01-30 20:41:12 -11932 [Curator-LeaderSelector-7] INFO - chooser7 leaves
2019-01-30 20:41:12 -11939 [Curator-LeaderSelector-5] INFO - chooser5 becomes leader
2019-01-30 20:41:14 -13944 [Curator-LeaderSelector-5] INFO - chooser5 leaves
2019-01-30 20:41:14 -13954 [Curator-LeaderSelector-6] INFO - chooser6 becomes leader
2019-01-30 20:41:19 -18956 [Curator-LeaderSelector-6] INFO - chooser6 leaves
2019-01-30 20:41:19 -18976 [Curator-LeaderSelector-2] INFO - chooser2 becomes leader
……
分布式队列
zk提供顺序节点,可以基于此实现一个分布式队列,节点将任务存储到顺序节点下,
如对于根节点queue,可以基于此在下面创建名为"member-"的节点,实际zk会在节点名追加顺序号,如运行以下实例:
public class Queue {
private static CuratorFramework client;
private static Logger logger = Logger.getLogger(Queue.class);
static {
client = initClient();
}
/**
* 初始化zk客户端
*
* @return
*/
private static CuratorFramework initClient() {
CuratorFramework client = CuratorFrameworkFactory.newClient("127.0.0.1:2181", new ExponentialBackoffRetry(1000, 3));
client.start();
return client;
}
public static void main(String[] args) throws Exception {
for (int i = 0; i < 10; i++) {
client.create().creatingParentsIfNeeded().withMode(CreateMode.PERSISTENT_SEQUENTIAL).forPath("/leader/member-", ("data" + i).getBytes());
}
}
}
最终zk会创建如下图所示10个顺序节点:
创建完节点后,我们可以根据如下4个步骤来确定执行顺序:
- 调用
List<String> nodes = client.getChildren().forPath("/leader")
获取队列中所有的元素 - 确定自己的节点序号在所有子节点中的顺序
- 如果自己不是序号最小的子节点,就进入等待,同时xiang向比自己序号小的最后一个节点注册Watcher监听
- 接收到Watcher通知后,重复步骤1。
参考实现如下:
public class Queue {
private static CuratorFramework client;
private static Logger logger = Logger.getLogger(Queue.class);
private static volatile boolean isBreak = false;
private static String QUEUE = "/queue";
static {
client = initClient();
}
/**
* 初始化zk客户端
*
* @return
*/
private static CuratorFramework initClient() {
CuratorFramework client = CuratorFrameworkFactory.newClient("127.0.0.1:2181", new ExponentialBackoffRetry(1000, 3));
client.start();
return client;
}
public static void main(String[] args) throws Exception {
// 在/queue下创建一个顺序节点,返回自己创建的实际节点名
String node = client.create().creatingParentsIfNeeded().withMode(CreateMode.PERSISTENT_SEQUENTIAL).forPath(QUEUE + "/member-", "data".getBytes());
String nodeName = node.substring((QUEUE + "/").length());
while (true) {
// 获取/queue下所有存在的顺序节点
List<String> nodes = client.getChildren().forPath(QUEUE);
// 根据字符顺序排序
nodes.sort(String::compareTo);
// 循环直到轮到当前节点
if (nodes.indexOf(nodeName) == 0) {
// 轮到当前节点
logger.info("模拟执行本地任务");
// 任务执行完,删除节点
client.delete().guaranteed().forPath(node);
break;
} else {
// 获取前一个节点
String preNode = nodes.get(nodes.indexOf(nodeName) - 1);
// 添加监听
client.getData().usingWatcher((Watcher) event -> isBreak = event.getType().equals(Watcher.Event.EventType.NodeDeleted)).forPath(QUEUE + "/" + preNode);
while (!isBreak) {
// 循环直到监听到前一个节点的
Thread.sleep(1000);
}
}
}
}
}
分布式屏障
类似于分布式队列,这里还可以实现分布式屏障,各个进程等待,直到有多个进程同时到达临界点,则所有进程同时进行后续操作,如对于/barrier节点,值为10,表示同时注册10个节点后触发下一步操作,具体实现流程如下:
- 在/barrier下创建当前节点
- 创建/barrier的子节点监听器,监听子节点变化,当节点新增达到指定阈值,修改标志
- 循环判断标志是否更新,如果判定到达阈值,标志更新,结束循环
- 执行本地任务
- 移除监听器
- 删除开始创建的节点
下面直接看代码示例:
private static void barrierTask() throws Exception {
// 创建当前节点
String node = client.create().creatingParentsIfNeeded().withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath("/barrier/data-");
// 标志是否达到阈值
boolean[] isBreak = new boolean[]{false};
final int cnt = Integer.valueOf(new String(client.getData().forPath("/barrier")));
// 创建监听器,在每次触发时间后,更新isBreak,判断是否执行本地任务
PathChildrenCache watcher = new PathChildrenCache(client, "/barrier", true);
watcher.getListenable().addListener((curatorFramework, pathChildrenCacheEvent) ->
isBreak[0] = client.getChildren().forPath("/barrier").size() == cnt);
watcher.start(PathChildrenCache.StartMode.POST_INITIALIZED_EVENT);
while (!isBreak[0]) {
Thread.sleep(1000);
}
System.out.println("模拟执行本地任务");
// 移除监听器
watcher.getListenable().clear();
// 删除当前节点
client.delete().guaranteed().forPath(node);
}
分布式锁
我们可以基于zk来同时实现分布式可重入读写锁。假设现在有如下结构:
|writeLock=x
|–readLock1
|–readLock2
在上述结构中:
- 当根节点存在且x=1,说明存在写锁,此时不存在readLock1和readLock2
- 当根节点不存在,或存在且x=0,说明不存在写锁,此时存在readLock1,readLock2
基于上述,可设计接口如下:
public interface ZKReentrantReadWriteLock {
public void lockWrite();
/**
* @return 创建临时顺序节点返回的实际节点名,需要记录下来,在解锁时进行删除
*/
public String readWrite();
public void unLockWrite();
public void unLockRead();
}
每个接口设计:
lockWrite
- 如果已经拿到锁,可重入,本地存储重入值,一直堵塞直到拿到写锁,是否能拿到锁可通过创建writeLock节点实现,未能拿到锁进入2,拿到锁进入3
- 节点已经存在,则创建监听器,监听writeLock节点删除事件,而后回到1
- 如果已经拿到读锁,可重入,本地存储重入值
readWrite
- 如果已经拿到锁,可重入,本地存储重入值,否则一直堵塞直到拿到读锁,是否能拿到读锁可先判定可通过判定writeLock节点是否存在,且值为0。根据条件进入2,3,4任一分支
- 如果writeLock节点不存在,则创建writeLock临时节点,值为0,而后在WriteLock下创建一个临时顺序节点,标志拿到读锁
- 如果writeLock节点存在,但值为0,说明存在其他读锁,在WriteLock下创建一个临时顺序节点,标志拿到读锁
- 如果writeLock节点存在且不为0,说明存在写锁,则添加监听器,监听直到writeLock节点被删除,回到分支1
unLockWrite
- 如果本地记录未拿到写锁,异常
- 如果本地重入值>1,则重入值-1
- 如果本地重入值=1,则删除临时节点,完成解锁操作
unLockRead
- 如果本地记录未拿到读锁,异常
- 如果本地重入值>1,则重入值-1
- 如果本地重入值=1,则删除临时节点,完成解锁操作
在实际实现中,需要考虑并发操作问题,因而在每步更新zk操作都需要考虑加乐观锁,即zk的版本控制功能。