Redis分布式锁与Zk分布式锁的详解与选型


近期项目在使用分布式锁实现业务并发控制方面频繁踩坑,事后对redis与zookeeper实现分布式锁的细节及选型的一些思考;

1. 分布式锁应用场景

在了解分布式锁之前先回顾一下JDK的synchronized和Lock,它们是作用在同一个JVM进程中的一种锁,但当你系统采用集群式部署之后,就无法使用JDK中的锁来保证全局互斥性了,这种情况下,就可以选用分布式锁来解决;
在这里插入图片描述
常用的分布式锁方案主要有:Redis、Zookeeper、Etcd来实现,但Etcd本次不做解释;
在项目中,我们该如何去做分布式锁的选型,在文章最后会给出个人所理解;

2. Redis分布式锁

2.1 原生redis实现

原生redis通常是使用:jedis.set(String key, String value, String nxxx, String expx, int time);

key:就是我们需要加锁的key;
value:这个一般是传入一个当前请求的唯一标识(比如uuid);
nxxx:它有两个取值 NX和XX,NX即当key不存在的问题进行set操作,若已存在,则不做任何操作;XX:当key存在的时候,才对键进行设置操作;
expx:指定key过期的时间单位,EX=秒;PX=毫秒;
time:表示key的过期时间;

2.1.1 加锁
jedis.set(lockKey, UUID.randomUUID().toString(),  NX,  PX, 3000);
2.1.2 解锁

解锁是不是认为直接jedis.del(lockKey)就可以了呢?当然不行,为什么呢?
在这里插入图片描述
正确的释放锁方式,应该先判断删除key的线程是否是加锁的线程,如果是同一个线程,则可以删除key,否则无法删除,但这块的判断逻辑需要我们借助lua脚本来实现;

// 获取lockKey的值是否== lockValue
if redis.call('get', KEYS[1]) == ARGV[1] then
	// 删除lockKey
	return redis.call('del', KEYS[1]) 
else
	return 0
end

jedis.eval(lua,1,lockKey, lockValue);
里面的1、KEYS[1]和ARGV[1]代表什么意思呢?

1:表示键名参数的数量,类似KEYS[]数组长度;
KEYS[1]:则是取lockKey的值;
ARGV[1]:则表示lockValue的值;

2.1.3 续期问题

上面的问题逻辑看似已经接近完美,但在项目中总会出现一些意想不到的问题,就是我们接下来要说的锁过期问题;
正常情况下,业务在锁过期之前执行完毕不会有任何问题,但总会有一些异常情况(比如网络)会导致在你设置的过期时间内没有执行完业务逻辑,但锁过期会自动释放,此时线程B就会竞争到此锁,同样会产生并发问题;
在这里插入图片描述
解决这个问题的思路:

  1. 将锁的过期时间设置过大
  2. 为锁的线程添加守护线程,定期为key续期;
  3. 通过redis中的发布订阅模式实现通知;

这里以定期为Key续期为例:

private class RenewalThread extends Thread{
	public RenewalThread(){
		this.setDaemon(true);
	}
	@Override
	public void run() {
		String renewalScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
				"return redis.call('expire',KEYS[1],ARGV[2]) " +
				"else " +
				"return 0 end";
		jedis.eval(renewalScript, 1, "NX", "PX", "3000");
	}
}

将该线程交由 ScheduledExecutorService定时去执行;

当然除了这个问题之外,还有锁可重入问题、集群环境还有锁的使用上可能需要while(true)去获取锁,获取不到的时候就休眠一会,然后再去获取,这种方式也增加了代码的危险因素;

2.2 redisson实现

综合以上因素,可以考虑使用redisson来解决以上的麻烦,redisson提供了锁重入问题、读写锁、信号量、红锁等功能非常的强大;详细介绍可参考官网

2.2.1 加锁
public boolean tryLock(String lockKey, long waitTime, long leaseTime) {
    boolean suc;
    try {
        RLock lock = redissonClient.getLock(lockKey);
        //第一个参数是等待时间,比如5秒内获取不到锁,则直接返回。 第二个参数 比如60是60秒后强制释放
        suc = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS);
    } catch (Throwable e) {
        String msg = String.format("LOCK FAILED: key=%s||tryLockTime=%s||lockExpiredTime=%s", lockKey, waitTime, leaseTime);
        throw new IllegalStateException(msg, e);
    }
    return suc;
}
2.2.2 释放锁
public void unlock(String lockKey) {
    try {
        RLock lock = redissonClient.getLock(lockKey);
        if (lock != null && lock.isHeldByCurrentThread()) {  
            lock.unlock();
        }
    } catch (Throwable e) {
        String msg = String.format("UNLOCK FAILED: key=%s", lockKey);
        throw new IllegalStateException(msg, e);
    }
}
2.2.3 加锁源码分析
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
    // 传入的锁等待时间
    long time = unit.toMillis(waitTime);
    // 当前时间
    long current = System.currentTimeMillis();
    // 当前线程id
    long threadId = Thread.currentThread().getId();
    // 尝试加锁,并返回锁的剩余时间
    // tryAcquire -> tryAcquireAsync -> tryLockInnerAsync
    // 此方法如果返回的null,说明加锁成功,如果返回的是key的过期时间,则表示没有加锁成功;
    Long ttl = tryAcquire(leaseTime, unit, threadId);
    // lock acquired
    if (ttl == null) {
        return true;
    }
    // 用当前时间-刚进入方法的时候,这个主要是判断上面的一系列操作时间是否>waitTime
    // 如果大于,则说明在等待的时间内没有获取到锁;
    time -= System.currentTimeMillis() - current;
    if (time <= 0) {
        acquireFailed(threadId);
        return false;
    }

    current = System.currentTimeMillis();
    // 订阅key的释放事件
    RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
    // 阻塞等待锁释放
    // await返回false,说明等待时间已经超出获取锁最大等待时间,取消订阅并返回获取锁失败
    // await返回true,进入循环尝试获取锁
    if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
        if (!subscribeFuture.cancel(false)) {
            subscribeFuture.onComplete((res, e) -> {
                if (e == null) {
                    unsubscribe(subscribeFuture, threadId);
                }
            });
        }
        acquireFailed(threadId);
        return false;
    }

    try {
        // 如果已经超时,则直接返回,获取锁失败
        time -= System.currentTimeMillis() - current;
        if (time <= 0) {
            acquireFailed(threadId);
            return false;
        }

        while (true) {
            long currentTime = System.currentTimeMillis();
            // 再次尝试申请锁,如果已经释放,则直接加锁成功返回;
            ttl = tryAcquire(leaseTime, unit, threadId);
            // lock acquired
            if (ttl == null) {
                return true;
            }
            // 再次判断是否超时
            time -= System.currentTimeMillis() - currentTime;
            if (time <= 0) {
                acquireFailed(threadId);
                return false;
            }

            // waiting for message
            currentTime = System.currentTimeMillis();
            if (ttl >= 0 && ttl < time) {
                // 如果锁剩余时间 < 当前线程剩余的等待时间
                // 利用共享锁来阻塞等待判断是否允许等待共享锁,允许则加入共享锁等待释放信号
                // 1、latch其实是个信号量Semaphore,调用其tryAcquire方法会让当前线程阻塞一段时间,避免了在while循环中频繁请求获取锁;
                2、该Semaphore的release方法,会在订阅解锁消息的监听器消息处理方法org.redisson.pubsub.LockPubSub#onMessage调用;当其他线程释放了占用的锁,会广播解锁消息,监听器接收解锁消息,并释放信号量,最终会唤醒阻塞在这里的线程。
                subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
            } else {
                subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
            }
            // 更新剩余的等待时间,如果超时则返回;
            time -= System.currentTimeMillis() - currentTime;
            if (time <= 0) {
                acquireFailed(threadId);
                return false;
            }
        }
    } finally {
        // 取消订阅
        unsubscribe(subscribeFuture, threadId);
    }
}

private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
    // 当直接调用tryLock()时,leaseTime的值为-1,设置了超时时间
    if (leaseTime != -1) {
        return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    // 未设置超时时间,就使用redisson的看门狗监视,默认30s,会自动续期
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
        if (e != null) {
            return;
        }

        // lock acquired
        if (ttlRemaining == null) {
            scheduleExpirationRenewal(threadId);
        }
    });
    return ttlRemainingFuture;
}


<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    internalLockLeaseTime = unit.toMillis(leaseTime);
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
            // 首先判断key是否存在,如果不存在则创建key并设置过期时间,nil ,表示获取锁成功
            "if (redis.call('exists', KEYS[1]) == 0) then " +
                    "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return nil; " +
                    "end; " +
                    // 如果key已经存在,并且value也匹配,表示是当前线程持有的锁,则加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; " +
                    // 走到这一步说明是没有获取到锁,返回key的过期时间;
                    "return redis.call('pttl', KEYS[1]);",
            Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
2.2.4 解锁源码分析
public void unlock() {
    try {
        // 这里传入当前线程的id,为了避免删错
        get(unlockAsync(Thread.currentThread().getId()));
    } catch (RedisException e) {
        if (e.getCause() instanceof IllegalMonitorStateException) {
            throw (IllegalMonitorStateException) e.getCause();
        } else {
            throw e;
        }
    }
}

public RFuture<Void> unlockAsync(long threadId) {
    RPromise<Void> result = new RedissonPromise<Void>();
    RFuture<Boolean> future = unlockInnerAsync(threadId);

    future.onComplete((opStatus, e) -> {
        cancelExpirationRenewal(threadId);

        if (e != null) {
            result.tryFailure(e);
            return;
        }

        if (opStatus == null) {
            IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
                    + id + " thread-id: " + threadId);
            result.tryFailure(cause);
            return;
        }

        result.trySuccess(null);
    });

    return result;
}

protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            // 若锁存在,但唯一标识不匹配:则表明锁被其他线程占用,当前线程不允许解锁其他线程持有的锁
            "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                    "return nil;" +
                    "end; " +
                    // 若锁存在,且唯一标识匹配:则先将锁重入计数减1
                    "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
                    // 锁重入计数减1后还大于0:表明当前线程持有的锁还有重入,不能进行锁删除操作,但可以友好地帮忙设置下过期时期
                    "if (counter > 0) then " +
                    "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;",
            Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));

}
//在这里删除key之后,会发送publish消息;此消息会被监控到;

public class LockPubSub extends PublishSubscribe<RedissonLockEntry> {

    public static final Long UNLOCK_MESSAGE = 0L;
    public static final Long READ_UNLOCK_MESSAGE = 1L;

    public LockPubSub(PublishSubscribeService service) {
        super(service);
    }

    @Override
    protected RedissonLockEntry createEntry(RPromise<RedissonLockEntry> newPromise) {
        return new RedissonLockEntry(newPromise);
    }

    @Override
    protected void onMessage(RedissonLockEntry value, Long message) {
        // 判断是否是释放锁的消息
        if (message.equals(UNLOCK_MESSAGE)) {
            Runnable runnableToExecute = value.getListeners().poll();
            if (runnableToExecute != null) {
                runnableToExecute.run();
            }
            // 释放一个信号量,唤醒等待的【entry.getLatch().tryAcquire()】去再次尝试申请锁(加锁源码中会再次竞争)
            value.getLatch().release();
        } else if (message.equals(READ_UNLOCK_MESSAGE)) {
            while (true) {
                Runnable runnableToExecute = value.getListeners().poll();
                if (runnableToExecute == null) {
                    break;
                }
                runnableToExecute.run();
            }

            value.getLatch().release(value.getLatch().getQueueLength());
        }
    }
}
2.2.5 红锁

Redlock算法的介绍看这里有超详细的说明,Redisson也对Redlock算法提供了实现;

RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
...
lock.unlock();
2.2.6 不同redis集群对redisson的影响

选用redisson没有问题,但是需要前期做好redis部署方式的调研,比如:我们的redis集群是基于codis实现的,而codis是不支持发布订阅模式,那这就不能很多的支持redisson了。顺便调研了一下 redis-cluster 对sub/pub的支持也不是太好。所以选择redisson是需要考虑你的redis集群是否支持sub/pub;

3. Zookeeper分布式锁

3.1 zk实现分布式锁原理

在这里插入图片描述
zk实现分布式锁的几个要求:

1.创建的节点是临时顺序节点,临时:断开连接自动删除节点,天然处理了锁无法释放问题,顺序:zookeeper会为我们创建000000001,0000000002类似这种节点;
2.创建一个临时顺序节点之后,获取当前key下的所有的节点,判断自己是否是最小的那一个,如果是则获取成功,否则,监听zookeeper的节点删除事件,监听比自己小1的节点的删除事件;
这样的话,整个并发加锁过程相当于形成一个链表串起来;

3.2 原生zookeeper

public interface Lock {
    /**
     * 获取锁
     */
    void getLock() throws Exception;

    /**
     * 释放锁
     */
    void unlock() throws Exception;
}

@Slf4j
public abstract class AbstractTemplateLock implements Lock {
    @Override
    public void getLock() {
        if (tryLock()) {
            log.info("获取锁成功");
        } else {
            //等待
            waitLock();//事件监听 如果节点被删除则可以重新获取
            // 重新获取
            getLock();
        }
    }

    protected abstract void waitLock();

    protected abstract boolean tryLock();

    protected abstract void releaseLock();

    @Override
    public void unlock() {
        releaseLock();
    }
}

@Slf4j
public class ZkSequenTemplateLock extends AbstractTemplateLock {
    private static final String zkServers = "zk集群地址";

    private static final int sessionTimeout = 16000;
    private static final int connectionTimeout = 8000;
    private static final String parentLockPath = "/lock_path";
    private String beforePath;
    private String currentPath;
    private ZkClient client;

    /**
     * 初始化父节点
     */
    public ZkSequenTemplateLock() {
        client = new ZkClient(zkServers, sessionTimeout, connectionTimeout);
        // 如果不存在,则说明还没有加过锁,则创建父节点;
        if (!client.exists(parentLockPath)) {
            client.createPersistent(parentLockPath);
        }
        log.info("zk client 连接成功:{}", zkServers);
    }

    @Override
    protected void waitLock() {
        // 如果没有获取到锁,则使用countdownlatch阻塞当前线程,当上个节点删除的时候,再继续获取;
        CountDownLatch latch = new CountDownLatch(1);
        IZkDataListener listener = new IZkDataListener() {
            @Override
            public void handleDataDeleted(String dataPath) throws Exception {
                log.info("监听到节点被删除");

                latch.countDown();
            }

            @Override
            public void handleDataChange(String dataPath, Object data) throws Exception {
            }
        };
        // 给排在前面的节点增加数据删除的watcher,本质是启动另一个线程去监听上一个节点
        client.subscribeDataChanges(beforePath, listener);

        // 阻塞自己
        if (client.exists(beforePath)) {
            try {
                latch.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 取消watcher注册
        client.unsubscribeDataChanges(beforePath, listener);
    }

    @Override
    protected boolean tryLock() {
        if (currentPath == null) {
            // 创建一个临时顺序节点
            currentPath = client.createEphemeralSequential(parentLockPath + "/", "lock-data");
        }

        // 获得所有的子节点并排序。临时节点名称为自增长的字符串
        List<String> childrens = client.getChildren(parentLockPath);
        // 排序list,按自然顺序排序
        Collections.sort(childrens);
        if (currentPath.equals(parentLockPath + "/" + childrens.get(0))) {
            return true;
        } else {
            // 如果当前节点不是排第一,则获取前面一个节点信息,赋值给beforePath
            int curIndex = childrens.indexOf(currentPath.substring(parentLockPath.length() + 1));
            beforePath = parentLockPath + "/" + childrens.get(curIndex - 1);
        }
        return false;
    }

    @Override
    public void releaseLock() {
        client.delete(currentPath);
    }
}

这上面实现的是一种公平锁,zk也可以实现非公平锁;

3.2.1 原生锁的缺点

  • 没有解决锁重入问题;
  • 没有网络连接失败重连处理;
  • 实现读写锁、信号量等比较麻烦;

3.3 Curator

zookeeper作者的一句话形容Curator:Guava is to Java that Curator to Zookeeper
实现代码也比较简单:

@Configuration
public class ZkProperties {

    @Value("${zklock.config.retryCount:3}")
    private int retryCount;

    @Value("${zklock.config.retryMs:1000}")
    private int retryMs;

    @Value("${zklock.config.connect}")
    private String connect;

    @Value("${zklock.config.sessionTimeout}")
    private int sessionTimeout;

    @Value("${zklock.config.connectionTimeout}")
    private int connectionTimeout;

    @Value("${zklock.config.rootPath}")
    private String rootPath;
    
    // ...省略get/set
}
@Configuration
public class CuratorFrameworkConfig {

    @Autowired
    private ZkProperties zkProperties;

    @Bean
    public CuratorFramework curatorFramework() {
        RetryPolicy retryPolicy = new RetryNTimes(zkProperties.getRetryCount(), zkProperties.getRetryMs());
        CuratorFramework client = CuratorFrameworkFactory.builder()
                .connectString(zkProperties.getConnect())
                .sessionTimeoutMs(zkProperties.getSessionTimeout())
                .connectionTimeoutMs(zkProperties.getConnectionTimeout())
                .retryPolicy(retryPolicy)
                .build();
        // 启动
        client.start();
        initRootPath(client);

        return client;
    }

    private void initRootPath(CuratorFramework client) {
        try {
            Stat stat = client.checkExists().forPath(zkProperties.getRootPath());
            if (null == stat) {
                client.create().withMode(CreateMode.PERSISTENT)
                        .forPath(zkProperties.getRootPath());
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

public interface Lock {
    /**
     * 获取锁
     */
    InterProcessLock getLock(String key) throws Exception;

    InterProcessLock getLock(String key, Long time, TimeUnit timeUnit) throws Exception;

    /**
     * 释放锁
     */
    void unlock(InterProcessLock lock) throws Exception;
}

@Component
public class ZkLockService implements Lock {

    @Autowired
    private CuratorFramework curatorFramework;

    @Autowired
    private ZkProperties zkProperties;

    /**
     * 公平可重入排它锁
     *
     * @param key
     * @return
     * @throws Exception
     */
    @Override
    public InterProcessLock getLock(String key) throws Exception {
        String keyPath = zkProperties.getRootPath() + "/" + key;
        // 实例化 zk分布式锁
        InterProcessMutex mutex = new InterProcessMutex(curatorFramework, keyPath);
        mutex.acquire();
        return mutex;
    }

    /**
     * 公平可重入排它锁
     *
     * @param key
     * @return
     * @throws Exception
     */
    @Override
    public InterProcessLock getLock(String key, Long time, TimeUnit timeUnit) throws Exception {
        String keyPath = zkProperties.getRootPath() + "/" + key;
        // 实例化 zk分布式锁
        InterProcessMutex mutex = new InterProcessMutex(curatorFramework, keyPath);
        mutex.acquire(time, timeUnit);
        return mutex;
    }

    @Override
    public void unlock(InterProcessLock lock) throws Exception {
        lock.release();
    }
}

1.zookeeper的实现方案中也存在一些其它的问题,只是目前我还没遇到,比如:假死、脑裂问题;这个后面单独讨论;
2.上面的CuratorFramework是单例的,那如何保证它的连接健康呢?其它zookeeper客户端与服务器连接成功之后,会一直发送心跳以保证当前连接可用;

4. redis与zookeeper实现分布式锁选型

在选型之前首先搞明白两点:1.并发量;2.CP还是AP;
4.1.1 并发量
使用上redis的性能要比zookeeper分布式锁的性能高很多,具体的对比指标后面有时间补充;

在测试环境:
zookeeper加锁需要200ms左右
redis需要50ms左右
此值不太准,只供参考,以免误导;

4.1.2 一致性问题
redis的架构设计是基于AP模型,保证可用性,牺牲一致性(最大保证一致性),而zookeeper的架构设计是基于CP模型,保证数据的一致性,尽最大努力保证可用性。那么这它和我们分布式锁的选型有什么关系呢?
以redis为例:
假如发生线程A创建锁成功,但是还没等到master->slave的时候,这时候master挂了,但redis集群重新选出新的master,此是线程B请求新的master加锁成功,产生了并发问题;
如果你的服务即要求并发又要求一致性,那只能在业务层面再做一层一致性的处理了;

注:以上说的redis是redis集群,如果是单机,当然不存在数据一致性的问题;

4.1.3 比较完美的解决方案
我调研了一下我们公司,有些项目已经开始尝试对etcd的探索,但目前个人对etcd的了解还不太熟悉;

站在巨人的肩膀上成长;

参考文献:
1.redisson使用手册
2.Redis分布式锁-官网
3.慢谈 Redis 实现分布式锁 以及 Redisson 源码解析
4.七张图彻底讲清楚ZooKeeper分布式锁的实现原理【石杉的架构笔记】

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值