使用reids实现分布式锁及高并发下解决reids数据不一致问题

1. redis数据问题

上篇博客演示了,在MySQL在高并发下数据异常和解决方案,这里解决redis数据问题。

1.1 引入问题

  • 同样以上次的订单为案例,演示高并发下库存问题。在redis设置一个string类型的库存剩余。
    在这里插入图片描述

  • 引入redis依赖

            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-data-redis</artifactId>
            </dependency>
    
  • 这里因为都是默认:地址和端口:localhost:6379,用户名密码都为空,数据库为0,不需要进行额外的配置。

  • 修改service方法:

        @Resource
     	private StringRedisTemplate stringRedisTemplate;
     
        @Override
        public Integer deStock() {
            String stock = stringRedisTemplate.opsForValue().get("stock");
    
            if (!Objects.isNull(stock) && stock.length() != 0){
                int stock_num = Integer.parseInt(stock);
                if (stock_num >= 1){
                    stringRedisTemplate.opsForValue().set("stock",String.valueOf(--stock_num));
                }
            }
            return 0;
        }
    
  • 使用Jmeter进行压力测试(10个用户,每个用户10次)。
    在这里插入图片描述

  • 发现这里数据出现了异常。

1.2 redis乐观锁

  • 在redis客户端中,使用下方:

    1. watch:监听指定一个或多个键的值,在当前事务执行之前被监听的键发生了变化,那么就取消执行。
    2. multi:开启事务;
    3. exec:执行事务;
  • 修改service方法:

        @Override
        public Integer deStock() {
            // 监听k
            stringRedisTemplate.execute(new SessionCallback<Object>() {
                @SneakyThrows
                @Override
                public Object execute(RedisOperations  operations) throws DataAccessException {
                    // 开启 watch
                    operations.watch("stock");
                    String stock = operations.opsForValue().get("stock").toString();
                    if (!Objects.isNull(stock) && stock.length() != 0){
                        int stock_num = Integer.parseInt(stock);
                        if (stock_num >= 1){
                            // 开启事务
                            operations.multi();
                            operations.opsForValue().set("stock",String.valueOf(--stock_num));
                            // 执行事务
                            List exec = operations.exec();
                            // 如果执行失败,递归调用
                            if (exec == null || exec.isEmpty()){
                                Thread.sleep(40);
                                deStock();
                            }
                        }
                    }
                    return null;
                }
            });
            return 0;
        }
    
  • 使用Jmeter测试,发现可以解决并发下数据异常问题。
    在这里插入图片描述

  • 缺陷分析:

    1. 效率过低;
    2. 由于服务性能问题,导致性能失效。

2. redis分布式锁

可以跨服务、跨进程、跨服务器的来使用。在分布式场景下,可以对资源进行加锁。

2.1 分析redis分布式锁

reids中有setnx k v命令,在执行时,如果当前k不存在就返回1,存在就返回0,可以使用当前的k来作为分布式锁,返回1代表获取锁,返回0代表获取锁失败。

  • 操作过程
    1. 加锁:setnx k v
    2. 解锁:del k
    3. 失败重试:递归。

2.2 搭建微服务项目

  • 技术选型:Eureka、OpenFeign;
  • 就是一个简单的微服务项目,使用OpenFeign来做负载均衡,eureka做注册中心。方便测试微服务下分布式锁的问题。
  • 就不写代码了,简单易懂。

2.3 实现分布式锁

  • 就是简单的使用StringRedisTemplate的方法去调用redis的setnx命令。

        @Override
        public String deStock(){
            // 加锁
            Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111");
            // 获取锁失败,递归重试
            if (Boolean.FALSE.equals(lock)){
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                deStock();
            }else {
                try {
                    String stock = stringRedisTemplate.opsForValue().get("stock");
                    if (Objects.nonNull(stock) && stock.length() != 0){
                        int stock_num = Integer.parseInt(stock);
                        if (stock_num > 0){
                            stringRedisTemplate.opsForValue().set("stock", String.valueOf(--stock_num));
                        }
                    }
                }finally {
                    // 释放锁
                    stringRedisTemplate.delete("lock");
                }
            }
            return stringRedisTemplate.opsForValue().get("stock");
        }
    
  • 优化: 将递归重试改为循环重试,节省栈内存空间。

        public String deStock(){
            // 加锁
            while (Boolean.FALSE.equals(stringRedisTemplate.opsForValue().setIfAbsent("lock", "111"))){
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            try {
                String stock = stringRedisTemplate.opsForValue().get("stock");
                if (Objects.nonNull(stock) && stock.length() != 0){
                    int stock_num = Integer.parseInt(stock);
                    if (stock_num > 0){
                        stringRedisTemplate.opsForValue().set("stock", String.valueOf(--stock_num));
                    }
                }
            }finally {
                // 释放锁
                stringRedisTemplate.delete("lock");
            }
            return stringRedisTemplate.opsForValue().get("stock");
        }
    
  • 问题分析: 如果服务从redis中获取到锁之后,redis立马宕机,当前服务就可能回触发死锁问题。可以给锁添加过期时间来解决。

  • 优化:

        public String deStock(){
            // 加锁并给锁设置过期时间避免死锁问题。
            while (Boolean.FALSE.equals(
                    stringRedisTemplate.opsForValue().setIfAbsent("lock", "111", 3, TimeUnit.SECONDS))){
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            try {
                String stock = stringRedisTemplate.opsForValue().get("stock");
                if (Objects.nonNull(stock) && stock.length() != 0){
                    int stock_num = Integer.parseInt(stock);
                    if (stock_num > 0){
                        stringRedisTemplate.opsForValue().set("stock", String.valueOf(--stock_num));
                    }
                }
            }finally {
                // 释放锁
                stringRedisTemplate.delete("lock");
            }
            return stringRedisTemplate.opsForValue().get("stock");
        }
    
  • 问题分析: 如果每个锁都设置相同的key,那么岂不是其他的方法也可以释放我的锁,这怎么行?
    我的就是我的!

  • 优化: 设置UUID来解决

        @Override
        public String deStock(){
            // 设置唯一标识UUID
            String uuid = UUID.randomUUID().toString();
            // 加锁
            while (Boolean.FALSE.equals(
                    stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS))){
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            try {
                String stock = stringRedisTemplate.opsForValue().get("stock");
                if (Objects.nonNull(stock) && stock.length() != 0){
                    int stock_num = Integer.parseInt(stock);
                    if (stock_num > 0){
                        stringRedisTemplate.opsForValue().set("stock", String.valueOf(--stock_num));
                    }
                }
            }finally {
                // 判断是不是自己的锁再解锁,释放锁
                if (uuid.equals(stringRedisTemplate.opsForValue().get("lock"))){
                    stringRedisTemplate.delete("lock");
                }
            }
            return stringRedisTemplate.opsForValue().get("stock");
        }
    
  • 问题分析: 当第一个请求执行时间超过了redis中的过期时间,即当前请求的锁没有了,那么他再执行删除时就会删除后面请求的锁,导致后面的请求无锁可用,那么就回造成数据异常问题。如果我们能够保证这条锁命令(判断和删除)的原子性就可以解决当前问题,但显然无法实现一条语句即判断又删除。请看下部分,lua脚本。

2.4 lua脚本

  • redis 中支持lua脚本,可以一次性发送多个指令给redis,而且redis是单线程执行了,在执行指令过程中不可能会被打断,这样就满足了操作的原子性。

  • 写lua脚本解决分布式锁原子性问题

    -- 判断 k1 获取的值是否等于 v1
    -- 也就是传入锁名称和uuid,根据锁名称获取uuid,判断和传入的uuid是否相等
    if redis.call('get', KEYS[1]) == ARGV[1]
    then
        return redis.call('del', KEYS[1]) -- 相等删除key(释放锁)
    else
        return 0
    end
    
  • 修改Java方法

        @Override
        public String deStock(){
            // 设置唯一标识UUID
            String uuid = UUID.randomUUID().toString();
            // 加锁
            while (Boolean.FALSE.equals(
                    stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS))){
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            try {
                String stock = stringRedisTemplate.opsForValue().get("stock");
                if (Objects.nonNull(stock) && stock.length() != 0){
                    int stock_num = Integer.parseInt(stock);
                    if (stock_num > 0){
                        stringRedisTemplate.opsForValue().set("stock", String.valueOf(--stock_num));
                    }
                }
            }finally {
                try {
                    // 读取lua脚本
                    String script = new String(Files.readAllBytes(Paths.get("lock.lua")));
    
                    // 使用lua脚本保证原子性
                    stringRedisTemplate.execute(
                            new DefaultRedisScript<>(script, Boolean.class),
                            Collections.singletonList("lock"), uuid);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            return stringRedisTemplate.opsForValue().get("stock");
        }
    

2.5 可重入锁分析

  • 假设一个请求中有两个或者多个方法都需要获取锁之后才能够执行,但是我们在执行了第一个方法之后将锁释放掉了,那么就无法执行下面的方法,一直重试直到获取到锁。由于两个方法之间互相持有对方的锁无法往下进行,就有可能回出现死锁问题。
  • 所谓的可重入锁可以获取一把锁之后可以执行多个方法。Java中的ReentrantLock就是一把可重入锁。
2.5.1 ReentrantLock
  • 构造函数:默认是一个非公平锁,只有在传入true时才会使用公平锁。非公平锁就是不按照进入到工作队列的顺序来执行。

        public ReentrantLock() {
            sync = new NonfairSync();
        }
        
        public ReentrantLock(boolean fair) {
            sync = fair ? new FairSync() : new NonfairSync();
        }
    
    
  • 以非公平锁为例,查看可重入锁的加锁流程

    1. ReentrantLock.lock()
    	// 根据构造的sync对象是公平锁还是非公平锁调用指定方法
        public void lock() {
            sync.lock();
        }
    
    1. NonfairSync.lock()
      // 加锁方法
            final void lock() {
            // 使用unSafe类来实现原子性加锁,加锁成功将state设置为1,并且记录当前线程为有锁线程。
                if (compareAndSetState(0, 1))
                    setExclusiveOwnerThread(Thread.currentThread());
                else
                    acquire(1);
            }
    
    1. AQS.acquire(1)
        public final void acquire(int arg) {
        	// 
            if (!tryAcquire(arg) &&
                acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
                selfInterrupt();
        }
    
    1. NonfairSync.tryAcquire(1)
            protected final boolean tryAcquire(int acquires) {
                return nonfairTryAcquire(acquires);
            }
    
    1. Sync.nonfairTryAcquire(1)
            final boolean nonfairTryAcquire(int acquires) {
            	// 获取当前线程
                final Thread current = Thread.currentThread();
                // 获取state值
                int c = getState();
                if (c == 0) {
                // 如果等于0 就原子性的设置为1
                    if (compareAndSetState(0, acquires)) {
                    // 记录当前线程为有锁线程
                        setExclusiveOwnerThread(current);
                        return true;
                    }
                }
                // 如果当前线程就是有锁的线程
                else if (current == getExclusiveOwnerThread()) {
                	// 对齐加一
                    int nextc = c + acquires;
                    if (nextc < 0) // 值异常抛出异常
                        throw new Error("Maximum lock count exceeded");
                     // 设置新的state
                    setState(nextc);
                    return true;
                }
                return false;
            }
    
  • 简要说明加锁流程:

    1. CAS获取锁,如果没有线程占用即state == 0,加锁成功就记录当前线程为有锁线程;
    2. 如果state != 0,说明锁已经被占用,判断当前线程是否为有锁线程,如果是就重入(state + 1);
    3. 如果加锁失败:会将当前线程放入到队列中等待。
  • 以非公平锁为例,查看可重入锁的解锁流程

    1. ReentrantLock.unlock()
        public void unlock() {
            sync.release(1);
        }
    
    1. AQS.release(1)
        public final boolean release(int arg) {
            if (tryRelease(arg)) {
                Node h = head;
                if (h != null && h.waitStatus != 0)
                    unparkSuccessor(h);
                return true;
            }
            return false;
        }
    
    1. Sync.tryRelease(1)
            protected final boolean tryRelease(int releases) {
            	// state - 1
                int c = getState() - releases;
                // 判断当前线程是否为有锁线程
                if (Thread.currentThread() != getExclusiveOwnerThread())
                    throw new IllegalMonitorStateException(); // 不是抛出异常
                boolean free = false;
                if (c == 0) { // 当state值减为0时,代表当前线程可以直接释放锁
                    free = true;
                    setExclusiveOwnerThread(null); // 设置有锁线程为空
                }
                // 重新设置state值,如果为0就标识其他线程可以获取锁了。
                //	如果不为0,那么就当前线程还是有锁的线程,只是state值减一。
                setState(c);
                return free;
            }
    
2.5.2 redis实现
  • 数据模型选择Hash模型,让key作为hash的key,uuid作为内部的key,其对应的value为state的值。使用lua脚本来保证原子性实现。

  • 加锁过程分析:

    1. 判断锁是否存在,不存在直接获取锁(存入hash到redis);
    2. 如果锁存在,就使用uuid判断是否为自己的锁,是就重入,state加一;
    3. 如果锁存在,但不是自己的锁,就进行重试;
  • 加锁脚本:

    -- 可重入锁 加锁
    
    -- KEYS[1]:锁名称
    -- ARGV[1]:uuid;ARGV[2]:过期时间
    if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1
    then
        -- 这里有一个细节:使用 hincrby 和 hset 都可以创建hash类型数据
        redis.call('hincrby', KEYS[1], ARGV[1] ,1)
        redis.call('expire', KEYS[1], ARGV[2])
        return 1
    else
        return 0
    end
    
  • 解锁过程分析:

    1. 判断自己的锁是否存在,不存在返回空;
    2. 如果子的锁存在,就减一操作;
    3. 减一之后为0,删除当前值,返回1表示解锁成功;
    4. 减一后不为0,返回0;
  • 解锁脚本:

    -- 可重入锁 解锁
    
    -- KEYS[1]:锁名称
    -- ARGV[1]:uuid
    if redis.call('hexists', KEYS[1], ARGV[1]) == 0 -- 判断锁是否存在
    then
        return nil -- 不存在就是恶意释放,抛出异常
    elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 -- 判断减一后是否为 0
    then
        return redis.call('del', KEYS[1]) -- 为0直接删除key,即释放锁
    else
        return 0 -- 不为0,不做额外处理
    end
    
  • 代码实现:工厂类返回对应的锁类

    @Component
    public class ReentryLockFactory {
    
        @Resource
        private StringRedisTemplate stringRedisTemplate;
    
        private String uuid;
    	
    	// 保证一个服务中只有一个uuid
        public ReentryLockFactory() {
            this.uuid = UUID.randomUUID().toString();
        }
    
        public RedisReentryLock getRedisLock(String lockName){
            return new RedisReentryLock(stringRedisTemplate, lockName ,uuid);
        }
    
    }
    
  • redis锁类:

    public class RedisReentryLock implements Lock {
    
        private StringRedisTemplate stringRedisTemplate;
    
        private String lockName;
    
        private String uuid;
    
        private Long expire = 30L;
    
        public RedisReentryLock(StringRedisTemplate stringRedisTemplate, String lockName, String uuid) {
            this.stringRedisTemplate = stringRedisTemplate;
            this.lockName = lockName;
            // uuid用来标识服务,线程id用来标识线程
            this.uuid = uuid + ":" + Thread.currentThread().getId();
        }
    
        @Override
        public void lock() {
            this.tryLock();
        }
    
    
        @Override
        public boolean tryLock() {
            try {
                return this.tryLock(-1L, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return false;
        }
    
        /**
         * 实现加锁方法
         * @param time
         * @param unit
         * @return
         * @throws InterruptedException
         */
        @Override
        public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
            if (time != -1){ // 设置过期时间
                expire = unit.toSeconds(time);
            }
            try {
                // 获取加锁lua脚本
                Path path = Paths.get("D:\\NewJava\\JavaFramework\\distributed-lock\\distributed-parent\\distributed-service8001\\src\\main\\resources\\reentry-lock.lua");
                String script = new String(Files.readAllBytes(path));
                while (Boolean.FALSE.equals(stringRedisTemplate.execute(
                        new DefaultRedisScript<>(script, Boolean.class),
                        Collections.singletonList(lockName),
                        uuid, String.valueOf(expire)))){
                    Thread.sleep(50);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
            return false;
        }
    
        /**
         * 实现解锁方法
         */
        @Override
        public void unlock() {
            try {
                // 获取解锁lua脚本
                Path path = Paths.get("D:\\NewJava\\JavaFramework\\distributed-lock\\distributed-parent\\distributed-service8001\\src\\main\\resources\\reentry-unlock.lua");
                String script = new String(Files.readAllBytes(path));
                Long flag = stringRedisTemplate.execute(
                        new DefaultRedisScript<>(script, Long.class),
                        Collections.singletonList(lockName),
                        uuid, String.valueOf(expire));
                if (Objects.isNull(flag)){
                    throw new IllegalStateException("this lock not belong to you");
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    
        @Override
        public Condition newCondition() {
            return null;
        }
    
        @Override
        public void lockInterruptibly() throws InterruptedException {
    
        }
    }
    
  • service方法:

        /**
         * lua脚本实现可重入锁
         * @return
         */
        @Override
        public String deStock(){
            RedisReentryLock redisLock = reentryLockFactory.getRedisLock("lock");
            redisLock.lock();
            try {
                String stock = stringRedisTemplate.opsForValue().get("stock");
                if (Objects.nonNull(stock) && stock.length() != 0){
                    int stock_mum = Integer.parseInt(stock);
                    if (stock_mum > 0){
                        stringRedisTemplate.opsForValue().set("stock", String.valueOf(--stock_mum));
                    }
                }
            }finally {
                redisLock.unlock();
            }
            return stringRedisTemplate.opsForValue().get("stock");
        }
    }
    

2.6 自动续期

保证加锁方法执行完毕之前锁不会过期。

  • 思路分析:使用Java自带的Timer定时器来定时执行方法,使用lua脚本来保证更新时间的原子性。

  • lua脚本:

    -- KEYS[1]:锁名称
    -- ARGV[1]:uuid;ARGV[2]:过期时间
    if redis.call('hexists', KEYS[1], ARGV[1]) == 1
    then
        return redis.call('expire', KEYS[1], ARGV[2])
    else
        return 0
    end
    
  • 修改RedisReentryLock

    public class RedisReentryLock implements Lock {
    
        private StringRedisTemplate stringRedisTemplate;
    
        private String lockName;
    
        private String uuid;
    
        private Long expire = 30L;
    
        public RedisReentryLock(StringRedisTemplate stringRedisTemplate, String lockName, String uuid) {
            this.stringRedisTemplate = stringRedisTemplate;
            this.lockName = lockName;
            // uuid用来标识服务,线程id用来标识线程
            this.uuid = uuid + ":" + Thread.currentThread().getId();
        }
    
        @Override
        public void lock() {
            this.tryLock();
        }
    
    
        @Override
        public boolean tryLock() {
            try {
                return this.tryLock(-1L, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return false;
        }
    
        /**
         * 实现加锁方法
         * @param time
         * @param unit
         * @return
         * @throws InterruptedException
         */
        @Override
        public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
            if (time != -1){ // 设置过期时间
                expire = unit.toSeconds(time);
            }
            try {
                // 获取加锁lua脚本
                Path path = Paths.get("D:\\NewJava\\JavaFramework\\distributed-lock\\distributed-parent\\distributed-service8001\\src\\main\\resources\\reentry-lock.lua");
                String script = new String(Files.readAllBytes(path));
                while (Boolean.FALSE.equals(stringRedisTemplate.execute(
                        new DefaultRedisScript<>(script, Boolean.class),
                        Collections.singletonList(lockName),
                        uuid, String.valueOf(expire)))){
                    Thread.sleep(50);
                }
                // 开启定时器自动续期
                renewExpire();
            } catch (IOException e) {
                e.printStackTrace();
            }
            return false;
        }
    
        /**
         * 实现解锁方法
         */
        @Override
        public void unlock() {
            try {
                // 获取解锁lua脚本
                Path path = Paths.get("D:\\NewJava\\JavaFramework\\distributed-lock\\distributed-parent\\distributed-service8001\\src\\main\\resources\\reentry-unlock.lua");
                String script = new String(Files.readAllBytes(path));
                Long flag = stringRedisTemplate.execute(
                        new DefaultRedisScript<>(script, Long.class),
                        Collections.singletonList(lockName),
                        uuid, String.valueOf(expire));
                if (Objects.isNull(flag)){
                    throw new IllegalStateException("this lock not belong to you");
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    
        @Override
        public Condition newCondition() {
            return null;
        }
    
        @Override
        public void lockInterruptibly() throws InterruptedException {
    
        }
    
        /**
         * 自动续期方法
         */
        private void renewExpire(){
            Path path = Paths.get("D:\\NewJava\\JavaFramework\\distributed-lock\\distributed-parent\\distributed-service8001\\src\\main\\resources\\auto-renewal.lua");
            try {
                String script = new String(Files.readAllBytes(path));
                new Timer().schedule(new TimerTask() {
                    @Override
                    public void run() {
                        // 如果续期成功就再次执行该方法,直到续期失败
                        if (Boolean.TRUE.equals(stringRedisTemplate.execute(
                                new DefaultRedisScript<>(script, Boolean.class),
                                Collections.singletonList(lockName),
                                uuid, String.valueOf(expire)))) {
                            renewExpire();
                        }
                    }
                }, this.expire * 1000 / 3); // 只执行一次定时任务。
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    
  • 问题分析:当处于redis集群环境时,当客户端A从master中获取锁,但是还没同步slave中,master就挂掉了,此时slave被选为master,那么客户端B在进入方法时是没有锁的,这就导致了锁机制失效。

3. 红锁算法

如下图中,有五个单机的redis服务器,没有任何的关联机制。

  • 加锁过程:
    1. 程序获取的客户端当前时间;
    2. 程序使用相同的键值对依次去各个redis实例中获取锁;
    3. 每个redis服务都有超市时间,如果从本台获取超时,那么会去下一台redis服务中去获取,避免被一个宕机的redis节点阻塞
    4. 计算获取锁的消耗时间:消耗时间 = 客户端当前时间 - 获取每个节点redis锁消耗的总时间
    5. 只有获取锁的消耗时间小于锁定时间切半数以上的节点都获取锁成功,才会任务获取锁成功;
    6. 计算剩余锁定时间:锁定时间 = 锁定时间 - 消耗时间
    7. 如果获取失败:对每个redis节点都释放锁(不需考虑真正有无锁)。
  • 解锁过程:
    1. 对每个节点都删除锁键值对即可;
    2. 也可以使用lua脚本来实现原子性的删除。
      在这里插入图片描述
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值