redis--20.2--锁--分布式锁(Redisson,RLock)

redis–20.2–锁–分布式锁(Redisson,RLock)


代码位置
https://gitee.com/DanShenGuiZu/learnDemo/tree/master/redis-learn/redisson

 
# 参考资料
https://blog.csdn.net/u011397981/article/details/130613740
 

1、RLock常用方法

1.1、常用方法

//获取RLock对象:
RLock lock = redisson.getLock("myLock");


//强制对name进行解锁,即此锁不论是那个线程持有都会进行解锁
lock.forceUnlock();


//当前线程对该锁的保持次数
int holdCount = lock.getHoldCount();

//该锁的剩余时间
long l = lock.remainTimeToLive();

 
/**
* 加锁 锁的有效期默认30秒
*/
void lock();


/**
* tryLock()方法是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false .
*/
boolean tryLock();


/**
* tryLock(long waitTime, TimeUnit unit)方法和tryLock()方法是类似的,只不过区别在于这个方法在拿不到锁时会等待一定的时间,
* 在时间期限之内如果还拿不到锁,就返回false。如果如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。
*
* @param waitTime 等待时间
* @param unit 时间单位 小时、分、秒、毫秒等
*/
boolean tryLock(long waitTime, TimeUnit unit) throws InterruptedException;


/**
* 解锁,如果锁不是该线程持有则会抛出异常
*/
void unlock();


/**
* 中断锁 表示该锁可以被中断 假如A和B同时调这个方法,A获取锁,B为获取锁,那么B线程可以通过
* Thread.currentThread().interrupt(); 方法真正中断该线程
*/
void lockInterruptibly();


/**
* 加锁 上面是默认30秒这里可以手动设置锁的有效时间
*
* @param leaseTime 锁有效时间
* @param unit      时间单位 小时、分、秒、毫秒等
*/
void lock(long leaseTime, TimeUnit unit);


/**
* 这里比上面多一个参数,多添加一个锁的有效时间
*
* @param waitTime  等待时间
* @param leaseTime 锁有效时间
* @param unit      时间单位 小时、分、秒、毫秒等
*/
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;


/**
* 检查该锁是否被任何线程所持有,如果被持有返回True
*/
boolean isLocked();


/**
* 检查当前线程是否获得此锁(这个和上面的区别就是该方法可以判断是否当前线程获得此锁,而不是此锁是否被线程占有)
* 这个比上面那个实用
*/
boolean isHeldByCurrentThread();


/**
* 中断锁 和上面中断锁差不多,只是这里如果获得锁成功,添加锁的有效时间
* @param leaseTime  锁有效时间
* @param unit       时间单位 小时、分、秒、毫秒等
*/
void lockInterruptibly(long leaseTime, TimeUnit unit);  

1.2、测试

@RequestMapping("rLockTest")
public Integer rLockTest(@RequestParam("proId") String proId) {
    //获取分布式锁
    RLock lock = redissonClient.getLock(proId);

    lock.lock();//获取锁

    //---------业务代码--begin
    ++num;
    logger.debug("当前数字:" + num);
    //---------业务代码--end

    lock.unlock();//释放锁
    return num;

}

2、Redisson原理分析

在这里插入图片描述

2.1、加锁机制

  • 线程去获取锁,获取成功: 执行lua脚本(原子性),保存数据到redis数据库。
  • 线程去获取锁,获取失败: 一直通过while循环尝试获取锁,获取成功后,执行lua脚本,保存数据到redis数据库。

2.2、watch dog自动延期机制

当业务执行的时间 大于 锁的过期时间,那么启动watch dog后台线程,不断的延长锁key的过期时间。

2.3、可重入加锁机制

好处:让相同线程不需要在等待锁,而是可以直接进行相应操作。提高并发能力

2.3.1、锁结构

  1. Redis存储锁的数据类型是 Hash类型
  2. Hash数据类型的key值包含了当前线程信息。

在这里插入图片描述

这里表面数据类型是Hash类型,Hash类型相当于我们java的 <key,<key1,value>> 类型,这里key是指 redisson
它的有效期还有9秒,我们再来看里们的key1值为078e44a3-5f95-4e24-b6aa-80684655a15a:45它的组成是:guid + 当前线程的ID。后面的value是就和可重入加锁有关。

举图说明

在这里插入图片描述

3、RLock分布式锁的缺点

3.1、发生场景

  1. 哨兵模式
  2. 主从模式

在主从切换的时候,可能多个线程都持有锁

3.1.1、举例:哨兵模式下

客户端1 对某个master节点写入了redisson锁,此时会异步复制给对应的 slave节点。但是这个过程中一旦发生 master节点宕机,主从切换,slave节点从变为了 master节点。

这时 客户端2 来尝试加锁的时候,在新的master节点上也能加锁,此时就会导致多个客户端对同一个分布式锁完成了加锁。

这时系统在业务语义上一定会出现问题,导致各种脏数据的产生。

3.2、解决方案

Redisson 提供了实现了redlock算法的 RedissonRedLock,RedissonRedLock 真正解决了单点失败的问题,代价是需要额外的为 RedissonRedLock 搭建Redis环境。

4、加锁/解锁 Lua脚本

4.1、加锁Lua脚本

4.1.1、脚本入参

参数示例值含义
KEY 个数1KEY个数
KEYS[1]my_first_lock_name锁名
ARGV[1]60000持有锁的有效时间:毫秒
ARGV[2]58c62432-bb74-4d14-8a00-9908cc8b828f:1唯一标识:获取锁时set的唯一值,实现上为redisson客户端ID(UUID)+线程ID

4.1.2、脚本内容

-- 若锁不存在:则新增锁,并设置锁重入计数为1、并设置锁过期时间
if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hset', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
 
-- 若锁存在,且唯一标识也匹配:则表明当前加锁请求为锁重入请求,故锁重入计数+1,并再次设置锁过期时间
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
 
-- 若锁存在,但唯一标识不匹配:表明锁是被其他线程占用,当前线程无权解他人的锁,直接返回锁剩余过期时间
return redis.call('pttl', KEYS[1]);

  • 返回nil:表示加锁成功
  • 返回剩余过期时间:表示加锁失败

在这里插入图片描述

4.2、解锁Lua脚本

4.2.1、脚本入参

参数示例值含义
KEY 个数2KEY个数
KEYS[1]my_first_lock_name锁名
KEYS[2]redisson_lock__channel:{my_first_lock_name}解锁消息PubSub频道
ARGV[1]0redisson定义0表示解锁消息
ARGV[2]30000设置锁的过期时间;默认值30秒
ARGV[3]58c62432-bb74-4d14-8a00-9908cc8b828f:1唯一标识;同加锁流程

4.2.2、脚本内容

-- 若锁不存在:则直接广播解锁消息,并返回1
if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('publish', KEYS[2], ARGV[1]);
    return 1; 
end;
 
-- 若锁存在,但唯一标识不匹配:则表明锁被其他线程占用,当前线程不允许解锁其他线程持有的锁
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
    return nil;
end; 
 
-- 若锁存在,且唯一标识匹配:则先将锁重入计数减1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); 
if (counter > 0) then 
    -- 锁重入计数减1后还大于0:表明当前线程持有的锁还有重入,不能进行锁删除操作,但可以友好地帮忙设置下过期时期
    redis.call('pexpire', KEYS[1], ARGV[2]); 
    return 0; 
else 
    -- 锁重入计数已为0:间接表明锁已释放了。直接删除掉锁,并广播解锁消息,去唤醒那些争抢过锁但还处于阻塞中的线程
    redis.call('del', KEYS[1]); 
    redis.call('publish', KEYS[2], ARGV[1]); 
    return 1;
end;
 
return nil;


  • 广播解锁消息:是为了通知其他争抢锁阻塞住的线程,从阻塞中解除,并再次去争抢锁。
  • 返回值0:表示 当前线程是重入锁,重入锁次数-1,但还不为0。
  • 返回值1:表示 当前线程真正释放了锁。
  • 返回nil:表示 当前线程不允许解锁其他线程持有的锁。

在这里插入图片描述

5、源码解读

5.1、void lock()方法


@Override
public void lock() {
    try {
        lockInterruptibly();
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

本质是调用lockInterruptibly方法(中断锁,表示可以被中断),捕获异常后用 Thread.currentThread().interrupt()来真正中断当前线程,其实它们是搭配一起使用的。

5.2、lockInterruptibly()

/**
 * 1、带上默认值调另一个中断锁方法
 */
@Override
public void lockInterruptibly() throws InterruptedException {
    lockInterruptibly(-1, null);
}
/**
 * 2、另一个中断锁的方法
 */
void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException 
/**
 * 3、这里已经设置了锁的有效时间默认为30秒  (commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout()=30)
 */
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
/**
 * 4、最后通过lua脚本访问Redis,保证操作的原子性
 */
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    internalLockLeaseTime = unit.toMillis(leaseTime);

	//加锁的lua脚本
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
            "if (redis.call('exists', KEYS[1]) == 0) then " +
                    "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return nil; " +
                    "end; " +
                    "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return nil; " +
                    "end; " +
                    "return redis.call('pttl', KEYS[1]);",
            Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}


5.3、tryLock(long waitTime, long leaseTime, TimeUnit unit)

 @Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
	 // 获取锁能容忍的最大等待时长
    long time = unit.toMillis(waitTime);
    long current = System.currentTimeMillis();
    long threadId = Thread.currentThread().getId();
	
	
	//【核心点1】尝试获取锁,若返回值为null,则表示已获取到锁
    Long ttl = tryAcquire(leaseTime, unit, threadId);
	
    //返回值为null,则表示已获取到锁,直接结束
    if (ttl == null) {
        return true;
    }
    // 可以容忍的等待时长=获取锁能容忍的最大等待时长 - 执行完上述操作流逝的时间
	//可以容忍的等待时长<0 ,返回false
    time -= System.currentTimeMillis() - current;
    if (time <= 0) {
        acquireFailed(threadId);
        return false;
    }

	
	 // 【核心点2】订阅解锁消息,见org.redisson.pubsub.LockPubSub#onMessage
     /**
     * 4.订阅锁释放事件,并通过await方法阻塞等待锁释放,有效的解决了无效的锁申请浪费资源的问题:
     * 基于信息量,当锁被其它资源占用时,当前线程通过 Redis 的 channel 订阅锁的释放事件,一旦锁释放会发消息通知待等待的线程进行竞争
     * 当 this.await返回false,说明等待时间已经超出获取锁最大等待时间,取消订阅并返回获取锁失败
     * 当 this.await返回true,进入循环尝试获取锁
     */
	 
    // 订阅监听redis消息,并且创建RedissonLockEntry,其中RedissonLockEntry中比较关键的是一个 Semaphore属性对象,用来控制本地的锁请求的信号量同步,返回的是netty框架的Future实现。
    final RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
   
   //await方法内部是用CountDownLatch来实现阻塞,获取subscribe异步执行的结果(应用了Netty 的 Future)
	//  阻塞等待subscribe的future的结果对象,如果subscribe方法调用超过了time,说明已经超过了客户端设置的最大wait time,则直接返回false,取消订阅,不再继续申请锁了。
    //  只有await返回true,才进入循环尝试获取锁
    if (!await(subscribeFuture, time, TimeUnit.MILLISECONDS)) {
        if (!subscribeFuture.cancel(false)) {
            subscribeFuture.addListener(new FutureListener<RedissonLockEntry>() {
                @Override
                public void operationComplete(Future<RedissonLockEntry> future) throws Exception {
                    if (subscribeFuture.isSuccess()) {
                        unsubscribe(subscribeFuture, threadId);
                    }
                }
            });
        }
        acquireFailed(threadId);
        return false;
    }
 
	// 订阅成功
	try {
		// 还可以容忍的等待时长=获取锁能容忍的最大等待时长 - 执行完上述操作流逝的时间
		time -= (System.currentTimeMillis() - current);
		if (time <= 0) {
			// 超出可容忍的等待时长,直接返回获取锁失败
			acquireFailed(threadId);
			return false;
		}
		//4、如果没有超过尝试获取锁的等待时间,那么通过While一直获取锁。最终只会有两种结果
		//1)、在等待时间内获取锁成功 返回true。
		//2)、等待时间结束了还没有获取到锁那么返回false。
		while (true) {
			long currentTime = System.currentTimeMillis();
			// 尝试获取锁;如果锁被其他线程占用,就返回锁剩余过期时间【同上】
			ttl = tryAcquire(leaseTime, unit, threadId);
			 // 获取锁成功
			if (ttl == null) {
				return true;
			}
			 //获取锁失败
			time -= (System.currentTimeMillis() - currentTime);
			if (time <= 0) {
				acquireFailed(threadId);
				return false;
			}

			// waiting for message
			currentTime = System.currentTimeMillis();

			//【核心点3】根据锁TTL,调整阻塞等待时长;
			// 注意:这里实现非常巧妙,
			// 1、latch其实是个信号量Semaphore,调用其tryAcquire方法会让当前线程阻塞一段时间,避免了在while循环中频繁请求获取锁;
		    //2、该Semaphore的release方法,会在订阅解锁消息的监听器消息处理方法org.redisson.pubsub.LockPubSub#onMessage调用;当其他线程释放了占用的锁,会广播解锁消息,监听器接收解锁消息,并释放信号量,最终会唤醒阻塞在这里的线程。
			if (ttl >= 0 && ttl < time) {
				getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
			} else {
				getEntry(threadId).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
			}

			time -= (System.currentTimeMillis() - currentTime);
			if (time <= 0) {
				acquireFailed(threadId);
				return false;
			}
		}
	} finally {
		// 取消解锁消息的订阅
		unsubscribe(subscribeFuture, threadId);
	}
}


tryLock一般用于特定满足需求的场合,但不建议作为一般需求的分布式锁,一般分布式锁建议用void lock(long leaseTime, TimeUnit unit)。因为从性能上考虑,在高并发情况下后者效率是前者的好几倍

5.4、tryAcquire(long leaseTime, TimeUnit unit, long threadId)

就是执行Lua脚本

private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
    // tryAcquireAsync异步执行Lua脚本,get方法同步获取返回结果
    return get(tryAcquireAsync(leaseTime, unit, threadId));
}
 
//  见org.redisson.RedissonLock#tryAcquireAsync
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {
    if (leaseTime != -1) {
        // 实质是异步执行加锁Lua脚本
        return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    ttlRemainingFuture.addListener(new FutureListener<Long>() {
        @Override
        public void operationComplete(Future<Long> future) throws Exception {
            //先判断这个异步操作有没有执行成功,如果没有成功,直接返回,如果执行成功了,就会同步获取结果
            if (!future.isSuccess()) {
                return;
            }
 
            Long ttlRemaining = future.getNow();
            // lock acquired
            //如果ttlRemaining为null,则会执行一个定时调度的方法scheduleExpirationRenewal
            if (ttlRemaining == null) {
                scheduleExpirationRenewal(threadId);
            }
        }
    });
    return ttlRemainingFuture;
}
 
// 见org.redisson.RedissonLock#tryLockInnerAsync
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    internalLockLeaseTime = unit.toMillis(leaseTime);
 
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
              "if (redis.call('exists', KEYS[1]) == 0) then " +
                  "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
              "end; " +
              "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                  "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
              "end; " +
              "return redis.call('pttl', KEYS[1]);",
                Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}

5.4.1、加锁过程小结

锁其实也是一种资源,各线程争抢锁操作对应到redisson中就是争抢着去创建一个hash结构,谁先创建就代表谁获得锁

  • hash的名称:为锁名
  • hash里面内容:仅包含一条键值对,键为redisson客户端唯一标识+持有锁线程id,值为锁重入计数;给hash设置的过期时间就是锁的过期时间。

在这里插入图片描述

5.4.2、加锁流程核心就3步

  • Step1:尝试获取锁,这一步是通过执行加锁Lua脚本来做
  • Step2:若第1步未获取到锁,则去订阅解锁消息,当获取锁到剩余过期时间后,调用信号量方法阻塞住,直到被唤醒或等待超时
  • Step3:一旦持有锁的线程释放了锁,就会广播解锁消息。于是,第2步中的解锁消息的监听器会释放信号量,获取锁被阻塞的那些线程就会被唤醒,并重新尝试获取锁。

5.5、unlock()


@Override
public void unlock() {
	// 执行解锁Lua脚本,这里传入线程id,是为了保证加锁和解锁是同一个线程,避免误解锁其他线程占有的锁
	Boolean opStatus = get(unlockInnerAsync(Thread.currentThread().getId()));

   //  非锁的持有者释放锁时抛出异常
	if (opStatus == null) {
		throw new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
				+ id + " thread-id: " + Thread.currentThread().getId());
	}
	if (opStatus) {
       // 释放锁后取消刷新锁失效时间的调度任务
		cancelExpirationRenewal();
	}
}
 
 // 通过 Lua 脚本执行 Redis 命令释放锁
// 见org.redisson.RedissonLock#unlockInnerAsync
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            "if (redis.call('exists', KEYS[1]) == 0) then " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; " +
            "end;" +
            "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                "return nil;" +
            "end; " +
            "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
            "if (counter > 0) then " +
                "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                "return 0; " +
            "else " +
                "redis.call('del', KEYS[1]); " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; "+
            "end; " +
            "return nil;",
            Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
 
}

 

5.6、看门狗

private void scheduleExpirationRenewal(final long threadId) {
		//先判断在expirationRenewalMap中是否存在了entryName,第一次加锁,肯定是不存在的
        if (expirationRenewalMap.containsKey(getEntryName())) {
            return;
        }
 
        Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
            @Override
            public void run(Timeout timeout) throws Exception {
                
                RFuture<Boolean> future = commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                        "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                            "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                            "return 1; " +
                        "end; " +
                        "return 0;",
                          Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
                
                future.addListener(new FutureListener<Boolean>() {
                    @Override
                    public void operationComplete(Future<Boolean> future) throws Exception {
                        expirationRenewalMap.remove(getEntryName());

						//如果调用失败会打一个错误日志并返回,更新锁过期时间失败
                        if (!future.isSuccess()) {
                            log.error("Can't update lock " + getName() + " expiration", future.cause());
                            return;
                        }
                        
   						//如果为true,就会调用本身,如此说来又会延迟10秒钟去执行这段逻辑
                        if (future.getNow()) {
                            // reschedule itself
                            scheduleExpirationRenewal(threadId);
                        }
                    }
                });
            }
        }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
 
        if (expirationRenewalMap.putIfAbsent(getEntryName(), task) != null) {
            task.cancel();
        }
    }

首先,会先判断在expirationRenewalMap中是否存在了entryName,这是个map结构,主要还是判断在这个服务实例中的加锁客户端的锁key是否存在,如果已经存在了,就直接返回;第一次加锁,肯定是不存在的,接下来就是搞了一个TimeTask,延迟internalLockLeaseTime/3之后执行,internalLockLeaseTime默认30秒,大约10秒钟执行一次,调用了一个异步执行的方法。

在这里插入图片描述

如图也是调用异步执行了一段lua脚本,首先判断这个锁key的map结构中是否存在对应的key8a9649f5-f5b5-48b4-beaa-d0c24855f9ab:anyLock:1,如果存在,就直接调用pexpire命令设置锁key的过期时间,默认30秒。

在上面任务调度的方法中,也是异步执行并且设置了一个监听器,在操作执行成功之后,会回调这个方法,如果调用失败会打一个错误日志并返回,更新锁过期时间失败;然后获取异步执行的结果,如果为true,就会调用本身,如此说来又会延迟10秒钟去执行这段逻辑,所以,这段逻辑在你成功获取到锁之后,会每隔十秒钟去执行一次,并且,在锁key还没有失效的情况下,会把锁的过期时间继续延长到30 秒,也就是说只要这台服务实例没有挂掉,并且没有主动释放锁,看门狗都会每隔十秒给你续约一下,保证锁一直在你手中。

  • 18
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值