Zookeeper应用场景实战二

目录

1. Zookeeper 分布式锁实战

1.1 什么是分布式锁

1.2 基于数据库设计思路

1.3 基于 Zookeeper 设计思路一

1.4 基于 Zookeeper 设计思路二

Curator 分布式锁示例

1.5 Curator 可重入分布式锁工作流程

1.6 总结

2. 基于 Zookeeper 实现服务的注册与发现

2.1 设计思路

2.2 Zookeeper 实现注册中心的优缺点

2.3 整合Spring Cloud Zookeeper实现微服务注册中心


1. Zookeeper 分布式锁实战

1.1 什么是分布式锁

在单体的应用开发场景中涉及并发同步的时候,大家往往采用Synchronized(同步)或者其他同一个JVM内Lock机制来解决多线程间的同步问题。在分布式集群工作的开发场景中,就需要一种更加高级的锁机制来处理跨机器的进程之间的数据同步问题,这种跨机器的锁就是分布式锁。

目前分布式锁,比较成熟、主流的方案:

(1)基于数据库的分布式锁。这种方案使用数据库的事务和锁机制来实现分布式锁。虽然在某些场景下可以实现简单的分布式锁,但由于数据库操作的性能相对较低,并且可能面临锁表的风险,所以一般不是首选方案。

(2)基于Redis的分布式锁。Redis分布式锁是一种常见且成熟的方案,适用于高并发、性能要求高且可靠性问题可以通过其他方案弥补的场景。Redis提供了高效的内存存储和原子操作,可以快速获取和释放锁。它在大规模的分布式系统中得到广泛应用。

(3)基于ZooKeeper的分布式锁。这种方案适用于对高可靠性和一致性要求较高,而并发量不是太高的场景。由于ZooKeeper的选举机制和强一致性保证,它可以处理更复杂的分布式锁场景,但相对于Redis而言,性能可能较低。

1.2 基于数据库设计思路

可以利用数据库的唯一索引来实现,唯一索引天然具有排他性

思考:基于数据库实现分布式锁存在什么问题?

  1. 单点故障: 如果数据库服务器成为单点故障,那么整个分布式系统的锁定机制将受到影响。数据库故障可能导致锁定无法释放或无法获取,从而影响系统的可用性。
  2. 死锁问题: 如果一个进程在获得锁之后崩溃或异常退出,那么锁可能永远不会被释放,导致死锁问题。解决这个问题可能需要引入超时机制或定期锁的自动释放。
  3. 性能瓶颈: 在大规模系统中,数据库可能成为性能瓶颈,因为所有的锁操作都要经过数据库。这可能导致竞争和延迟问题。
  4. 分布式环境复杂性: 在分布式环境中,数据库锁定机制需要处理网络延迟、时钟差异和各种故障情况。这增加了锁的复杂性和难度。
  5. 非原子性操作: 一些数据库引擎的锁操作不是原子性的,可能需要多个步骤来获取或释放锁。这可能导致竞争条件和不一致性问题。
  6. 缺乏高级功能: 数据库锁通常缺乏可重入锁、读写锁、公平锁等。如果应用程序需要这些功能,可能需要自行实现。

伪代码示例:

@Component
public class MysqlDistributedLock extends AbstractLock {

    @Autowired
    private MethodlockMapper methodlockMapper;

    @Override
    public boolean tryLock() {
        try {
            //插入一条数据(唯一键)   insert into
            methodlockMapper.insert(new Methodlock("lock"));
        }catch (Exception e){
            //插入失败
            return false;
        }
        return true;
    }

    @Override
    public void waitLock() {
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void unlock() {
        //删除数据   delete
        methodlockMapper.deleteByMethodlock("lock");
        System.out.println("-------释放锁------");
    }
}

public abstract class AbstractLock implements Lock{
    /**
     * 加锁,增加重试逻辑
     */
    @Override
    public void lock() {
        //尝试获取锁
        if(tryLock()){
            System.out.println("---------获取锁---------");
        }else {
            //等待锁 阻塞
            waitLock();
            //重试 策略    3 5 次, 重试太多会栈溢出
            lock();
        }
    }

    //尝试获取锁
    public abstract boolean tryLock() ;

    //等待锁
    public abstract void waitLock() ;
}

1.3 基于 Zookeeper 设计思路一

使用临时 znode 来表示获取锁的请求,创建 znode成功的用户拿到锁。

思考:上述设计存在什么问题?

如果所有的锁请求者都 watch 锁持有者,当代表锁持有者的 znode 被删除以后,所有的锁请求者都会通知到,但是只有一个锁请求者能拿到锁。可参考队列先进先出的特点解决

1.4 基于 Zookeeper 设计思路二

使用临时有序znode来表示获取锁的请求,创建最小后缀数字 znode 的用户成功拿到锁。

公平锁的实现

public class ZKLockTest implements Runnable{

    private  OrderCodeGenerator orderCodeGenerator = new OrderCodeGenerator();

    private Lock lock = new DistributedLock();//测试DistributedLock类的方法临时有序节点
    //private Lock lock = new DistributedLockByEPHEMERAL();//测试DistributedLockByEPHEMERAL类的方法,临时节点

    @Override
    public void run() {

        lock.lock();
        try {
            String orderCode = orderCodeGenerator.getOrderCode();
            System.out.println("生成订单号 "+orderCode);
        }finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {

        for(int i=0;i<30;i++){
            new Thread(new ZKLockTest()).start();
        }

        Thread.currentThread().join();
    }
}

public abstract class AbstractLock implements Lock{
    /**
     * 加锁,增加重试逻辑
     */
    @Override
    public void lock() {
        //尝试获取锁
        if(tryLock()){
            System.out.println("---------获取锁---------");
        }else {
            //等待锁 阻塞
            waitLock();
            //重试 策略    3 5 次, 重试太多会栈溢出
            lock();
        }
    }
    
    //尝试获取锁
    public abstract boolean tryLock() ;

    //等待锁
    public abstract void waitLock() ;
    
}
public class DistributedLockByEPHEMERAL extends AbstractLock {

    private static final String connectString = "192.168.189.131:2181";
    private static final int sessionTimeout = 5000;
    private static final String LOCK_PATH = "/lock";
    private ZooKeeper zooKeeper;
    private CountDownLatch lockAcquiredSignal = new CountDownLatch(1);


    public DistributedLockByEPHEMERAL()  {
        try {
            CountDownLatch connectLatch = new CountDownLatch(1);
            zooKeeper = new ZooKeeper(connectString, sessionTimeout, new Watcher() {
                @Override
                public void process(WatchedEvent event) {
                    // 连接建立时, 打开latch, 唤醒wait在该latch上的线程
                    if (event.getState() == Event.KeeperState.SyncConnected) {
                        connectLatch.countDown();
                    }

                    // 发生了waitPath的删除事件
                    if (event.getType() == Event.EventType.NodeDeleted && event.getPath().equals(LOCK_PATH)) {
                        lockAcquiredSignal.countDown();
                    }
                }
            });
            // 等待连接建立
            connectLatch.await();

        } catch (Exception e) {
            throw new RuntimeException(e);
        }

    }

    @Override
    public boolean tryLock() {
        try {
            // 创建临时节点/lock
            zooKeeper.create(LOCK_PATH, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
        }catch (Exception e){
            //节点已经存在,创建失败
            return false;
        }

        return true;
    }

    @Override
    public void waitLock() {
        try {
            //判断是否存在,监听节点
            Stat stat = zooKeeper.exists(LOCK_PATH, true);
            if(null != stat){
                lockAcquiredSignal.await();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Override
    public void unlock()  {
        try {
            //删除临时节点
            zooKeeper.delete(LOCK_PATH, -1);
            System.out.println("-------释放锁------");
        } catch (Exception e) {
        }
    }
}

运行结果

......//省略
---------获取锁---------
生成订单号 20231009093622-1
-------释放锁------
2023-10-09 21:36:32.772 [main-SendThread(192.168.189.131:2181)] INFO  org.apache.zookeeper.ClientCnxn --- Opening socket connection to server 192.168.189.131/192.168.189.131:2181.
2023-10-09 21:36:32.772 [main-SendThread(192.168.189.131:2181)] INFO  org.apache.zookeeper.ClientCnxn --- SASL config status: Will not attempt to authenticate using SASL (unknown error)
2023-10-09 21:36:32.773 [main-SendThread(192.168.189.131:2181)] INFO  org.apache.zookeeper.ClientCnxn --- Socket connection established, initiating session, client: /192.168.189.1:54542, server: 192.168.189.131/192.168.189.131:2181
2023-10-09 21:36:32.777 [main-SendThread(192.168.189.131:2181)] INFO  org.apache.zookeeper.ClientCnxn --- Session establishment complete on server 192.168.189.131/192.168.189.131:2181, session id = 0x100008466b40035, negotiated timeout = 5000
2023-10-09 21:36:32.777 [main] INFO  org.apache.zookeeper.ZooKeeper --- Initiating client connection, connectString=192.168.189.131:2181 sessionTimeout=5000 watcher=com.bubble.lock.zk.DistributedLockByEPHEMERAL$1@2b98378d
2023-10-09 21:36:32.777 [main] INFO  org.apache.zookeeper.ClientCnxnSocket --- jute.maxbuffer value is 1048575 Bytes
2023-10-09 21:36:32.777 [main] INFO  org.apache.zookeeper.ClientCnxn --- zookeeper.request.timeout value is 0. feature enabled=false
---------获取锁---------
生成订单号 20231009093632-2
-------释放锁------
2023-10-09 21:36:42.695 [main-SendThread(192.168.189.131:2181)] INFO  org.apache.zookeeper.ClientCnxn --- Opening socket connection to server 192.168.189.131/192.168.189.131:2181.
2023-10-09 21:36:42.695 [main-SendThread(192.168.189.131:2181)] INFO  org.apache.zookeeper.ClientCnxn --- SASL config status: Will not attempt to authenticate using SASL (unknown error)
2023-10-09 21:36:42.696 [main-SendThread(192.168.189.131:2181)] INFO  org.apache.zookeeper.ClientCnxn --- Socket connection established, initiating session, client: /192.168.189.1:54545, server: 192.168.189.131/192.168.189.131:2181
2023-10-09 21:36:42.700 [main-SendThread(192.168.189.131:2181)] INFO  org.apache.zookeeper.ClientCnxn --- Session establishment complete on server 192.168.189.131/192.168.189.131:2181, session id = 0x100008466b40036, negotiated timeout = 5000
2023-10-09 21:36:42.700 [main] INFO  org.apache.zookeeper.ZooKeeper --- Initiating client connection, connectString=192.168.189.131:2181 sessionTimeout=5000 watcher=com.bubble.lock.zk.DistributedLockByEPHEMERAL$1@1d057a39
2023-10-09 21:36:42.701 [main] INFO  org.apache.zookeeper.ClientCnxnSocket --- jute.maxbuffer value is 1048575 Bytes
2023-10-09 21:36:42.701 [main] INFO  org.apache.zookeeper.ClientCnxn --- zookeeper.request.timeout value is 0. feature enabled=false
---------获取锁---------
生成订单号 20231009093642-3
-------释放锁------
.........//省略
2023-10-09 21:41:09.312 [main-SendThread(192.168.189.131:2181)] INFO  org.apache.zookeeper.ClientCnxn --- Opening socket connection to server 192.168.189.131/192.168.189.131:2181.
2023-10-09 21:41:09.312 [main-SendThread(192.168.189.131:2181)] INFO  org.apache.zookeeper.ClientCnxn --- SASL config status: Will not attempt to authenticate using SASL (unknown error)
2023-10-09 21:41:09.313 [main-SendThread(192.168.189.131:2181)] INFO  org.apache.zookeeper.ClientCnxn --- Socket connection established, initiating session, client: /192.168.189.1:54883, server: 192.168.189.131/192.168.189.131:2181
2023-10-09 21:41:09.318 [main-SendThread(192.168.189.131:2181)] INFO  org.apache.zookeeper.ClientCnxn --- Session establishment complete on server 192.168.189.131/192.168.189.131:2181, session id = 0x100008466b40051, negotiated timeout = 5000
---------获取锁---------
生成订单号 20231009094109-30
-------释放锁------

 

public class DistributedLock implements Lock {

    // zookeeper server列表
    private String connectString = "192.168.189.131:2181";
    // 超时时间
    private int sessionTimeout = 5000;

    private ZooKeeper zk;

    private String rootNode = "locks";
    private String subNode = "seq-";
    // 当前client等待的子节点
    private String waitPath;

    //ZooKeeper连接
    private CountDownLatch connectLatch = new CountDownLatch(1);
    //ZooKeeper节点等待
    private CountDownLatch waitLatch = new CountDownLatch(1);

    // 当前client创建的子节点
    private String currentNode;

    // 和zk服务建立连接,并创建根节点
    public DistributedLock()  {

        try {
            zk = new ZooKeeper(connectString, sessionTimeout, new Watcher() {
                @Override
                public void process(WatchedEvent event) {
                    // 连接建立时, 打开latch, 唤醒wait在该latch上的线程
                    if (event.getState() == Event.KeeperState.SyncConnected) {
                        connectLatch.countDown();
                    }

                    // 发生了waitPath的删除事件
                    if (event.getType() == Event.EventType.NodeDeleted && event.getPath().equals(waitPath)) {
                        waitLatch.countDown();
                    }
                }
            });
            // 等待连接建立
            connectLatch.await();

            //获取根节点状态
            Stat stat = zk.exists("/" + rootNode, false);

            //如果根节点不存在,则创建根节点,根节点类型为永久节点
            if (stat == null) {
                System.out.println("根节点不存在");
                zk.create("/" + rootNode, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }


    }

    // 加锁方法
    @Override
    public void lock() {

        try {
            //在根节点下创建临时顺序节点,返回值为创建的节点路径
            currentNode = zk.create("/" + rootNode + "/" + subNode, null, ZooDefs.Ids.OPEN_ACL_UNSAFE,
                    CreateMode.EPHEMERAL_SEQUENTIAL);

            // wait一小会, 让结果更清晰一些
            Thread.sleep(50);

            // 注意, 没有必要监听"/locks"的子节点的变化情况
            List<String> childrenNodes = zk.getChildren("/" + rootNode, false);

            // 列表中只有一个子节点, 那肯定就是currentNode , 说明client获得锁
            if (childrenNodes.size() == 1) {
                return;
            } else {
                //对根节点下的所有临时顺序节点进行从小到大排序
                Collections.sort(childrenNodes);

                //当前节点名称
                String thisNode = currentNode.substring(("/" + rootNode + "/").length());
                //获取当前节点的位置
                int index = childrenNodes.indexOf(thisNode);

                if (index == -1) {
                    System.out.println("数据异常");
                } else if (index == 0) {
                    // index == 0, 说明thisNode在列表中最小, 当前client获得锁
                    return;
                } else {
                    // 获得排名比currentNode 前1位的节点
                    this.waitPath = "/" + rootNode + "/" + childrenNodes.get(index - 1);
                    // 在waitPath上注册监听器, 当waitPath被删除时, zookeeper会回调监听器的process方法
                    zk.getData(waitPath, true, new Stat());
                    //进入等待锁状态
                    waitLatch.await();

                    return;
                }
            }
        } catch (Exception e) {
        }
    }

    // 解锁方法
    @Override
    public void unlock() {
        try {
            zk.delete(this.currentNode, -1);
        } catch (InterruptedException | KeeperException e) {
            e.printStackTrace();
        }
    }
}

运行结果同上

在实际的开发中,如果需要使用到分布式锁,不建议去自己“重复造轮子”,而建议直接使用Curator客户端中的各种官方实现的分布式锁,例如其中的InterProcessMutex可重入锁。

Curator 分布式锁示例

public class CuratorLockTest implements  Runnable{
    // 创建 CuratorFramework 客户端实例,用于连接 ZooKeeper 服务器
    final  static CuratorFramework client= CuratorFrameworkFactory.builder()
            .connectString("192.168.189.131:2181")// 指定 ZooKeeper 服务器地址和端口
            .retryPolicy(new ExponentialBackoffRetry(100,1)).build();// 设置重试策略
    private OrderCodeGenerator orderCodeGenerator = new OrderCodeGenerator();
    //可重入互斥锁
    final InterProcessMutex lock=new InterProcessMutex(client,"/curator_lock");

    public static void main(String[] args) throws InterruptedException {
        client.start();// 启动 CuratorFramework 客户端
        // 创建多个线程来模拟多个客户端尝试获取锁
        for(int i=0;i<30;i++){
            new Thread(new CuratorLockTest()).start();
        }
        // 主线程等待,以保持示例运行
        Thread.currentThread().join();

    }

    @Override
    public void run() {
        try {
            // 加锁, 尝试获取锁,如果锁不可用,线程将等待
            lock.acquire();
            // 在获取锁后,执行业务代码
            String orderCode = orderCodeGenerator.getOrderCode();
            System.out.println("生成订单号 "+orderCode);
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            try {
                // 释放锁
                lock.release();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}
public class OrderCodeGenerator {
    private static int count = 0;

    /**
     * 生成订单号
     */
    public String getOrderCode(){
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMddhhmmss");
        return simpleDateFormat.format(new Date()) + "-" + ++count;
    }
}

运行结果

......
2023-10-08 23:12:14.075 [main] INFO  o.a.curator.framework.imps.CuratorFrameworkImpl --- Default schema
2023-10-08 23:12:24.260 [main-SendThread(192.168.189.131:2181)] INFO  org.apache.zookeeper.ClientCnxn --- Opening socket connection to server 192.168.189.131/192.168.189.131:2181.
2023-10-08 23:12:24.260 [main-SendThread(192.168.189.131:2181)] INFO  org.apache.zookeeper.ClientCnxn --- SASL config status: Will not attempt to authenticate using SASL (unknown error)
2023-10-08 23:12:24.261 [main-SendThread(192.168.189.131:2181)] INFO  org.apache.zookeeper.ClientCnxn --- Socket connection established, initiating session, client: /192.168.189.1:64155, server: 192.168.189.131/192.168.189.131:2181
2023-10-08 23:12:24.268 [main-SendThread(192.168.189.131:2181)] INFO  org.apache.zookeeper.ClientCnxn --- Session establishment complete on server 192.168.189.131/192.168.189.131:2181, session id = 0x100008466b4001f, negotiated timeout = 40000
2023-10-08 23:12:24.271 [main-EventThread] INFO  o.a.curator.framework.state.ConnectionStateManager --- State change: CONNECTED
2023-10-08 23:12:24.280 [main-EventThread] INFO  org.apache.curator.framework.imps.EnsembleTracker --- New config event received: {}
2023-10-08 23:12:24.280 [main-EventThread] INFO  org.apache.curator.framework.imps.EnsembleTracker --- New config event received: {}
生成订单号 20231008111224-1
生成订单号 20231008111224-2
生成订单号 20231008111224-3
生成订单号 20231008111224-4
生成订单号 20231008111224-5
生成订单号 20231008111224-6
生成订单号 20231008111224-7
生成订单号 20231008111224-8
生成订单号 20231008111224-9
生成订单号 20231008111224-10
生成订单号 20231008111224-11
生成订单号 20231008111224-12
生成订单号 20231008111224-13
生成订单号 20231008111224-14
生成订单号 20231008111224-15
生成订单号 20231008111224-16
生成订单号 20231008111224-17
生成订单号 20231008111224-18
生成订单号 20231008111224-19
生成订单号 20231008111224-20
生成订单号 20231008111224-21
生成订单号 20231008111224-22
生成订单号 20231008111224-23
生成订单号 20231008111224-24
生成订单号 20231008111224-25
生成订单号 20231008111224-26
生成订单号 20231008111224-27
生成订单号 20231008111224-28
生成订单号 20231008111224-29
生成订单号 20231008111224-30

1.5 Curator 可重入分布式锁工作流程

1.6 总结

优点:ZooKeeper分布式锁(如InterProcessMutex),具备高可用、可重入、阻塞锁特性,可解决失效死锁问题,使用起来也较为简单。

缺点:因为需要频繁的创建和删除节点,性能上不如Redis。

在高性能、高并发的应用场景下,不建议使用ZooKeeper的分布式锁。而由于ZooKeeper的高可靠性,因此在并发量不是太高的应用场景中,还是推荐使用ZooKeeper的分布式锁。

2. 基于 Zookeeper 实现服务的注册与发现

基于 ZooKeeper 本身的特性可以实现服务注册中心

2.1 设计思路

2.2 Zookeeper 实现注册中心的优缺点

优点:

  • 高可用性:ZooKeeper是一个高可用的分布式系统,可以通过配置多个服务器实例来提供容错能力。如果其中一个实例出现故障,其他实例仍然可以继续提供服务。
  • 强一致性:ZooKeeper保证了数据的强一致性。当一个更新操作完成时,所有的服务器都将具有相同的数据视图。这使得ZooKeeper非常适合作为服务注册中心,因为可以确保所有客户端看到的服务状态是一致的。
  • 实时性:ZooKeeper的监视器(Watcher)机制允许客户端监听节点的变化。当服务提供者的状态发生变化时(例如,上线或下线),客户端会实时收到通知。这使得服务消费者能够快速响应服务的变化,从而实现动态服务发现。

缺点:

  • 性能限制:ZooKeeper的性能可能不如一些专为服务注册中心设计的解决方案,如nacos或Consul。尤其是在大量的读写操作或大规模集群的情况下,ZooKeeper可能会遇到性能瓶颈。

2.3 整合Spring Cloud Zookeeper实现微服务注册中心

官网:Spring Cloud Zookeeper

第一步:在父pom文件中指定Spring Cloud版本

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>2.3.2.RELEASE</version>
  <relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
  <java.version>1.8</java.version>
  <spring-cloud.version>Hoxton.SR8</spring-cloud.version>
</properties>
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-dependencies</artifactId>
      <version>${spring-cloud.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

注意: springboot和springcloud的版本兼容问题

第二步:微服务pom文件中引入Spring Cloud Zookeeper注册中心依赖

<!-- zookeeper服务注册与发现 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-zookeeper-discovery</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.apache.zookeeper</groupId>
            <artifactId>zookeeper</artifactId>
        </exclusion>
    </exclusions>
</dependency>

<!-- zookeeper client -->
<dependency>
    <groupId>org.apache.zookeeper</groupId>
    <artifactId>zookeeper</artifactId>
    <version>3.8.0</version>
</dependency>

注意: zookeeper客户端依赖和zookeeper sever的版本兼容问题

Spring Cloud整合Zookeeper注册中心核心源码入口: ZookeeperDiscoveryClientConfiguration

第三步: 微服务配置文件application.yml中配置zookeeper注册中心地址

spring:
  cloud:
    zookeeper:    
      connect-string: localhost:2181
      discovery:
        instance-host: 127.0.0.1

注册到zookeeper的服务实例元数据信息如下:

注意:如果address有问题,会出现找不到服务的情况,可以通过instance-host配置指定

第四步:整合feign进行服务调用

@RequestMapping(value = "/findOrderByUserId/{id}")
public R  findOrderByUserId(@PathVariable("id") Integer id) {
    log.info("根据userId:"+id+"查询订单信息");
    //feign调用   
    R result = orderFeignService.findOrderByUserId(id);
    return result;
}

测试:http://localhost:8040/user/findOrderByUserId/1

 

  • 22
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值