当前位置:   article > 正文

进阶分布式系统架构系列(十三):Zookeeper 分布式锁原理与实现

zk分布式锁实战

点击下方名片,设为星标

回复“1024”获取2TB学习资源!

前面介绍了 Zookeeper 集群 ZAB 协议配置中心注册中心数据与存储会话与事务管理等相关的知识点,今天我将详细的为大家介绍 zookeeper 分布式锁相关知识,希望大家能够从中收获多多!如有帮助,请点在看、转发支持一波!!!

什么是分布式锁?

在平时我们对锁的使用,在针对单个服务,我们可以用 Java 自带的一些锁来实现,资源的顺序访问,但是随着业务的发展,现在基本上公司的服务都是多个,单纯的 Lock或者 Synchronize 只能解决单个JVM线程的问题,那么针对于单个服务的 Java 的锁是无法满足我们业务的需要的,为了解决多个服务跨服务访问共享资源,于是就有了分布锁,分布式锁产生的原因就是集群。bd2099010997cb3cac0865ae6d67b452.png在分布式系统中,多个进程或节点可能需要同时访问共享资源。为了确保数据一致性和并发控制,需要使用分布式锁来协调这些进程或节点之间的访问。分布式锁可以让每个进程或节点按照特定的规则访问共享资源,从而避免冲突和竞争条件的发生。

下图就是一个分布式锁的常见应用案例。8fa9d4e35352b440b8342011088c10e4.png

实现分布式锁的方式有哪些呢?

分布式锁的实现方式主要以(ZooKeeper、Reids、Mysql)这三种为主。

今天我们主要讲解的是使用 ZooKeeper来实现分布式锁,ZooKeeper的应用场景主要包含这几个方面:

  • 1.服务注册与订阅(共用节点)

  • 2.分布式通知(监听ZNode)

  • 3.服务命令(ZNode特性)

  • 4.数据订阅、发布(Watcher)

  • 5.分布式锁(临时节点)

ZooKeeper实现分布式锁,主要是得益于ZooKeeper 保证了数据的强一致性,锁的服务可以分为两大类:

保持独占所有试图来获取当前锁的客户端,最终有且只有一个能够成功得到当前锁的钥匙,通常我们会把 ZooKeeper 上的节点(ZNode)看做一把锁,通过 create 临时节点的方式来实现,当多个客户端都去创建一把锁的时候,那么只有成功创建了那个客户端才能拥有这把锁。

控制时序所有试图获取锁的客户端,都是被顺序执行,只是会有一个序号(zxid),我们会有一个节点,例如:/testLock,所有临时节点都在这个下面去创建,ZK的父节点(/testLock) 维持了一个序号,这个是ZK自带的属性,他保证了子节点创建的时序性,从而也形成了每个客户端的一个全局时序。

ZooKeeper的分布式锁实现原理

ZooKeeper的分布式锁是基于ZooKeeper提供的有序节点(Sequential Nodes)和 Watch 机制实现的。具体实现步骤如下:

  • 1.每个进程或节点在ZooKeeper的某个节点上创建一个有序节点,节点名称可以是一个递增的数字,也可以是其他可以排序的字符串。

  • 2.进程或节点根据节点名称的顺序来竞争获取锁,获取到锁的进程或节点可以访问共享资源,其他进程或节点需要等待。

  • 3.当有一个进程或节点释放锁时,ZooKeeper会通知等待队列中的第一个进程或节点,让其继续竞争获取锁。

因为ZooKeeper的有序节点是按照创建的顺序排序的,所以可以通过监听前一个节点的变化来实现获取锁。当一个进程或节点需要获取锁时,它会在ZooKeeper上创建一个有序节点,并获取所有有序节点中的最小值。如果当前节点是最小值,则表示该进程或节点已经获取到锁;否则,该进程或节点需要监听前一个节点的变化,等待前一个节点释放锁后再次尝试获取锁。

ZooKeeper分布式锁的优点和局限性

ZooKeeper 分布式锁的优点

ZooKeeper的分布式锁具有以下优点:

  • 可以确保分布式环境下的并发控制和数据一致性。

  • 可以避免死锁和竞争条件的发生。

  • 可以提供较高的性能和可靠性。

ZooKeeper分布式锁的局限性

但是,ZooKeeper的分布式锁也存在一些局限性:

  • 1.由于需要频繁地在ZooKeeper上进行节点的创建、删除和监听操作,因此会产生较高的网络和性能开销。

  • 2.当锁被持有时,其他进程或节点需要等待前一个节点释放锁才能继续尝试获取锁,因此锁的竞争情况就会相对平均,不会出现某一个进程或节点一直占用锁的情况。

ZooKeeper 分布式锁的实现流程

使用ZooKeeper实现分布式锁的基本流程如下:

  • 1.创建一个ZooKeeper客户端,并连接到ZooKeeper服务器。

  • 2.在ZooKeeper的一个目录下创建一个锁节点,例如/locks/lock_node。

  • 3.当需要获取锁时,调用create()方法在/locks目录下创建一个临时有序节点,例如/locks/lock_node/lock_000000001,同时设置watcher事件,监控它的前一个节点。

  • 4.调用getChildren()方法获取/locks目录下所有的子节点,判断自己创建的节点是否为序号最小的节点。

  • 5.如果是序号最小的节点,则表示获取到了锁,可以执行临界区代码;否则调用exists()方法监控自己前面的一个节点。

  • 6.当前面的一个节点被删除时,触发watcher事件,重复第4和第5步,直到获取到锁为止。

  • 7.释放锁时,调用delete()方法删除自己创建的节点,其他等待锁的进程或节点就可以获取到锁。

需要注意的是,分布式锁的实现还需要处理以下问题:

  • 1.临时节点的创建和删除必须是原子性的,否则会出现多个节点同时创建或删除的情况,导致锁的失效。

  • 2.如果一个进程或节点创建了临时节点但没有及时删除,就会造成死锁,因为其他进程或节点永远也无法获取到锁。

  • 3.如果一个进程或节点获取到锁后因为某些原因没有及时释放锁,就会导致其他进程或节点一直等待,降低了系统的性能。

因此,在实现分布式锁时,需要考虑锁的可靠性高效性容错性,并对异常情况进行处理,以确保锁的正确性和系统的稳定性。

另外 ZooKeeper 还提供了一种基于临时节点的分布式锁机制,这种锁被称为“短暂节点锁”。使用短暂节点锁时,每个客户端进程会在 ZooKeeper 上创建一个临时节点,并在该节点上注册一个 Watcher 来监听该节点。当客户端进程需要获取锁时,它会在指定的 ZooKeeper 节点下创建一个短暂节点。如果该节点的序号是当前所有节点中最小的,则该客户端进程获得锁;否则,该进程需要等待,直到 Watcher 监听到节点被删除为止。

短暂节点锁的优点是它不会出现羊群效应,而且当进程失去锁时,它所创建的短暂节点会被自动删除,这可以有效减少ZooKeeper上的数据量。不过,它的缺点是每个客户端都需要创建一个短暂节点,如果客户端数量很多,ZooKeeper上的节点数量可能会很快增加,从而导致性能下降。

ZooKeeper的分布式锁机制可以通过不同的实现方式来满足不同的需求。开发者需要根据实际情况选择适合自己的锁实现方式,以实现高效、可靠的分布式系统。其中包含了分布式锁的实现。使用ZooKeeper实现分布式锁可以避免多个节点同时操作共享资源的问题,确保数据的一致性和可靠性。

在ZooKeeper中,分布式锁的实现基于临时节点和Watch机制,同时需要实现两个基本操作:加锁释放锁。 具体实现方法有两种:

  • 一种是使用顺序节点实现锁的竞争,

  • 另一种是使用锁路径中的版本号实现锁的控制。

无论哪种实现方法,都需要处理锁竞争的情况和节点异常退出的情况,以确保锁的正确性和可靠性。分布式锁的实现需要考虑多个因素,包括锁的粒度、锁的持有时间、锁的竞争方式等,需要根据具体应用场景进行调整和优化。

基本实现

实现思路
  • 多个请求同时添加一个相同的临时节点,只有一个可以添加成功。添加成功的获取到锁

  • 执行业务逻辑

  • 完成业务流程后,删除节点释放锁。

初始化链接

由于zookeeper获取链接是一个耗时过程,这里可以在项目启动时,初始化链接,并且只初始化一次。借助于spring特性,代码实现如下:

  1. @Component
  2. public class zkClient {
  3.     private static final String connectString = "192.168.107.135";
  4.  
  5.     private static final String ROOT_PATH = "/distributed";
  6.  
  7.     private ZooKeeper zooKeeper;
  8.  
  9.     @PostConstruct
  10.     public void init() throws IOException {
  11.         this.zooKeeper = new ZooKeeper(connectString, 30000new Watcher() {
  12.             @Override
  13.             public void process(WatchedEvent watchedEvent) {
  14.                 System.out.println("zookeeper 获取链接成功");
  15.             }
  16.         });
  17.         //创建分布式锁根节点
  18.         try {
  19.             if (this.zooKeeper.exists(ROOT_PATH, false) == null) {
  20.                 this.zooKeeper.create(ROOT_PATH, null,
  21.                         ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
  22.             }
  23.         } catch (KeeperException e) {
  24.             e.printStackTrace();
  25.         } catch (InterruptedException e) {
  26.             e.printStackTrace();
  27.         }
  28.     }
  29.  
  30.     @PreDestroy
  31.     public void destroy() {
  32.         if (zooKeeper != null) {
  33.             try {
  34.                 zooKeeper.close();
  35.             } catch (InterruptedException e) {
  36.                 e.printStackTrace();
  37.             }
  38.         }
  39.     }
  40.     /**
  41.      * 初始化分布式对象方法
  42.      */
  43.     public ZkDistributedLock getZkDistributedLock(String lockname){
  44.         return new ZkDistributedLock(zooKeeper,lockname);
  45.     }
  46. }

代码落地

  1. public class ZkDistributedLock {
  2.     public static final String ROOT_PATH = "/distribute";
  3.     private String path;
  4.     private ZooKeeper zooKeeper;
  5.  
  6.  
  7.     public ZkDistributedLock(ZooKeeper zooKeeper, String lockname) {
  8.         this.zooKeeper = zooKeeper;
  9.         this.path = ROOT_PATH + "/" + lockname;
  10.     }
  11.  
  12.     public void lock() {
  13.         try {
  14.             zooKeeper.create(path, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
  15.         } catch (KeeperException e) {
  16.             e.printStackTrace();
  17.         } catch (InterruptedException e) {
  18.             e.printStackTrace();
  19.         }
  20.         try {
  21.             Thread.sleep(200);
  22.             lock();
  23.         } catch (InterruptedException e) {
  24.             e.printStackTrace();
  25.         }
  26.  
  27.     }
  28.  
  29.     public  void  unlock(){
  30.         try {
  31.             this.zooKeeper.delete(path,0);
  32.         } catch (InterruptedException e) {
  33.             e.printStackTrace();
  34.         } catch (KeeperException e) {
  35.             e.printStackTrace();
  36.         }
  37.  
  38.     }
  39. }

改造StockService的checkAndLock方法:

  1. @Autowired
  2.     private zkClient client;
  3.     
  4.     public void checkAndLock() {
  5.         // 加锁,获取锁失败重试
  6.         ZkDistributedLock lock = this.client.getZkDistributedLock("lock");
  7.         lock.lock();
  8.         // 先查询库存是否充足
  9.         Stock stock = this.stockMapper.selectById(1L);
  10.         // 再减库存
  11.         if (stock != null && stock.getCount() > 0) {
  12.             stock.setCount(stock.getCount() - 1);
  13.             this.stockMapper.updateById(stock);
  14.         }
  15.         lock.unlock();
  16.     }
Jmeter压力测试

586cf6403bf5668e3ff8c860e49d1e87.png性能一般,mysql数据库的库存余量为0(注意:所有测试之前都要先修改库存量为5000)。

基本实现存在的问题
  • 性能一般(比mysql略好)

  • 不可重入

接下来首先来提高性能。

性能优化

基本实现中由于无限自旋影响性能:a889554a84e9b61b647f7c9711f89e1f.png试想:每个请求要想正常的执行完成,最终都是要创建节点,如果能够避免争抢必然可以提高性能。这里借助于zk的临时序列化节点,实现分布式锁:3880ad6acae350d16cb65234ecb35d67.png

实现阻塞锁
代码实现
  1. public class ZkDistributedLock {
  2.     public static final String ROOT_PATH = "/distribute";
  3.     private String path;
  4.     private ZooKeeper zooKeeper;
  5.  
  6.  
  7.     public ZkDistributedLock(ZooKeeper zooKeeper, String lockname) {
  8.         this.zooKeeper = zooKeeper;
  9.         try {
  10.             this.path = zooKeeper.create(ROOT_PATH + "/" + lockname + "_",
  11.                     null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT_SEQUENTIAL);
  12.         } catch (KeeperException e) {
  13.             e.printStackTrace();
  14.         } catch (InterruptedException e) {
  15.             e.printStackTrace();
  16.         }
  17.     }
  18.  
  19.     public void lock() {
  20.         String preNode = getpreNode(path);
  21.         //如果该节点没有前一个节点,说明该节点是最小的节点
  22.         if (StringUtils.isEmpty(preNode)) {
  23.             return;
  24.         }
  25.         //重新检查是否获取到锁
  26.         try {
  27.             Thread.sleep(20);
  28.             lock();
  29.         } catch (InterruptedException e) {
  30.             e.printStackTrace();
  31.         }
  32.     }
  33.  
  34.     /**
  35.      * 获取指定节点的前节点
  36.      *
  37.      * @param path
  38.      * @return
  39.      */
  40.     private String getpreNode(String path) {
  41.         //获取当前节点的序列化序号
  42.         Long curSerial = Long.valueOf(StringUtil.substringAfter(path, '_'));
  43.         //获取根路径下的所有序列化子节点
  44.         try {
  45.             List<String> nodes = this.zooKeeper.getChildren(ROOT_PATH, false);
  46.             //判空处理
  47.             if (CollectionUtils.isEmpty(nodes)) {
  48.                 return null;
  49.             }
  50.             //获取前一个节点
  51.             Long flag = 0L;
  52.             String preNode = null;
  53.             for (String node : nodes) {
  54.                 //获取每个节点的序列化号
  55.                 Long serial = Long.valueOf(StringUtil.substringAfter(path, '_'));
  56.                 if (serial < curSerial && serial > flag) {
  57.                     flag = serial;
  58.                     preNode = node;
  59.                 }
  60.             }
  61.             return preNode;
  62.         } catch (KeeperException e) {
  63.             e.printStackTrace();
  64.         } catch (InterruptedException e) {
  65.             e.printStackTrace();
  66.         }
  67.         return null;
  68.     }
  69.  
  70.     public void unlock() {
  71.         try {
  72.             this.zooKeeper.delete(path, 0);
  73.         } catch (InterruptedException e) {
  74.             e.printStackTrace();
  75.         } catch (KeeperException e) {
  76.             e.printStackTrace();
  77.         }
  78.  
  79.     }
  80. }

主要修改了构造方法和lock方法:e6acd0312e4ed774297af0e6ed073cd1.png并添加了getPreNode获取前置节点的方法。

测试结果如下:195e9bf5c90ca7e0bde2c6f8ccbe564f.png性能反而更弱了。

原因:虽然不用反复争抢创建节点了,但是会自选判断自己是最小的节点,这个判断逻辑反而更复杂更 耗时。

解决方案:监听实现阻塞锁
监听实现阻塞锁

对于这个算法有个极大的优化点:假如当前有1000个节点在等待锁,如果获得锁的客户端释放锁时,这1000个客户端都会被唤醒,这种情况称为“羊群效应”;在这种羊群效应中,zookeeper需要通知1000个 客户端,这会阻塞其他的操作,最好的情况应该只唤醒新的最小节点对应的客户端。应该怎么做呢?在 设置事件监听时,每个客户端应该对刚好在它之前的子节点设置事件监听,例如子节点列表 为/lock/lock-0000000000、/lock/lock-0000000001、/lock/lock-0000000002,序号为1的客户端监听 序号为0的子节点删除消息,序号为2的监听序号为1的子节点删除消息。

所以调整后的分布式锁算法流程如下:
  • 客户端连接zookeeper,并在/lock下创建临时的且有序的子节点,第一个客户端对应的子节点 为/lock/lock-0000000000,第二个为/lock/lock-0000000001,以此类推;

  • 客户端获取/lock下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子 节点,如果是则认为获得锁,否则监听刚好在自己之前一位的子节点删除消息,获得子节点变更通 知后重复此步骤直至获得锁;

  • 执行业务代码;

  • 完成业务流程后,删除对应的子节点释放锁。

改造ZkDistributedLock的lock方法:
  1. public void lock() {
  2.         String preNode = getpreNode(path);
  3.         //如果该节点没有前一个节点,说明该节点是最小的节点
  4.         if (StringUtils.isEmpty(preNode)) {
  5.             return;
  6.         } else {
  7.             CountDownLatch countDownLatch = new CountDownLatch(1);
  8.             try {
  9.                 if (this.zooKeeper.exists(ROOT_PATH + "/" + preNode, watchedEvent -> {
  10.                     countDownLatch.countDown();
  11.                 }) == null) {
  12.                     return;
  13.                 }
  14.                 countDownLatch.await();
  15.                 return;
  16.  
  17.             } catch (KeeperException e) {
  18.                 e.printStackTrace();
  19.             } catch (InterruptedException e) {
  20.                 e.printStackTrace();
  21.             }
  22.             try {
  23.                 Thread.sleep(200);
  24.             } catch (InterruptedException e) {
  25.                 e.printStackTrace();
  26.             }
  27.             lock();
  28.         }
  29.     }

压力测试效果如下:191a914f34513f581e5601bce5ae227e.png由此可见性能提高不少仅次于redis的分布式锁。

优化:可重入锁

引入ThreadLocal线程局部变量保证zk分布式锁的可重入性。

在对应的线程的存储数据。

  1. public class ZkDistributedLock {
  2.     public static final String ROOT_PATH = "/distribute";
  3.     private String path;
  4.     private ZooKeeper zooKeeper;
  5.     private static final ThreadLocal<Integer> THREAD_LOCAL = new ThreadLocal<>();
  6.  
  7.  
  8.     public ZkDistributedLock(ZooKeeper zooKeeper, String lockname) {
  9.         this.zooKeeper = zooKeeper;
  10.         try {
  11.             this.path = zooKeeper.create(ROOT_PATH + "/" + lockname + "_",
  12.                     null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT_SEQUENTIAL);
  13.         } catch (KeeperException e) {
  14.             e.printStackTrace();
  15.         } catch (InterruptedException e) {
  16.             e.printStackTrace();
  17.         }
  18.     }
  19.  
  20.     public void lock() {
  21.         Integer flag = THREAD_LOCAL.get();
  22.         if (flag != null && flag > 0) {
  23.             THREAD_LOCAL.set(flag + 1);
  24.             return;
  25.         }
  26.         String preNode = getpreNode(path);
  27.         //如果该节点没有前一个节点,说明该节点是最小的节点
  28.         if (StringUtils.isEmpty(preNode)) {
  29.             return;
  30.         } else {
  31.             CountDownLatch countDownLatch = new CountDownLatch(1);
  32.             try {
  33.                 if (this.zooKeeper.exists(ROOT_PATH + "/" + preNode, watchedEvent -> {
  34.                     countDownLatch.countDown();
  35.                 }) == null) {
  36.                     return;
  37.                 }
  38.                 countDownLatch.await();
  39.                 THREAD_LOCAL.set(1);
  40.                 return;
  41.  
  42.             } catch (KeeperException e) {
  43.                 e.printStackTrace();
  44.             } catch (InterruptedException e) {
  45.                 e.printStackTrace();
  46.             }
  47.             try {
  48.                 Thread.sleep(200);
  49.             } catch (InterruptedException e) {
  50.                 e.printStackTrace();
  51.             }
  52.             lock();
  53.         }
  54.     }
  55.  
  56.     /**
  57.      * 获取指定节点的前节点
  58.      *
  59.      * @param path
  60.      * @return
  61.      */
  62.     private String getpreNode(String path) {
  63.         //获取当前节点的序列化序号
  64.         Long curSerial = Long.valueOf(StringUtil.substringAfter(path, '_'));
  65.         //获取根路径下的所有序列化子节点
  66.         try {
  67.             List<String> nodes = this.zooKeeper.getChildren(ROOT_PATH, false);
  68.             //判空处理
  69.             if (CollectionUtils.isEmpty(nodes)) {
  70.                 return null;
  71.             }
  72.             //获取前一个节点
  73.             Long flag = 0L;
  74.             String preNode = null;
  75.             for (String node : nodes) {
  76.                 //获取每个节点的序列化号
  77.                 Long serial = Long.valueOf(StringUtil.substringAfter(path, '_'));
  78.                 if (serial < curSerial && serial > flag) {
  79.                     flag = serial;
  80.                     preNode = node;
  81.                 }
  82.             }
  83.             return preNode;
  84.         } catch (KeeperException e) {
  85.             e.printStackTrace();
  86.         } catch (InterruptedException e) {
  87.             e.printStackTrace();
  88.         }
  89.         return null;
  90.     }
  91.  
  92.     public void unlock() {
  93.         try {
  94.             THREAD_LOCAL.set(THREAD_LOCAL.get() - 1);
  95.             if (THREAD_LOCAL.get() == 0) {
  96.                 this.zooKeeper.delete(path, 0);
  97.                 THREAD_LOCAL.remove();
  98.             }
  99.  
  100.         } catch (InterruptedException e) {
  101.             e.printStackTrace();
  102.         } catch (KeeperException e) {
  103.             e.printStackTrace();
  104.         }
  105.  
  106.     }
  107. }

f4d340b83200034a96f7c4b1fe6b0911.pngf90347af9ca20d5a7a05ec9aa5392045.png

zk分布式锁小结

  • 互斥 排他:zk节点的不可重复性,以及序列化节点的有序性。

  • 防死锁:

    • 可自动释放锁:临时节点。

    • 可重入锁:借助于ThreadLocal。

  • 防误删:临时节点。

  • 加锁/解锁要具备原子性。

  • 单点问题:使用Zookeeper可以有效的解决单点问题,ZK一般是集群部署的。

  • 集群问题:zookeeper集群是强一致性的,只要集群中有半数以上的机器存活,就可以对外提供服务。

  • 公平锁:有序性节点。

ZooKeeper与Redis实现分布式锁对比

我们知道了 Redis 主要是通过 setnx 命令实现分布式锁,Zookeeper 采用临时节点和事件监听机制可以实现分布式锁。那么这两种方式有哪些关键的区别呢?

  • Redis分布式锁,获取不到锁时,需要不断轮询去尝试获取锁,比较消耗性能;ZooKeeper分布式锁,获取不到锁时,注册监听器即可,不需要不断主动尝试获取锁,性能开销较小;

  • 锁未释放时服务器宕机。Redis只能等超时时间到将锁释放。ZooKeeper的临时节点检测不到服务器的心跳,节点移除,锁自动被释放;

这样看好像ZooKeeper比Redis更胜一筹,但Redis提供的API和库更加丰富,在很大程度上能够减少开发的工作量。而且如果是小规模的项目,已经部署了Redis,可能没太大必要去再部署一套ZooKeeper集群去实现分布式锁,大家根据场景自行选择。

参考文章:https://blog.csdn.net/polsnet/article/

details/130444403 https://blog.csdn.net/m0_62436868

/article/details/130465612

读者专属技术群

构建高质量的技术交流社群,欢迎从事后端开发、运维技术进群(备注岗位,已在技术交流群的请勿重复添加)。主要以技术交流、内推、行业探讨为主,请文明发言。广告人士勿入,切勿轻信私聊,防止被骗。

扫码加我好友,拉你进群

abe3f4f8bf8b86eeeacf7b10286026a9.jpeg

推荐阅读 点击标题可跳转

IPv4 开始收费!或将是一场新的 IT 灾难。。。

第一大服务器厂商:收入骤降 100 亿

发现一款吊炸天的远程控制与监控工具,有点牛逼

一个比 ping 更强大、更牛逼的命令行工具!

外资IT连连败退!Citrix和Radware或将撤离中国

新来个技术总监:谁再用 rebase 提交合并开除

741e47056fdb870aca7005826acb118e.png

PS:因为公众号平台更改了推送规则,如果不想错过内容,记得读完点一下在看,加个星标,这样每次新文章推送才会第一时间出现在你的订阅列表里。点在看支持我们吧!

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/天景科技苑/article/detail/965822
推荐阅读
相关标签
  

闽ICP备14008679号