zookeeper 的 Watch 功能三种应用场景

什么是 Watch 功能点

这是 zookeeper 非常重要的功能。如下图所示,
在这里插入图片描述

上图中,有两个客户端,client1 创建了 /app/lock 临时节点,临时节点有个好处,client2 在可以在临时节点上建一个 watch (观察点),观察什么呢?可以观察到临时节点创建、删除、修改、子目录的变化,而且是事件性的回调 client2 定义的 watch 接口的代码。

watcher 功能的三种应用场景

在大部分的分布式主从(或者主备)模式下,HA 的实现都是应用了 watch 临时节点的 delete 事件。拿 NameNode 来说吧, 在 NameNode 所在的集群上都有一个 zkfc 专门监控 NameNode 运行状态,如果 NameNode 意外结束,或者整个服务器都宕机了,zookeeper 中的 /hadoop-ha/ActiveStandbyElectorLock 临时节点就会消失,而其他的 standby 节点开始选举新节点的程序。另外多说一下,/hadoop-ha/ActiveBreadCrumb 永久节点是为了防止脑裂的。

在分布式的系统中,有这么一种需求是根据规则为每条记录打标签。听着还挺简单的,但是难的是规则是经常变化的,通常的办法不是修改代码就是修改配置文件来修改规,这种方式需要重启集群才能让新规则生效,要不就是写一个死循环从 redis 这种缓存中不断的查询配置的版本号,如果版本号变化就加载新规则,这种方式克服了冷部署的确定,可以在不停服务器的情况下,替换旧规则。但是有个缺点是,规则在大部分时间内是不需要更改的。如果使用 zookeeper 的 watch 功能监控某个节点的 change 事件,而 change 节点更改的内容就是新的规则。这就是分布式的配置,只有节点中的配置信息被修改了,使用此配置的节点都后收到 change event 事件的通知,然后去同步新的配置。

如果将配置信息替换为命令的话,就能使用 Zookeeper 在分布式系统中发布消息给系统,各个节点在根据命令信息,执行命令。Clickhouse 的副本、分片就是这样实现的。

在雪崩场景下,当 1000 个客户端对 100 个失效的 key 进行请求的话,就造成了 100 个击穿的效果,对 mysql 造成巨大的压力。解决的办法是当出现这种情况,要协调一下这 1000 客户端,让它们先去申请一把锁,当然这把锁是分布式下的锁,可以理解为可重入式锁的升级版。 只有申请到这把锁的客户端才能去 mysql 请求数据,当此客户端请求为数据之后,再将数据更新到 redis ,不就完美了。然后客户端释放这把锁,然后其他线程申请到这把锁,先去 redis 看看有了没有,有个 redis 中已经了需要访问的 key ,则直接取回,然后再释放锁。

以上三个场景就是 watch 共功能的三种场景:管理节点的高可用、分布式系统中的配置热部署、分布式锁。

三种场景的模拟实现

管理节点高可用的实现。每个节点的运行逻辑如下所示:

在这里插入图片描述

总结一下流程图吧:
分成两个线程来描述,两个线程分别为主线和 zk 客户端线程。
主线值做两件事,一件事情是启动 zk 客户端,另外一件事情是根据 running 的值来判断是否处理正常的业务逻辑。
zk 客户端也是做两件事件,连接 zk server ,连接上之后尝试建 /ha/rm 这个目录,如果失败说明,已经有其他进程处理业务逻辑了,当前线程就在 /ha/rm 上 watch ,
watch 中的逻辑和 zk 客户端刚启动的逻辑查不多。

上代码。

模拟 ResourceManager 的代码:

import org.apache.zookeeper.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import streaming.zk.uitils.ZkUtils;

import java.nio.charset.StandardCharsets;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

/**
 * @className: ZookeeperTest
 * @Description:
 * @Author: wangyifei
 * @Date: 2022/11/7 16:01
 */
public class ResourceManager {
    private static Logger logger = LoggerFactory.getLogger(ResourceManager.class);
    private volatile boolean running = false ;
    private String name ;
    public ResourceManager(String name){
        this.name = name ;
    }
    public void setRunning(boolean running) {
        this.running = running;
    }

    public void entrypoint(){
        HAWatchCallback haWatchCallback = new HAWatchCallback();
        haWatchCallback.setRm(this);
        haWatchCallback.createPath("/ha/rm");
        ScheduledExecutorService schedule = Executors.newScheduledThreadPool(1);
        schedule.scheduleAtFixedRate(()->{
                    if(running){
                        System.out.println("rm(" + name +") active ,  do business");
                    }else{
                        System.out.println("rm(" + name +") standby ");
                    }
                } , 1
                ,1
                , TimeUnit.SECONDS);
    }
    public static void main(String[] args) throws Exception {
        // 启动时候,需要配置 ideal 可以多实例启动,然后修改 name 的名称,这样就能打印除谁是 active 的,谁是 standby 了
        ResourceManager rm1 = new ResourceManager("rm2");
        rm1.entrypoint();
    }


}
import org.apache.zookeeper.*;
import org.apache.zookeeper.data.Stat;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import streaming.zk.uitils.ZkUtils;

import java.nio.charset.StandardCharsets;
import java.util.concurrent.CountDownLatch;

/**
 * @className: HAWatchCallback
 * @Description:
 * @Author: wangyifei
 * @Date: 2022/11/8 20:12
 */
public class HAWatchCallback implements Watcher , AsyncCallback.StatCallback , AsyncCallback.DataCallback {
    private static Logger logger = LoggerFactory.getLogger(HAWatchCallback.class);
    private CountDownLatch cdl = new CountDownLatch(1);
    private ZooKeeper zk ;
    private String path ;
    private ResourceManager rm ;

    public void setRm(ResourceManager rm) {
        this.rm = rm;
    }

    public void createPath(String path){
        this.path = path ;
        ZkUtils.setWatcher(this);
        zk = ZkUtils.newZkClient();
        try {
            cdl.await();
            zk.create(path , "test".getBytes(StandardCharsets.UTF_8) , ZooDefs.Ids.OPEN_ACL_UNSAFE , CreateMode.EPHEMERAL);
            rm.setRunning(true);
        } catch (KeeperException e) {
            if(e instanceof KeeperException.NodeExistsException){
                // 如果不存在则对,此节点进行 watch
                rm.setRunning(false);
                zk.getData(path , this , this , "none");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    public ZooKeeper getZk() {
        return zk;
    }

    public void setZk(ZooKeeper zk) {
        this.zk = zk;
    }

    /**
     * @return:
     * @desc: 监听数据修改的回调函数
     * @author
     * @date
     * @param
     */
    @Override
    public void processResult(int i, String s, Object o, byte[] bytes, Stat stat) {
        // 当
    }
    /**
     * @return:
     * @desc: 监听是节点状态的回调函数
     * @author
     * @date
     * @param
     */
    @Override
    public void processResult(int i, String s, Object o, Stat stat) {

    }
    /**
     * @return:
     * @desc: 监听事件完成的类型
     * @author
     * @date
     * @param
     */
    @Override
    public void process(WatchedEvent watchedEvent) {
        // path 的事件类型,
        Event.EventType type = watchedEvent.getType();
        switch (type) {
            case None:
                break;
            case NodeCreated:
                break;
            case NodeDeleted:
                // 当 watch 到节点消息之后,需要新建一个节点,并且将自己的 ip:thread_id 放到 path 的值中
                try {
                    zk.create( path
                            , "test".getBytes(StandardCharsets.UTF_8)
                            , ZooDefs.Ids.OPEN_ACL_UNSAFE
                            , CreateMode.EPHEMERAL
                    );
                    rm.setRunning(true);
                } catch (KeeperException e) {
                    if(e instanceof KeeperException.NodeExistsException){
                        // 如果不存在则对,此节点进行 watch
                        rm.setRunning(false);
                        zk.getData(path , this , this , "none");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 如果没有创建成功,则要查看是否已经存在,如果存在则修改 running = true, 让现在这个实例跑起来处理业务逻辑

                break;
            case NodeDataChanged:
                break;
            case NodeChildrenChanged:
                break;
        }
        Event.KeeperState state = watchedEvent.getState();
        // 命令执行的返回值状态。
        switch (state) {
            case Unknown:
                break;
            case Disconnected:
                break;
            case NoSyncConnected:
                break;
            case SyncConnected:
                cdl.countDown();
                System.out.println("server connected");
                break;
            case AuthFailed:
                break;
            case ConnectedReadOnly:
                break;
            case SaslAuthenticated:
                break;
            case Expired:
                break;
        }
    }
}

分布式系统中的配置热部署的实现。,下图是每个应用运行时的流程图:
在这里插入图片描述

总结一下上面这个图吧:
启动 main 线程,第一件事情就是启动 zookeeper 的客户端。
客户端连接是异步的,在异步回调的时候,会调用 zk.exists() 函数查看节点是否存在,如果存在则调用 getData() 函数,在 getData 函数中定义callback 函数,在这个函数中,会拿到节点修改后的最新值,拿到最新的值之后,就更新本地的配置数据。本地配置信息保存在单例对象中的 ConcurrentHashMap 中。
当 main 线程启动完 zookeeper 客户端线程之后,还会继续往下执行,往下执行就开始正常的业务逻辑的处理,当需要取到配置的时候,可以通过单例拿到配置的对象,对象中使用 ConcurrentHashMap 以 k-v 的方式来管理本地配置。
下面的例子中,配置的是一个 IP 地址,它是一个字符串,大家自己脑补一下,我们可以保存 json 格式的数据,这样就能保存更加复杂得内容了。

代码如下所示:
下面是进入 main 的类:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

/**
 * @className: Standby
 * @Description:
 * @Author: wangyifei
 * @Date: 2022/11/7 16:29
 */
public class ClusterDistributeConfigByZookeeper {
    private static Logger logger = LoggerFactory.getLogger(ClusterDistributeConfigByZookeeper.class);
    public static String expression = null ;
    public static final String path = "/ha" ;
    public static void main(String[] args) throws Exception {
        ClusterConfigWatchCallback haWatchCallback = new ClusterConfigWatchCallback(path);
        // 里面都使用的异步的 API ,每次配置变更后,都会更新本地的配置,然后再对配置路径进行 watch
        // 所以下面的代码不会阻塞,会直接往下执行。
        haWatchCallback.watchAndUpdateConfigAsync();

        ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
        // 启动一个死循环的线程,此线程模拟了处理正常业务逻辑的代码,
        // 这里仅仅是打印出来了配置的内容,
        scheduledExecutorService.scheduleAtFixedRate(
                ()->{
                    System.out.println(ClusterConfiguration.Singleton.instance.getConfigByKey("code"));
                }
                , 1
                , 3
                , TimeUnit.SECONDS
        );
    }
}

下面是 Watch、StateCallback、DataCallback 的实现类:

import org.apache.zookeeper.*;
import org.apache.zookeeper.data.Stat;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import streaming.zk.uitils.ZkUtils;

import java.util.concurrent.CountDownLatch;

/**
 * @className: HAWatchCallback
 * @Description: Watcher 是返回的对事件的描述。
 * exists create getData 方法,它们有两种方式: 同步和异步方式
 * 当使用异步的情况下就会两种情况:
 * AsyncCallback.StatCallback : 状态相关的回调 , 比如 exists 两种。
 * AsyncCallback.DataCallback : 数据相关的回调,比如 getData 方法中,就需要这种回调函数
 * @Author: wangyifei
 * @Date: 2022/11/8 14:06
 */
public class ClusterConfigWatchCallback implements Watcher , AsyncCallback.StatCallback , AsyncCallback.DataCallback {
    private static Logger logger = LoggerFactory.getLogger(ClusterConfigWatchCallback.class);
    private CountDownLatch cdl ;
    private ZooKeeper zk ;
    private String path ;
    public ClusterConfigWatchCallback(){}

    public ClusterConfigWatchCallback(String path){
        this.path = path;
    }


    public void watchAndUpdateConfigAsync(){
        ZkUtils.setWatcher(this);
        ZooKeeper zk = ZkUtils.newZkClient();
        this.setZk(zk);
        cdl = new CountDownLatch(1);
        // zookeeper 客户端是是不连接的,当连接上 zk server 之后,会回调 watch 的方法,此方法中是 asynchrozation 类型
        // 这里需要指定 CountDownLatch 来让 main 线程等待 zk 客户端连接到 server 之后再往下执行
        try {
            cdl.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("server connected");
        zk.exists(path , false , this, "none");
    }


    public ZooKeeper getZk() {
        return zk;
    }

    public void setZk(ZooKeeper zk) {
        this.zk = zk;
    }

    public CountDownLatch getCdl() {
        return cdl;
    }

    public void setCdl(CountDownLatch cdl) {
        this.cdl = cdl;
    }

    @Override
    public void process(WatchedEvent watchedEvent) {
        System.out.println(watchedEvent.toString());
        Event.KeeperState state = watchedEvent.getState();
        switch (state) {
            case Unknown:
                break;
            case Disconnected:
                break;
            case NoSyncConnected:
                break;
            case SyncConnected:
                cdl.countDown();
                break;
            case AuthFailed:
                break;
            case ConnectedReadOnly:
                break;
            case SaslAuthenticated:
                break;
            case Expired:
                break;
        }
        
        switch (type) {
            case None:
                System.out.println(watchedEvent.getPath() + "none");
                break;
            case NodeCreated:
                System.out.println(watchedEvent.getPath() + "created");
                break;
            case NodeDeleted:
                break;
            case NodeDataChanged:
                System.out.println(watchedEvent.getPath() + "changed");
                zk.getData(path,this,this,"none");
                break;
            case NodeChildrenChanged:
                break;
        
    }
    /**
     * @return:
     * @desc:
     * @author
     * @date
     * @param
     */
    @Override
    public void processResult(int rc, String path, Object ctn, Stat stat) {
        if(rc == KeeperException.Code.OK.intValue()){
            // 如果配置节点存在
            System.out.println("the path("+path+") exists , and set watcher on it");
            zk.getData(path , this , this , "as");
        }
        cdl.countDown();
    }

    @Override
    public void processResult(int rc, String path, Object o, byte[] bytes, Stat stat) {
        // 1. 监测到数据已经改变了,所以就修改本地配置的值,这里应该可以使用上锁方式的单例。
        String key = "code" ;
        String value = new String(bytes) ;

        ClusterConfiguration.Singleton.instance.setConfig(key , value);
        // 2. 当更改完本地的配置之后,就要对配置节点重新进行 watch ,这样节点的内容再发生变化之后,就有能监测到了。
        
    }
}

下面是管理本地配置的单例类:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.ConcurrentHashMap;

/**
 * @className: HAConfiguration
 * @Description:
 * @Author: wangyifei
 * @Date: 2022/11/8 15:43
 */
public class ClusterConfiguration {
    private static Logger logger = LoggerFactory.getLogger(ClusterConfiguration.class);
    private ConcurrentHashMap<String , String> config = new ConcurrentHashMap<>();
    public static class Singleton{
        public final static ClusterConfiguration instance = new ClusterConfiguration();
    }
    public String getConfigByKey(String key){
        return config.get(key);
    }
    public void setConfig(String key , String value){
        config.put(key , value);
    }
    private ClusterConfiguration(){}
}

zookeeper 的工具类:

import org.apache.zookeeper.Watcher;
import org.apache.zookeeper.ZooKeeper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.util.concurrent.CountDownLatch;

/**
 * @className: zkUtils
 * @Description:
 * @Author: wangyifei
 * @Date: 2022/11/8 11:36
 */
public class ZkUtils {
    private static Logger logger = LoggerFactory.getLogger(ZkUtils.class);
    private static final String ZOOKEEPER = "ip:2181" ;
    private static final int sessionTimeout = 4000 ;
    private static Watcher watcher ;
    public static Watcher getWatcher() {
        return watcher;
    }

    public static void setWatcher(Watcher watcher) {
        ZkUtils.watcher = watcher;
    }

    public static ZooKeeper newZkClient(Watcher watcher){
        ZooKeeper zk = null ;
        try {
            zk = new ZooKeeper(ZOOKEEPER , sessionTimeout , watcher);
        } catch (IOException e) {
            e.printStackTrace();
        }
        return zk ;
    }

    public static ZooKeeper newZkClient(){
        return newZkClient(watcher);
    }

}

分布式锁。 业务流程图如下所示:

在这里插入图片描述

流程描述:

主要是利用了 zookeeper 的 ephemeral equential path 的属性,可以在高并发的情况下,在某个目录下创建可以自动话后缀的临时子目录。这样就能在本地知道自己是否是最小的那个了。
在 getChildren 函数中,使用的是异步方式来监控子目录的修改,我使用 CountdowLatch 来进行同步的。这里还可以 getChildren 另外一个可以拿到变化后子目录的重载函数。有了这个函数就可以直接拿到现在子目录的,然后直接判断自己是不是最小目录。

下面是非 react 风格的实现方式:

执行分布式锁的入口代码:

import org.slf4j.Logger ;
import org.slf4j.LoggerFactory ;

import java.util.concurrent.CountDownLatch;

/**
* @className: DistributeLock
* @Description:
* @Author: wangyifei
* @Date: 2022/11/8 17:53
*/
public class DistributeLock {
    private static Logger logger = LoggerFactory.getLogger(DistributeLock.class);

    private LockWatchCallback lock = new LockWatchCallback();
    /**
     * @return:
     * @desc: 申请分布式锁,如果申请成功了,则继续往下执行,如果申请不成功,则进入阻塞状态。
     * 所谓的阻塞状态就是调用 getData ,监测 children change
     * @author
     * @date
     * @param
     */
    public void lock(){
        /**
         * 1. 在 /lock 目录下创建 distribute_lock_XXXXXX 的目录
         * 2. 查看 /lock 下面,看看自己是不是最小的哪一个
         * */
        String ephemeralPath = lock.createEphemeralPath();
        System.out.println("thread:("+Thread.currentThread().getName()+") , ephemeralPath is :"+ ephemeralPath);
        String first = lock.firstChildrenPath();
        while(true){
            first = lock.firstChildrenPath();
            System.out.println(first);
            if(ephemeralPath.replace("/lock/","").equals(first)){
                // 如果自己对应的路径是最小的哪一个,创建路径 current path
                lock.createCurrentPath();
                break ;
            }else{
                // 进入监控 /lock/ 子目录变化的事件,当有目录伤处后,再执行 firstChildrenPath 函数
                lock.childrenPathDispeared();
                first = lock.firstChildrenPath();
            }
        }
    }
    /**
     * @return:
     * @desc: 删除掉当前线程对应的路径
     * @author
     * @date
     * @param
     */
    public void unlock(){
        // 删除 currentLock 目录
        lock.deleteCurrentLock();

    }
    public void runBusiness(){
        System.out.println("do business");
    }
    public static void main(String[] args) throws InterruptedException {

        CountDownLatch cdl = new CountDownLatch(10);

        Runnable running = ()->{
            DistributeLock lock = new DistributeLock();
            lock.lock();
            try{
                System.out.println("------------fffuck:" + Thread.currentThread().getName());
                Thread.sleep(3000);
            }catch (Exception e){
                e.printStackTrace();
            }finally {
                lock.unlock();
            }
            cdl.countDown();
        };
        for (int i = 0; i < 10; i++) {
            new Thread(running).start();
        }
        cdl.await();
    }
}

回调和 lock() 和 unclock() 的实现:

import org.apache.zookeeper.*;
import org.apache.zookeeper.data.Stat;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import streaming.zk.uitils.ZkUtils;

import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;

/**
 * @className: LockWatchCallback
 * @Description:
 * @Author: wangyifei
 * @Date: 2022/11/9 11:16
 */
public class LockWatchCallback implements Watcher , AsyncCallback.DataCallback , AsyncCallback.StatCallback {

    private static Logger logger = LoggerFactory.getLogger(LockWatchCallback.class);

    private static final String childrenPath = "/distribute_lock_" ;

    private static final String currentLock = "/lock/currentLock" ;

    private CountDownLatch cdl = new CountDownLatch(1) ;

    private ZooKeeper zk;

    private static final String path = "/lock" ;

    public String createEphemeralPath(){
        String ansPath = null ;
        ZkUtils.setWatcher(this);
        zk = ZkUtils.newZkClient(this);
        try {
            cdl.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        try {

            ansPath = zk.create(path + childrenPath
                    , "dl".getBytes(StandardCharsets.UTF_8)
                    , ZooDefs.Ids.OPEN_ACL_UNSAFE
                    , CreateMode.EPHEMERAL_SEQUENTIAL
             );
        } catch (KeeperException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return ansPath ;
    }

    @Override
    public void processResult(int i, String s, Object o, byte[] bytes, Stat stat) {

    }

    @Override
    public void processResult(int i, String s, Object o, Stat stat) {

    }

    @Override
    public void process(WatchedEvent watchedEvent) {
        Event.EventType type = watchedEvent.getType();
        switch (type) {
            case None:
                break;
            case NodeCreated:
                break;
            case NodeDeleted:
                break;
            case NodeDataChanged:
                break;
            case NodeChildrenChanged:
                // /lock 目录下面的子目录消失后,应该执行正常的业务逻辑了。
                System.out.println("children changed");
                cdl.countDown();
                break;
        }
        Event.KeeperState state = watchedEvent.getState();
        switch (state) {
            case Unknown:
                break;
            case Disconnected:
                break;
            case NoSyncConnected:
                break;
            case SyncConnected:
                System.out.println("connected");
                cdl.countDown();
                break;
            case AuthFailed:
                break;
            case ConnectedReadOnly:
                break;
            case SaslAuthenticated:
                break;
            case Expired:
                break;
        }

    }

    public String firstChildrenPath() {
        String ans = null ;
        try {
            List<String> children = zk.getChildren(path, false);
            Optional<String> first = children.stream().sorted().findFirst();
            ans = first.get();
        } catch (KeeperException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return ans ;
    }

    public void createCurrentPath() {
        try {
            zk.create( currentLock
                    , "currentLock".getBytes(StandardCharsets.UTF_8)
                    , ZooDefs.Ids.OPEN_ACL_UNSAFE
                    , CreateMode.EPHEMERAL
            );
        } catch (KeeperException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public List<String> childrenPathDispeared() {
        Stat stat = new Stat();
        cdl = new CountDownLatch(1);
        List<String> data = null ;
        try {
            data = zk.getChildren(path, this, stat);
            cdl.await();
        } catch (KeeperException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return data ;
    }

    public void deleteCurrentLock() {
        try {
            zk.delete(currentLock , 0);
            zk.close();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (KeeperException e) {
            e.printStackTrace();
        }
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值