简单粗暴的分布式定时任务解决方案

news2024/11/25 18:52:18

分布式定时任务

  • 1.为什么需要定时任务?
  • 2.数据库实现分布式定时任务
  • 3.基于redis实现

1.为什么需要定时任务?

因为有时候我们需要定时的执行一些操作,比如业务中产生的一些临时文件,临时文件不能立即删除,因为不清楚用户是否操作完毕,不能立即删除,需要隔一段时间然后定时清楚,还有像是一些电商项目,每月进行数据清算。比如某些业务的排行榜,实时性不是高的也可以使用定时任务去统计,然后在做更新。但是我们现在大多数的应用都是分布式的?相当于你写的一个定时任务会在多个子系统中运行,而且是同时的,我们只需要其中一个任务运行就可以了,如果多次运行不仅会无故消耗系统资源,还会导致任务执行出现意外,那么怎么保证这个任务只执行一次呢?其实解决方案有很多。

分布式任务执行出现的问题,如下图所示:

image-20230310211627527

  1. 使用数据库唯一约束加锁
  2. 使用redis的setNX命令
  3. 使用分布式框架Quartz,TBSchedule,elastic-job,Saturn,xxl-job等

当然技术是为业务服务的,我们怎么选择合适的技术,还得是看业务场景,比如一些任务的执行频率不高,也不是特别要求效率,也不复杂,我们完全用不上为了一个定时任务去引入一些第三方的框架作为定时任务实现,我们来介绍两种方式来实现分布式定时任务。

2.数据库实现分布式定时任务

数据库实现定时任务的核心思路:

  1. 需要两张表,一张定时任务配置表,还有一张定时任务运行记录表
  2. 任务配置表有一个唯一约束字段,运行记录表由运行日期+任务名称作为唯一约束,这是实现的核心思路
  3. 使用注解+aop对定时任务进行代理,统一进行加锁操作,避免多次运行
  4. 这种适合任务不频繁,一天在某个时间点执行,对性能要求不高的业务场景,实现起来也比较简单

表SQL语句:

-- 任务运行记录表
CREATE TABLE `task_record` (
  `ID` varchar(20) NOT NULL COMMENT 'ID',
  `start_time` datetime DEFAULT NULL COMMENT '定时任务开始时间',
  `ent_time` datetime DEFAULT NULL COMMENT '定时任务结束时间',
  `is_success` varchar(1) DEFAULT NULL COMMENT '是否执行成功',
  `error_cause` longtext COMMENT '失败原因',
  `task_name` varchar(100) NOT NULL COMMENT '任务名称',
  `run_date` varchar(6) DEFAULT NULL COMMENT '运行日期',
  PRIMARY KEY (`ID`),
  UNIQUE KEY `run_date_task_name_idx` (`run_date`,`task_name`) USING BTREE COMMENT '运行日期+任务名称作为唯一约束'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='定时任务运行记录表';

-- 任务配置表
CREATE TABLE `task_config` (
  `id` varchar(32) NOT NULL COMMENT '序号',
  `task_describe` varchar(225) DEFAULT NULL COMMENT '任务描述',
  `task_name` varchar(100) DEFAULT NULL COMMENT '任务名称',
  `task_valid` varchar(1) DEFAULT NULL COMMENT '任务有效标志',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `task_index` (`task_name`) COMMENT '唯一性约束'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='定时任务配置表';

1.定时任务标识注解:

/**
 * 标注在定时任务上,避免多个微服务的情况下定时任务执行重复
 * @author compass
 * @date 2023-03-09
 * @since 1.0
 **/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DatabaseLock {
    //定时任务使用的键,千万不要重复
    String lockName() default "";
    //定时任务描述
    String lockDesc() default "";
}

2.使用aop代理定时任务方法进行拦截

/**
 * 代理具体的定时任务,仅有一个任务会被成功执行
 *
 * @author compass
 * @date 2023-03-09
 * @since 1.0
 **/
@Aspect
@Slf4j
@Component
public class DatabaseLockAspect {


    @Resource
    private TaskService taskService;
    private static final String TASK_IS_VALID = "1";

    @Around("@annotation( com.springboot.example.annotation.DatabaseLock)")
    public Object cacheLockPoint(ProceedingJoinPoint pjp) {
        Date startTime = new Date();
        TaskRecord taskRecord = new TaskRecord();

        String isRunSuccess = "1";
        String taskConfigId;
        String errorCause = "";

        Boolean currentDayRunRecord ;


        Method cacheMethod = null;
        for (Method method : pjp.getTarget().getClass().getMethods()) {
            if (null != method.getAnnotation(DatabaseLock.class)) {
                cacheMethod = method;
                break;
            }
        }
        if (cacheMethod != null) {
            String lockName = cacheMethod.getAnnotation(DatabaseLock.class).lockName();
            String lockDesc = cacheMethod.getAnnotation(DatabaseLock.class).lockDesc();
            // 运行主键,避免多次运行核心关键
            String runDate = DateUtil.format(new Date(), "yyyyMMdd");
            String taskRecordId = IdUtil.getSnowflakeNextIdStr();
            taskRecord.setTaskName(lockName);
            taskRecord.setId(taskRecordId);
            taskRecord.setRunDate(runDate);

            if (StringUtils.isBlank(lockName)) {
                throw new RuntimeException("定时任务锁名称不能为空");
            }
            if (StringUtils.isBlank(lockDesc)) {
                throw new RuntimeException("定时任务锁描述不能为空");
            }

            TaskConfig taskConfig = taskService.hasRun(lockName);
            // 还未运行过,进行初始化处理
            if (taskConfig == null) {
                TaskConfig config = new TaskConfig();
                taskConfigId = IdUtil.getSnowflakeNextIdStr();
                config.setId(taskConfigId);
                config.setTaskDescribe(lockDesc);
                config.setTaskName(lockName);
                config.setTaskValid("1");
                config.setCreateTime(new Date());
                try {
                    // 添加时出现异常,已经运行过该定时任务
                    taskService.addTaskConfig(config);
                    taskConfig = config;
                } catch (Exception e) {
                    e.printStackTrace();
                }

                // 有效标志位0表示无需执行
            } else if (!TASK_IS_VALID.equals(taskConfig.getTaskValid())) {
                String message =  "该定时任务已经禁用";
                log.warn("method:{}未获取锁:{}[运行失败原因:{}]", cacheMethod, lockName, message);
                throw new RuntimeException(String.format("method:%s未获取锁:%s[运行失败原因:%s]", cacheMethod, lockName, message));
            }



            // 添加运行记录,以runKey为唯一标识,插入异常,说明执行过
            try {
                currentDayRunRecord = taskService.addCurrentDayRunRecord(taskRecord);
            } catch (Exception e) {
                log.warn("method:{}未获取锁:{}[运行失败原因:已经有别的服务进行执行]", cacheMethod, lockName);
                return  null;
            }

            // 没有执行过,开始执行
            if (currentDayRunRecord) {
                try {
                    log.warn("method:{}获取锁:{},运行成功!", cacheMethod, lockName);
                    return pjp.proceed();
                } catch (Throwable e) {
                    e.printStackTrace();
                    isRunSuccess = "0";
                    errorCause = ExceptionUtils.getExceptionDetail(e);
                } finally {
                    Date endTime = new Date();
                    taskRecord.setStartTime(startTime);
                    taskRecord.setId(IdUtil.getSnowflakeNextIdStr());
                    taskRecord.setEntTime(endTime);
                    taskRecord.setIsSuccess(isRunSuccess);
                    taskRecord.setErrorCause(errorCause);

                    // 修改运行记录
                    taskService.updateTaskRunRecord(taskRecord);
                }
            }

        }
        return null;

    }
}

3.TaskService实现操作数据库接口与实现

public interface TaskService {
    /**
     * 判断定时任务是否运行过
     *
     * @param taskName
     * @return com.springboot.example.bean.task.TaskConfig
     * @author compass
     * @date 2023/3/10 21:22
     * @since 1.0.0
     **/
    TaskConfig hasRun(String taskName);

    /**
     * 将首次运行的任务添加到任务配置表
     *
     * @param taskConfig
     * @return java.lang.Boolean
     * @author compass
     * @date 2023/3/10 21:23
     * @since 1.0.0
     **/
    Boolean addTaskConfig(TaskConfig taskConfig);

    /**
     * 更新定时任务运行记录
     *
     * @param taskRecord
     * @return java.lang.Boolean
     * @author compass
     * @date 2023/3/10 21:23
     * @since 1.0.0
     **/
    Boolean updateTaskRunRecord(TaskRecord taskRecord);

    /**
     * 新增一条运行记录,只有新增成功的服务才可以得到运行劝
     * @param taskRecord
     * @return java.lang.Boolean
     * @author compass
     * @date 2023/3/10 21:23
     * @since 1.0.0
     **/
    Boolean addCurrentDayRunRecord(TaskRecord taskRecord);
}

@Slf4j
@Service
public class TaskServiceImpl implements TaskService {
    @Resource
    private TaskConfigMapper taskConfigMapper;
    @Resource
    private TaskRecordMapper taskRecordMapper;

    @Override
    public TaskConfig hasRun(String taskName) {
        QueryWrapper<TaskConfig> wrapper = new QueryWrapper<>();
        wrapper.eq("task_name",taskName);
        return taskConfigMapper.selectOne(wrapper);
    }

    @Override
    public Boolean addTaskConfig(TaskConfig taskConfig) {
        return taskConfigMapper.insert(taskConfig)>0;
    }


    @Override
    public Boolean updateTaskRunRecord(TaskRecord taskRecord ) {
        QueryWrapper<TaskRecord> wrapper = new QueryWrapper<>();
        wrapper.eq("task_name",taskRecord.getTaskName());
        wrapper.eq("run_date",taskRecord.getRunDate());
        return  taskRecordMapper.update(taskRecord,wrapper)>0;
    }

    @Override
    public Boolean addCurrentDayRunRecord(TaskRecord taskRecord) {
       return taskRecordMapper.insert(taskRecord)>0;
    }
}

4.数据库对应的实体类

// 配置类
@Data
@TableName("task_config")
public class TaskConfig {
    /**
     * 序号
     */
    @TableId(value = "id", type = IdType.ASSIGN_ID)
    private String id;
    /**
     * 任务描述
     */
    private String taskDescribe;
    /**
     * 任务名称
     */
    private String taskName;
    /**
     * 任务有效标志
     */
    private String taskValid;
    /**
     * 创建时间
     */
    private Date createTime;

}
// 运行记录类
@Data
@TableName("task_record")
public class TaskRecord {
    /**
     * ID
     */
    @TableId(value = "id", type = IdType.ASSIGN_ID)
    private String id;
    /**
     * 定时任务开始时间
     */
    private Date startTime;
    /**
     * 定时任务结束时间
     */
    private Date entTime;

    /**
     * 是否执行成功
     */
    private String isSuccess;
    /**
     * 失败原因
     */
    private String  errorCause;
    /**
     * 运行日期[yyyyMMdd]
     */
    private String  runDate;
    /**
     * 任务名称(任务名称+运行日期为唯一索引)
     */
    private String  taskName;



}

3.基于redis实现

  1. 主要是基于setNX来实现的,setNX表示这个key存在,则设置value失败,只有这个key不存在的时候,才会set成功
  2. 我们可以给这个key指定过期时间,让他一定会释放锁,不然容易出现死锁的情况

1.操作redis锁的工具类

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

/**
 * redis锁工具类,如果redis是集群的话需要考虑数据延时性,这里默认为redis单个节点
 *
 * @author compass
 * @date 2023-03-10
 * @since 1.0
 **/
@SuppressWarnings(value = {"all"})
@Component
public class RedisLockUtils {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 加锁
     *
     * @param key
     * @param value
     * @param time
     * @param timeUnit
     * @return boolean
     * @author compass
     * @date 2023/3/10 22:13
     * @since 1.0.0
     **/
    public boolean lock(String key, String value, long time, TimeUnit timeUnit) {
        return (Boolean)redisTemplate.execute((RedisCallback) connection -> {
            Boolean setNX = connection.setNX(key.getBytes(), value.getBytes());
            if (setNX){
              return   connection.expire(key.getBytes(),time);
            }
            return false;
        });
    }

    /**
     * 立即释放锁,如果任务执行的非常快,可能会导致其他应用获得到锁,二次执行
     *
     * @param key
     * @return boolean
     * @author compass
     * @date 2023/3/10 22:13
     * @since 1.0.0
     **/
    public boolean fastReleaseLock(String key) {
        return redisTemplate.delete(key);
    }

    /**
     * 缓慢释放锁(隔离小段时间再释放锁,可以完全避免掉别的应用获取到锁)
     *
     * @param key
     * @param time
     * @param timeUnit
     * @return boolean
     * @author compass
     * @date 2023/3/10 22:13
     * @since 1.0.0
     **/
    public boolean turtleReleaseLock(String key, long time, TimeUnit timeUnit) {
        return redisTemplate.expire(key, time, timeUnit);
    }


}

2.aop切入,统一管理定时任务

import com.springboot.example.annotation.CacheLock;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;

/**
 * 代理具体的定时任务,仅有一个任务会被成功执行
 *
 * @author compass
 * @date 2023-03-09
 * @since 1.0
 **/
@Aspect
@Slf4j
@Component
public class CacheLockAspect {

    @Resource
    private RedisLockUtils redisLockUtils;

    /**
    * 加锁值,可以是任意值
    **/
    private static final String LOCK_VALUE = "1";

    @Around("@annotation(com.springboot.example.annotation.CacheLock)")
    public Object cacheLockPoint(ProceedingJoinPoint pjp) {


        Method cacheMethod = null;
        for (Method method : pjp.getTarget().getClass().getMethods()) {
            if (null != method.getAnnotation(CacheLock.class)) {
                cacheMethod = method;
                break;
            }
        }

        if (cacheMethod!=null){
            CacheLock cacheLock = cacheMethod.getAnnotation(CacheLock.class);
            String lockName = cacheLock.lockName();
            long time = cacheLock.timeOut();

            boolean successLock = redisLockUtils.lock(lockName,LOCK_VALUE, time, TimeUnit.SECONDS);
            if (successLock){
                log.info("method:{}获取锁成功:{}", cacheMethod, lockName);
                try {
                    // 获得锁调用被代理的定时任务
                    return pjp.proceed();
                } catch (Throwable throwable) {
                    throwable.printStackTrace();
                }finally {
                    // 延时5秒再去释放锁
                    redisLockUtils.turtleReleaseLock(lockName,5,TimeUnit.SECONDS);
                }
            }else {
                log.warn("method:{}获取锁失败:{}", cacheMethod, lockName);

            }

        }

        return null;

    }
}

3.自定义注解


/**
 * 标注在定时任务上,避免多个微服务的情况下定时任务执行重复
 * @author compass
 * @date 2023-03-09
 * @since 1.0
 **/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CacheLock {
    //定时任务使用的键,千万不要重复
    String lockName() ;
    // 占用锁的时间,单位是秒,默认10分钟
    long timeOut() default 60*10;
}

今天就先介绍这两种方式,后续我再为大家续上使用别的框架进行实现,不过在实现的过程中使用 redisTemplate.opsForValue().setIfAbsent() 出现了一点小肯,他返回的是null值,然后出现空指针,然后我不得不采用execute的方式去执行。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/403324.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

基于FPGA实现正弦插值算法

1、正弦插值的算法分析 1.1 信号在时域与频域的映射关系 在进行正弦算法分析之前&#xff0c;我们回顾一下《数字信号处理》课程中&#xff0c;对于信号在时域与频域之间的映射关系&#xff0c;如下图。 对于上图中的原始信号x(t)&#xff0c;使用ADC对信号进行采样&#xff0…

【操作系统】进程句柄

进程句柄句柄是什么为什么需要句柄作用句柄是什么 先给结论&#xff0c;句柄&#xff08;handle&#xff09;实际上是一个指向指针的指针。 它指向进程所要访问的进程对象的地址&#xff0c;是用来找到目标进程的索引&#xff0c;当我们想要访问对象进程时&#xff0c;就要利…

从一道面试题看 TCP 的吞吐极限

分享一个 TCP 面试题&#xff1a;单条 TCP 流如何打满香港到旧金山的 320Gbps 专线&#xff1f;(补充&#xff0c;写成 400Gbps 更具迷惑性&#xff0c;但预测大多数人都会跑偏&#xff0c;320Gbps 也就白给了) 这个题目是上周帮一个朋友想的&#xff0c;建议他别问三次握手&a…

C#:Krypton控件使用方法详解(第十六讲) ——kryptonCheckedListBox

今天介绍的Krypton控件中的kryptonCheckedListBox。下面介绍控件的外观属性如下图所示&#xff1a;Cursor属性&#xff1a;表示鼠标移动过该控件的时候&#xff0c;鼠标显示的形状。属性值如下图所示&#xff1a;UseWaitCursor属性&#xff1a;表示鼠标在控件中等待时&#xff…

问ChatGPT:零基础如何学好.Net Core?

更多开源项目请查看&#xff1a;一个专注推荐.Net开源项目的榜单 ChatGPT横空出世&#xff0c;一下子让全球互联网企业都慌了&#xff0c;纷纷表示&#xff1a;马上跟进发布ChatGPT&#xff0c;媒体纷纷报道大有改变教培行业。 下面我们问问ChatGPT&#xff1a;零基础如何学好…

EasyCVR视频融合平台开放插件功能:支持EasyNTS与EasyShark抓包

EasyCVR视频融合平台基于云边端一体化架构&#xff0c;具有强大的数据接入、处理及分发能力&#xff0c;平台支持海量视频汇聚管理&#xff0c;能在复杂的网络环境中&#xff0c;将分散的各类视频资源进行统一汇聚、整合、集中管理&#xff0c;实现视频资源的鉴权管理、按需调阅…

GPS/GPRS车载定位系统智能终端设计μC/OS-Ⅱ调度液晶显示汽车行驶记录仪电路

wx供重浩&#xff1a;创享日记 对话框发送&#xff1a;gps电路 免费下载完整无水印论文报告&#xff08;包含主板电路图和采集板电路图&#xff09; 文章目录一、绪论二、车载智能终端三、车载智能终端的硬件结构及设计四、车载智能终端的软件结构及设计五、总结和展望附录一 汽…

小i机器人登陆纳斯达克:市值4.2亿美元,与苹果打了10年专利侵权官司

‍数据智能产业创新服务媒体——聚焦数智 改变商业要问当前科技圈里最靓的仔是谁&#xff1f;那当然是非 ChatGPT莫属。当下谁能推出真正意义上的中国版ChatGPT&#xff0c;并且在这轮AI浪潮竞争白热化阶段中笑到最后&#xff0c;已经成为人们关注的焦点。美东时间3月9日&…

使用chatgpt写6.5分作文范文

其实使用chatgpt最大的背单词好处就是你可以看到真正的外国人的思维到底是如何的。而且&#xff0c;你也可以看到chatgpt这个模型&#xff0c;如果是编写代码的话&#xff0c;你如果使用中文&#xff0c;它编写的效果是没有英文输入的好的&#xff0c;为什么呢&#xff1f;因为…

Vector - CAPL - log回放模块函数

Replay log回放模块作为我们常见的问题分析小工具,是大部分车载圈老人的必备工具,不过自从CANoe软件11.0之后的版本变动依然无法阻挡每一个车载人使用的热情,除了我们常见的手动回放log外,我们工作中还有一部分低概率以及极低概率出现的问题,这时候我们就需要通过自动化对…

python趣味编程-2048游戏

在上一期我们用Python实现了一个盒子追逐者的游戏&#xff0c;这一期我们继续使用Python实现一个简单的2048游戏&#xff0c;让我们开始今天的旅程吧~ 在 Python 免费源代码中使用 Tkinter 的简单 2048 游戏 使用 Tkinter 的简单 2048 游戏是一个用Python编程语言编码的桌面游…

阅读笔记DeepAR: Probabilistic Forecasting with Autoregressive Recurrent Networks

zi,t∈Rz_{i,t}\in \mathbb{R}zi,t​∈R表示时间序列iii在ttt时刻的值。给一个连续时间段t∈[1,T]t\in [1, T]t∈[1,T]&#xff0c;将其划分为context window[1,t0)[1,t_0)[1,t0​)和prediction window[t0,T][t_0,T][t0​,T]。用context window的时间序列预测prediction window…

关于异常控制流和系统级 I/O:进程

&#x1f4ad; 写在前面&#xff1a;本文将学习《深入理解计算机系统》的第六章 - 关于异常控制流和系统级 I/O 的 进程部分。CSAPP 是计算机科学经典教材《Computer Systems: A Programmers Perspective》的缩写&#xff0c;该教材由Randal E. Bryant和David R. OHallaron 合著…

C 去除字符串中重复字母(LeetCode)

&#x1f31f;前言摆烂太久&#xff0c;好久没有更文了&#xff0c;小九和大家一起看看题写写题找回手感吧&#xff0c;也希望这篇文章可以帮助正在寻找解题答案的朋友&#xff0c;你们的支持就是我最大的动力&#xff01;求三连&#xff01;求关注呀&#xff01;&#x1f31f;…

信息系统项目管理师第4版教材的变化:PMBOK第六版和第七版的叠加

昨天下午&#xff0c;软考官方网站突然发布了第4版考纲和教材&#xff0c;这让很多在准备今年上半年信息系统项目管理师考试的考生们有些紧张。那么这次教材改版主要的变化是什么&#xff0c;5月份的考试是否会按照新版的教材来考呢&#xff1f;让我们逐个章节进行分析。信息化…

Spark RDD编程基本操作

RDD是Spark的核心概念&#xff0c;它是一个只读的、可分区的分布式数据集&#xff0c;这个数据集的全部或部分可以缓存在内存中&#xff0c;可在多次计算间重用。Spark用Scala语言实现了RDD的API&#xff0c;程序员可以通过调用API实现对RDD的各种操作&#xff0c;从而实现各种…

数据结构刷题(二十三):47全排列II、51N皇后、37解数独

1.全排列II题目链接思路&#xff1a;回溯之排列问题并且有数组排序标记数组。 回溯三部曲同46. 全排列。过程图&#xff1a;https://programmercarl.com/0047.%E5%85%A8%E6%8E%92%E5%88%97II.html#%E6%80%9D%E8%B7%AF注意&#xff1a;本题的去重操作主要在树层上。就是说同一树…

2.JVM常识之 运行时数据区

1.JVM核心组成 2.JVM 运行时数据区&#xff08;jdk8&#xff09; 程序计数器&#xff1a;线程私有&#xff0c;当前线程所执行字节码的行号指示器 jvm栈&#xff1a;线程私有&#xff0c;Java 虚拟机栈为 JVM 执行 Java 方法服务 本地方法栈&#xff1a;线程私有&#xff0c;本…

SpringBoot接口 - 如何统一异常处理

SpringBoot接口如何对异常进行统一封装&#xff0c;并统一返回呢&#xff1f;以上文的参数校验为例&#xff0c;如何优雅的将参数校验的错误信息统一处理并封装返回呢&#xff1f;为什么要优雅的处理异常如果我们不统一的处理异常&#xff0c;经常会在controller层有大量的异常…

【Java】初识Java

Java和C语言有许多类似之处&#xff0c;这里就只挑不一样的点来说&#xff0c;所以会比较杂乱哈~ 目录 1.数据类型 2.输入与输出 2.1三种输出 2.2输入 2.3循环输入输出 //猜数字小游戏 //打印乘法口诀表 3.方法 //交换两个数&#xff08;数组的应用&#xff09; //模…