Zookeeper分布式锁

文章目录

一、MAC安装

  1. 安装zookeeper

brew install zookeeper

配置文件目录:

/usr/local/etc/zookeeper

其他文件目录:

/usr/local/Cellar/zookeeper

  1. 修改配置文件
java 复制代码
// 心跳时间 单位:毫秒
tickTime=2000
// 初始通信时限
initLimit=10
// 同步通信时限
syncLimit=5
// 数据目录,存储内存数据库序列化后的快照路径,也是唯一需要修改的地方
dataDir=/usr/local/Cellar/zookeeper/3.7.0_1/data
// 端口
clientPort=2181

只需要修改dataDir目录就行,然后重启

brew services restart zookeeper

  1. 进入zookeeper客户端

cd /usr/local/Cellar/zookeeper

//找到对应bin目录

cd bin

// 进入脚本即 zkCli

zkCli

二、Zookeeper

类似Linux的目录结构

一). 基本指令

  • ls 查看子节点

ls (path) 查看某一路径的子节点

  • get 查看节点的数据

  • create 创建某节点,不能越级创建,只能一层层目录创建

    也可以加参数,这样直接就在该节点下存储了数据

    如果要修改该节点的数据值,不能用create再次覆盖,而要用set

  • delete 删除某节点

    如果要删除掉的节点有子节点,那就用deleteall

  • set 修改已存在的某节点(只能修改已存在的节点)

    已存在的节点成功修改数据。

二). Znode节点类型

  1. 永久节点 create /path content

  2. 临时节点 create -e /path content

    只要客户端程序断开连接,会自动删除

  3. 永久序列化节点 create -s /path content

    同样的一个节点可创建多个,每个节点后自动生成序列号

  4. 临时序列化节点 create -s -e /path content (-s -e 顺序前后都可)

    会自动生成序列号,只要客户端程序断开连接会自动删除

三) 节点的事件监听 一次性

  1. 节点创建 nodeCreated

    stat -w /xx 监听/xx目录节点的创建

    A先开启一个对/watch的监听

    B创建/watch目录
    再看A

  2. 节点删除 nodeDeleted

    stat -w /xx 监听/xx目录节点的删除

    重新监听该节点的删除(由于节点监听是一次性的)

  3. 节点数据变化 NodeDataChanged

    get -w /xx

A监听/bb节点的数据变化

B修改节点数据

再看A

  1. 子节点变化 NodeChildrenChanged
    ls -w /xx

三) zookeeper的java客户端

官方提供、ZkClient、Curator

简单客户端实现

  • 引入pom.xml
java 复制代码
		<dependency>
			<groupId>org.apache.zookeeper</groupId>
			<artifactId>zookeeper</artifactId>
			<version>3.7.0</version>
		</dependency>
  1. 代码实现 新建ZkTest
java 复制代码
package com.distributed.lock.zk;

import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;
import org.apache.zookeeper.ZooKeeper;

import java.io.IOException;

public class ZkTest {
    public static void main(String[] args) throws InterruptedException {
        ZooKeeper zooKeeper = null;
        try {
            zooKeeper = new ZooKeeper("127.0.0.1:2181", 30000, new Watcher() {
                @Override
                public void process(WatchedEvent watchedEvent) {
                    System.out.println("获取链接了");
                }
            });
            System.out.println("一顿操作");
        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            if(zooKeeper != null){
                zooKeeper.close();
            }
        }
    }
}
  • 启动测试:

提示jar包重复依赖

把pom.xml新添的zookeeper依赖修改下:

java 复制代码
		<dependency>
			<groupId>org.apache.zookeeper</groupId>
			<artifactId>zookeeper</artifactId>
			<version>3.7.0</version>
			<exclusions>
				<exclusion>
					<groupId>org.slf4j</groupId>
					<artifactId>slf4j-log4j12</artifactId>
				</exclusion>
			</exclusions>
		</dependency>

再启动测试:

发现问题,先一顿操作,再获取链接,且获取了两次。

  1. 先输出了一顿操作
    添加CountDowLatch, 等获取链接后再开始操作

CountDownLatch:允许一个或者多个线程去等待其他线程完成操作

CountDownLatch接收一个int型参数,表示要等待的工作线程的个数。

await(): 使当前线程进入同步队列进行等待,直到latch的值被减到0或者当前线程被中断,当前线程就会被唤醒。

countDown(): 使latch的值减1,如果减到了0,则会唤醒所有等待在这个latch上的线程。

那就是先初始化

java 复制代码
CountDownLatch countDownLatch = new CountDownLatch(1);

获取链接后

java 复制代码
countDownLatch.countDown();

在一顿操作的前面加上await(),直到获取了链接才准开始操作

java 复制代码
 countDownLatch.await();
  1. 监听到了两次获取链接

获取链接时加个参数看看

java 复制代码
System.out.println("获取链接了" + watchedEvent);

很明显,是关闭的时候watcher也监听到了。

watchedEvent有多个状态

判断下状态是不是我要的,再输出"获取链接了"即可。即:只监听想监听到的状态。

java 复制代码
Event.KeeperState state = watchedEvent.getState();
if(Event.KeeperState.SyncConnected.equals(state)){
  	System.out.println("获取到链接了" + watchedEvent);
}else if(Event.KeeperState.Closed.equals(state)) {
  	System.out.println("关闭链接了" + watchedEvent);
}

重启测试:

成功。

CRUD操作

  1. 节点新增
java 复制代码
String create(String path, byte[] data, List<ACL> acl, CreateMode createMode) 

path 路径

data 数据 要求是字节,字符串后加getBytes()

acl 权限 OPEN_ACL_UNSAFE 所有人都可以操作即可

createMode 创建类型

CreateMode.PERSISTENT 永久节点

CreateMode.EPHEMERAL 临时节点

CreateMode.PERSISTENT_SEQUENTIAL 永久序列化节点

CreateMode.EPHEMERAL_SEQUENTIAL 临时序列化节点

java 复制代码
zooKeeper.create("/ustc","hello zookeeper".getBytes(), 
	ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);

启动成功,查看zookeeper

  1. 节点查询
java 复制代码
			// 判断节点是否存在
            Stat stat = zooKeeper.exists("/ustc",false);
            if(stat != null){
                System.out.println("当前节点存在");
            }else{
                System.out.println("当前节点不存在");
            }

            // 获取当前节点中的数据内容
            byte[] data = zooKeeper.getData("/ustc",false,stat);
            System.out.println("当前节点内容" + new String(data));

            // 获取当前子节点
            List<String> children = zooKeeper.getChildren("/ustc",false);
            System.out.println("当前子节点:" + children);
  1. 节点更新
java 复制代码
// 更新:版本号必须和当前节点的版本号一致,否则更新失败。也可以指定为-1,代表不关心版本号
    zooKeeper.setData("/ustc","bye bye".getBytes(), stat.getVersion());
  1. 删除节点
java 复制代码
// 删除
	zooKeeper.delete("/ustc", -1);

监听

监听子节点

java 复制代码
// 获取当前子节点
   List<String> children = zooKeeper.getChildren("/ustc",true);

同时保证main方法不能结束,最后一行添加

java 复制代码
 System.in.read();

启动成功,去修改节点值看能否监听到.

这种监听方式只能生效一次。

也可以自己加个new watcher(),不过也是只生效一次。

想一直生效可以把它提取出来当作方法,然后最后递归的调用自己。

java 复制代码
// 监听当前子节点
            List<String> children = zooKeeper.getChildren("/ustc", new Watcher() {
                @Override
                public void process(WatchedEvent watchedEvent) {
                    System.out.println("当前子节点发生变化....");
                }
            });

三、Zookeeper分布式锁

1. 独占排它锁 自旋锁(其他线程在不停重试)

zookeeper的create方法无法覆盖新的路径,只能成功一次 ,可以依靠这个特效来实现独占排它锁

多个线程去create一个path的时候,只有一个线程可以create成功,也就相当于抢到了锁,其他线程只能不停的尝试create,而抢到锁的线程A,在执行完后释放锁,即delete path,就会有下一个线程create成功。

为了避免出现,抢到锁后客户端宕机,导致锁无法释放,即拿锁线程不会delete path,create的节点设置为临时节点,这样当客户端宕机时,节点也跟着自动销毁,也就解锁了。

代码实现

  • ZkDistributedLock
java 复制代码
public class ZkDistributedLock implements Lock {

    private ZooKeeper zooKeeper;
    private String lockName;

    // 根节点
    private static final String ROOT_PATH = "/locks";
    public ZkDistributedLock(ZooKeeper zooKeeper, String lockName){
        this.zooKeeper = zooKeeper;
        this.lockName = lockName;
        try {
            // 保证根节点存在
            if(zooKeeper.exists(ROOT_PATH,false) == null){
                zooKeeper.create(ROOT_PATH,null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
            }
        } catch (KeeperException e) {
            throw new RuntimeException(e);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public void lock() {
        this.tryLock();
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    @Override
    public boolean tryLock() {
        // 创建znode节点过程
        try {
            // 临时节点,避免zk客户端获取到锁后,宕机带来的死锁问题,相当于redis的过期时间
            this.zooKeeper.create(ROOT_PATH + "/" + lockName, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
            return true;
        } catch (Exception e) {
            // 出问题后一直递归调用,不停尝试
            try {
                Thread.sleep(100);
                this.tryLock();
            } catch (InterruptedException ex) {
                throw new RuntimeException(ex);
            }

        }
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return false;
    }

    @Override
    public void unlock() {
        // 删除znode节点
        try {
            this.zooKeeper.delete(ROOT_PATH+"/"+lockName,-1);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } catch (KeeperException e) {
            throw new RuntimeException(e);
        }

    }

    @Override
    public Condition newCondition() {
        return null;
    }
}
  • ZkClient
java 复制代码
@Component
public class ZkClient {

    public ZooKeeper zooKeeper;
    /**
     * 在无参构造后立刻执行
     * 获取联建,项目启动时
      */
    @PostConstruct
    public void init(){
        CountDownLatch countDownLatch = new CountDownLatch(1);
        try {
            zooKeeper = new ZooKeeper("127.0.0.1:2181", 30000, new Watcher() {
                @Override
                public void process(WatchedEvent watchedEvent) {
                    Event.KeeperState state = watchedEvent.getState();
                    if (Event.KeeperState.SyncConnected.equals(state) && Event.EventType.None.equals(watchedEvent.getType())) {
                        System.out.println("获取到链接了" + watchedEvent);
                    } else if (Event.KeeperState.Closed.equals(state)) {
                        System.out.println("关闭链接了" + watchedEvent);
                    } else {
                        System.out.println("节点事件....");
                    }
                    countDownLatch.countDown();
                }
            });
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

    }

    /**
     * 释放zk的链接
     */
    @PreDestroy
    public void destroy(){
        if(zooKeeper != null){
            try {
                zooKeeper.close();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }

    public ZkDistributedLock getLock(String lockName){
        return new ZkDistributedLock(zooKeeper,lockName);
    }

}
  • StockService
java 复制代码
 public void deduct() {
        ZkDistributedLock lock = zkClient.getLock("lock");
        lock.lock();

        try{
            // 1。 查询库存
            String stockStr = redisTemplate.opsForValue().get("stock");
            if (!StringUtil.isNullOrEmpty(stockStr)) {
                int stock = Integer.parseInt(stockStr);
                // 2。 判断条件是否满足
                if (stock > 0) {
                    // 3 更新redis
                    redisTemplate.opsForValue().set("stock", String.valueOf(stock - 1));
                }
            }
        }finally {
            lock.unlock();
        }
    }

简单来说,就是create实现排它锁,加锁是create临时节点,解锁就是delete临时节点.

jmeter测试:

并发量高达700,库存也成功清0

2. 阻塞锁 临时序列化节点(自旋太耗性能)

思路:通过创建临时序列化节点,每个线程创建的节点序列号依次递增。

最小的节点序列号抢到锁,比如0,然后1监听比自己小的序列化节点也就是0,2监听比自己小的节点1,如此下去,相当于阻塞着排队等待。同时也是公平锁。

1)所有请求要求获取锁时,给每一个请求创建临时序列化节点

2)获取当前节点的前置节点(比自己序列号小1),如果前置节点为空,则获取锁成功。否则监听前置节点。

把根节点的孩子节点全部拿到,然后通过排序比较,拿到前置节点。

3)获取锁成功后执行业务操作,然后释放当前节点的锁。

代码实现

java 复制代码
public class ZkDistributedLock implements Lock {

    private ZooKeeper zooKeeper;
    private String lockName;
    // 前置节点路径
    private String currentNodePath;

    // 根节点
    private static final String ROOT_PATH = "/locks";
    public ZkDistributedLock(ZooKeeper zooKeeper, String lockName){
        this.zooKeeper = zooKeeper;
        this.lockName = lockName;
        try {
            // 保证根节点存在
            if(zooKeeper.exists(ROOT_PATH,false) == null){
                zooKeeper.create(ROOT_PATH,null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
            }
        } catch (KeeperException e) {
            throw new RuntimeException(e);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public void lock() {
        this.tryLock();
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    /**
     *
     * @return true 成功获取锁
     */
    @Override
    public boolean tryLock() {
        // 创建znode节点过程
        try {
            // 所有请求要求获取锁时,给每一个请求创建临时序列化节点,currentNodePath返回的是全路径
            currentNodePath = this.zooKeeper.create(ROOT_PATH + "/" + lockName + "-", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
            // 获取前置节点,若前置节点为空,则获取锁成功,否则监听前置节点
            String preNode = this.getPreNode();
            // 因为获取前置节点的操作不具有原子性,故再次判断zk中的前置节点是否存在
            if(preNode != null){
                // 利用闭锁思想,实现阻塞功能
                CountDownLatch countDownLatch = new CountDownLatch(1);

                if(this.zooKeeper.exists(ROOT_PATH + "-" + preNode, new Watcher() {
                    @Override
                    public void process(WatchedEvent watchedEvent) {
                        // 监听是否存在,若不存在则触发
                        countDownLatch.countDown();
                    }
                }) == null){
                    // 若前置节点不存在了,我就是第一个,直接获取锁
                    return true;
                }
                // 不能直接返回true,需要等待监听到存在的状态变化
                countDownLatch.await();
            }

            return true;
        } catch (Exception e) {

        }
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return false;
    }

    @Override
    public void unlock() {
        // 删除znode节点
        try {
            this.zooKeeper.delete(ROOT_PATH+"/"+lockName,-1);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } catch (KeeperException e) {
            throw new RuntimeException(e);
        }

    }

    @Override
    public Condition newCondition() {
        return null;
    }

    private String getPreNode(){
        // 获取根节点下的所有节点
        try {
            List<String> children = this.zooKeeper.getChildren(ROOT_PATH,false);
            Assert.notEmpty(children,"根节点下没有子节点!");
            // 删选出lockName对应的子节点
            List<String> nodes = children.stream().filter(
                    node ->
                        StringUtils.startsWithIgnoreCase(node,lockName+"-")
            ).collect(Collectors.toList());
            Assert.notEmpty(children,"没有该锁的子节点!");

            // 排好队
            Collections.sort(nodes);

            // 获取当前节点的下标
            // 获取当前节点的名称 currentNodePath是全路径 但是children不是
            String currentNode =  StringUtil.substringAfter(currentNodePath,'/');
            int index = Collections.binarySearch(nodes,currentNode);
            if(index < 0){
                throw new IllegalMonitorStateException("没有当前锁的子节点");
            }else if(index > 0){
                return nodes.get(index-1); // 若不是最小的节点,返回前置节点
            }
            return null; // 如果当前节点没有前置节点,即是第一个节点,返回null

        } catch (Exception e) {
            throw new RuntimeException("非法操作");
        }

    }
}

Jmeter测试

并发达到了600,略低于独占排它锁。库存也成功清0。

3. 可重入锁

思路:通过ThreadLocal保存线程的入锁次数

如果ThreadLocal中数值>0,说明已经入锁,可重入,直接+1,抢锁成功

如果ThreadLocal无数值,那就按照之前的步骤去抢锁。

在抢到锁后ThreadLocal数值设置为1。

解锁的时候将ThreadLocal数值-1,如果是0就delete删除节点,即释放锁。

java 复制代码
package com.distributed.lock.zk;

import cn.hutool.core.lang.Assert;
import io.netty.util.internal.StringUtil;
import org.apache.zookeeper.*;
import org.springframework.util.StringUtils;

import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.stream.Collectors;


public class ZkDistributedLock implements Lock {

    private ZooKeeper zooKeeper;
    private String lockName;
    // 前置节点路径
    private String currentNodePath;

    // 根节点
    private static final String ROOT_PATH = "/locks";

    public static final ThreadLocal<Integer> THREAD_LOCAL = new ThreadLocal<>();
    public ZkDistributedLock(ZooKeeper zooKeeper, String lockName){
        this.zooKeeper = zooKeeper;
        this.lockName = lockName;
        try {
            // 保证根节点存在
            if(zooKeeper.exists(ROOT_PATH,false) == null){
                zooKeeper.create(ROOT_PATH,null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
            }
        } catch (KeeperException e) {
            throw new RuntimeException(e);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public void lock() {
        this.tryLock();
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    /**
     *
     * @return true 成功获取锁
     */
    @Override
    public boolean tryLock() {
        // 创建znode节点过程
        try {
            //判断ThreadLocal中是否已经有锁,如果有锁直接重入(+1),返回true,重入成功
            Integer num = THREAD_LOCAL.get();
            if( num != null || num > 0){
                THREAD_LOCAL.set(num+1);
                return true;
            }
            // 所有请求要求获取锁时,给每一个请求创建临时序列化节点,currentNodePath返回的是全路径
            currentNodePath = this.zooKeeper.create(ROOT_PATH + "/" + lockName + "-", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
            // 获取前置节点,若前置节点为空,则获取锁成功,否则监听前置节点
            String preNode = this.getPreNode();
            // 因为获取前置节点的操作不具有原子性,故再次判断zk中的前置节点是否存在
            if(preNode != null){
                // 利用闭锁思想,实现阻塞功能
                CountDownLatch countDownLatch = new CountDownLatch(1);

                if(this.zooKeeper.exists(ROOT_PATH + "-" + preNode, new Watcher() {
                    @Override
                    public void process(WatchedEvent watchedEvent) {
                        // 监听是否存在,若不存在则触发
                        countDownLatch.countDown();
                    }
                }) == null){
                    // 若前置节点不存在了,我就是第一个,直接获取锁
                    THREAD_LOCAL.set(1);
                    return true;
                }
                // 不能直接返回true,需要等待监听到存在的状态变化
                countDownLatch.await();
            }
            THREAD_LOCAL.set(1);
            return true;
        } catch (Exception e) {

        }
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return false;
    }

    @Override
    public void unlock() {
        // 删除znode节点
        try {
            // 可重入锁,先删除Thread_Local中的数值
            THREAD_LOCAL.set(THREAD_LOCAL.get()-1);
            if(THREAD_LOCAL.get() == 0){
                this.zooKeeper.delete(ROOT_PATH+"/"+lockName,-1);
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } catch (KeeperException e) {
            throw new RuntimeException(e);
        }

    }

    @Override
    public Condition newCondition() {
        return null;
    }

    private String getPreNode(){
        // 获取根节点下的所有节点
        try {
            List<String> children = this.zooKeeper.getChildren(ROOT_PATH,false);
            Assert.notEmpty(children,"根节点下没有子节点!");
            // 删选出lockName对应的子节点
            List<String> nodes = children.stream().filter(
                    node ->
                        StringUtils.startsWithIgnoreCase(node,lockName+"-")
            ).collect(Collectors.toList());
            Assert.notEmpty(children,"没有该锁的子节点!");

            // 排好队
            Collections.sort(nodes);

            // 获取当前节点的下标
            // 获取当前节点的名称 currentNodePath是全路径 但是children不是
            String currentNode =  StringUtil.substringAfter(currentNodePath,'/');
            int index = Collections.binarySearch(nodes,currentNode);
            if(index < 0){
                throw new IllegalMonitorStateException("没有当前锁的子节点");
            }else if(index > 0){
                return nodes.get(index-1); // 若不是最小的节点,返回前置节点
            }
            return null; // 如果当前节点没有前置节点,即是第一个节点,返回null

        } catch (Exception e) {
            throw new RuntimeException("非法操作");
        }

    }
}

测试后

吞吐量高达700,库存也成功清0。

4. 和redis分布式锁对比

  1. Zookeeper分布式锁:

    独占排它

    阻塞锁

    可重入

  2. redis分布式锁

  • 独占排它互斥

    redis: setnx

    zk: 节点不重复

  • 防死锁:客户端获取到锁后服务器宕机

    redis: 过期时间

    zk-临时节点:一旦客户端服务器宕机,链接就会关闭,此时zk心跳检测不到客户端程序,删除对应的临时节点

  • 可重入

    redis: hash脚本+lua脚本

    zk: ThreadLocal保存入锁次数/节点数据/concurrentHashMap

  • 防误删

    redis: 唯一ID

    zk: 每个请求线程创建一个唯一的序列化节点

  • 原子性

    redis:

    zk: 创建、删除、查询及监听具备原子性

  • 自动续期

    redis: Timer定时器

    zk: 临时节点,没有过期时间,不需要自动续期

  • 单机故障问题

    redis: 会,主从需要时间

    zk: 一般都是集群部署,偏向于一致性的集群

  • 阻塞锁

    zk: 临时节点序列化,监听上一个小的节点,实现排队阻塞,实现公平锁

    但redis需要不停自旋尝试,是非公平锁

相关推荐
wuxingge1 小时前
k8s1.30.0高可用集群部署
云原生·容器·kubernetes
谭震鸿2 小时前
Zookeeper集群搭建Centos环境下
分布式·zookeeper·centos
志凌海纳SmartX2 小时前
趋势洞察|AI 能否带动裸金属 K8s 强势崛起?
云原生·容器·kubernetes
锅总2 小时前
nacos与k8s service健康检查详解
云原生·容器·kubernetes
BUG弄潮儿3 小时前
k8s 集群安装
云原生·容器·kubernetes
何遇mirror3 小时前
云原生基础-云计算概览
后端·云原生·云计算
颜淡慕潇4 小时前
【K8S系列】kubectl describe pod显示ImagePullBackOff,如何进一步排查?
后端·云原生·容器·kubernetes
Linux运维日记5 小时前
k8s1.31版本最新版本集群使用容器镜像仓库Harbor
linux·docker·云原生·容器·kubernetes
天冬忘忧7 小时前
Kafka 工作流程解析:从 Broker 工作原理、节点的服役、退役、副本的生成到数据存储与读写优化
大数据·分布式·kafka
长囧鹿10 小时前
云原生之k8s服务管理
云原生·容器·kubernetes