ZooKeeper系列之请求处理核心机制与流程全解析

1. 基本信息

1.1 客户端

1.1.1 核心组件

  • Zookeeper
    • 定义:API入口和会话管理,封装了所有公开的API
    • 实现细节
      1. 异步建立会话,并初始化所有核心组件
      2. 异步结果通过注册的Watcher监听SyncConnexted事件确认就绪
      3. 异常重试也通过Watcher监听来实现
  • HostProvider
    • 定义:服务器连接管理器,在初始连接和重连时获取服务器连接地址,用于负载均衡和重连
    • 实现细节:将配置的地址列随机打乱,提供服务器连接时以轮询的方式依次提供服务器连接地址
  • ClientCnxn
    • 定义:网络通信引擎,负责管理客户端和服务端之间的所有网络通信
    • 子组件
      • SendThread:持续轮询发送队列outgoingQueue待确认队列pendingQueue;维护会话心跳(发送Ping请求)
        • outgoingQueue:发送给服务器的请求Packet会保存在该队列中,发送后将Packet移入pendingQueue
        • pendingQueue:读取服务端的响应,并将请求从pendingQueue队列中移除
      • EventThread:处理回调事件的工作线程,轮询waitingEvents队列并处理,共有两类事件:
        • 异步事件:处理异步调用相关的完成事件
        • Watcher通知:服务端触发Watcher通知后,所有Watcher的process方法都在此线程被串行调用,需避免process的同步耗时操作
    • 关键属性
      • sessionId:全局的会话id,会话身份凭证
      • negotiatedSessionTimeout:和服务端协商后实际生效的会话超时时间,不等于初始化时的sessionTimeout
      • sessionPasswd:会话恢复的安全凭证,重连时必须sessionId和sessionPasswd一致才能成功
  • Watcher
    • 定义:Zookeeper提供的监视器接口,注册后都是一次性的,一旦触发若想再次监听需要重新注册
    • 实现细节:定义的顶层接口,通过实现process()方法来处理回调事件
    • 回调事件
      • KeeperState:连接状态事件,Zookeeper会话保持状态
        • SyncConnected:会话已创建连接
        • Disconnected:已断开连接
        • Expired:心跳维持会话失败,会话过期
        • AuthFailed:认证连接失败
      • EventType:节点变更事件
        • None:此时仅有连接状态事件,无节点变更事件
        • NodeCreated:监听的数据节点被创建
        • NodeDeleted:监听的数据节点被删除
        • NodeDataChanged:监听的数据节点内容或版本号发生变更。即使数据内容相同,版本号更新就会触发
        • NodeChildrenChanged:监听的数据节点的子节点列表发生变更(子节点数量或组成形式发生变化),子节点内容变化不会触发
    • 注册监听:使用Zookeeper会话对象提供的公开API可注册不同事件
      • exists(path, watcher):监听节点的NodeCreated、NodeDeleted和NodeDataChanged
      • getData(path, watcher):监听节点的NodeDeleted和NodeDataChanged
      • getChildren(path, watcher):监听节点的NodeChildrenChanged
  • AsyncCallback
    • 定义:绑定API,着重于获取当前操作的结果,每次异步调用都会触发
    • 实现细节:和Watcher一样都是由EventThread串行执行,异步对象存储在对应的Packet中
  • ZKWatchManager
    • 定义:Watcher管理器,管理本地所有注册的Watcher,将其保存在不同的Map中,分为下面几类:
      1. dataWatches:通过getData()和getConfig()注册,监听NodeDeleted和NodeDataChanged
      2. existsWatches:通过exists()注册,监听NodeCreated、NodeDeleted和NodeDataChanged
      3. childWatches:通过getChildren()注册,监听NodeChildrenChanged
      4. defaultWatcher:Watcher对象,非Map,Zookeeper初始化时注册的,用于处理连接状态改变等会话事件
      5. persistentWatches:3.6+版本引入,addWatch()方法注册,可监听多种事件类型,长久有效
      6. persistentRecursiveWatches:3.6+引入,addWatch()方法注册并指定递归模式,监听指定节点及递归子节点事件长久有效
    • 实现细节
      • 保存Watcher的Mapkey是节点路径,value是Set
      • EventThread轮询到waitingEvents有事件后,从ZKWatchManager获取并删除需要被触发的Watcher集合
  • Packet
    • 定义:客户端和服务端进行网络通信的数据包单元,每次请求和响应都会被封装成Packet对象,并咋outgoingQueue和pendingQueue流转
    • 实现细节:一个Packet对象包含一次请求和响应的全部信息:
      • RequestHeader:请求头
        • type:请求类型,如create、getData等
        • xid:客户端事务id
      • ReplyHeader:服务端返回的响应头
        • xid:客户端事务id
        • zxid:服务端事务id,反应操作的全局顺序
        • err:错误码
      • watchRegistration:包含响应需要触发的Watcher注册信息,创建请求时写入
      • AsyncCallback:实际需要触发的异步调用对象,创建请求时写入
      • ByteBuffer:准备通过网络发送的序列化数据
      • ctx:用户自定义的异步回调上下文
      • request:Record类型,由具体操作类型决定
      • response:Record类型,由具体操作类型决定

1.1.2 核心API

所有公开的API都是Zookeeper对象提供的,大部分都支持异步回调

  • create:指定路径创建节点
    • createMode:节点类型(持久/临时/顺序)
  • delete:删除指定路径节点
  • exists:检查节点是否存在,支持Watcher
  • getData:获取指定节点和状态信息,支持Watcher
  • setData:更新指定节点的数据
  • getChildren:获取指定节点的所有子节点列表,支持Watcher
  • getACL/setACL:获取或设置节点的访问控制列表
  • sync:发送集群同步指令,手动保证集群数据的一致性,不同节点收到有不同效果:
    • Leader
      1. 创建提案:为其分配zxid并封装为SYNC提案,建立一个集群状态的同步点
      2. 广播提案:Leader将这个SYNC提案广播给所有的Follower及Observer服务器
      3. 日志持久化:Learner收到SYNC提案后,将其写入本地事务日志,其中Follower会向Leader返回ACK确认
      4. 提交事务:Leader等待直到超过半数Follower的ACK确认,再广播COMMIT给所有的Follower;广播INFORM给所有的Observer
      5. 响应客户端:COMMIT后,Leader直接向客户端响应
    • Learner:分为Follower和Observer
      1. 请求转发:Learner将sync转发给集群的Leader服务器
      2. Leader协调:Leader把sync操作封装成**提案(Proposal)**并发起广播提案,直到提交事务
      3. 提交本地事务:Follower收到COMMIT并更新本地数据;Observer收到INFORM并更新本地数据
      4. 响应客户端:向客户端发送sync操作成功响应

1.2 服务端

角色

  • Leader
    • 定义:集群的领导者,有且只有一个,管理和维护集群所有会话写操作,并同步给其他角色
    • 特点
      • 执行写操作响应读操作
      • 负责向集群广播ProposalCOMMITINFORM
      • 收集Follower的ACK
  • Learner转发写操作响应读操作
    • Follower:跟随者,接收处理Leader广播信息,并向Leader响应ACK
    • Observer:观察者,仅接收处理Leader的广播信息
Proposal和COMMIT
Write Request和ACK
INFORM
Write Request
Read Response
Read Response
Read Response
Leader
Follower
Observer
Client1
Client2
Client3

1.2.1 核心组件

  • ServerCnxnFactory:网络连接工厂,监听端口,接收新连接,为每个客户端创建ServerCnxn实例
    • AcceptThread:用于接收客户端连接请求线程,接收到连接请求后创建SelectorThread进一步处理Socket
    • SelectorThread:创建Socket对应的客户端对象ServerCnxn,并监听Socket是否有可读/可写事件,有则创建IOWorkRequest处理
    • IOWorkRequest:负责具体的读写操作,若是读操作,则调用ServerCnxn开始处理请求;若是写则把数据放入outgoingBuffers队列,并写入Socket
    • ConnectionExpirerThread:定期检查客户端对象ServerCnxn是否过期,过期则关闭网络连接释放资源
  • ServerCnxn:代表一个客户端连接,处理该客户端的所有网络IO,请求反序列化和响应序列化,并调用业务层入口ZooKeeperServer
  • LearnerHandler:运行在Leader服务器的线程,当有Learner连接到Leader,创建该线程维护对应Learner的所有网络通信
    • 发送消息:需要发送给Learner的消息先被放入queuedPackets队列,再由PacketSender线程完成发送
    • 接收消息:由LearnerHandler持续监听Learner,若有消息则读取并交给业务层ZooKeeperServer
  • LearnerSender
    • 定义:Follower和Observer向Leader发送请求的通信组件
    • 实现细节:内部有阻塞队列queuedPackets,要发送的请求放入该队列,会被轮询发送给Leader
  • SessionTracker
    • 定义:会话管理器,管理客户端的会话生命周期
      • Leader:拥有集群所有客户端的会话
      • Learner:仅拥有连接该服务器的客户端会话
    • 实现细节:不会实时检查每个会话是否超时, 而是将在同一个时间间隔内过期的会话放入同一个时间点,时间点是tickTime的整数倍
    • 计算方式
      • 会话过期时间点((currentTimestamp + negotiatedSessionTimeout)/tickTime + 1)*tickTime
      • 时间点检查:SessionTracker每隔tickTime检测一次当前时间点是否有过期会话
    • 核心数据结构
      • sessionSets:HashMap<Long, SessionSet>,时间点对应的过期会话集合,3.6.x之前的实现
      • expiryMap:ConcurrentHashMap<Long, Set>,时间点对应的过期会话集合,3.6.x+
      • sessionsById:HashMap<Long, SessionImpl>,sessionId对应的会话信息
      • sessionsWithTimeout:ConcurrentHashMap<Long, Integer>,sessionId和对应的超时时间negotiatedSessionTimeout
    • 检测会话过期示例
      • negotiatedSessionTimeout:10000
      • tickTime:1000
      • currentTimestamp:2168760
      • session过期点:((2168760+10000)/1000 + 1)*1000=2179760
      • 结果:需要经过11个tickTime时间点检查,会话才会被处理为已过期
  • ZooKeeperServer:作为通网络通组件和请求处理链的桥梁,不同角色有不同实现:
    • Leader实现:LeaderZookeeperServer
    • Follower实现:FollowerZookeeperServer
    • Observer实现:ObserverZookeeperServer
  • WatchManager:管理客户端会话和节点监听关系,服务端中Watcher=ServerCnxn
    • watchTable:hMap<String, Set>,key为数据节点路径,value是监听该节点路径的所有客户端
    • watch2Paths:Map<Watcher, Set>,key为Watcher,value是该客户端监听了哪些节点路径

1.2.2 请求处理链

  • 定义:使用责任链模式,不同角色有不同的处理链,处理链的不同实现完成不同功能
  • 设计实现:内部维护阻塞队列来解耦请求的接收和处理,以提高吞吐量和并发能力
  • 特点
    • 单线程顺序:每个处理器都是一个线程,轮询内部阻塞队列,以保证处理的顺序性
    • 生产消费者:前一个处理器作为生产者,当前处理器作为消费者,当前处理器的阻塞队列为消息队列
  • Leader
    1. PrepRequestProcessor
      • 生产消息:ZooKeeperServer
      • 阻塞队列:submittedRequests
      • 读取消息:从submittedRequests读取消息,进行会话验证、ACL检查和版本检查,并生成zxid
    2. ProposalRequestProcessor
      • 生产消息:PrepRequestProcessor
      • 读取消息:无阻塞队列,进入后直接调用下一个处理器,随后生成Proposal放入Leader的outstandingProposals队列
    3. SyncRequestProcessor
      • 生产消息:ProposalRequestProcessor
      • 阻塞队列:queuedRequests
      • 读取消息:从queuedRequests读取消息后将事务请求异步、批量写入磁盘事务日志文件
    4. AckRequestProcessor
      • 生产消息:SyncRequestProcessor
      • 读取消息:无阻塞队列,同步向Leader自己发送一个本地ACK
    5. CommitProcessor
      • 生产消息
        • queuedRequests队列:ProposalRequestProcessor
        • *committedRequests队列:Leader收到过半ACK后提交消息到committedRequests队列
      • 阻塞队列:queuedRequests队列存放新到达的请求;committedRequests队列存放被Leader确认可提交的事务请求
      • 读取消息
        1. 从queuedRequests读取请求
        2. 非事务请求若前面没有阻塞的事务请求,提交给下个处理器
        3. 事务请求则标记为nextPending,等待对应的COMMIT消息
        4. 轮询committedRequests队列,若zxid和nextPending的zxid匹配,则事务可提交
    6. ToBeAppliedRequestProcessor
      • 生产消息:CommitProcessor
      • 阻塞队列:toBeApplied
      • 读取消息:无,该队列只记录已提交但未应用的事务请求
    7. FinalRequestProcessor
      • 生产消息:ToBeAppliedRequestProcessor
      • 读取消息:同步处理,将事务应用到ZKDatabase并响应客户端
  • Follower
    1. FollowerRequestProcessor
      • 生产消息:FollowerZooKeeperServer
      • 阻塞队列:queuedRequests
      • 读取消息:读取queuedRequests消息识别请求类型,若为事务请求则转发给Leader;若非事务请求则传递给下一个
    2. CommitProcessor:Follower收到Leader的COMMIT消息并调用该处理器开始提交事务
    3. FinalRequestProcessor:由CommitProcessor生产消息
    4. SyncRequestProcessor
      • 生产消息:FollowerZooKeeperServer
      • 阻塞队列:queuedRequests
      • 读取消息:Follower接收到Leader的Proposal后异步、批量的把事务写入日志磁盘
    5. SendAckRequestProcessor
      • 生产消息:SyncRequestProcessor
      • 读取消息:同步处理,生成ACK请求并调用LearnerSender发送给Leader
  • Observer
    1. ObserverRequestProcessor
      • 生产消息:ObserverZooKeeperServer
      • 阻塞队列:queuedRequests
      • 读取消息:读取queuedRequests消息识别请求类型,若为事务请求则转发给Leader;若非事务请求则传递给下一个
    2. CommitProcessor:Observer收到Leader的INFORM消息并调用该处理器开始提交事务
    3. FinalRequestProcessor:由CommitProcessor生产消息

1.2.3 请求类型

主要分为事务请求和非事务请求:

  • 事务请求
    • 定义会改变Zookeeper服务器状态的写操作,需要保证集群内的顺序一致性和原子性
    • 特点集群所有事务请求都要经过Leader处理,并完成后续的提案+ACK+COMMIT流程
    • 请求类型:分为初始化连接和常规写操作
      • 初始化连接
        • 新会话创建:使用SessionTracker.createSession创建会话,包含以下操作:
          • sessionId:生成全局唯一的会话id
          • 保存会话信息:在sessionsById和sessionsWithTimeout保存会话,并计算出会话下次会话超时时间点
        • 会话重连:校验sessionId和sessionPasswd是否有效,通过则激活会话,并计算出会话下次会话超时时间点;若没通过则标记为无效或过期,并通知客户端
      • 常规写操作:create、delete、setData和setACL
  • 非事务请求
    • 定义不会改变Zookeeper服务器状态的读操作
    • 特点:读请求可由接收到该请求的服务器直接在本地处理,无论是Leader、Follower还是Observer
    • 常规读请求:exists、getData、getChildren和getACL等,可能读到旧数据
  • 特殊操作:同步集群数据sync,

2. 流程解析

2.1 客户端

2.1.1 初始化连接

  1. 调用初始化入口:执行new Zookeeper(connectString, sessionTimeout, watcher),开始初始化
  2. 初始化组件:解析服务器地址列表,创建HostProvider并初始化核心网络组件ClientCnxn
  3. 注册默认Watcher:将Watcher注册到ZKWatchManager的defaultWatcher中
  4. 启动网络线程:分别启动SendThread和EventThread核心线程
  5. 建立TCP连接:SendThread从HostProvider获取服务地址,使用NIO或Netty建立TCP连接
  6. 发送创建会话请求:创建ConnectRequest请求并放入outgoingQueue的头位,由SendThread读取并发送给服务端,发送后ConnectRequest会被放入pendingQueue
  7. 处理会话响应:当服务端响应了ConnectResponse,将sessionId、sessionPasswd和negotiatedSessionTimeout更新到本地,并标注已初始化
  8. 创建监听事件:SendThread创建WatchedEvent对象,放入waitingEvents队列
  9. 本地触发Watcher:EventThread从waitingEvents读取事件,从ZKWatchManager的defaultWatcher获取默认Watcher并调用processon()方法

2.1.2 普通请求处理

  1. 调用API:调用Zookeeper的公开API
  2. 封装请求:将请求(如CreateRequest)封装成Packet对象,并放入SendThread的outgoingQueue中
  3. 轮询并发送请求:SendThread线程轮询outgoingQueue获取到Packet对象,序列化后发送给服务端,并移入pendingQueue
  4. 接收响应:SendThread接收到响应,根据xid找到pendingQueue中的Packet,将数据反序列化填入
  5. 处理响应:根据响应类型做不同的操作:
    • 同步请求:唤醒阻塞的调用线程
    • Watcher/异步事件:生成相应的事件放入waitingQueue队列
  6. 触发回调:EventThread轮询waitingQueue获取事件,并根据响应类型做不同操作:
    • Watcher:根据节点路径从ZKWatchManager中获取对应的Watcher,调用对应的process()方法
    • 异步事件:直接调用Packet中的processResult()方法

2.1.3 心跳检测

  1. 计算心跳触发间隔:SendThread根据连接初始化协商后获得的negotiatedSessionTimeout计算心跳间隔:timeToNextPing=negotiatedSessionTimeout/3

  2. 封装Ping请求:创建type=OpCode.ping,xid=-2的Packet对象,并添加到outgoingQueue队列中发送给服务端,随后移入pendingQueue队列

  3. 接收响应:反序列化得到ReplayHead而后,根据xid=-2识别这是心跳响应

  4. 更新状态:重置更新心跳检测相关属性指标

2.1.4 核心参数计算

所有核心参数单位都是毫秒(ms)

静态参数:一旦设置后将不再变动

  • sessionTimeout
    • 设置方式:创建Zookeeper时传入的初始会话超时时间
    • 作用:开发者设定的会话期望超时时间
    • 示例:60000ms
  • negotiatedSessionTimeout
    • 设置方式:服务端区间是[minSessionTimeout,maxSessionTimeout],sessionTimeout需要在该区间中
    • 作用实际生效会话超时时间,客户端和服务端一致遵守
    • 示例:服务端区间=[10000,30000],协商后实际值为30000ms
  • readTimeout
    • 设置方式:连接前sessionTimeout * 2/3,连接后negotiatedSessionTimeout * 2/3
    • 作用最大空闲等待时间,计算心跳间隔和判断会话的基准
    • 示例:最终20000ms
  • connectTimeout
    • 设置方式:连接前sessionTimeout * 2/3,连接后negotiatedSessionTimeout * 2/3
    • 作用初始连接阶段使用的超时时间
    • 示例:连接前40000ms,连接后20000ms

动态计算参数

  • lastSend:
    • 设置方式:记录最后一次发送请求成功的系统时间戳
  • idleSend
    • 计算方式now - lastSend,若idleSend>=10000,会强制发送ping请求
    • 作用距离上次发送的空闲时间,每次成功发送后重置为0,此后随时间增长
    • 示例:空闲了5000ms
  • lastHeard
    • 设置方式:记录最后一次接收响应的系统时间戳
  • idleRecv
    • 计算方式now - lastHeard
    • 作用:**距离上次接收的空闲时间,每次接收响应后重置为0,此后随时间增长
    • 示例:空闲了8000ms
  • timeToNextPing
    • 计算方式timeToNextPing = readTimeout / 2 - idleSend,换算后可得timeToNextPing = negotiatedSessionTimeout / 3 - idleSend,若idleSend > 1000,则额外-1000ms
    • 作用距离下次发送心跳的剩余时间
    • 示例30000 / 3 - 5000 - 1000=4000,需要再过至少4s才发送心跳检测
  • to
    • 计算方式readTimeout - idleRecv,timeToNextPing小于to,则to设置为timeToNextPing
    • 作用接收响应的最大阻塞时间,如果此时间未收到服务端响应,客户端会主动判断会话超时
    • 示例20000 - 8000=12000,本次轮询等待12s,若<0则抛出SessionTimeoutException异常

2.2 服务端

2.2.1 建立客户端通信通道

服务端接收连接请求并创建会话流程所有服务端前置流程一样

  1. 客户端发起请求:客户端发起ConnectRequest连接请求
  2. 服务端接收请求:由ServerCnxnFactory的AcceptThread接收Socket连接
  3. 创建ServerCnxn:AcceptThread创建分配SelectorThread,在该线程中创建ServerCnxn,此时initialized=false
  4. 接收可读数据:SelectorThread检测到有数据可读,判断!initialized,进入初始化会话阶段
  5. 读取请求:ServerCnxn将字节流反序列化为ConnectRequest对象
  6. 验证参数:校验客户端是否为只读模式和客户端保存zxid,协商客户端的会话超时时间
    • 客户端zxid校验:若客户端的最新zxid大于服务端的最新zxid,会关闭客户端并重新建立连接
    • 确定会话超时时间:受服务端配置的minSessionTimeout和maxSessionTimeout限制,必须在[minSessionTimeout,maxSessionTimeout]区间内
  7. 创建会话:根据ConnectRequest是否包含sessionId判断是创建新会话还是重连会话,若是新会话正式开始创建流程

服务端接收普通请求前提是完成建立客户端通信通道

  1. 接收客户端请求:SelectorThread检测到有数据可读,且initialized=true,读取普通请求
  2. 读取请求:将字节流反序列化为Request对象,包括请求头信息
  3. 节流阀控制处理速度:3.6.x+引入,在提交给处理请求流程前放入submittedRequests队列,并异步读取提交给处理器链

2.2.2 处理请求流程

Leader

  1. 接收请求:共有两个入口:
    • 接收客户端连接请求客户端直连Leader,Leader的LeaderZooKeeperServer是被ServerCnxn调用的
    • 接收Learner转发请求:通过集群专属通信组件LearnerHandler接收,并调用LeaderZooKeeperServer入口
  2. 处理请求类型:处理不同请求类型的操作逻辑,同时更新对应会话的超时时间
  3. 事务提案:处理请求后包装成Proposal放入outstandingProposals队列,并广播给集群其它机器
  4. 事务持久化:将事务持久化到磁盘日志文件,保证可靠性
  5. 提案ACK确认:Follower向Leader发送ACK请求,Leader收到后会将机器outstandingProposals队列中对应的Proposal移除
  6. 事务提交:收到集群Follower的ACK过半后,根据zxid顺序提交事务,为Follower广播COMMIT,为Observer广播INFORM
  7. 响应客户端:客户端若是连接的Leader,Leader构建完响应并添加到ServerCnxn的outgoingBuffers队列,并触发写操作发送给客户端

Follower/Observer

  1. 接收请求:只有客户端直连入口,ServerCnxn读取序列化请求后调用不同实现:
    • Follower实现:FollowerZooKeeperServer和FollowerRequestProcessor
    • Observer实现:ObserverZooKeeperServer和ObserverRequestProcessor
  2. 处理请求类型:对于事务请求和非事务请求处理不一样,
    • 事务请求:处理链的第一个处理器将请求转发给Leader,此时进入Leader接收请求流程
    • 非事务请求:处理链直接提交给最终执行者,读取本地内存数据库,并跳至响应客户端步骤
  3. 接收提案:Follower接收Leader发送的Proposal,并持久化到本地事务;Observer会忽略Proposal
  4. 响应ACK:只有Follower处理完Proposal后才能向Leader发送ACK确认,过半Follower响应ACK则集群处理成功
  5. 接收COMMIT/INFORM通知
    • Follower:接收到COMMIT后根据zxid顺序提交事务
    • Observer:接收到INFORM后提取请求相关信息并根据zxid顺序提交请求事务
  6. 响应客户端:客户端若是连接的当前Follower/Observer,构建完响应并添加到ServerCnxn的outgoingBuffers队列,并触发写操作发送给客户端

2.2.3 Watcher注册与触发

注册流程

  1. 识别注册请求:FinalRequestProcessor处理器识别到请求的getWatch()=true,此时触发注册Watcher
  2. 注册Watcher客户端的Watcher不会保存到服务端,服务端保存的Watcher是客户端对应的ServerCnxn对象
  3. 存入WatcherManager:Watcher(也就是ServerCnxn)会被添加到WatcherManager的watchTable和watch2Paths集合

触发流程

  1. 更新节点状态:若更新了节点的数据,如setData,会使用节点路径调用WatcherManager.triggerWatch()
  2. 查询Watcher:从watchTable中查询出该路径所有的Watcher,根据路径和类型创建WatchedEvent对象,并从集合中移除
  3. 通知客户端:ServerCnxn实现了Watcher接口,该接口会将事件通知放入outgoingBuffers队列,并触发写操作发送给客户端
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值