2024年最新Kubernetes任务调用Job与CronJob及源码分析(1),java面试问项目的开发流程

总结

如果你选择了IT行业并坚定的走下去,这个方向肯定是没有一丝问题的,这是个高薪行业,但是高薪是凭自己的努力学习获取来的,这次我把P8大佬用过的一些学习笔记(pdf)都整理在本文中了

《Java中高级核心知识全面解析》

小米商场项目实战,别再担心面试没有实战项目:

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

需要这份系统化的资料的朋友,可以点击这里获取

}

这段代码是用来判断job是否运行失败,判断依据是job重试次数是否超过了BackoffLimit,以及job的运行时间是否超过了设置的ActiveDeadlineSeconds。

上面这里会获取上一次运行的Failed次数和这次的job的failed次数进行比较,如果failed多了表示又产生了新的运行失败的pod。如果运行失败会标识出失败原因,以及设置jobFailed为true。

在上面的代码中调用了pastBackoffLimitOnFailure方法和pastActiveDeadline方法,我们分别看一下:

pastBackoffLimitOnFailure

func pastBackoffLimitOnFailure(job *batch.Job, pods []*v1.Pod) bool {

//如果RestartPolicy为OnFailure,那么直接返回

if job.Spec.Template.Spec.RestartPolicy != v1.RestartPolicyOnFailure {

return false

}

result := int32(0)

for i := range pods {

po := pods[i]

//如果pod状态为Running或Pending

//获取到pod对应的重启次数以及Container状态,包含pod中的InitContainer

if po.Status.Phase == v1.PodRunning || po.Status.Phase == v1.PodPending {

for j := range po.Status.InitContainerStatuses {

stat := po.Status.InitContainerStatuses[j]

result += stat.RestartCount

}

for j := range po.Status.ContainerStatuses {

stat := po.Status.ContainerStatuses[j]

result += stat.RestartCount

}

}

}

//如果BackoffLimit等于,那么只要重启了一次,则返回true

if *job.Spec.BackoffLimit == 0 {

return result > 0

}

//比较重启次数是否超过了BackoffLimit

return result >= *job.Spec.BackoffLimit

}

这个方法会校验job的RestartPolicy策略,不是OnFailure才继续往下执行。然后会遍历pod列表,将pod列表中的重启次数累加并与BackoffLimit进行比较,超过了则返回true。

pastActiveDeadline

func pastActiveDeadline(job *batch.Job) bool {

if job.Spec.ActiveDeadlineSeconds == nil || job.Status.StartTime == nil {

return false

}

now := metav1.Now()

start := job.Status.StartTime.Time

duration := now.Time.Sub(start)

allowedDuration := time.Duration(*job.Spec.ActiveDeadlineSeconds) * time.Second

return duration >= allowedDuration

}

这个方法会算出job的运行时间duration,然后和ActiveDeadlineSeconds进行比较,如果超过了则返回true。

我们回到syncJob中继续往下:

func (jm *Controller) syncJob(key string) (bool, error) {

//job运行失败

if jobFailed {

errCh := make(chan error, active)

//将job里面的active的pod删除

jm.deleteJobPods(&job, activePods, errCh)

select {

case manageJobErr = <-errCh:

if manageJobErr != nil {

break

}

default:

}

// update status values accordingly

//清空active数

failed += active

active = 0

job.Status.Conditions = append(job.Status.Conditions, newCondition(batch.JobFailed, failureReason, failureMessage))

jm.recorder.Event(&job, v1.EventTypeWarning, failureReason, failureMessage)

} else {

//如果job需要同步,并且job没有被删除,则调用manageJob进行同步工作

if jobNeedsSync && job.DeletionTimestamp == nil {

active, manageJobErr = jm.manageJob(activePods, succeeded, &job)

}

//完成数等于pod 运行成功的数量

completions := succeeded

complete := false

//如果没有设置Completions,那么只要有pod完成,那么job就算完成

if job.Spec.Completions == nil {

if succeeded > 0 && active == 0 {

complete = true

}

} else {

//如果实际完成数大于或等于Completions

if completions >= *job.Spec.Completions {

complete = true

//如果还有pod处于active状态,发送EventTypeWarning事件

if active > 0 {

jm.recorder.Event(&job, v1.EventTypeWarning, “TooManyActivePods”, “Too many active pods running after completion count reached”)

}

//如果实际完成数大于Completions,发送EventTypeWarning事件

if completions > *job.Spec.Completions {

jm.recorder.Event(&job, v1.EventTypeWarning, “TooManySucceededPods”, “Too many succeeded pods running after completion count reached”)

}

}

}

//job完成了则更新 job.Status.Conditions 和 job.Status.CompletionTime 字段

if complete {

job.Status.Conditions = append(job.Status.Conditions, newCondition(batch.JobComplete, “”, “”))

now := metav1.Now()

job.Status.CompletionTime = &now

jm.recorder.Event(&job, v1.EventTypeNormal, “Completed”, “Job completed”)

}

}

}

这一段中会根据jobFailed的状态进行判断。

如果jobFailed为true则表示这个job运行失败,需要删除这个job关联的所有pod,并且清空active数。

如果jobFailed为false则表示这个job处于非false状态。如果job需要同步,并且job没有被删除,则调用manageJob进行同步工作;

接下来会对设置的Completions进行处理,如果Completions没有设置,那么只要有一个pod运行完毕,那么这个pod就算完成;

如果实际完成的pod数量大于completions或仍然有pod处于active中,则发送相应的事件信息。最后更新job的状态为完成。

我们接下来一口气看看manageJob中这个同步方法里面做了什么,这个方法是job管理pod运行数量的核心方法:

Controller#manageJob

func (jm *Controller) manageJob(activePods []*v1.Pod, succeeded int32, job *batch.Job) (int32, error) {

//如果处于 active 状态的 pods 数大于 job 设置的并发数 job.Spec.Parallelism

if active > parallelism {

//多出的个数

diff := active - parallelism

errCh = make(chan error, diff)

jm.expectations.ExpectDeletions(jobKey, int(diff))

klog.V(4).Infof(“Too many pods running job %q, need %d, deleting %d”, jobKey, parallelism, diff)

//pods 排序,以便可以优先删除一些pod:

// 判断 pod 状态:Not ready < ready

// 是否已经被调度:unscheduled< scheduled

//判断 pod phase :pending < running

sort.Sort(controller.ActivePods(activePods))

active -= diff

wait := sync.WaitGroup{}

wait.Add(int(diff))

for i := int32(0); i < diff; i++ {

//并发删除多余的 active pods

go func(ix int32) {

defer wait.Done()

if err := jm.podControl.DeletePod(job.Namespace, activePods[ix].Name, job); err != nil {

// Decrement the expected number of deletes because the informer won’t observe this deletion

jm.expectations.DeletionObserved(jobKey)

if !apierrors.IsNotFound(err) {

klog.V(2).Infof(“Failed to delete %v, decremented expectations for job %q/%q”, activePods[ix].Name, job.Namespace, job.Name)

activeLock.Lock()

active++

activeLock.Unlock()

errCh <- err

utilruntime.HandleError(err)

}

}

}(i)

}

wait.Wait()

//若处于 active 状态的 pods 数小于 job 设置的并发数,则需要创建出新的 pod

} else if active < parallelism {

wantActive := int32(0)

//如果没有声明Completions,那么active的pod应该等于parallelism,如果有pod已经完成了,那么不再创建新的。

if job.Spec.Completions == nil {

if succeeded > 0 {

wantActive = active

} else {

wantActive = parallelism

}

// 如果声明了Completions,那么需要比较Completions和succeeded

// 如果wantActive大于parallelism,那么需要创建的Pod数等于parallelism

} else {

// Job specifies a specific number of completions. Therefore, number

// active should not ever exceed number of remaining completions.

wantActive = *job.Spec.Completions - succeeded

if wantActive > parallelism {

wantActive = parallelism

}

}

//计算出 diff 数

diff := wantActive - active

if diff < 0 {

utilruntime.HandleError(fmt.Errorf(“More active than wanted: job %q, want %d, have %d”, jobKey, wantActive, active))

diff = 0

}

//表示已经有足够的pod,不需要再创建了

if diff == 0 {

return active, nil

}

jm.expectations.ExpectCreations(jobKey, int(diff))

errCh = make(chan error, diff)

klog.V(4).Infof(“Too few pods running job %q, need %d, creating %d”, jobKey, wantActive, diff)

active += diff

wait := sync.WaitGroup{}

//创建的 pod 数依次为 1、2、4、8…,呈指数级增长

for batchSize := int32(integer.IntMin(int(diff), controller.SlowStartInitialBatchSize)); diff > 0; batchSize = integer.Int32Min(2*batchSize, diff) {

errorCount := len(errCh)

wait.Add(int(batchSize))

for i := int32(0); i < batchSize; i++ {

//并发程创建pod

go func() {

defer wait.Done()

//创建pod

err := jm.podControl.CreatePodsWithControllerRef(job.Namespace, &job.Spec.Template, job, metav1.NewControllerRef(job, controllerKind))

if err != nil {

}

//创建失败的处理

if err != nil {

defer utilruntime.HandleError(err)

klog.V(2).Infof(“Failed creation, decrementing expectations for job %q/%q”, job.Namespace, job.Name)

jm.expectations.CreationObserved(jobKey)

activeLock.Lock()

active–

activeLock.Unlock()

errCh <- err

}

}()

}

wait.Wait()

diff -= batchSize

}

}

return active, nil

}

这个方法的逻辑十分的清晰,我们下面撸一撸~

这段代码在开始用一个if判断来校验active的pod是否超过了parallelism,如果超过了需要算出超过了多少,存在diff字段中;然后需要删除多余的pod,不过这个时候有个细节的地方,这里会根据pod的状态进行排序,会首先删除一些不是ready状态、unscheduled、pending状态的pod;

若active的pod小于parallelism,那么首先需要判断Completions,如果没有被设置,并且已经有pod运行成功了,那么不需要创建新的pod,否则还是需要创建pod至parallelism指定个数;如果设置了Completions,那么还需要根据pod完成的数量来做一个判断需要创建多少新的pod;

如果需要创建的pod数小于active的pod数,那么直接返回即可;

接下来会在一个for循环中循环并发创建pod,不过创建的数量是依次指数递增,避免一下子创建太多pod。

定时任务CronJob


基本使用

我们从一个例子开始,如下:

apiVersion: batch/v1beta1

kind: CronJob

metadata:

name: hello

spec:

schedule: “*/1 * * * *”

jobTemplate:

spec:

template:

spec:

containers:

  • name: hello

image: busybox

args:

  • /bin/sh

  • -c

  • date; echo Hello from the Kubernetes cluster

restartPolicy: OnFailure

这个CronJob会每分钟创建一个Pod:

$ kubectl get pod

NAME READY STATUS RESTARTS AGE

hello-1596406740-tqnlb 0/1 ContainerCreating 0 8s

cronjob会记录最近的调度时间:

$ kubectl get cronjob hello

NAME SCHEDULE SUSPEND ACTIVE LAST SCHEDULE AGE

hello */1 * * * * False 1 16s 2m33s

spec.concurrencyPolicy

如果设置的间隔时间太短,那么可能会导致任务还没执行完成又创建了新的Pod。所以我们可以通过修改spec.concurrencyPolicy来定义处理策略:

  • Allow,这也是默认情况,这意味着这些 Job 可以同时存在;

  • Forbid,这意味着不会创建新的 Pod,该创建周期被跳过;

  • Replace,这意味着新产生的 Job 会替换旧的、没有执行完的 Job。

如果某一次 Job 创建失败,这次创建就会被标记为“miss”。当在指定的时间窗口内,miss 的数目达到 100 时,那么 CronJob 会停止再创建这个 Job。

spec.startingDeadlineSeconds可以指定这个时间窗口。startingDeadlineSeconds=200意味着过去 200 s 里,如果 miss 的数目达到了 100 次,那么这个 Job 就不会被创建执行了。

cronjob源码分析

CronJob的源码在cronjob_controller.go中,主要实现是在Controller的syncAll方法中。

下面我们看看CronJob是在源码中如何创建运行的:

Controller#syncAll

func (jm *Controller) syncAll() {

//列出所有的job

jobListFunc := func(opts metav1.ListOptions) (runtime.Object, error) {

return jm.kubeClient.BatchV1().Jobs(metav1.NamespaceAll).List(context.TODO(), opts)

}

js := make([]batchv1.Job, 0)

//遍历jobListFunc然后将状态正常的job放入到js集合中

err := pager.New(pager.SimplePageFunc(jobListFunc)).EachListItem(context.Background(), metav1.ListOptions{}, func(object runtime.Object) error {

jobTmp, ok := object.(*batchv1.Job)

if !ok {

return fmt.Errorf(“expected type *batchv1.Job, got type %T”, jobTmp)

}

js = append(js, *jobTmp)

return nil

})

//列出所有的cronJobs

cronJobListFunc := func(opts metav1.ListOptions) (runtime.Object, error) {

return jm.kubeClient.BatchV1beta1().CronJobs(metav1.NamespaceAll).List(context.TODO(), opts)

}

//遍历所有的jobs,根据ObjectMeta.OwnerReference字段确定该job是否由cronJob所创建

//key为uid,value为job集合

jobsByCj := groupJobsByParent(js)

klog.V(4).Infof(“Found %d groups”, len(jobsByCj))

//遍历cronJobs

err = pager.New(pager.SimplePageFunc(cronJobListFunc)).EachListItem(context.Background(), metav1.ListOptions{}, func(object runtime.Object) error {

cj, ok := object.(*batchv1beta1.CronJob)

if !ok {

return fmt.Errorf(“expected type *batchv1beta1.CronJob, got type %T”, cj)

}

//进行同步

syncOne(cj, jobsByCj[cj.UID], time.Now(), jm.jobControl, jm.cjControl, jm.recorder)

//清理所有已经完成的jobs

cleanupFinishedJobs(cj, jobsByCj[cj.UID], jm.jobControl, jm.cjControl, jm.recorder)

return nil

})

if err != nil {

utilruntime.HandleError(fmt.Errorf(“Failed to extract cronJobs list: %v”, err))

return

}

}

syncAll方法会列出所有job以及对应的cronJobs,然后按照cronJobs来进行归类,然后遍历这个列表调用syncOne方法进行同步,之后再调用cleanupFinishedJobs清理所有已经完成的jobs。

然后我们在看看syncOne是具体怎么处理job的:

syncOne

func syncOne(cj *batchv1beta1.CronJob, js []batchv1.Job, now time.Time, jc jobControlInterface, cjc cjControlInterface, recorder record.EventRecorder) {

nameForLog := fmt.Sprintf(“%s/%s”, cj.Namespace, cj.Name)

childrenJobs := make(map[types.UID]bool)

//遍历job列表

for _, j := range js {

childrenJobs[j.ObjectMeta.UID] = true

//查看这个job是否是在Active列表中

found := inActiveList(*cj, j.ObjectMeta.UID)

//如果这个job不是在Active列表中,并且这个job还没有跑完,发送一个异常事件。

if !found && !IsJobFinished(&j) {

recorder.Eventf(cj, v1.EventTypeWarning, “UnexpectedJob”, “Saw a job that the controller did not create or forgot: %s”, j.Name)

// 如果该job在Active列表中,并且已经跑完了,那么从Active列表移除

} else if found && IsJobFinished(&j) {

_, status := getFinishedStatus(&j)

deleteFromActiveList(cj, j.ObjectMeta.UID)

recorder.Eventf(cj, v1.EventTypeNormal, “SawCompletedJob”, “Saw completed job: %s, status: %v”, j.Name, status)

}

}

//反向再遍历Active列表,如果存在上面记录的jobs,那么就移除

for _, j := range cj.Status.Active {

if found := childrenJobs[j.UID]; !found {

recorder.Eventf(cj, v1.EventTypeNormal, “MissingJob”, “Active job went missing: %v”, j.Name)

deleteFromActiveList(cj, j.UID)

}

}

//上面做了cronJob的Active列表的修改,所以需要更新一下状态

updatedCJ, err := cjc.UpdateStatus(cj)

if err != nil {

klog.Errorf(“Unable to update status for %s (rv = %s): %v”, nameForLog, cj.ResourceVersion, err)

return

}

*cj = *updatedCJ

//cronJob已经被删除了,直接返回

if cj.DeletionTimestamp != nil {

return

}

//cronJob处于suspend,直接返回

if cj.Spec.Suspend != nil && *cj.Spec.Suspend {

klog.V(4).Infof(“Not starting job for %s because it is suspended”, nameForLog)

return

}

//获取最近的调度时间

times, err := getRecentUnmetScheduleTimes(*cj, now)

if err != nil {

recorder.Eventf(cj, v1.EventTypeWarning, “FailedNeedsStart”, “Cannot determine if job needs to be started: %v”, err)

klog.Errorf(“Cannot determine if %s needs to be started: %v”, nameForLog, err)

return

}

//等于0说明还没有开始调度

if len(times) == 0 {

klog.V(4).Infof(“No unmet start times for %s”, nameForLog)

return

}

if len(times) > 1 {

klog.V(4).Infof(“Multiple unmet start times for %s so only starting last one”, nameForLog)

}

//获取列表中的最后一次时间

scheduledTime := times[len(times)-1]

tooLate := false

//如果设置了StartingDeadlineSeconds,那么计算是否满足条件

if cj.Spec.StartingDeadlineSeconds != nil {

tooLate = scheduledTime.Add(time.Second * time.Duration(*cj.Spec.StartingDeadlineSeconds)).Before(now)

}

if tooLate {

klog.V(4).Infof(“Missed starting window for %s”, nameForLog)

recorder.Eventf(cj, v1.EventTypeWarning, “MissSchedule”, “Missed scheduled time to start a job: %s”, scheduledTime.Format(time.RFC1123Z))

return

}

//处理concurrencyPolicy策略

//如果设置的是Forbid,并且Active列表大于0,直接return

if cj.Spec.ConcurrencyPolicy == batchv1beta1.ForbidConcurrent && len(cj.Status.Active) > 0 {

klog.V(4).Infof(“Not starting job for %s because of prior execution still running and concurrency policy is Forbid”, nameForLog)

return

}

//如果设置的是Replace,则删除所有的Active列表,等后面重新创建

if cj.Spec.ConcurrencyPolicy == batchv1beta1.ReplaceConcurrent {

for _, j := range cj.Status.Active {

klog.V(4).Infof(“Deleting job %s of %s that was still running at next scheduled start time”, j.Name, nameForLog)

job, err := jc.GetJob(j.Namespace, j.Name)

if err != nil {

recorder.Eventf(cj, v1.EventTypeWarning, “FailedGet”, “Get job: %v”, err)

return

}

if !deleteJob(cj, job, jc, recorder) {

return

}

}

}

//根据cronJob.spec.JobTemplate填充job的完整信息

jobReq, err := getJobFromTemplate(cj, scheduledTime)

if err != nil {

klog.Errorf(“Unable to make Job from template in %s: %v”, nameForLog, err)

return

}

//创建job

jobResp, err := jc.CreateJob(cj.Namespace, jobReq)

if err != nil {

if !errors.HasStatusCause(err, v1.NamespaceTerminatingCause) {

recorder.Eventf(cj, v1.EventTypeWarning, “FailedCreate”, “Error creating job: %v”, err)

}

return

}

klog.V(4).Infof(“Created Job %s for %s”, jobResp.Name, nameForLog)

recorder.Eventf(cj, v1.EventTypeNormal, “SuccessfulCreate”, “Created job %v”, jobResp.Name)

ref, err := getRef(jobResp)

if err != nil {

klog.V(2).Infof(“Unable to make object reference for job for %s”, nameForLog)

} else {

//把创建好的job信息放入到Active列表中

cj.Status.Active = append(cj.Status.Active, *ref)

最后

分享一套我整理的面试干货,这份文档结合了我多年的面试官经验,站在面试官的角度来告诉你,面试官提的那些问题他最想听到你给他的回答是什么,分享出来帮助那些对前途感到迷茫的朋友。

面试经验技巧篇
  • 经验技巧1 如何巧妙地回答面试官的问题
  • 经验技巧2 如何回答技术性的问题
  • 经验技巧3 如何回答非技术性问题
  • 经验技巧4 如何回答快速估算类问题
  • 经验技巧5 如何回答算法设计问题
  • 经验技巧6 如何回答系统设计题
  • 经验技巧7 如何解决求职中的时间冲突问题
  • 经验技巧8 如果面试问题曾经遇见过,是否要告知面试官
  • 经验技巧9 在被企业拒绝后是否可以再申请
  • 经验技巧10 如何应对自己不会回答的问题
  • 经验技巧11 如何应对面试官的“激将法”语言
  • 经验技巧12 如何处理与面试官持不同观点这个问题
  • 经验技巧13 什么是职场暗语

面试真题篇
  • 真题详解1 某知名互联网下载服务提供商软件工程师笔试题
  • 真题详解2 某知名社交平台软件工程师笔试题
  • 真题详解3 某知名安全软件服务提供商软件工程师笔试题
  • 真题详解4 某知名互联网金融企业软件工程师笔试题
  • 真题详解5 某知名搜索引擎提供商软件工程师笔试题
  • 真题详解6 某初创公司软件工程师笔试题
  • 真题详解7 某知名游戏软件开发公司软件工程师笔试题
  • 真题详解8 某知名电子商务公司软件工程师笔试题
  • 真题详解9 某顶级生活消费类网站软件工程师笔试题
  • 真题详解10 某知名门户网站软件工程师笔试题
  • 真题详解11 某知名互联网金融企业软件工程师笔试题
  • 真题详解12 国内某知名网络设备提供商软件工程师笔试题
  • 真题详解13 国内某顶级手机制造商软件工程师笔试题
  • 真题详解14 某顶级大数据综合服务提供商软件工程师笔试题
  • 真题详解15 某著名社交类上市公司软件工程师笔试题
  • 真题详解16 某知名互联网公司软件工程师笔试题
  • 真题详解17 某知名网络安全公司校园招聘技术类笔试题
  • 真题详解18 某知名互联网游戏公司校园招聘运维开发岗笔试题

资料整理不易,点个关注再走吧

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

需要这份系统化的资料的朋友,可以点击这里获取

经验技巧5 如何回答算法设计问题

  • 经验技巧6 如何回答系统设计题
  • 经验技巧7 如何解决求职中的时间冲突问题
  • 经验技巧8 如果面试问题曾经遇见过,是否要告知面试官
  • 经验技巧9 在被企业拒绝后是否可以再申请
  • 经验技巧10 如何应对自己不会回答的问题
  • 经验技巧11 如何应对面试官的“激将法”语言
  • 经验技巧12 如何处理与面试官持不同观点这个问题
  • 经验技巧13 什么是职场暗语

[外链图片转存中…(img-JEaED6wy-1715151382288)]

面试真题篇
  • 真题详解1 某知名互联网下载服务提供商软件工程师笔试题
  • 真题详解2 某知名社交平台软件工程师笔试题
  • 真题详解3 某知名安全软件服务提供商软件工程师笔试题
  • 真题详解4 某知名互联网金融企业软件工程师笔试题
  • 真题详解5 某知名搜索引擎提供商软件工程师笔试题
  • 真题详解6 某初创公司软件工程师笔试题
  • 真题详解7 某知名游戏软件开发公司软件工程师笔试题
  • 真题详解8 某知名电子商务公司软件工程师笔试题
  • 真题详解9 某顶级生活消费类网站软件工程师笔试题
  • 真题详解10 某知名门户网站软件工程师笔试题
  • 真题详解11 某知名互联网金融企业软件工程师笔试题
  • 真题详解12 国内某知名网络设备提供商软件工程师笔试题
  • 真题详解13 国内某顶级手机制造商软件工程师笔试题
  • 真题详解14 某顶级大数据综合服务提供商软件工程师笔试题
  • 真题详解15 某著名社交类上市公司软件工程师笔试题
  • 真题详解16 某知名互联网公司软件工程师笔试题
  • 真题详解17 某知名网络安全公司校园招聘技术类笔试题
  • 真题详解18 某知名互联网游戏公司校园招聘运维开发岗笔试题

[外链图片转存中…(img-oLwn7yFg-1715151382288)]

资料整理不易,点个关注再走吧

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

需要这份系统化的资料的朋友,可以点击这里获取

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值