MIT-6.824-Lab2,Raft部分笔记|Use Go

前记

  • 趁着研一考完期末有点点空余时间,把Raft的实验补了
  • 记录着看视频、论文和实验过程中的一些想法

Paper6:Raft

概述(来自NewBing):

raft算法是一种用于分布式一致性的共识算法,旨在解决分布式系统中的领导者选举和日志复制等问题。它的设计目标是易于理解和实现,并且能够提供强一致性保证。raft算法的核心原理包括三个关键组件:领导者选举、日志复制和安全性。

  • 领导者选举:raft算法将时间划分为一个个的任期(term),每个任期的开始都是一次选举,一个或多个候选者(candidate)会尝试成为领导者(leader)。如果一个候选者赢得了大多数节点的投票,它就会成为新的领导者,并在该任期内管理整个集群。如果没有选出领导者,就会开始下一个任期,并重新进行选举。raft算法保证在任意时刻最多只有一个领导者。
  • 日志复制:领导者选出后,就开始接收客户端的请求,并将请求作为日志条目(log entry)加入到它的日志中,然后并行地向其他节点发送附加日志请求(AppendEntries RPC)复制日志条目。当这条日志被复制到大多数节点上,领导者就将这条日志应用到它的状态机,并向客户端返回执行结果。领导者会不断地重试附加日志请求,直到所有的节点都存储了所有的日志条目。
  • 安全性:raft算法增加了一些限制来保证安全性,即只有拥有最新的已提交的日志条目的节点才有资格成为领导者,而且领导者只能提交当前任期的日志条目,旧任期的日志条目要等到提交当前任期的日志条目来间接提交。这样可以避免已提交的日志条目被覆盖的情况。

有一个经验:要想在Raft中假设A情况,你首先得考虑A情况是否有可能发生。

细节点:

  1. Raft Basic

    1. Figure 2是非常重要的一个图。
    2. follower只接受请求,不主动发起请求
    3. 当client发送请求到follower时,会转发给ld处理
    4. ld会通过发送AE来抑制其他server发起选举。也就是说,ld不出错的话,它永远是ld。
    5. 当ld提交时,会通知follower
    6. term会通过包的发送来交换。如果接受者发现有更大的任期,那么它会更新自己的任期。如果发现更小term的包,那么会拒绝。如果ld或candidate发现自己的任期更小了,那么会立刻转变为follower
    7. 时序:client request -> ld -> 将log entry复制到半数以上服务器,并得到回复 -> ld commited、apply -> 返回响应给client ->后面心跳检测(AE)时,随便告诉其他服务器自己已经commited并apply
  2. Leader election

    1. 当election timer计时结束,还没有ld发请求,那么follower会认为ld死了,会变成candicate,发起选举
    2. 如果split votes发生了,那么term会增加,并再次发起选举
    3. timer计时器,是随机的
    4. 注意:每一个新ld的产生,由于它要获得多数server的认可,在请求投票时会把最新的term同步给多数的server!因此term也是一个“多数共识”的标识。
  3. Log replication

    1. 当ld 接收到log entry时,它会保证大多数server都接受到这个log entry,才会继续自己的下一步操作(如返回响应给client)
    2. 如果一个server对接受log entry没有响应,ld会无限地尝试
    3. 如果ld认为LE(log entry)是足够安全的了,那么这个时候叫作committed;注意,apply和committed是不一样的
    4. 在之后,ld会通过AE来将已经committed的index告知给各个follower
    5. 当ld发送AE给follower时,如果follower发现日志冲突,那么ld会decrements nextIndex,直到两者保持一致。注意:ld永远不会重写/删除自己的日志。(decrements nextIndex,不会把follower上的日志删除吗?会的,但是ld一定有了所有能被commited的日志,因此以ld为准。)
  4. Safety

    1. raft保证,被选举出的ld,一定会存有所有已经commited的日志(这个最难理解了。没有commited的entry又怎么处理呢?这样理解,要想commited,那么日志一定是被复制到“半数以上的服务器”。而新ld一定是“半数以上的服务器”中的一个。因此,只要是新ld,那么一定有能保证被commited的日志 。)。:the voter denies its vote if its own log is more up-to-date than that of the candidate。也就是说,要被投票的candidate的日志,要比半数以上的voter的日志要more up-to-date。(什么叫more up-to-date?更大term;如果term相同,则更大index)

      leader的前提是majority,因此leader肯定拥有majority的log(这就是为什么叫做共识)

      上面说法有问题:更正一下:raft保证,被选举出的ld,一定会存有比大多数server更up-to-date的日志

      leader的前提是majority都同意,因此leader肯定拥有majority都同意的更up-to-date的log(这就是为什么叫做共识)

      总结一下:大多数server都认为A服务器有比自己more up-tp-date的log**(共识)**,那么A就可以是ld。

    2. ld只允许commited自己任期内的日志?为什么:

      1. 新ld上线时,不能够确定旧的entry是否已经被commited(如重启后,commitedIndex被重置),旧的entry是否已经被大多数server所有。

        当能commite自己任期内的log entry时,就已经说明前面任期的所有日志一定已经复制到大多数server了。

        因此,只能通过提交自己任期内的log entry,来间接说明之前的日志可以提交。

        同时,能commited自己任期内的日志,说明已经通知到了半数以上服务器,这个时候,半数以上的服务器的日志中的term是和ld一样是最新的。

      2. 图8,描述了,log即使存储在大多数的server上,仍然可能被后面的新ld覆盖(图8的d)。

        因此不能够直接通过复制的数量来判断之前任期的日志条目是否提交(图8的c)。

        所以才出现下下面的解决方案:ld只允许提交自己任期内的log entry。这样保证,当 图8的e 的S1的任期4日志被提交时,任期为3的自然也算被提交了。

  5. Timing and availability:broadcastTimeelectionTimeoutMTBF(平均故障时间)

    1. broadcastTimeelectionTimeout ,如果不能保证这个,那么会频繁出现选举失败
    2. electionTimeoutMTBF,保证这个是因为,让选举平稳进行。这个用脑子想想就知道~,为了保证系统平衡运行,肯定这种定时器要小于故障时间的。
  6. Cluster membership changes

    1. 使用了joint consensus,两阶段转变。首先转为joint状态(中间态),然后再转变为new configuration状态
    2. raft的实现中,在中间态依然可以正常进行选举和处理client的请求
  7. Log compaction

    1. 使用了快照。这个好像在lab3用得到。
  8. Client interaction

    1. 初始时,client会随机挑选一个server进行连接
    2. client发送的请求,都会带有一个 唯一的id
    3. 如果重复请求,那么server会通过请求的id,直接返回之前已经执行过的请求的响应

文章的假设:

  • 应用程序状态是易失的
  • 当一个机器死掉后,通过心跳确认自己的apply位置,并重演一次操作(先不考虑日志压缩)

问题:

  1. 如何避免脑裂?

    1. majority voting system。这个是整个机制的基石
    2. odd servers
    3. 当出现网络partition时,绝不可能有超过一边拥有一半以上的服务器
    4. 大多数投票机制保证:在每一步要投票时,保证投票的“大多数”机器中,一定有一部分会和上次投票重叠。
  2. 如何知道“半数以上”?

    1. raft算法中,每个服务器(节点)都会保存一些关于集群的信息,其中就包括集群的总数目(server count)。这个信息可以在集群初始化的时候,由配置文件或者命令行参数来指定,也可以在集群运行的过程中,由领导者节点来动态更新。
    2. 总数目是固定的,不会因为机器的故障而减少!!!
  3. 要是因网络故障,完全将raft的服务器分区成两半,怎么办?

    1. 绝不可能有超过一边拥有一半以上的服务器
  4. Raft集群最大能忍受的故障server数目是多少?

    1. 假设一共有2f + 1台机器,那么最多允许f台机器故障。底层原理是因为投票机制是多数机制
    2. 如果是有n台机器,那么就是 2f+1=n,f = (n - 1) / 2
  5. 有不确定性操作的命令怎么办?

  6. 为什么需要log entry?

    1. 为了保证操作顺序
    2. 暂存操作。因为操作到来不确定是否一被要执行。
    3. 保存副本,当网络故障时,可以重发给follower
  7. Raft为什么需要要ld?(比如Paxos就没有ld)

    1. 加速系统应答
    2. 让系统更加容易理解
  8. 如果因为网络故障分裂成两个partition,旧ld在一半,但另一半有超过半数的server并且选举出了新ld,这个时候如何处理?为什么旧ld不会导致错误发生?

    1. 因为旧ld不会得到多数server的回应,因此不会成功执行任何命令
  9. 如果一个server的term跑得太快怎么办?

    1. 快就快。有投票机制的保证,没有关系的。
  10. 那么这些entry在旧ld执行后返回给client时失败了怎么办?新上任的ld还会再执行再给返回client吗?

    1. 请求是有带id的,可以通过id来寻找已经执行的结果。
  11. 如果一个ld已经把log复制给大多数server了,但是在commited之前挂掉了。那么这个log算是commited吗?

    1. 还是Figure8©。不算的。只有当前ld将自己任期内的log提交了,之前的所有log才算是commited!
  12. 不能够通过“已经复制日志给大多数server”,来判断“是否已经commited”

    1. 还是Figure8©
  13. 为什么不选有最长日志的,作为领导人?

    1. 最长不代表最up-to-date 。最长不代表能复制给多数server。也就是没有共识。
  14. 什么是线性化语义?

    在Raft协议中,“linearizable semantics”(线性化语义)是一个重要的概念。这意味着,对于任何两个操作,如果在实际执行中,操作A在操作B之前完成,那么在系统的状态中,操作A就应该在操作B之前

    在Raft协议中,为了实现线性化语义,通常会在服务器端维护一个关于已经执行过的请求的记录。这个记录通常包括每个客户端的最后一个请求的ID以及对应的响应。当收到一个新的请求时,服务器首先会检查这个请求的ID是否已经存在于记录中。如果存在,说明这个请求是重复的,服务器就会直接返回之前的响应,而不会再次执行这个请求

    总的来说,"linearizable semantics"是Raft协议中保证一致性的重要机制,它能够确保所有的操作都能按照一定的顺序执行,并且每个操作只执行一次

LEC5、6:Raft

随便记记:

  • mapReduce、GFS、VM-FT都单一实体来决定谁是ld
  • 最详细对raft的资料:https://raft.github.io/
  • 教学动画:https://thesecretlivesofdata.com/raft/

LAB2

注意点:

  1. Raft will continue to operate as long as at least a majority of the servers are alive and can talk to each other. If there is no such majority, Raft will make no progress, but will pick up where it left off as soon as a majority can communicate again.
  2. the service expects your implementation to send an ApplyMsg for each newly committed log entry to the applyCh channel argument to Make()
  3. Your Raft peers should exchange RPCs using the labrpc Go package (source in src/labrpc).:因为测试器会模拟丢包等网络故障
2A

http://nil.csail.mit.edu/6.824/2020/labs/lab-raft.html

task
  1. 实现ld选举 及 心跳检测。当旧ld死掉时(当follower收不到旧ld的心跳时),要有接管机制
Hint
  1. 只关注你当前的任务(但是应该也要考虑到后面的扩展)
  2. 本实验中:the number of Raft peers (usually 3 or 5)
  3. Add the Figure 2 state for leader election to the Raft struct in raft.go.
  4. Modify Make() to create a background goroutine that will kick off leader election periodically by sending out RequestVote RPCs when it hasn’t heard from another peer for a while. This way a peer will learn who is the leader, if there is already a leader, or become the leader itself. Implement the RequestVote() RPC handler so that servers will vote for one another. 就是要有一个机制,心跳超时后要发起选举。
  5. The tester requires that the leader send heartbeat RPCs no more than ten times per second.每秒不得多于10次的心跳检测
  6. 旧ld死掉后,要求5秒内就要完成新ld的选举。因为要求选举时间和心跳间隔选得尽可能短
  7. The paper’s Section 5.2 mentions election timeouts in the range of 150 to 300 milliseconds. Such a range only makes sense if the leader sends heartbeats considerably more often than once per 150 milliseconds. Because the tester limits you to 10 heartbeats per second, you will have to use an election timeout larger than the paper’s 150 to 300 milliseconds, but not too large, because then you may fail to elect a leader within five seconds.(?) 总的来说,这个是说election timer的时间,应该设置超过150~300 milSecond。(gpt建议300到500)。但是不知道原理。。
  8. 使用loop+time.Sleep()来完成周期性任务
  9. You may find this guide useful, as well as this advice about locking and structure for concurrency.
  10. The tester calls your Raft’s rf.Kill() when it is permanently shutting down an instance. You can check whether Kill() has been called using rf.killed(). You may want to do this in all loops, to avoid having dead Raft instances print confusing messages. 就是test发送信号要永久杀死一个server,但是不是通过进程强制杀死。要处理这个信号。周期性任务要通过这个标志判断,是否要继续。
  11. debug:
    1. 可以用print语句,然后使用 go test -run 2A > out. 查看
    2. You might find DPrintf in util.go useful to turn printing on and off as you debug different problems.
  12. 要通过RPC发送的字段,必须大写
  13. 使用go test -race,来保证代码没有race
  14. 测试时间限制: For all of labs 2, 3, and 4, the grading script will fail your solution if it takes more than 600 seconds for all of the tests (go test), or if any individual test takes more than 120 seconds.
locking
  1. 不论读写,如果要保证一致性的,一定要加锁
  2. Code that waits should first release locks. If that’s not convenient, sometimes it’s useful to create a separate goroutine to do the wait.
  3. 等待的代码,要释放锁。但是等待的代码执行完毕后,要重新获得锁,并判断自己的assumption是否还和当前的环境相同(将当时主线程的参数复制到goroutine中)。
structure
  1. 长时间运行的 activities ,应该在不同的线程运行。不同activities 应该也要分割开
  2. 处理timeout的通用思路:存储一个最后时间a,睡眠,然后在某个时间b,判断 b - a > timeout?
  3. 在将log送入到applych时,必须保证顺序。因此这个操作只能在一个goroutine执行
  4. 推进 commitIndex 的代码需要触发应用 Goroutine。为了实现这一点,建议使用条件变量(如 Go 中的 sync.Cond):就是signal机制。
  5. 每个RPC的发送和处理,应该在同一个goroutine.
guide
  1. figure2是绝对权威的总指导

  2. hearbeat返回时,如果返回true,是隐式地告诉ld,当前follower的日志和ld完全同步。因此即使是hearbeat,也要检测里面的term等状态。

  3. 什么时候重置election timer?

    1. 从当前ld接受到AE
    2. 自己开始一个选举期
    3. 投票给其他peer的时候。
  4. 一个任期内,只能投票一次!但是由于这个规则的存在:If RPC request or response contains term T > currentTerm: set currentTerm = T, convert to follower。这会改变当前follower的term,因此可以再次投票

  5. Check 2 for the AppendEntries RPC handler should be executed even if the leader didn’t send any entries. 也就是说,heartbeat应该同正常的AE同等对待。

  6. nextIndex 和matchIndex的不同:

    1. nextIndex:是极其乐观的,初始化为 index+1。ld用来指导FO从哪里开始复制日志
    2. matchIndex:是保守、安全的,初始化为-1。用来指示FO已经复制的最高索引。只有在FO对AE请求返回true时,这个matchIndex才更新。
  7. 对于过期响应的怎么处理:应该在args的参数中保留你发送时的term,然后在返回响应时,比较当前term和当前发送的term是否一样。如果不一样,直接丢了。

  8. matchIndex的更新:如果在收到响应后,已经check过assumption了,那么应该将matchIndex=prevLogIndex+len(entrites[]),而不是直接 matchIndex=nextIndex-1 or matchIndex = len(log)。因为在发送请求的期间,nextIndex可能已经改变了(比如故障重启),len(log)更会改变,比如有Client请求拼接。

  9. 快速恢复的nextIndex:https://thesquareplanet.com/blog/students-guide-to-raft/#an-aside-on-optimizations

    在AE的reply时,返回多两个字段:conflictIndex和conflitTerm

    1. 如果FO没有prevLogIndex的日志,那么直接返回 conflictIndex=len(FO自己的所有log), conflictTerm=None
    2. 如果FO有prevLogIndex的日志,但是term不匹配(设FO的冲突的Term为termA)。则返回 conflictTerm=log[prevLogIndex].Term=termA。并把 conflictIndex设置为termA的第一个log
    3. 当LD收到冲突响应时,首先查找自己是否有 conflictTerm=termA的日志。如果有,将nextIndex设置为termaA的最后一个日志的下一个位置。
    4. 如果没有termA的日志,则直接将nextIndex=conflictIndex

    简单折中实现就是只用conflictIndex不用conflictTerm,但这样在某些情况下会多发送些log entry。

    conflictIndex的作用:

    • 跳过所有FO自己没有的日志
    • 跳过一个term的日志(这里就是把所有冲突的termA全部跳过)

    conflitTerm的作用:

    • 辅助,让 LD 的nextIndex不要把所有的termA日志都跳过
  10. If the leader has no new entries to send to a particular peer, the AppendEntries RPC contains no entries, and is considered a heartbeat. 也就是说,同步日志的频率至少和hearbeat一样!或者说,直接把同步日志这个功能做成heartbeat。如果有需要同步的日志,那么直接同步。如果没有需要同步的日志,则看成是一个heartBeat(entries=null)。

    遇到的问题:在hearbeat实现的时候,没有按AE的规则来严格设置prevLogIndex和相应的entries[],导致一些bug出现。比如一个hearbeat过来,带了最新的leadercommit,把FO的rf.commit更新了,但hearbeat并没有携带最新的log。

    因此,在实现的时候,直接把同步日志做成心跳检测。逻辑上统一了。并设置条件变量,在需要的时候手动触发一次同步日志。(也就是设置死循环,用和heartBeat一样的频率触发这个条件变量。然后这个条件变量也可以在其他地方手动触发)

    所以是把 AE看成是hearbeat… 有点奇怪的感觉

设计与编码

有哪些周期性activity:

  1. ld的心跳检测
  2. FO的election timeout

要加哪些额外的状态?

  1. 当前ld的id

动作有哪些?

  1. ld发送心跳
  2. FO变成CA,并发起选举,请求多数投票
  • 被killed()了,那么剩下的已经启动的goRoutine怎么办?难道说每一个动作后面都要加killed()判断?,那也太麻烦了吧。目前看来,只要在各个函数头加上killed()判断就行,即使剩下的go routine被执行了,也不会造成实质性的伤害。 然后每一个动作后加上killed()判断,更直观点,毕竟killed()也是assumption之一。
  • 注意:rf.dead变量虽然是原子的,但这种原子性是为了保护这个变量。所以判断killed()的时候,最好也当成关键性代码,用mutex包裹起来。
  • 小心!defer如果放在一个死循环里面,会执行不了!
2B
task

Implement the leader and follower code to append new log entries, so that the go test -run 2B tests pass.

Hint
  1. 实现Start()函数拼接日志,并同步
  2. One way to fail to reach agreement in the early Lab 2B tests is to hold repeated elections even though the leader is alive. Look for bugs in election timer management, or not sending out heartbeats immediately after winning an election
设计与编码
  1. 当service调用Start()时,拼接日志到log[],signal条件变量cond1
  2. 当ld的 last log index >= nextIndex时,要发送同步日志 syncLogToPeer。成功则更新nextIndex和matchIndex。失败的话,减少nextIndex,继续尝试,signal一下条件变量cond1。然后刚成为LD的时候,应该也要启动一下这个。
  3. 如何确认能commited?当 N > commitIndex,并且N大于大多数的matchIndex,并且log[N].term == rf.currentTerm,那么就能commit到这里。什么时候检查?先定期吧,简单点。
2C
task
  1. Complete the functions persist() and readPersist()
  2. Insert calls to persist() at the points where your implementation changes persistent state
Hint
  1. You will probably need the optimization that backs up nextIndex by more than one entry at a time
question

实现中疑惑的点:

  • 同步日志的时候,如果用条件变量来实现,那么当一个server crash的时候,没有时机去触发这个条件变量。后面我还是用了for+sleep来实现。有什么好的办法?
  • 条件变量的用处似乎不大?因为都是周期性任务。而且如果用触发来做,有些情况又触发不了。不过实现的时候,apply log用了条件变量。这个是一定可以被触发的。
  • 代码中还有比较多的todo偷懒没搞hh,那些都是可以优化的点
后记
  1. 批量测试脚本:由于1次实验有偶然性,要多运行几次,小概率的bug才会出现。(第一次通过的时候高兴了好久,结果运行多几次又发现新bug了(T_T))

    1. 脚本地址:https://gist.github.com/jonhoo/f686cacb4b9fe716d5aa#file-go-test-many-sh

    2. 放在raft目录下,新建一个.sh文件go-test-many.sh:使用./go-test-many.sh 100(测试次数) 4(同一时刻测试的数量) 2(lab名称)

    3. 本次运行100次,均通过

      在这里插入图片描述

  2. 写代码前,还是得弄清楚需求(这里是看大量lab相关的文档)。写代码加调试花了2~3天(周日下午到周三早上,期间还帮师兄改了论文),但看论文和文档的时间挺长的。

  3. 写完后,看了别人的代码,差别还是挺大的hh,自己实现得比较乱,别人可以这么优雅

  4. 而且要遵守建议和论文中的规则,自己创造性地“乱写”(没有被证明对),那么debug之路会很漫长

  5. RPC调用与assumption

  6. 日志索引从1开始,确实很妙,省去了非常多的代码判断。

LEC5:GO, Threads, and Raft

  1. 在写实验的时候,用大粒度锁就行,别trick.去想着从cpu层面优化~
  2. The Go Memory Model:If you must read the rest of this document to understand the behavior of your program, you are being too clever. Don’t be clever.
  3. 如果你的代码中出现了常数,要想想为什么可以是常数?为什么是这个常数,其他常数不行?
go threads技巧

waitGroup、闭包(能访问到创建该线程的上下文)

//1.可以作为投票的一种实现
func main() {
	var wg sync.WaitGroup
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func(x int) {
			sendRPC(x)
			wg.Done()
		}(i) //2.i要传递
	}
	wg.Wait()
}

func sendRPC(i int) {
	println(i)
}

周期性任务

func periodic() {
	for {
		println("tick")
		time.Sleep(1 * time.Second)
		mu.Lock() //如果不加锁,无法保证能马上见到其他人作的改变
		if done {
			return
		}
		mu.Unlock()
	}
}

条件变量 (signal):建议实验中直接用broadcast

func main() {
	rand.Seed(time.Now().UnixNano())

	count := 0
	finished := 0
	var mu sync.Mutex
	cond := sync.NewCond(&mu)

	for i := 0; i < 10; i++ {
		go func() {
			vote := requestVote()
			mu.Lock()
			defer mu.Unlock()
			if vote {
				count++
			}
			finished++
			cond.Broadcast()
		}()
	}

	mu.Lock() //注意。要先获得lock才能wait。相当于java中,synchorized 这个功能放进了Object中了
	for count < 5 && finished != 10 {
		cond.Wait()
	}
	if count >= 5 {
		println("received 5+ votes!")
	} else {
		println("lost")
	}
	mu.Unlock()
}

func requestVote() bool {
	time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
	return rand.Int() % 2 == 0
}
mu.Lock()
// do something that might affect the condition
cond.Broadcast()
mu.Unlock()

----

mu.Lock()
while condition == false {
	cond.Wait()
}
// now condition is true, and we have the lock
mu.Unlock()

channel(无缓冲channel):

  • 是一种没有容量的队列。当发送时,如果没有接收方,发送线程会被阻塞。相反地,如果没有人发送,接收方也会被阻塞
  • 可用于生产者消费者
  • waitGroup效果
func main() {
	done := make(chan bool)
	for i := 0; i < 5; i++ {
		go func(x int) {
			sendRPC(x)
			done <- true
		}(i)
	}
    //和waitGroup的效果一样
	for i := 0; i < 5; i++ {
		<-done
	}
}

func sendRPC(i int) {
	println(i)
}
raft实验易错点
  1. 不应该在调用RPC时,持有锁。1影响效率,2容易死锁。可以通过传递assumption消除
debug技巧
  1. cirt + /,可以打印堆栈
  • 19
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Meow_Sir

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值