任务调度的三种解决方式

一、任务调度的三种解决方案

一是使用JDK 自带的 Timer,二是使用 Spring Task,三是使用第三方组件 Quartz

Timer 是 JDK 自带的定时任务工具,其简单易用,但是对于复杂的定时规则无法满足,在实际项目开发中也很少使用到。而 Spring Task使用起来很简单,除 Spring 相关的包外不需要额外的包,而且支持注解和配置文件两种形式。 Quartz 功能强大,但是使用起来相对笨重。

  • 单体项目架构使用Spring Task

  • 分布式项目架构使用Quartz

二、三种解决方案的区别

Spring-task是单线程,且功能简单。执行任务只需开启开关@EnableScheduling,在要执行的任务方法上加

@Scheduled(cron = "*/1 * * * * *")注解。它的使用弊端:

  1. 任务A的执行时间会影响任务B的执行间隔,但是任务A和任务B是两个任务,不应该相互影响。

  2. 没有固定组件,持久化等功能,也就没法形成作业系统

Quartz是多线程的高可用的任务调度框架,支持持久化,多线程,集群模式,且有固定组件结构Job、Trigger、scheduler。他的优点一一说明

  1. 有固定组件,有持久化功能,这样就能基于Quartz开发一个任务调度系统,通过UI界面去管理任务调度。

  2. 任务进行持久化之后,重启服务器会加载持久化的任务继续执行。

  3. 任务支持集群模式,如果任务调度模块是一个集群n个节点,那么任务调度不会因为一个节点挂掉而挂掉,且任务在集群之间形成负载均衡。

 4.Quartz官方文档

三、Cron表达式讲解

1.关于 cronExpression 表达式

有至少 6 个(也可能是 7 个)由空格分隔的时间元素。从左至右,这些元素的定义如下:

1.秒(0–59)

2.分钟(0–59)

3.小时(0–23)

4.月份中的日期(1–31)

5.月份(1–12 或 JAN–DEC)

6.星期中的日期(1–7 或 SUN–SAT)

7.年份(1970–2099)

2.各个时间可用值如下:

秒 0-59 , - * /

分 0-59 , - * /

小时 0-23 , - * /

日 1-31 , - * ? / L W C

月 1-12 or JAN-DEC , - * /

周几 1-7 or SUN-SAT , - * ? / L C #

年(可选字段) empty, 1970-2099 , - * /

3.可用值详细分析如下:

"*" —— 字符可以用于所有字段,在"分"字段中设为"*",表示"每一分钟"的含义。
"?" —— 字符可以用在"日"和"周几"字段,它用来指定"不明确的值"。
       这在你需要指定这两个字段中的某一个值而不是另外一个的时候会被用到。在后面的例子中可以看到其含义。
"-" —— 字符被用来指定一个值的范。
       比如在"小时"字段中设为"10-12",表示"10 点到 12 点"。 
"," —— 字符指定数个值。
       比如在"周几"字段中设为"MON,WED,FRI",表示"the days Monday, Wednesday, and Friday"。 
"/" —— 字符用来指定一个值的的增加幅度。
       比如在"秒"字段中设置为"0/15"表示"第 0, 15, 30,和 45 秒"。
       而"5/15"则表示"第 5, 20, 35,和 50"。
       在'/'前加"*"字符相当于指定从 0 秒开始。每个字段都有一系列可以开始或结束的数值。
       对于"秒"和"分"字段来说,其数值范围为 0 到 59。
       对于"小时"字段来说其为 0 到 23,对于“日”字段来说为 0 到 31。
       而对于"月"字段来说为 1 到 12。
       "/"字段仅仅只是帮助你在允许的数值范围内从开始"第 n"的值。
"L" —— 字符可用在"日"和"周几"这两个字段。它是"last"的缩写,但是在这两个字段中有不同的含义。
       "日"字段中的"L"表示"一个月中的最后一天",对于一月就是 31 号,对于二月来说就是 28 号(非闰年)。
       "周几"字段中,它简单的表示"7" or "SAT"。
       但是如果在"周几"字段中使用时跟在某个数字之后,它表示"该月最后一个星期×"。
       比如"6L"表示"该月最后一个周五"。
       当使用"L"选项时,指定确定的列表或者范围非常重要,否则你会被结果搞糊涂的。
"W" —— 可用于"日"字段。用来指定历给定日期最近的工作日(周一到周五)。
       比如将"日"字段设为"15W",意为: "离该月 15 号最近的工作日"。
       因此如果 15 号为周六,触发器会在 14 号即周五调用。
       如果 15 号为周日,触发器会在 16 号也就是周一触发。如果 15 号为周二,那么当天就会触发。
       如果"日"字段设为"1W",而一号是周六,会于下周一即当月的 3 号触发,它不会越过当月的值的范围边界。
       "W"字符只能用于"日"字段的值为单独的一天而不是一系列值的时候。
       "L"和"W"可以组合用于“日”字段表示为'LW',意为"该月最后一个工作日"。 
"#" —— 字符可用于"周几"字段。该字符表示"该月第几个周×"。
       比如"6#3"表示该月第三个周五( 6 表示周五,而"#3"该月第三个)。
       再比如: "2#1" 表示该月第一个周一,而"4#5" 该月第五个周三。
       注意如果你指定"#5"该月没有第五个"周×",该月是不会触发的。
"C" —— 字符可用于"日"和"周几"字段,它是"calendar"的缩写。
       它表示为基于相关的日历所计算出的值(如果有)。如果没有关联的日历,那它等同于包含全部日历。
       "日"字段值为"5C",表示"日历中的第一天或者 5 号以后"。
       "周几"字段值为"1C",则表示"日历中的第一天或者周日以后"。
       对于"月份"字段和"周几"字段来说合法的字符都不是大小写敏感的。

4.实际开发在线文档自动生成

在线Cron表达式生成器通过这个生成器,您可以在线生成任务调度比如Quartz的Cron表达式,对Quartz Cron 表达式的可视化双向解析和生成.icon-default.png?t=N7T8https://cron.qqe2.com/

四、jdk 与 spring-task实现案例

1.jdk实现任务调度

/**
 * 基于jdk的任务调度
 */
public class JdkTaskDemo {
    public static void main(String[] args) {
        //创建定时类
        Timer timer = new Timer();
        //创建任务类
        TimerTask task = new TimerTask() {
            @Override
            public void run() {
                System.out.println("定时任务执行了......"+ LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
            }
        };
        //执行定时任务
        timer.schedule(task,new Date(),2000);
    }
}

2.spring-task实现任务调度

①引入依赖

此依赖整合了spring-task依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
 </dependency>

②打开任务调度注解

@SpringBootApplication
@EnableScheduling
public class TaskStudyApplication {

    public static void main(String[] args) {
        SpringApplication.run(TaskStudyApplication.class, args);
    }
}

③编写任务类

@Scheduled(cron = "*/1 * * * * *")此注解cron表达式规定任务执行规则
public void task1() throws InterruptedException {
    System.out.println(Thread.currentThread().getName()+":task1--->"+ LocalDateTime.now());
    Thread.sleep(5000);
}

  @Scheduled(cron = "*/1 * * * * *") //每秒执行1次
    public void task1() throws InterruptedException {
        System.out.println(Thread.currentThread().getName()+":task1--->"+ LocalDateTime.now());
        Thread.sleep(5000);
    }
}
  • Spring-task 执行任务按照单线程执行并合理执行,不会因为第一个执行任务时间过长而执行第二个

  • Spring-task是单线程的处理任务能力有限,不建议处理分布式架构的任务调度。

五、 Quartz介绍及实现

 1.Quartz API 介绍

  • 任务 Job : 需要实现的任务类,实现 execute() 方法,执行后完成任务。

  • 触发器 Trigger : 包括 SimpleTriggerCronTrigger

  • 调度器 Scheduler : 任务调度器,负责基于 Trigger触发器,来执行 Job任务。

2.QuartzAPI详细讲解

①JobDetail 的作用是绑定 Job,是一个任务实例,它为 Job 添加了许多扩展参数。

主要字段含义
name任务名称
group任务分组,默认分组DEFAULT
jobClass要执行的Job实现类
jobDataMap任务参数信息,JobDetail、Trigger都可以使用JobDataMap来设置一些参数或者信息

每次Scheduler调度执行一个Job的时候,首先会拿到对应的Job,然后创建该Job实例,再去执行Job中的execute()的内容,任务执行结束后,关联的Job对象实例会被释放,且会被JVM GC清除。

为什么设计成JobDetail + Job,不直接使用Job?

JobDetail 定义的是任务数据,而真正的执行逻辑是在Job中。

这是因为任务是有可能并发执行,如果Scheduler直接使用Job,就会存在对同一个Job实例并发访问的问题。

JobDetail & Job 方式,Sheduler每次执行,都会根据JobDetail创建一个新的Job实例,这样就可以 规避并发访问 的问题。

②SimpleTrigger

这是比较简单的一类触发器,用它能实现很多基础的应用。使用它的主要场景包括:

  • 在指定时间段内,执行一次任务

最基础的 Trigger 不设置循环,设置开始时间。

  • 在指定时间段内,循环执行任务

在 1 基础上加上循环间隔。可以指定 永远循环、运行指定次数

③CronTrigger

CronTrigger 是基于日历的任务调度器,在实际应用中更加常用

Trigger trigger = TriggerBuilder.newTrigger()
                .withIdentity("trigger2","group1")
                .startNow()
                .withSchedule(
                        //使用日历触发器
                        CronScheduleBuilder.cronSchedule("0/1 * * * * ? "))
                .build();

六、SpringBoot整合Quartz

1.依赖引入

      <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-quartz</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.26</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.60</version>
        </dependency>

2.配置文件application.yml

server:
  port: 80
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: 123456
    url: jdbc:mysql://127.0.0.1:3306/quartz?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
  # 定时配置
  quartz:
    # 相关属性配置
    properties:
      org:
        quartz:
          # 数据源
          dataSource:
            globalJobDataSource:
              # URL必须大写
              URL: jdbc:mysql://127.0.0.1:3306/quartz?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
              driver: com.mysql.cj.jdbc.Driver
              maxConnections: 5
              username: root
              password: 123456
              # 必须指定数据源类型
              provider: hikaricp
          scheduler:
            instanceName: globalScheduler
            # 实例id
            instanceId: AUTO
            type: com.alibaba.druid.pool.DruidDataSource
          jobStore:
            # 数据源
            dataSource: globalJobDataSource
            # JobStoreTX将用于独立环境,提交和回滚都将由这个类处理
            class: org.quartz.impl.jdbcjobstore.JobStoreTX
            # 驱动配置
            driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
            # 表前缀
            tablePrefix: QRTZ_
            # 失效阈值(只有配置了这个时间,超时策略根据这个时间才有效)
            misfireThreshold: 100
            # 集群配置
            isClustered: true
          # 线程池配置
          threadPool:
            class: org.quartz.simpl.SimpleThreadPool
            # 线程数
            threadCount: 10
            # 优先级
            threadPriority: 5

更多的配置可以到官网查看。
Configuration ReferenceA full-featured, Java-based, In-process job scheduler.icon-default.png?t=N7T8http://www.quartz-scheduler.org/documentation/quartz-2.3.0/configuration/

3.配置application.properties 自动生成表(第一次启动时需要,第二次及以后需要注释掉)

spring.quartz.jdbc.initialize-schema: always
spring.quartz.job-store-type: jdbc

4.实体类用于接收参数

@Data
public class JobInfo {
    /**
     * 任务名称
     */
    private String jobName;
    /**
     * 任务组
     */
    private String jobGroup;
    /**
     * 触发器名称
     */
    private String triggerName;
    /**
     * 触发器组
     */
    private String triggerGroup;
    /**
     * cron表达式
     */
    private String cron;
    /**
     * 类名
     */
    private String className;
    /**
     * 状态
     */
    private String status;
    /**
     * 下一次执行时间
     */
    private String nextTime;
    /**
     * 上一次执行时间
     */
    private String prevTime;
    /**
     * 配置信息(data)
     */
    private String config;
}

5.任务类

@DisallowConcurrentExecution
@PersistJobDataAfterExecution
@Slf4j
@Component
public class MyTask extends QuartzJobBean {
    @Override
    protected void executeInternal(JobExecutionContext context) {
        System.out.println("TimeEventJob正在执行..." + LocalDateTime.now());
        // 执行9秒
        try {
            Thread.sleep(9000);
            System.out.println("TimeEventJob执行完毕..." + LocalDateTime.now());
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 @DisallowConcurrentExecution:这个注解的作用就是同一个任务必须在上一次执行完毕之后,再按照corn时间执行,不会并行执行
 @PersistJobDataAfterExecution:这个注解的作用就是下一个任务用到上一个任务的修改数据(定时任务里面的jobData数据流转)

 6.任务的添加暂停继续删除操作

@Configuration
public class JobHandler {

    @Resource
    private Scheduler scheduler;

    /**
     * 添加任务
     */
    @SuppressWarnings("unchecked")
    public void addJob(JobInfo jobInfo) throws SchedulerException, ClassNotFoundException {
        Objects.requireNonNull(jobInfo, "任务信息不能为空");

        // 生成job key
        JobKey jobKey = JobKey.jobKey(jobInfo.getJobName(), jobInfo.getJobGroup());
        // 当前任务不存在才进行添加
        if (!scheduler.checkExists(jobKey)) {
            Class<Job> jobClass = (Class<Job>)Class.forName(jobInfo.getClassName());
            // 任务明细
            JobDetail jobDetail = JobBuilder
                    .newJob(jobClass)
                    .withIdentity(jobKey)
                    .withIdentity(jobInfo.getJobName(), jobInfo.getJobGroup())
                    .withDescription(jobInfo.getJobName())
                    .build();
            // 配置信息
            jobDetail.getJobDataMap().put("config", jobInfo.getConfig());
            // 定义触发器
            TriggerKey triggerKey = TriggerKey.triggerKey(jobInfo.getTriggerName(), jobInfo.getTriggerGroup());
            // 设置任务的错过机制
            Trigger trigger = TriggerBuilder.newTrigger()
                    .withIdentity(triggerKey)
                    .withSchedule(CronScheduleBuilder.cronSchedule(jobInfo.getCron()).withMisfireHandlingInstructionDoNothing())
                    .build();
            scheduler.scheduleJob(jobDetail, trigger);
        } else {
            throw new SchedulerException(jobInfo.getJobName() + "任务已存在,无需重复添加");
        }
    }

    /**
     * 任务暂停
     */
    public void pauseJob(String jobGroup, String jobName) throws SchedulerException {
        JobKey jobKey = JobKey.jobKey(jobName, jobGroup);
        if (scheduler.checkExists(jobKey)) {
            scheduler.pauseJob(jobKey);
        }
    }

    /**
     * 继续任务
     */
    public void continueJob(String jobGroup, String jobName) throws SchedulerException {
        JobKey jobKey = JobKey.jobKey(jobName, jobGroup);
        if (scheduler.checkExists(jobKey)) {
            scheduler.resumeJob(jobKey);
        }
    }

    /**
     * 删除任务
     */
    public boolean deleteJob(String jobGroup, String jobName) throws SchedulerException {
        JobKey jobKey = JobKey.jobKey(jobName, jobGroup);
        if (scheduler.checkExists(jobKey)) {
            // 这里还需要先删除trigger相关
            //TriggerKey triggerKey = TriggerKey.triggerKey(jobInfo.getTriggerName(), jobInfo.getTriggerGroup());
            //scheduler.getTrigger()
            //scheduler.rescheduleJob()
            return scheduler.deleteJob(jobKey);
        }
        return false;
    }

    /**
     * 获取任务信息
     */
    public JobInfo getJobInfo(String jobGroup, String jobName) throws SchedulerException {
        JobKey jobKey = JobKey.jobKey(jobName, jobGroup);
        if (!scheduler.checkExists(jobKey)) {
            return null;
        }
        List<? extends Trigger> triggers = scheduler.getTriggersOfJob(jobKey);
        if (Objects.isNull(triggers)) {
            throw new SchedulerException("未获取到触发器信息");
        }
        TriggerKey triggerKey = triggers.get(0).getKey();
        Trigger.TriggerState triggerState = scheduler.getTriggerState(triggerKey);
        JobDetail jobDetail = scheduler.getJobDetail(jobKey);

        JobInfo jobInfo = new JobInfo();
        jobInfo.setJobName(jobGroup);
        jobInfo.setJobGroup(jobName);
        jobInfo.setTriggerName(triggerKey.getName());
        jobInfo.setTriggerGroup(triggerKey.getGroup());
        jobInfo.setClassName(jobDetail.getJobClass().getName());
        jobInfo.setStatus(triggerState.toString());

        if (Objects.nonNull(jobDetail.getJobDataMap())) {
            jobInfo.setConfig(JSONObject.toJSONString(jobDetail.getJobDataMap()));
        }

        CronTrigger theTrigger = (CronTrigger) triggers.get(0);
        jobInfo.setCron(theTrigger.getCronExpression());
        return jobInfo;
    }
}

7.controller层

@RestController
@RequestMapping("/job")
public class QuartzController {

    @Resource
    private JobHandler jobHandler;
    @Resource
    private Scheduler scheduler;

    /**
     * 查询所有的任务
     */
    @RequestMapping("/all")
    public List<JobInfo> list() throws SchedulerException {
        List<JobInfo> jobInfos = new ArrayList<>();
        List<String> triggerGroupNames = scheduler.getTriggerGroupNames();
        for (String triggerGroupName : triggerGroupNames) {
            Set<TriggerKey> triggerKeySet = scheduler
                    .getTriggerKeys(GroupMatcher.triggerGroupEquals(triggerGroupName));
            for (TriggerKey triggerKey : triggerKeySet) {
                Trigger trigger = scheduler.getTrigger(triggerKey);
                JobKey jobKey = trigger.getJobKey();
                JobInfo jobInfo = jobHandler.getJobInfo(jobKey.getGroup(), jobKey.getName());
                jobInfos.add(jobInfo);
            }
        }
        return jobInfos;
    }

    /**
     * 添加任务
     */
    @PostMapping("/add")
    public JobInfo addJob(@RequestBody JobInfo jobInfo) throws SchedulerException, ClassNotFoundException {
        jobHandler.addJob(jobInfo);
        return jobInfo;
    }

    /**
     * 暂停任务
     */
    @RequestMapping("/pause")
    public void pauseJob(@RequestParam("jobGroup") String jobGroup, @RequestParam("jobName") String jobName)
            throws SchedulerException {
        jobHandler.pauseJob(jobGroup, jobName);
    }

    /**
     * 继续任务
     */
    @RequestMapping("/continue")
    public void continueJob(@RequestParam("jobGroup") String jobGroup, @RequestParam("jobName") String jobName)
            throws SchedulerException {
        jobHandler.continueJob(jobGroup, jobName);
    }

    /**
     * 删除任务
     */
    @RequestMapping("/delete")
    public boolean deleteJob(@RequestParam("jobGroup") String jobGroup, @RequestParam("jobName") String jobName)
            throws SchedulerException {
        return jobHandler.deleteJob(jobGroup, jobName);
    }
}

8.实现开启服务自动执行任务

@PostConstruct
    public void init(){
        addMyTask();
    }

    private void addMyTask() {
        try {
        JobInfo jobInfo = new JobInfo();
        jobInfo.setJobName("job1");
        jobInfo.setJobGroup("group1");
        jobInfo.setCron("0/1 * * * * ?");//每秒执行一次
        jobInfo.setClassName("com.banana.task.MyTask");//任务类全限路径
        jobInfo.setTriggerName("trigger1");
        jobInfo.setTriggerGroup("group1");
        this.addJob(jobInfo);
        } catch (SchedulerException e) {
            throw new RuntimeException(e);
        } catch (ClassNotFoundException e) {
            throw new RuntimeException(e);
        }

    }

该注解是Java jdk提供的注解,@PostConstruct和@PreDestroy两个作用于Servlet生命周期的注解,实现Bean初始化之前和销毁之前的自定义操作。

9.任务调度持久化的好处

如果任务调度没有持久化,而任务又是基于动态设置,不是开机自启的,会有一个问题,服务重启之后设置的任务都会失效了。如果任务整合持久化之后,设置的动态任务信息就会保存到数据库,开机自启就会加载这些数据库信息,就会按照原来的设置运行任务。

10.Quartz 集群执行与单机执行区别

Quartz是一个开源的作业调度框架,用于在Java应用程序中调度任务。Quartz集群和非集群的区别主要体现在以下几个方面:

  1. 高可用性:Quartz集群可以提供高可用性,即使其中一个节点出现故障,其他节点仍然可以继续工作。而非集群模式下,如果应用程序所在的服务器出现故障,任务调度将会停止。

  2. 负载均衡:Quartz集群可以通过将任务分配给不同的节点来实现负载均衡。这意味着任务将在集群的各个节点上分布,从而提高系统整体的性能和吞吐量。非集群模式下,所有的任务将在单个节点上运行,可能会导致性能瓶颈。

  3. 数据共享:Quartz集群可以共享任务调度的数据,包括作业和触发器等。这意味着当一个节点添加或删除任务时,其他节点也能够感知到。非集群模式下,每个节点都有自己独立的任务调度数据,可能导致数据不一致。

需要注意的是,Quartz集群需要配置和管理多个节点,可能需要更多的系统资源和维护工作。非集群模式则相对简单,适用于小规模的应用程序。选择使用哪种模式应根据具体的需求和系统要求来决定。

  • 13
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Apache DolphinScheduler(incubator,原EasyScheduler)是一个大数据分布式工作流任务调度系统,主要解决大数据研发过程中ETL处理错综复杂的依赖关系,而不能直观监控任务健康状态等问题。DolphinScheduler以DAG流式的方式将Task组装起来,可实时监控任务的运行状态,同时支持重试、从指定节点恢复失败、暂停及Kill任务等操作。 设计特点:一个分布式易扩展的可视化DAG工作流任务调度系统。致力于解决数据处理流程中错综复杂的依赖关系,使调度系统在数据处理流程中开箱即用。 其主要目标如下: 1、以DAG图的方式将Task按照任务的依赖关系关联起来,可实时可视化监控任务的运行状态 2、支持丰富的任务类型:Shell、MR、Spark、SQL(mysql、postgresql、hive、sparksql),Python,Sub_Process、Procedure等 3、支持工作流定时调度、依赖调度、手动调度、手动暂停/停止/恢复,同时支持失败重试/告警、从指定节点恢复失败、Kill任务等操作 4、支持工作流优先级、任务优先级及任务的故障转移及任务超时告警/失败 5、支持工作流全局参数及节点自定义参数设置 6、支持资源文件的在线上传/下载,管理等,支持在线文件创建、编辑 7、支持任务日志在线查看及滚动、在线下载日志等 8、实现集群HA,通过Zookeeper实现Master集群和Worker集群去中心化 9、支持对Master/Worker cpu load,memory,cpu在线查看 10、支持工作流运行历史树形/甘特图展示、支持任务状态统计、流程状态统计 11、支持补数 12、支持多租户 13、支持国际化 14、还有更多等待伙伴们探索

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值