Redis源码阅读|主从复制(同步syn)

Redis的复制功能分为同步(syn)和命令传播(command propgate)两个操作:
同步操作(syn):作用于将从服务器的数据库状态更新至主服务当前所处的数据库状态。
命令传播(Command propgate):作用于在出数据库状态被修改,导致从数据库状态出现不一致时,让主从服务器的数据重新回到一致状态。

同步操作(syn)

Redis从2.8版本开始,使用PSYNC命令来执行复制时的同步操作。

PSYNC命令具有完整重同步(full resynchronization)和部分重同步(partial resynchronization)两种模式:

  • 完整重同步用于处理初次复制情况:通过让主服务器创建并发送RDB文件,以及向从服务器发送保存在缓冲区里面的写命令来进行同步。
  • 部分重同步则用于处理断线后重复制情况:当从服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收并执行这些写命令,就可以将数据库更新至主服务器当前所处的状态。

问题来了?主从同步源码在哪,从哪看起?

主从同步入口

为了弄明白同步操作的原理,我们尝试先找到设置主从关系的入口。

Redis 主从关系的设置通常不在 Redis 服务器启动的初始阶段自动进行,而是需要手动配置或通过外部管理工具(如 Redis Sentinel)在服务器启动后进行配置。

具体来说,主从关系的设置可以通过以下几个步骤进行:

  1. 配置文件设置:在从节点的 Redis 配置文件 (redis.conf) 中,可以通过设置 replicaof(或旧版本中的 slaveof)指令来指定主节点的 IP 地址和端口号。例如,replicaof master_ip master_port

  2. 启动时命令行参数:在启动从节点 Redis 服务时,可以直接在命令行使用 --replicaof 参数来指定主节点,例如 redis-server ./redis.conf --replicaof master_ip master_port

  3. 启动后动态配置:如果 Redis 服务器已经启动,你也可以在从节点上通过 Redis CLI 执行 replicaof master_ip master_port 命令来动态设置主节点。这种方式在不重启 Redis 实例的情况下即可更改复制关系。

  4. Redis Sentinel 自动故障转移:在使用 Redis Sentinel 进行高可用配置时,Sentinel 会监控主节点的状态。如果主节点不可达,Sentinel 将会选举出一个新的主节点,并自动通知相关的从节点更改它们的复制目标,这一步也是在 Redis 服务运行期间动态发生的。

因此,Redis 的主从关系并不是在服务器启动时立即设置的默认行为,而是需要根据具体需求通过配置或命令动态配置。服务器启动后,根据配置或接收的命令,从节点会主动与主节点建立连接并开始数据同步过程

在从节点启动时,它会尝试连接到指定的主节点并开始复制过程,而这个过程是在clusterCron()函数中进行的,对应源码如下:

/*
 * 检查当前节点是否为从节点(slave node),并且复制(replication)功能是否未开启。
 * 如果是,并且我们知道了主节点(master)的地址且主节点看起来是可用的,
 * 则自动设置该主节点的地址并启用复制功能。
 */
    /* 判断条件:当前节点是从节点,且服务器未设置主节点地址,
     * 但当前节点已知其要复制的主节点地址且该主节点可达。 */
    if (nodeIsSlave(myself) &&
        server.masterhost == NULL &&
        myself->slaveof &&
        nodeHasAddr(myself->slaveof))
    {
        /* 设置主节点的IP和端口,启用复制功能 */
        replicationSetMaster(myself->slaveof->ip, myself->slaveof->port);
    }

replicationSetMaster()函数

/*
 * 为从节点设置主节点的ip和端口。
 * 参数:
 *   ip - 主节点的IP地址。
 *   port - 主节点的端口号。
 * 说明:
 *   该函数为从节点设置指定的主节点进行复制。它会断开当前的主服务器连接(如果有的话), 清理相关状态,并尝试连接到新的主服务器。
 */
void replicationSetMaster(char *ip, int port) {
    int was_master = server.masterhost == NULL; // 检查服务器之前是否是主服务器。

    sdsfree(server.masterhost); // 释放旧的主服务器主机名。
    server.masterhost = NULL; // 清空主服务器主机名。
    if (server.master) {
        freeClient(server.master); // 断开与当前主服务器的连接。
    }
    disconnectAllBlockedClients(); // 断开所有被阻塞的客户端连接。(从主服务器变为从服务器)

    /* 设置masterhost要在freeClient之后进行,因为freeClient内部可能会直接触发重新连接。 */
    server.masterhost = sdsnew(ip); // 设置新的主服务器IP。
    server.masterport = port; // 设置新的主服务器端口。

    /* 更新OOM_score_adj值。 */
    setOOMScoreAdj(-1);

    /* 强制从服务器与我们重新同步。 */
    disconnectSlaves();
    cancelReplicationHandshake(0);
    /* 如果之前是主服务器,清除缓存的主服务器状态,并使用自身参数创建一个新的缓存主服务器,以便后续与新主服务器进行PSYNC。 */
    if (was_master) {
        replicationDiscardCachedMaster();
        replicationCacheMasterUsingMyself();
    }

    /* 触发复制角色变更的模块事件。 */
    moduleFireServerEvent(REDISMODULE_EVENT_REPLICATION_ROLE_CHANGED,
                          REDISMODULE_EVENT_REPLROLECHANGED_NOW_REPLICA,
                          NULL);

    /* 如果当前复制状态为已连接,则触发主服务器链接状态变更的模块事件。 */
    if (server.repl_state == REPL_STATE_CONNECTED)
        moduleFireServerEvent(REDISMODULE_EVENT_MASTER_LINK_CHANGE,
                              REDISMODULE_SUBEVENT_MASTER_LINK_DOWN,
                              NULL);

    server.repl_state = REPL_STATE_CONNECT; // 更新复制状态为正在连接。
    serverLog(LL_NOTICE,"Connecting to MASTER %s:%d",
        server.masterhost, server.masterport); // 记录日志。
    connectWithMaster(); // 尝试连接到新的主服务器。
}

主从同步发生在从节点与主节点建立连接之后,继续跟进connectWithMaster()函数查看与从节点与主节点建立连接的细节。

connectWithMaster()函数

/**
 * 连接到主服务器(MASTER)
 * 
 * 本函数尝试使用TCP(如果server.tls_replication为假)或TLS(如果server.tls_replication为真)
 * 创建一个连接到配置的主服务器(MASTER)。如果连接成功,会更新相关的服务器状态以反映连接建立。
 * 如果连接失败,会记录错误日志并关闭建立的连接。
 * 
 * @return C_OK 如果成功连接到主服务器;C_ERR 如果连接失败。
 */
int connectWithMaster(void) {
    // 根据是否启用TLS,创建相应的连接对象
    server.repl_transfer_s = server.tls_replication ? connCreateTLS() : connCreateSocket();
    
    // 尝试连接到主服务器,并执行syncWithMaster
    if (connConnect(server.repl_transfer_s, server.masterhost, server.masterport,
                NET_FIRST_BIND_ADDR, syncWithMaster) == C_ERR) {
        // 如果连接失败,记录错误信息并关闭连接
        serverLog(LL_WARNING,"Unable to connect to MASTER: %s",
                connGetLastError(server.repl_transfer_s));
        connClose(server.repl_transfer_s);
        server.repl_transfer_s = NULL;
        return C_ERR;
    }

    // 更新服务器状态,表示正在与主服务器同步
    server.repl_transfer_lastio = server.unixtime;
    server.repl_state = REPL_STATE_CONNECTING;
    serverLog(LL_NOTICE,"MASTER <-> REPLICA sync started");
    return C_OK;
}

connConnect()函数的入参可以看到一个syncWithMaster()函数,从它的名字不难看出它就是用来执行主从同步的。

connConnect(server.repl_transfer_s, server.masterhost, server.masterport,
                NET_FIRST_BIND_ADDR, syncWithMaster)

现在来看一下对connConnect()函数的解析:

  • connConnect:这是一个自定义的连接函数,用于创建一个新的网络连接。它属于Redis网络层的API,用于初始化与远程服务器的网络通信。

  • server.repl_transfer_s:这是Redis服务器结构体中的一个字段,表示用于复制数据传输的连接句柄。在这个上下文中,它将被用来建立或重置与主节点的连接。

  • server.masterhostserver.masterport:分别代表主节点的IP地址(或主机名)和端口号,是从属节点(slave)配置中指定的,用以连接到主节点。

  • NET_FIRST_BIND_ADDR:这是一个常量,通常用于指示在建立连接时首先尝试绑定到本地的第一个可用IP地址。这在网络配置较复杂,存在多个网络接口的情况下特别有用,确保连接尝试从预期的网络接口发起。

  • syncWithMaster:这是一个回调函数指针,将在连接成功建立后被调用。它指向的函数负责与主节点进行实际的数据同步工作,比如执行PSYNC命令以同步数据。如果连接过程失败,这个函数将不会被调用。

  • C_ERR:这是表示错误的常量,通常用于指示函数执行失败。如果connConnect函数返回C_ERR,说明与主节点的连接尝试未成功。

综上,connConnect()函数尝试通过指定的主节点地址和端口,使用server.repl_transfer_s连接句柄建立一个新的网络连接,并计划在连接成功后通过syncWithMaster回调函数同步数据。

因此,执行PSYN的正是syncWithMaster()函数。

syncWithMaster()函数

由于syncWithMaster()函数非常长,把源码放在了最后,有兴趣的可以往后翻阅。总的来说,syncWithMaster()函数旨负责处理从节点(Replica)与主节点(Master)之间的同步过程。具体来说,syncWithMaster函数旨在实现以下功能:

  1. 状态检查:确认从节点是否已经转变成独立的主节点(通过SLAVEOF NO ONE命令),如果是,则关闭与原主节点的连接并退出。

  2. 连接检查:确保与主节点的连接是正常的,非阻塞连接后检查是否有错误。

  3. PING-PONG握手:发送PING命令给主节点以测试连接,并等待PONG响应,确认主节点可以正常响应。

  4. 身份验证:如果设置了masterauth(主节点认证密码),则发送AUTH命令进行身份验证。

  5. 配置同步:通知主节点从节点的监听端口、IP地址等信息,以及从节点支持的功能(如EOF风格的RDB传输、PSYNC v2协议)。

  6. 尝试部分同步:使用PSYNC命令尝试进行部分重新同步(如果可能的话),以减少数据传输量。根据PSYNC的结果决定后续动作,比如是否需要进行全量同步或继续等待。

关键步骤解析

  • 非阻塞连接后的状态转换:当连接建立成功后,通过修改server.repl_state状态来控制同步流程的下一步动作,如从REPL_STATE_CONNECTING转为REPL_STATE_RECEIVE_PING_REPLY

  • 错误处理:通过goto语句快速跳转到错误处理逻辑,如write_error标签处处理写入命令时的错误。

  • 安全性与认证:通过AUTH命令处理认证逻辑,确保只有授权的从节点能够与主节点同步数据。

  • 灵活性与兼容性:在发送REPLCONF命令告知主节点从节点的端口、IP等信息时,代码考虑到了不同版本的Redis兼容性问题,对于不被理解的命令,只是记录日志但不中断同步流程。

  • 性能优化:初次连接时无缓存信息的全量同步,以及之后基于已有复制ID和偏移量的部分同步,以此来减少数据传输量和加快同步速度。

void syncWithMaster(connection *conn)
{
    char tmpfile[256], *err = NULL;
    int dfd = -1, maxtries = 5;
    int psync_result;

    /* If this event fired after the user turned the instance into a master
     * with SLAVEOF NO ONE we must just return ASAP. */
    if (server.repl_state == REPL_STATE_NONE)
    {
        connClose(conn);
        return;
    }

    /* Check for errors in the socket: after a non blocking connect() we
     * may find that the socket is in error state. */
    if (connGetState(conn) != CONN_STATE_CONNECTED)
    {
        serverLog(LL_WARNING, "Error condition on socket for SYNC: %s",
                  connGetLastError(conn));
        goto error;
    }

    /* Send a PING to check the master is able to reply without errors. */
    if (server.repl_state == REPL_STATE_CONNECTING)
    {
        serverLog(LL_NOTICE, "Non blocking connect for SYNC fired the event.");
        /* Delete the writable event so that the readable event remains
         * registered and we can wait for the PONG reply. */
        connSetReadHandler(conn, syncWithMaster);
        connSetWriteHandler(conn, NULL);
        server.repl_state = REPL_STATE_RECEIVE_PING_REPLY;
        /* Send the PING, don't check for errors at all, we have the timeout
         * that will take care about this. */
        err = sendCommand(conn, "PING", NULL);
        if (err)
            goto write_error;
        return;
    }

    /* Receive the PONG command. */
    if (server.repl_state == REPL_STATE_RECEIVE_PING_REPLY)
    {
        err = receiveSynchronousResponse(conn);

        /* We accept only two replies as valid, a positive +PONG reply
         * (we just check for "+") or an authentication error.
         * Note that older versions of Redis replied with "operation not
         * permitted" instead of using a proper error code, so we test
         * both. */
        if (err[0] != '+' &&
            strncmp(err, "-NOAUTH", 7) != 0 &&
            strncmp(err, "-NOPERM", 7) != 0 &&
            strncmp(err, "-ERR operation not permitted", 28) != 0)
        {
            serverLog(LL_WARNING, "Error reply to PING from master: '%s'", err);
            sdsfree(err);
            goto error;
        }
        else
        {
            serverLog(LL_NOTICE,
                      "Master replied to PING, replication can continue...");
        }
        sdsfree(err);
        err = NULL;
        server.repl_state = REPL_STATE_SEND_HANDSHAKE;
    }

    if (server.repl_state == REPL_STATE_SEND_HANDSHAKE)
    {
        /* AUTH with the master if required. */
        if (server.masterauth)
        {
            char *args[3] = {"AUTH", NULL, NULL};
            size_t lens[3] = {4, 0, 0};
            int argc = 1;
            if (server.masteruser)
            {
                args[argc] = server.masteruser;
                lens[argc] = strlen(server.masteruser);
                argc++;
            }
            args[argc] = server.masterauth;
            lens[argc] = sdslen(server.masterauth);
            argc++;
            err = sendCommandArgv(conn, argc, args, lens);
            if (err)
                goto write_error;
        }

        /* Set the slave port, so that Master's INFO command can list the
         * slave listening port correctly. */
        {
            int port;
            if (server.slave_announce_port)
                port = server.slave_announce_port;
            else if (server.tls_replication && server.tls_port)
                port = server.tls_port;
            else
                port = server.port;
            sds portstr = sdsfromlonglong(port);
            err = sendCommand(conn, "REPLCONF",
                              "listening-port", portstr, NULL);
            sdsfree(portstr);
            if (err)
                goto write_error;
        }

        /* Set the slave ip, so that Master's INFO command can list the
         * slave IP address port correctly in case of port forwarding or NAT.
         * Skip REPLCONF ip-address if there is no slave-announce-ip option set. */
        if (server.slave_announce_ip)
        {
            err = sendCommand(conn, "REPLCONF",
                              "ip-address", server.slave_announce_ip, NULL);
            if (err)
                goto write_error;
        }

        /* Inform the master of our (slave) capabilities.
         *
         * EOF: supports EOF-style RDB transfer for diskless replication.
         * PSYNC2: supports PSYNC v2, so understands +CONTINUE <new repl ID>.
         *
         * The master will ignore capabilities it does not understand. */
        err = sendCommand(conn, "REPLCONF",
                          "capa", "eof", "capa", "psync2", NULL);
        if (err)
            goto write_error;

        server.repl_state = REPL_STATE_RECEIVE_AUTH_REPLY;
        return;
    }

    if (server.repl_state == REPL_STATE_RECEIVE_AUTH_REPLY && !server.masterauth)
        server.repl_state = REPL_STATE_RECEIVE_PORT_REPLY;

    /* Receive AUTH reply. */
    if (server.repl_state == REPL_STATE_RECEIVE_AUTH_REPLY)
    {
        err = receiveSynchronousResponse(conn);
        if (err[0] == '-')
        {
            serverLog(LL_WARNING, "Unable to AUTH to MASTER: %s", err);
            sdsfree(err);
            goto error;
        }
        sdsfree(err);
        err = NULL;
        server.repl_state = REPL_STATE_RECEIVE_PORT_REPLY;
        return;
    }

    /* Receive REPLCONF listening-port reply. */
    if (server.repl_state == REPL_STATE_RECEIVE_PORT_REPLY)
    {
        err = receiveSynchronousResponse(conn);
        /* Ignore the error if any, not all the Redis versions support
         * REPLCONF listening-port. */
        if (err[0] == '-')
        {
            serverLog(LL_NOTICE, "(Non critical) Master does not understand "
                                 "REPLCONF listening-port: %s",
                      err);
        }
        sdsfree(err);
        server.repl_state = REPL_STATE_RECEIVE_IP_REPLY;
        return;
    }

    if (server.repl_state == REPL_STATE_RECEIVE_IP_REPLY && !server.slave_announce_ip)
        server.repl_state = REPL_STATE_RECEIVE_CAPA_REPLY;

    /* Receive REPLCONF ip-address reply. */
    if (server.repl_state == REPL_STATE_RECEIVE_IP_REPLY)
    {
        err = receiveSynchronousResponse(conn);
        /* Ignore the error if any, not all the Redis versions support
         * REPLCONF listening-port. */
        if (err[0] == '-')
        {
            serverLog(LL_NOTICE, "(Non critical) Master does not understand "
                                 "REPLCONF ip-address: %s",
                      err);
        }
        sdsfree(err);
        server.repl_state = REPL_STATE_RECEIVE_CAPA_REPLY;
        return;
    }

    /* Receive CAPA reply. */
    // CAPA消息是主从节点间同步初始化时的一个协商步骤,确保复制过程能高效且兼容地进行。
    if (server.repl_state == REPL_STATE_RECEIVE_CAPA_REPLY)
    {
        err = receiveSynchronousResponse(conn);
        /* Ignore the error if any, not all the Redis versions support
         * REPLCONF capa. */
        if (err[0] == '-')
        {
            serverLog(LL_NOTICE, "(Non critical) Master does not understand "
                                 "REPLCONF capa: %s",
                      err);
        }
        sdsfree(err);
        err = NULL;
        server.repl_state = REPL_STATE_SEND_PSYNC;
    }

    /* Try a partial resynchonization. If we don't have a cached master
     * slaveTryPartialResynchronization() will at least try to use PSYNC
     * to start a full resynchronization so that we get the master replid
     * and the global offset, to try a partial resync at the next
     * reconnection attempt.
     * 尝试进行部分重新同步。如果我们没有缓存的主节点信息,slaveTryPartialResynchronization() 
     * 函数至少会尝试使用 PSYNC 命令来启动一次完整的重新同步。
     * 这样做的目的是获取主节点的复制ID(replid)和全局偏移量。
     * 有了这些信息,在下一次重新连接尝试时,就可以尝试进行部分重新同步了。*/
    if (server.repl_state == REPL_STATE_SEND_PSYNC)
    {
        if (slaveTryPartialResynchronization(conn, 0) == PSYNC_WRITE_ERROR)
        {
            err = sdsnew("Write error sending the PSYNC command.");
            abortFailover("Write error to failover target");
            goto write_error;
        }
        server.repl_state = REPL_STATE_RECEIVE_PSYNC_REPLY;
        return;
    }

    /* If reached this point, we should be in REPL_STATE_RECEIVE_PSYNC. */
    if (server.repl_state != REPL_STATE_RECEIVE_PSYNC_REPLY)
    {
        serverLog(LL_WARNING, "syncWithMaster(): state machine error, "
                              "state should be RECEIVE_PSYNC but is %d",
                  server.repl_state);
        goto error;
    }
    // 尝试发起psyn,部分同步
    psync_result = slaveTryPartialResynchronization(conn, 1);
    if (psync_result == PSYNC_WAIT_REPLY)
        return; /* Try again later... */

    /* Check the status of the planned failover. We expect PSYNC_CONTINUE,
     * but there is nothing technically wrong with a full resync which
     * could happen in edge cases. */
    if (server.failover_state == FAILOVER_IN_PROGRESS)
    {
        if (psync_result == PSYNC_CONTINUE || psync_result == PSYNC_FULLRESYNC)
        {
            clearFailoverState();
        }
        else
        {
            abortFailover("Failover target rejected psync request");
            return;
        }
    }

    /* If the master is in an transient error, we should try to PSYNC
     * from scratch later, so go to the error path. This happens when
     * the server is loading the dataset or is not connected with its
     * master and so forth. */
    if (psync_result == PSYNC_TRY_LATER)
        goto error;

    /* Note: if PSYNC does not return WAIT_REPLY, it will take care of
     * uninstalling the read handler from the file descriptor. */

    if (psync_result == PSYNC_CONTINUE)
    {
        serverLog(LL_NOTICE, "MASTER <-> REPLICA sync: Master accepted a Partial Resynchronization.");
        if (server.supervised_mode == SUPERVISED_SYSTEMD)
        {
            redisCommunicateSystemd("STATUS=MASTER <-> REPLICA sync: Partial Resynchronization accepted. Ready to accept connections in read-write mode.\n");
        }
        return;
    }

    /* PSYNC failed or is not supported: we want our slaves to resync with us
     * as well, if we have any sub-slaves. The master may transfer us an
     * entirely different data set and we have no way to incrementally feed
     * our slaves after that. */
    disconnectSlaves();       /* Force our slaves to resync with us as well. */
    freeReplicationBacklog(); /* Don't allow our chained slaves to PSYNC. */

    /* Fall back to SYNC if needed. Otherwise psync_result == PSYNC_FULLRESYNC
     * and the server.master_replid and master_initial_offset are
     * already populated. */
    if (psync_result == PSYNC_NOT_SUPPORTED)
    {
        serverLog(LL_NOTICE, "Retrying with SYNC...");
        if (connSyncWrite(conn, "SYNC\r\n", 6, server.repl_syncio_timeout * 1000) == -1)
        {
            serverLog(LL_WARNING, "I/O error writing to MASTER: %s",
                      strerror(errno));
            goto error;
        }
    }

    /* Prepare a suitable temp file for bulk transfer */
    if (!useDisklessLoad())
    {
        while (maxtries--)
        {
            snprintf(tmpfile, 256,
                     "temp-%d.%ld.rdb", (int)server.unixtime, (long int)getpid());
            dfd = open(tmpfile, O_CREAT | O_WRONLY | O_EXCL, 0644);
            if (dfd != -1)
                break;
            sleep(1);
        }
        if (dfd == -1)
        {
            serverLog(LL_WARNING, "Opening the temp file needed for MASTER <-> REPLICA synchronization: %s", strerror(errno));
            goto error;
        }
        server.repl_transfer_tmpfile = zstrdup(tmpfile);
        server.repl_transfer_fd = dfd;
    }

    /* Setup the non blocking download of the bulk file. */
    if (connSetReadHandler(conn, readSyncBulkPayload) == C_ERR)
    {
        char conninfo[CONN_INFO_LEN];
        serverLog(LL_WARNING,
                  "Can't create readable event for SYNC: %s (%s)",
                  strerror(errno), connGetInfo(conn, conninfo, sizeof(conninfo)));
        goto error;
    }

    server.repl_state = REPL_STATE_TRANSFER;
    server.repl_transfer_size = -1;
    server.repl_transfer_read = 0;
    server.repl_transfer_last_fsync_off = 0;
    server.repl_transfer_lastio = server.unixtime;
    return;

error:
    if (dfd != -1)
        close(dfd);
    connClose(conn);
    server.repl_transfer_s = NULL;
    if (server.repl_transfer_fd != -1)
        close(server.repl_transfer_fd);
    if (server.repl_transfer_tmpfile)
        zfree(server.repl_transfer_tmpfile);
    server.repl_transfer_tmpfile = NULL;
    server.repl_transfer_fd = -1;
    server.repl_state = REPL_STATE_CONNECT;
    return;

write_error: /* Handle sendCommand() errors. */
    serverLog(LL_WARNING, "Sending command to master in replication handshake: %s", err);
    sdsfree(err);
    goto error;
}

关于主从复制的源码分析到此就结束了,感谢您的阅读。我对Redis源码的理解不够深刻,整个分析过程略为粗糙,还请见谅,待我多读源码会来继续优化和输出我的阅读理解,再次感谢您的时间。
注:本文的Redis源码来自Redis 6.2.14版本。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值