lettuce共享连接如何实现以及command发送流程

在此之前现需要了解一下netty,来看看底层的多路复用是怎么一回事,reactor模型,future-liustener机制(类似于lettuce里面的响应式编程)是怎么来进行非阻塞的

小总结:看我前面几个blog《netty实战》总结

网上找个lettuce调用路线图

 

网上又找了个connection连接建立路线图

 

问题:

两者都是连接数的问题,殊途同归

一个是关心连接数对于跨机房延时损耗的修补

一个是关心连接数的大小对客户端整体QPS的性能

现象:

我们在redis pipeline管道和client连接提升TPS中已经通过new连接数来模拟多连接得出结论:通过增加连接数能够提升优化跨机房网络延迟造成的QPS下降(但是这个时候瓶颈在客户端性能,因为线程池不能无限增加,哈哈哈这不是逗人玩呢)

但是在lettuce实验的时候遇见了一个这样的问题,发现lettuce底层是共享一个连接的,多个连接咱们好理解,那么共享一个连接怎么保证QPS呢?

先来进行试验来验证一下共享一个连接是否如同官方所说性能卓越(关于JMH的使用JMH基础介绍

有人可能对代码中的异步有疑问,放心也抽样测试了sync同步,性能基本上一直,毕竟下面的代码future.get()阻塞等待

 

@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 1)
@Threads(100)
@State(Scope.Benchmark)
@Measurement(iterations = 2, time = 600, timeUnit = TimeUnit.MILLISECONDS)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class LettuceBenchmarkTest {
    private static final int LOOP = 1;
    private StatefulRedisConnection<String, String> connection;
    @Setup
    public void setup() {
        RedisURI redisUri = RedisURI.builder()
//                .withHost("localhost")
                .withHost("10.10.*.*")
//                .withHost("****")
                .withPort(6379)
                .withTimeout(Duration.of(10, ChronoUnit.SECONDS))
                .build();
        RedisClient client = RedisClient.create(redisUri);
        connection = client.connect();
        connection.sync().ping();
    }
 
    @Benchmark
    public void get() throws ExecutionException, InterruptedException {
        RedisAsyncCommands<String, String> commands = connection.async();
        List<RedisFuture<String>> redisFutureList = new ArrayList<>();
        for (int i = 0; i < LOOP; ++i) {
            RedisFuture<String> future = commands.get("a");
            redisFutureList.add(future);
            future.get();
        }
        redisFutureList.forEach(f -> {
            try {
                f.get();
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
    }
 
    public static void main(String[] args) throws RunnerException {
        Options options = new OptionsBuilder().include(LettuceBenchmarkTest.class.getSimpleName())
                .output("log.home_IS_UNDEFINED/lettuceAsync-Throughput.log").forks(1).build();
        new Runner(options).run();
    }
}

因为使用阿里云总是会出现timeout error

io.lettuce.core.RedisConnectionException: Unable to connect to ***:6379
   at io.lettuce.core.RedisConnectionException.create(RedisConnectionException.java:78)
   at io.lettuce.core.RedisConnectionException.create(RedisConnectionException.java:56)
   at io.lettuce.core.AbstractRedisClient.getConnection(AbstractRedisClient.java:235)
   at io.lettuce.core.RedisClient.connect(RedisClient.java:204)
   at io.lettuce.core.RedisClient.connect(RedisClient.java:189)
   at com.xueqiu.infra.push.user.server.LettuceBenchmarkTest.setup(LettuceBenchmarkTest.java:46)
   at com.xueqiu.infra.push.user.server.generated.LettuceBenchmarkTest_get_jmhTest._jmh_tryInit_f_lettucebenchmarktest0_G(LettuceBenchmarkTest_get_jmhTest.java:434)
   at com.xueqiu.infra.push.user.server.generated.LettuceBenchmarkTest_get_jmhTest.get_Throughput(LettuceBenchmarkTest_get_jmhTest.java:71)
   at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
   at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
   at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
   at java.lang.reflect.Method.invoke(Method.java:498)
   at org.openjdk.jmh.runner.BenchmarkHandler$BenchmarkTask.call(BenchmarkHandler.java:453)
   at org.openjdk.jmh.runner.BenchmarkHandler$BenchmarkTask.call(BenchmarkHandler.java:437)
   at java.util.concurrent.FutureTask.run(FutureTask.java:266)
   at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511)
   at java.util.concurrent.FutureTask.run(FutureTask.java:266)
   at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
   at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
   at java.lang.Thread.run(Thread.java:748)
Caused by: io.netty.channel.AbstractChannel$AnnotatedConnectException: Operation timed out: ****/****:6379
   at sun.nio.ch.SocketChannelImpl.checkConnect(Native Method)
   at sun.nio.ch.SocketChannelImpl.finishConnect(SocketChannelImpl.java:717)
   at io.netty.channel.socket.nio.NioSocketChannel.doFinishConnect(NioSocketChannel.java:327)
   at io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.finishConnect(AbstractNioChannel.java:340)
   at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:665)
   at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:612)
   at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:529)
   at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:491)
   at io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:905)
   at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
   ... 1 more
Caused by: java.net.ConnectException: Operation timed out
   ... 11 more

 

所以下表采用10.10.*.*时延1ms,阿里云时延3.8ms

线程数(对redis-benchmark连接数)

localhost

localhost(redis-benchmark)

10.10.*.*

10.10.*.*(redis-benchmark)

阿里云

阿里云(redis-benchmark)

30082kCan't create socket: Too many open files88kCan't create socket: Too many open files Could not connect to Redis at ***:6379: nodename nor servname provided, or not known
20090k92k97k50k 23k
10080k97k45k43k 14k
5072k95k24k35k

1.1k/10

2.0k/20

3.1k/30

8.7k
121k2.8k1k1k207247

疑问:

命令:redis-benchmark -h 10.10.*.* -p 6379 -n 10000 -c 500 -t set,get -q

结果:Could not connect to Redis at 10.10.*.*:6379: Can't create socket: Too many open files

但是java代码就可以,那么就涉及到java是怎么处理这些句柄的


分析:

上面代码跑出来的数据已经很能说明问题,在我们进行lettuce的时候看到最多的字眼就是:基于netty、多线程共享、异步优势、支持streaming模式;

怎么基于netty保证多线程安全?

基于netty的网络模型,从lettuce的client源码可以看出,创建连接流程是:

传入redisClient的相关参数 → 通过封装的connectionBuilder进行启动 → 将处理的handler绑定上去(commandHandler重要) → 异步获取到connection连接

 

//真正的去建立Redis物理连接,这里面有很多基于Future的异步操作,如果看不太懂,建议先看看Future的相关知识,多看几遍。
private void initializeChannelAsync0(ConnectionBuilder connectionBuilder, CompletableFuture<Channel> channelReadyFuture,
            SocketAddress redisAddress) {
 
        logger.debug("Connecting to Redis at {}", redisAddress);
 
        Bootstrap redisBootstrap = connectionBuilder.bootstrap();
        //创建PlainChannelInitializer对象,PlainChannelIntializer对象会在Channel初始化的时候添加很多Handlers(Netty的Handler概念可以参考Netty权威指南),如:CommandEncoder、CommandHandler(非常重要的Handler)、ConnectionWatchdog(实现断线重连)
        RedisChannelInitializer initializer = connectionBuilder.build();
        //RedisChannelInitializer配置到Bootstrap中
        redisBootstrap.handler(initializer);
 
        //调用一些通过ClientResources自定义的回调函数
        clientResources.nettyCustomizer().afterBootstrapInitialized(redisBootstrap);
        //获取initFuture 对象,如果Channel初始化完成,可以通过该对象获取到初始化的结果
        CompletableFuture<Boolean> initFuture = initializer.channelInitialized();
        //真正的通过Netty异步的方式去建立物理连接,返回ChannelFuture对象
        ChannelFuture connectFuture = redisBootstrap.connect(redisAddress);
        //配置异常处理
        channelReadyFuture.whenComplete((c, t) -> {
 
            if (t instanceof CancellationException) {
                connectFuture.cancel(true);
                initFuture.cancel(true);
            }
        });
 
        connectFuture.addListener(future -> {
            //异常处理
            if (!future.isSuccess()) {
 
                logger.debug("Connecting to Redis at {}: {}", redisAddress, future.cause());
                connectionBuilder.endpoint().initialState();
                //赋值channelReadyFuture告知出现异常了
                channelReadyFuture.completeExceptionally(future.cause());
                return;
            }
            //当Channel初始化完成之后,根据初始化的结果做判断
            initFuture.whenComplete((success, throwable) -> {
                //如果异常为空,则初始化成功。
                if (throwable == null) {
 
                    logger.debug("Connecting to Redis at {}: Success", redisAddress);
                    RedisChannelHandler<?, ?> connection = connectionBuilder.connection();
                    connection.registerCloseables(closeableResources, connection);
                    //把成功之后的结果赋值给channelReadyFuture对象
                    channelReadyFuture.complete(connectFuture.channel());
                    return;
                }
                 
                //如果初始化Channel的过程中出现异常的处理逻辑
                logger.debug("Connecting to Redis at {}, initialization: {}", redisAddress, throwable);
                connectionBuilder.endpoint().initialState();
                Throwable failure;
 
                if (throwable instanceof RedisConnectionException) {
                    failure = throwable;
                } else if (throwable instanceof TimeoutException) {
                    failure = new RedisConnectionException("Could not initialize channel within "
                            + connectionBuilder.getTimeout(), throwable);
                } else {
                    failure = throwable;
                }
                //赋值channelReadyFuture告知出现异常了
                channelReadyFuture.completeExceptionally(failure);
            });
        });
    }

连接建立之后,基于StatefulRedisConnectionImpl无状态调用过程中线程安全的保证,但是发送命令和返回的结果是怎么对应起来的呢,流程如下:

当Lettuce收到Redis的回复消息时就从stack的头上取第一个RedisCommand,这个RedisCommand就是与该Redis返回结果对应的RedisCommand。为什么这样就能对应上呢,是因为Lettuce与Redis之间只有一条tcp连接,在Lettuce端放入stack时是有序的,tcp协议本身是有序的,redis是单线程处理请求的,所以Redis返回的消息也是有序的。这样就能保证Redis中返回的消息一定对应着stack中的第一个RedisCommand。当然如果连接断开又重连了,这个肯定就对应不上了,Lettuc对断线重连也做了特殊处理,防止对应不上。

 

private void writeSingleCommand(ChannelHandlerContext ctx, RedisCommand<?, ?, ?> command, ChannelPromise promise)
 {
 
    if (!isWriteable(command)) {
            promise.trySuccess();
            return;
    }
    //把当前command放入一个特定的栈中,这一步是关键
    addToStack(command, promise);
    // Trace操作,暂不关心
    if (tracingEnabled && command instanceof CompleteableCommand) {
            ...
    }
    //调用ChannelHandlerContext把命令真正发送给Redis,当然在发送给Redis之前会由CommandEncoder类对RedisCommand进行编码后写入ByteBuf
    ctx.write(command, promise);
     
    private void addToStack(RedisCommand<?, ?, ?> command, ChannelPromise promise) {
 
        try {
            //再次验证队列是否满了,如果满了就抛出异常
            validateWrite(1);
            //command.getOutput() == null意味这个这个Command不需要Redis返回影响。一般不会走这个分支
            if (command.getOutput() == null) {
                    // fire&forget commands are excluded from metrics
                    complete(command);
            }
            //这个应该是用来做metrics统计用的,暂时先不考虑
            RedisCommand<?, ?, ?> redisCommand = potentiallyWrapLatencyCommand(command);
            //无论promise是什么类型的,最终都会把command放入到stack中,stack是一个基于数组实现的双向队列
            if (promise.isVoid()) {
                    //如果promise不是Future类型的就直接把当前command放入到stack
                    stack.add(redisCommand);
            } else {
                    //如果promise是Future类型的就等future完成后把当前command放入到stack中,当前场景下就是走的这个分支
                    promise.addListener(AddToStack.newInstance(stack, redisCommand));
            }
        } catch (Exception e) {
            command.completeExceptionally(e);
            throw e;
        }
    }
}
 
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
 
        ByteBuf input = (ByteBuf) msg;
 
        ...
 
        try {
            ...
                        //重点在这里
            decode(ctx, buffer);
        } finally {
            input.release();
        }
    }
         
        protected void decode(ChannelHandlerContext ctx, ByteBuf buffer) throws InterruptedException {
                //如果stack为空,则直接返回,这个时候一般意味着返回的结果找到对应的RedisCommand了
        if (pristine && stack.isEmpty() && buffer.isReadable()) {
 
            ...
 
            return;
        }
 
        while (canDecode(buffer)) {
                        //重点来了。从stack的头上取第一个RedisCommand
            RedisCommand<?, ?, ?> command = stack.peek();
            if (debugEnabled) {
                logger.debug("{} Stack contains: {} commands", logPrefix(), stack.size());
            }
 
            pristine = false;
 
            try {
                                //直接把返回的结果buffer给了stack头上的第一个RedisCommand。
                                //decode操作实际上拿到RedisCommand的commandoutput对象对Redis的返回结果进行反序列化的。
                if (!decode(ctx, buffer, command)) {
                    return;
                }
            } catch (Exception e) {
 
                ctx.close();
                throw e;
            }
 
            if (isProtectedMode(command)) {
                onProtectedMode(command.getOutput().getError());
            } else {
 
                if (canComplete(command)) {
                    stack.poll();
 
                    try {
                        complete(command);
                    } catch (Exception e) {
                        logger.warn("{} Unexpected exception during request: {}", logPrefix, e.toString(), e);
                    }
                }
            }
 
            afterDecode(ctx, command);
        }
 
        if (buffer.refCnt() != 0) {
            buffer.discardReadBytes();
        }
    }

以怎样的形式发送命令?

这个主要看底层提供连接的那个handler和处理流程的endpoint,

发送的流程:

获取连接 → 封装命令 → 调用channelWriter(注意此处buffer缓冲区) → writer将命令发送给redis → 同时记录到一个内存stack中 (请求队列容量,requestQueueSize:Integer#MAX_VALUE)

@Override
    public <K, V, T> RedisCommand<K, V, T> write(RedisCommand<K, V, T> command) {
 
        LettuceAssert.notNull(command, "Command must not be null");
 
            try {
                    //sharedLock是Lettuce自己实现的一个共享排他锁。incrementWriters相当于获取一个共享锁,当channel状态发生变化的时候,如断开连接时会获取排他锁执行一些清理操作。
                    sharedLock.incrementWriters();
                    // validateWrite是验证当前操作是否可以执行,Lettuce内部维护了一个保存已经发送但是还没有收到Redis消息的Command的stack,可以配置这个stack的长度,防止Redis不可用时stack太长导致内存溢出。如果这个stack已经满了,validateWrite会抛出异常
                    validateWrite(1);
                    //autoFlushCommands默认为true,即每执行一个Redis命令就执行Flush操作发送给Redis,如果设置为false,则需要手动flush。由于flush操作相对较重,在某些场景下需要继续提升Lettuce的吞吐量可以考虑设置为false。
                    if (autoFlushCommands) {
                            if (isConnected()) {
                                    //写入channel并执行flush操作,核心在这个方法的实现中
                                    writeToChannelAndFlush(command);
                            } else {
                                    // 如果当前channel连接已经断开就先放入Buffer中,直接返回AsyncCommand,重连之后会把Buffer中的Command再次尝试通过channel发送到Redis中
                                    writeToDisconnectedBuffer(command);
                            }
 
                    } else {
                            writeToBuffer(command);
                    }
            } finally {
                    //释放共享锁
                    sharedLock.decrementWriters();
                    if (debugEnabled) {
                            logger.debug("{} write() done", logPrefix());
                    }
            }
 
            return command;
    }
    private void writeToChannelAndFlush(RedisCommand<?, ?, ?> command) {
                //queueSize字段做cas  1操作
        QUEUE_SIZE.incrementAndGet(this);
                 
        ChannelFuture channelFuture = channelWriteAndFlush(command);
                //Lettuce的可靠性:保证最多一次。由于Lettuce的保证是基于内存的,所以并不可靠(系统crash时内存数据会丢失)
        if (reliability == Reliability.AT_MOST_ONCE) {
            // cancel on exceptions and remove from queue, because there is no housekeeping
            channelFuture.addListener(AtMostOnceWriteListener.newInstance(this, command));
        }
                //Lettuce的可靠性:保证最少一次。由于Lettuce的保证是基于内存的,所以并不可靠(系统crash时内存数据会丢失)
        if (reliability == Reliability.AT_LEAST_ONCE) {
            // commands are ok to stay within the queue, reconnect will retrigger them
            channelFuture.addListener(RetryListener.newInstance(this, command));
        }
    }
         
        //可以看到最终还是调用了channle的writeAndFlush操作,这个Channel就是netty中的NioSocketChannel
        private ChannelFuture channelWriteAndFlush(RedisCommand<?, ?, ?> command) {
 
        if (debugEnabled) {
            logger.debug("{} write() writeAndFlush command {}", logPrefix(), command);
        }
 
        return channel.writeAndFlush(command);
    }
//发送时的编码
public void encode(ByteBuf buf) {
                 
        buf.writeByte('*');
                //写入参数的数量
        CommandArgs.IntegerArgument.writeInteger(buf, 1   (args != null ? args.count() : 0));
                //换行
        buf.writeBytes(CommandArgs.CRLF);
                //写入命令的类型,即get
        CommandArgs.BytesArgument.writeBytes(buf, type.getBytes());
 
        if (args != null) {
                        //调用Args的编码,这里面就会使用我们之前配置的codec序列化,当前使用的是String.UTF8
            args.encode(buf);
        }
    }

 

以上来个小总结:获取到连接之后的发送部分的逻辑都是靠DefaultEndpoint这个类来做的,要是看源码就着重分析这个类

异步优势指的是啥?

虽然大部分情况下Redis缓存访问很快,但毕竟是一个网络IO操作,同步访问缓存多少也会增加一点RT,特别是在循环中操作缓存时,就积少成多了。
比如下面的例子从jdbc结果集读取了数据,然后写缓存:

//......获取数据库连接,执行SQL,得到ResultSet,省略
while(resultSet.next()){
    User user = new User();
    user.setId(resultSet.getLong("user_id"));
    //.....省略一部分设置用户属性的代码
    
    cache.put(user.getId(), user);
}

仔细分析一下就会发现,其实这个put方法完全没有必要同步执行,因为我们并不关心它的返回值(很多类似的场景下即使操作缓存出现了错误,我们也不能干什么,只能忽略错误继续处理)。

注意:既然已经选择了异步的开发方式,在回调中不能调用堵塞方法,以免堵塞其他的线程(回调方法很可能是在event loop线程中执行的)。

支持streaming有啥用?

这个对于大数据量有帮助,具体没有实操

 

注意:

高可用和分片:

MasterSlave中提供的方法如果只要求传入一个RedisURI实例,那么Lettuce会进行拓扑发现机制,自动获取Redis主从节点信息;如果要求传入一个RedisURI集合,那么对于普通主从模式来说所有节点信息是静态的,不会进行发现和更新。

批量命令执行:

Lettuce pipeline底层并非合并所有命令一次发送(甚至可能是单条发送),如果真的有大量执行Redis命令的场景,使用RedisTemplate的时候请务必注意这一点。

读写模式支持:

如果Lettuce连接面向的是非单个Redis节点,连接实例提供了数据读取节点偏好(ReadFrom)设置,可选值有:这个文章分析的有

  • MASTER:只从Master节点中读取。
  • MASTER_PREFERRED:优先从Master节点中读取。
  • SLAVE_PREFERRED:优先从Slavor节点中读取。
  • SLAVE:只从Slavor节点中读取。
  • NEAREST:使用最近一次连接的Redis实例读取。

事务模式:

不建议使用,一个做cache的不要给搞的那么重

疑问:

为什么jedis在多线程环境下是不安全的?

jedis是基于redis设计的,redis本身就是单线程的,所以jedis就没有做多线程的处理。
jedis实例抽象的是发送命令相关,一个jedis实例使用一个线程与使用100个线程去发送命令
没有本质上的区别,所以没有必要设置为线程安全的。
但是redis的性能瓶颈主要在网络通讯,网络通讯速度比redis处理初度要慢很多。
单客户端会导致网络通讯的时间里,redis处于闲暇,无法发挥其的处理能力。
所以就需要用多线程方式访问redis服务器。那就使用多个jedis实例,每个线程对应一个jedis
实例,而不是一个jedis实例多个线程共享。一个jedis关联一个client,相当于一个客户端,client
继承了connection,connection维护了socket连接,对于socket这种昂贵的连接,一半都会做池化,所以jedis提供了jedispool.

会出现的问题:从异常信息来看,首先是在'zadd'操作时出现"Socket读取超时异常",具体异常信息"JedisConnectionException: java.net.SocketTimeoutException: Read timed out"。
出现异常后,会销毁这个阻塞的Jedis连接池对象(CustomShardedJedisPool.returnBrokenResource(CustomShardedJedisPool.java:121)),但在请求Redis服务端关闭连接时,出现"强制类型转换异常",
具体异常信息"ClassCastException: java.lang.Long cannot be cast to [B"
查看 Jedis 源码发现它的Connection中对网络输出流做了一个封装(RedisInputStream),其中自建了一个buffer。当发生异常的时候,
这个buffer里还残存着上次没有发送或者发送不完整的命令。这个时候没有做处理,直接将该连接返回到连接池,那么重用该连接执行下次命令的时候,就会将上次没有发送的命令一起发送过去,所以才会出现上面的错误“返回值类型不对”。
所以,正确的写法应该是:在发送异常的时候,销毁这个连接,不能再重用!

lettuce这么好那么jedis是不是就可以被舍弃了?

lettuce的作者这么说的:https://github.com/spring-projects/spring-session/issues/789

Jedis是直接的Redis客户端,当应用程序要跨多个线程共享单个Jedis实例时,它不是线程安全的。在多线程环境中使用Jedis的方法是使用连接池。在Jedis交互期间,使用Jedis的每个并发线程都会获得自己的Jedis实例。连接池是以每个Jedis实例的物理连接为代价的,这增加了Redis连接的数量。

Lettuce建立在netty之上,并且连接实例(StatefulRedisConnection)可以在多个线程之间共享。因此多线程应用程序可以使用单个连接,无论与Lettuce交互的并发线程数如何,当然这个也是可伸缩的设计,一个连接实例不够的情况也可以按需增加连接实例。

当在Redis上使用连接限制或连接数超过合理的连接数时,可能需要限制连接数。

下图是两者在redis官方推荐java SDK的描述,jedis使用A blazingly small and sane redis java client这句话带过

 

那到底还要不要使用线程池?

 

lettuce参考:

https://lettuce.io/core/snapshot/reference/#redis-cluster.connection-count

https://github.com/lettuce-io/lettuce-core/issues

https://github.com/lettuce-io/lettuce-core/wiki/Client-options#auto-reconnect

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值