zoukankan      html  css  js  c++  java
  • Yarn源码分析之如何确定作业运行方式Uber or Non-Uber?

    在MRAppMaster中,当MapReduce作业初始化时,它会通过作业状态机JobImpl中InitTransition的transition()方法,进行MapReduce作业初始化相关操作,而这其中就包括:

            1、调用createSplits()方法,创建分片,并获取任务分片元数据信息TaskSplitMetaInfo数组taskSplitMetaInfo;

            2、确定Map Task数目numMapTasks:分片元数据信息数组的长度,即有多少分片就有多少numMapTasks;

            3、确定Reduce Task数目numReduceTasks,取作业参数mapreduce.job.reduces,参数未配置默认为0;

            4、根据分片元数据信息计算输入长度inputLength,也就是作业大小;

            5、根据作业大小inputLength,调用作业的makeUberDecision()方法,决定作业运行模式是Uber模式还是Non-Uber模式。

            相关关键代码如下:

    [java] view plain copy
     
     在CODE上查看代码片派生到我的代码片
    1. // 调用createSplits()方法,创建分片,并获取任务分片元数据信息TaskSplitMetaInfo数组taskSplitMetaInfo  
    2. TaskSplitMetaInfo[] taskSplitMetaInfo = createSplits(job, job.jobId);  
    3.   
    4. // 确定Map Task数目numMapTasks:分片元数据信息数组的长度,即有多少分片就有多少numMapTasks  
    5. job.numMapTasks = taskSplitMetaInfo.length;  
    6. // 确定Reduce Task数目numReduceTasks,取作业参数mapreduce.job.reduces,参数未配置默认为0  
    7. job.numReduceTasks = job.conf.getInt(MRJobConfig.NUM_REDUCES, 0);  
    8.   
    9. // 省略部分代码  
    10.   
    11. // 根据分片元数据信息计算输入长度inputLength,也就是作业大小  
    12. long inputLength = 0;  
    13. for (int i = 0; i < job.numMapTasks; ++i) {  
    14.   inputLength += taskSplitMetaInfo[i].getInputDataLength();  
    15. }  
    16.   
    17. // 根据作业大小inputLength,调用作业的makeUberDecision()方法,决定作业运行模式是Uber模式还是Non-Uber模式  
    18. job.makeUberDecision(inputLength);  

            由此,我们可以看出,作业运行方式Uber or Non-Uber是通过Job的makeUberDecision()方法,传入作业大小inputLength来确定的,本文,我们将研究这一话题,即如何确定作业运行方式Uber or Non-Uber?

            在《Yarn源码分析之MRAppMaster:作业运行方式Local、Uber、Non-Uber》一文中我们了解了Uber和Non-Uber两种作业运行方式的含义,如下:

            1、Uber模式:为降低小作业延迟而设计的一种模式,所有任务,不管是Map Task,还是Reduce Task,均在同一个Container中顺序执行,这个Container其实也是MRAppMaster所在Container;

            2、Non-Uber模式:对于运行时间较长的大作业,先为Map Task申请资源,当Map Task运行完成数目达到一定比例后再为Reduce Task申请资源。

            在确定了解上述内容后,我们再来看下Job的makeUberDecision()方法,这个Job的实现为JobImpl类,其makeUberDecision()方法代码如下:

    [java] view plain copy
     
     在CODE上查看代码片派生到我的代码片
    1.  /** 
    2.   * Decide whether job can be run in uber mode based on various criteria. 
    3.   * @param dataInputLength Total length for all splits 
    4.   */  
    5.  private void makeUberDecision(long dataInputLength) {  
    6.    //FIXME:  need new memory criterion for uber-decision (oops, too late here;  
    7.    // until AM-resizing supported,  
    8.    // must depend on job client to pass fat-slot needs)  
    9.    // these are no longer "system" settings, necessarily; user may override  
    10.     
    11. // 获取系统Uber模式下允许的最大Map任务数sysMaxMaps,  
    12. // 取参数mapreduce.job.ubertask.maxmaps,参数未配置默认为9  
    13.    int sysMaxMaps = conf.getInt(MRJobConfig.JOB_UBERTASK_MAXMAPS, 9);  
    14.   
    15.    // 获取系统Uber模式下允许的最大Reduce任务数sysMaxReduces,  
    16.     // 取参数mapreduce.job.ubertask.maxreduces,参数未配置默认为1  
    17.     int sysMaxReduces = conf.getInt(MRJobConfig.JOB_UBERTASK_MAXREDUCES, 1);  
    18.   
    19.    // 获取系统Uber模式下允许的任务包含数据量最大字节数sysMaxBytes,  
    20.    // mapreduce.job.ubertask.maxbytes,参数未配置默认为远程作业提交路径remoteJobSubmitDir所在文件系统的默认数据块大小  
    21.    long sysMaxBytes = conf.getLong(MRJobConfig.JOB_UBERTASK_MAXBYTES,  
    22.        fs.getDefaultBlockSize(this.remoteJobSubmitDir)); // FIXME: this is wrong; get FS from  
    23.                                   // [File?]InputFormat and default block size  
    24.                                   // from that  
    25.   
    26.    // 获取系统为Uber模式设置的内存资源单元槽Slot大小sysMemSizeForUberSlot,  
    27.    // 取参数yarn.app.mapreduce.am.resource.mb,参数未配置默认为1536M  
    28.    long sysMemSizeForUberSlot =  
    29.        conf.getInt(MRJobConfig.MR_AM_VMEM_MB,  
    30.            MRJobConfig.DEFAULT_MR_AM_VMEM_MB);  
    31.   
    32.    // 获取系统为Uber模式设置的CPU资源单元槽Slot大小sysCPUSizeForUberSlot,  
    33.    // 取参数yarn.app.mapreduce.am.resource.cpu-vcores,参数未配置默认为1  
    34.    long sysCPUSizeForUberSlot =  
    35.        conf.getInt(MRJobConfig.MR_AM_CPU_VCORES,  
    36.            MRJobConfig.DEFAULT_MR_AM_CPU_VCORES);  
    37.   
    38.    // 获取系统是否允许Uber模式标志位uberEnabled,  
    39.    // 取参数mapreduce.job.ubertask.enable,参数未配置默认为false,不启用  
    40.    boolean uberEnabled =  
    41.        conf.getBoolean(MRJobConfig.JOB_UBERTASK_ENABLE, false);  
    42.      
    43.    // 判断Map任务数是否满足系统为Uber模式设定的限制条件,结果赋值给smallNumMapTasks  
    44.    boolean smallNumMapTasks = (numMapTasks <= sysMaxMaps);  
    45.    // 判断Reduce任务数是否满足系统为Uber模式设定的限制条件,结果赋值给smallNumReduceTasks  
    46.    boolean smallNumReduceTasks = (numReduceTasks <= sysMaxReduces);  
    47.    // 判断任务包含数据量大小是否满足系统为Uber模式设定的限制条件,结果赋值给smallInput  
    48.    boolean smallInput = (dataInputLength <= sysMaxBytes);  
    49.    // ignoring overhead due to UberAM and statics as negligible here:  
    50.      
    51.    // 获取系统配置的Map任务要求的内存大小requiredMapMB,  
    52.    // 取参数mapreduce.map.memory.mb,参数未配置默认为0  
    53.    long requiredMapMB = conf.getLong(MRJobConfig.MAP_MEMORY_MB, 0);  
    54.      
    55.    // 获取系统配置的Map任务要求的内存大小requiredReduceMB,  
    56.    // 取参数mapreduce.reduce.memory.mb,参数未配置默认为0  
    57.    long requiredReduceMB = conf.getLong(MRJobConfig.REDUCE_MEMORY_MB, 0);  
    58.      
    59.    // 计算要求的任务内存大小requiredMB,  
    60.    // 取Map任务要求的内存大小requiredMapMB与Reduce任务要求的内存大小requiredReduceMB中的较大者  
    61.    long requiredMB = Math.max(requiredMapMB, requiredReduceMB);  
    62.      
    63.    // 获取系统uber模式下Map任务要求的CPU核数requiredMapCores,  
    64.     // 取参数mapreduce.map.cpu.vcores,参数未配置默认为1  
    65.    int requiredMapCores = conf.getInt(  
    66.            MRJobConfig.MAP_CPU_VCORES,   
    67.            MRJobConfig.DEFAULT_MAP_CPU_VCORES);  
    68.      
    69.    // 获取系统uber模式下Reduce任务要求的CPU核数requiredReduceCores,  
    70.     // 取参数mapreduce.reduce.cpu.vcores,参数未配置默认为1  
    71.    int requiredReduceCores = conf.getInt(  
    72.            MRJobConfig.REDUCE_CPU_VCORES,   
    73.            MRJobConfig.DEFAULT_REDUCE_CPU_VCORES);  
    74.   
    75.    // 计算要求的任务需要CPU核数requiredCores,  
    76.    // 取Map任务要求的CPU核数requiredMapCores与Reduce任务要求的CPU核数requiredReduceCores中的较大者  
    77.    int requiredCores = Math.max(requiredMapCores, requiredReduceCores);      
    78.      
    79.    // 特殊处理:如果Reduce任务数目为0,即当为Map-Only任务时,  
    80.    // 要求的内存大小、CPU核数,以Map任务要求的为准  
    81.    if (numReduceTasks == 0) {  
    82.      requiredMB = requiredMapMB;  
    83.      requiredCores = requiredMapCores;  
    84.    }  
    85.      
    86.    // 当MR作业中任务要求的内存大小requiredMB小于等于系统为Uber模式设置的内存资源单元槽Slot大小sysMemSizeForUberSlot时,  
    87.    // 或者sysMemSizeForUberSlot被设定为不受限制时,  
    88.    // 确定为小内存要求,即标志位smallMemory为true  
    89.    boolean smallMemory =  
    90.        (requiredMB <= sysMemSizeForUberSlot)  
    91.        || (sysMemSizeForUberSlot == JobConf.DISABLED_MEMORY_LIMIT);  
    92.      
    93.    // 当MR作业中任务要求的CPU核数requiredCores小于等于系统为Uber模式设置的CPU资源单元槽Slot大小sysCPUSizeForUberSlot时,  
    94.    // 确定为小CPU要求,即标志位smallCpu为true  
    95.    boolean smallCpu = requiredCores <= sysCPUSizeForUberSlot;  
    96.      
    97.    // 确定作业是否为链式作业,并赋值给标志位notChainJob,ture表示非链式作业,false表示为链式作业  
    98.    boolean notChainJob = !isChainJob(conf);  
    99.   
    100.    // User has overall veto power over uberization, or user can modify  
    101.    // limits (overriding system settings and potentially shooting  
    102.    // themselves in the head).  Note that ChainMapper/Reducer are  
    103.    // fundamentally incompatible with MR-1220; they employ a blocking  
    104.    // queue between the maps/reduces and thus require parallel execution,  
    105.    // while "uber-AM" (MR AM + LocalContainerLauncher) loops over tasks  
    106.    // and thus requires sequential execution.  
    107.      
    108.    // 判断是否为Uber模式,赋值给isUber,  
    109.    // 判断的依据为,以下七个条件必须全部满足:  
    110.    // 1、参数mapreduce.job.ubertask.enable配置为true,即系统允许Uber模式;  
    111.    // 2、Map任务数满足系统为Uber模式设定的限制条件,即小于等于参数mapreduce.job.ubertask.maxmaps配置的值,如果参数未配置,则应该小于等于9;  
    112.    // 3、Reduce任务数满足系统为Uber模式设定的限制条件,即小于等于参数mapreduce.job.ubertask.maxreduces配置的值,如果参数未配置,则应该小于等于1;  
    113.    // 4、任务包含数据量大小满足系统为Uber模式设定的限制条件,即任务数据量小于等于参数mapreduce.job.ubertask.maxbytes配置的值,如果参数未配置,则应小于等于远程作业提交路径remoteJobSubmitDir所在文件系统的默认数据块大小;  
    114.    // 5、MR作业中任务要求的内存大小requiredMB小于等于系统为Uber模式设置的内存资源单元槽Slot大小sysMemSizeForUberSlot时,或者sysMemSizeForUberSlot被设定为不受限制;  
    115.    // 6、MR作业中任务要求的CPU核数requiredCores小于等于系统为Uber模式设置的CPU资源单元槽Slot大小sysCPUSizeForUberSlot;  
    116.    // 7、作业为非链式作业;  
    117.    isUber = uberEnabled && smallNumMapTasks && smallNumReduceTasks  
    118.        && smallInput && smallMemory && smallCpu   
    119.        && notChainJob;  
    120.   
    121.    if (isUber) {// 当作业为Uber模式运行时,设置一些必要的参数  
    122.      LOG.info("Uberizing job " + jobId + ": " + numMapTasks + "m+"  
    123.          + numReduceTasks + "r tasks (" + dataInputLength  
    124.          + " input bytes) will run sequentially on single node.");  
    125.   
    126.      // make sure reduces are scheduled only after all map are completed  
    127.      // mapreduce.job.reduce.slowstart.completedmaps参数设置为1,  
    128.      // 即全部Map任务完成后才会为Reduce任务分配资源  
    129.      conf.setFloat(MRJobConfig.COMPLETED_MAPS_FOR_REDUCE_SLOWSTART,  
    130.                        1.0f);  
    131.      // uber-subtask attempts all get launched on same node; if one fails,  
    132.      // probably should retry elsewhere, i.e., move entire uber-AM:  ergo,  
    133.      // limit attempts to 1 (or at most 2?  probably not...)  
    134.      // Map、Reduce任务的最大尝试次数均为1  
    135.      conf.setInt(MRJobConfig.MAP_MAX_ATTEMPTS, 1);  
    136.      conf.setInt(MRJobConfig.REDUCE_MAX_ATTEMPTS, 1);  
    137.   
    138.      // disable speculation  
    139.      // 禁用Map、Reduce任务的推测执行机制  
    140.      conf.setBoolean(MRJobConfig.MAP_SPECULATIVE, false);  
    141.      conf.setBoolean(MRJobConfig.REDUCE_SPECULATIVE, false);  
    142.    } else {// 当作业为Non-Uber模式时,通过info级别日志,输出作业不能被判定为Uber模式的原因,根据上述7个标志位判断即可  
    143.      StringBuilder msg = new StringBuilder();  
    144.      msg.append("Not uberizing ").append(jobId).append(" because:");  
    145.      if (!uberEnabled)  
    146.     // Uber模式开关未打开,这种模式被禁用了  
    147.        msg.append(" not enabled;");  
    148.      if (!smallNumMapTasks)  
    149.     // 有太多的Map任务  
    150.        msg.append(" too many maps;");  
    151.      if (!smallNumReduceTasks)  
    152.     // 有太多的Reduce任务  
    153.        msg.append(" too many reduces;");  
    154.      if (!smallInput)  
    155.     // 有太大的输入  
    156.        msg.append(" too much input;");  
    157.      if (!smallCpu)  
    158.     // 需要占用过多的CPU  
    159.        msg.append(" too much CPU;");  
    160.      if (!smallMemory)  
    161.     // 需要占用过多的内存  
    162.        msg.append(" too much RAM;");  
    163.      if (!notChainJob)  
    164.     // 是一个链式作业,无法使用Uber模式  
    165.        msg.append(" chainjob;");  
    166.        
    167.      // 记录无法使用Uber模式的日志信息  
    168.      LOG.info(msg.toString());  
    169.    }  
    170.  }  

            makeUberDecision()方法的逻辑十分清晰,但是涉及到的判断条件、参数比较多,总的来说,一个MapReduce是使用Uber模式还是Non-Uber模式运行,要综合考虑以下7个因素,这些条件缺一不可:

            1、 参数mapreduce.job.ubertask.enable配置为true,即系统允许Uber模式,这是一个Uber模式的开关;

            2、Map任务数满足系统为Uber模式设定的限制条件,即小于等于参数mapreduce.job.ubertask.maxmaps配置的值,如果参数未配置,则应该小于等于9;

            3、Reduce任务数满足系统为Uber模式设定的限制条件,即小于等于参数mapreduce.job.ubertask.maxreduces配置的值,如果参数未配置,则应该小于等于1;

            4、任务包含数据量大小满足系统为Uber模式设定的限制条件,即任务数据量小于等于参数mapreduce.job.ubertask.maxbytes配置的值,如果参数未配置,则应小于等于远程作业提交路径remoteJobSubmitDir所在文件系统的默认数据块大小;

            5、MR作业中任务要求的内存大小requiredMB小于等于系统为Uber模式设置的内存资源单元槽Slot大小sysMemSizeForUberSlot时,或者sysMemSizeForUberSlot被设定为不受限制;

            6、MR作业中任务要求的CPU核数requiredCores小于等于系统为Uber模式设置的CPU资源单元槽Slot大小sysCPUSizeForUberSlot;

            7、作业为非链式作业。

            前面6个条件在上面的描述和makeUberDecision()方法代码及其注释中都描述的很清晰,读者可自行查阅。

            下面,我们重点看看第7个条件:作业为非链式作业,这个条件是如何判断的呢?它是通过isChainJob()方法来判断的,代码如下:

    [java] view plain copy
     
     在CODE上查看代码片派生到我的代码片
    1. /** 
    2.  * ChainMapper and ChainReducer must execute in parallel, so they're not 
    3.  * compatible with uberization/LocalContainerLauncher (100% sequential). 
    4.  */  
    5. private boolean isChainJob(Configuration conf) {  
    6.   boolean isChainJob = false;  
    7.   try {  
    8.       
    9.     // 获取取Map类名mapClassName,取参数mapreduce.job.map.class  
    10.     String mapClassName = conf.get(MRJobConfig.MAP_CLASS_ATTR);  
    11.     if (mapClassName != null) {  
    12.     // 通过Map类名mapClassName获取Map类Class实例mapClass  
    13.       Class<?> mapClass = Class.forName(mapClassName);  
    14.         
    15.       // 通过Class的isAssignableFrom()方法,看看mapClass是否为ChainMapper的子类,或者就是ChainMapper,  
    16.       // 是的话,我们认为它就是一个链式作业  
    17.       if (ChainMapper.class.isAssignableFrom(mapClass))  
    18.         isChainJob = true;  
    19.     }  
    20.   } catch (ClassNotFoundException cnfe) {  
    21.     // don't care; assume it's not derived from ChainMapper  
    22.   } catch (NoClassDefFoundError ignored) {  
    23.   }  
    24.   try {  
    25.       
    26.     // 获取取Reduce类名reduceClassName,取参数mapreduce.job.reduce.class  
    27.     String reduceClassName = conf.get(MRJobConfig.REDUCE_CLASS_ATTR);  
    28.     if (reduceClassName != null) {  
    29.         
    30.     // 通过Reduce类名reduceClassName获取Reduce类Class实例reduceClass  
    31.       Class<?> reduceClass = Class.forName(reduceClassName);  
    32.         
    33.       // 通过Class的isAssignableFrom()方法,看看reduceClass是否为ChainReducer的子类,或者就是ChainReducer,  
    34.       // 是的话,我们认为它就是一个链式作业  
    35.       if (ChainReducer.class.isAssignableFrom(reduceClass))  
    36.         isChainJob = true;  
    37.     }  
    38.   } catch (ClassNotFoundException cnfe) {  
    39.     // don't care; assume it's not derived from ChainReducer  
    40.   } catch (NoClassDefFoundError ignored) {  
    41.   }  
    42.   return isChainJob;  
    43. }  

            它实际上就是看Map或Reduce是否是ChainMapper或ChainReducer的直接或间接子类,或者就是二者,通过参数mapreduce.job.map.class、mapreduce.job.reduce.class取类名并利用Class.forName构造Class实例,然后通过Class的isAssignableFrom()方法判断Map或Reduce是否是ChainMapper或ChainReducer的直接或间接子类,或者就是二者,就是这么简单。

            那么问题又来了,什么是链式作业?为什么继承了ChainMapper或ChainReducer就不能在Uber模式下运行?下面我们一一解答。

            首先,链式作业是什么呢?有些时候,你会发现,一个单独的MapReduce Job无法实现你的业务需求,你需要更多的MapReduce Job来处理你的数据,而此时,将多个MapReduce Job串成一条链就形成一个更大的MapReduce Job,这就是链式作业。而链式作业实现的一个根本条件就是其Mapper或Reducer分别继承自ChainMapper和ChainReducer。

            那么,为什么继承了ChainMapper或ChainReducer就不能在Uber模式下运行?连同什么是ChainMapper、ChainReducer这个问题,我们一起来做一个最直接最简单的解答,更多详细内容请查看关于专门介绍ChainMapper或ChainReducer的文章。

            首先看下ChainMapper的实现,在其内部,有一个Chain类型的成员变量chain,定义并在setup()方法中初始化如下:

    [java] view plain copy
     
     在CODE上查看代码片派生到我的代码片
    1. private Chain chain;  
    2.   
    3. protected void setup(Context context) {  
    4.   chain = new Chain(true);  
    5.   chain.setup(context.getConfiguration());  
    6. }  

            而Chain中有两个最关键的变量,Mapper列表mappers和Thread列表threads如下:

    [java] view plain copy
     
     在CODE上查看代码片派生到我的代码片
    1. private List<Mapper> mappers = new ArrayList<Mapper>();  
    [java] view plain copy
     
     在CODE上查看代码片派生到我的代码片
    1. private List<Thread> threads = new ArrayList<Thread>();  

            在ChainMapper的run()方法内,会将Chain的mappers中每个Mapper通过chain的addMapper()方法添加至chain中,而chain的addMapper()方法本质上就是基于每个Mapper生成一个MapRunner线程,然后添加到threads列表内,然后再由Mapper启动chain中所有线程threads,关键代码如下:

            ChainMapper的run()方法

    [java] view plain copy
     
     在CODE上查看代码片派生到我的代码片
    1. public void run(Context context) throws IOException, InterruptedException {  
    2.   
    3.   setup(context);  
    4.   
    5.   int numMappers = chain.getAllMappers().size();  
    6.   if (numMappers == 0) {  
    7.     return;  
    8.   }  
    9.   
    10.   ChainBlockingQueue<Chain.KeyValuePair<?, ?>> inputqueue;  
    11.   ChainBlockingQueue<Chain.KeyValuePair<?, ?>> outputqueue;  
    12.   if (numMappers == 1) {  
    13.     chain.runMapper(context, 0);  
    14.   } else {  
    15.     // add all the mappers with proper context  
    16.     // add first mapper  
    17.     outputqueue = chain.createBlockingQueue();  
    18.     chain.addMapper(context, outputqueue, 0);  
    19.     // add other mappers  
    20.     for (int i = 1; i < numMappers - 1; i++) {  
    21.       inputqueue = outputqueue;  
    22.       outputqueue = chain.createBlockingQueue();  
    23.       chain.addMapper(inputqueue, outputqueue, context, i);  
    24.     }  
    25.     // add last mapper  
    26.     chain.addMapper(outputqueue, context, numMappers - 1);  
    27.   }  
    28.     
    29.   // start all threads  
    30.   chain.startAllThreads();  
    31.     
    32.   // wait for all threads  
    33.   chain.joinAllThreads();  
    34. }  

            Chain的其中一个addMapper()方法

    [java] view plain copy
     
     在CODE上查看代码片派生到我的代码片
    1. /** 
    2.  * Add mapper that reads and writes from/to the queue 
    3.  */  
    4. @SuppressWarnings("unchecked")  
    5. void addMapper(ChainBlockingQueue<KeyValuePair<?, ?>> input,  
    6.     ChainBlockingQueue<KeyValuePair<?, ?>> output,  
    7.     TaskInputOutputContext context, int index) throws IOException,  
    8.     InterruptedException {  
    9.   Configuration conf = getConf(index);  
    10.   Class<?> keyClass = conf.getClass(MAPPER_INPUT_KEY_CLASS, Object.class);  
    11.   Class<?> valueClass = conf.getClass(MAPPER_INPUT_VALUE_CLASS, Object.class);  
    12.   Class<?> keyOutClass = conf.getClass(MAPPER_OUTPUT_KEY_CLASS, Object.class);  
    13.   Class<?> valueOutClass = conf.getClass(MAPPER_OUTPUT_VALUE_CLASS,  
    14.       Object.class);  
    15.   RecordReader rr = new ChainRecordReader(keyClass, valueClass, input, conf);  
    16.   RecordWriter rw = new ChainRecordWriter(keyOutClass, valueOutClass, output,  
    17.       conf);  
    18.   MapRunner runner = new MapRunner(mappers.get(index), createMapContext(rr,  
    19.       rw, context, getConf(index)), rr, rw);  
    20.   threads.add(runner);  
    21. }  

            可以看出,ChainMapper实际上实现了一种多重Mapper,即multiple Mapper,它不再依托一个单独的Map Task,执行一种Map任务,而是依托多个Map Task,执行多种Map任务,所以,它肯定不适合Uber模式,因为Uber模式只限于Map、Reduce等各个任务的单线程串行执行。
            ChainReducer也是如此,不再做特别的说明。

  • 相关阅读:
    松软科技web课堂:SQLServer之UCASE() 函数
    松软科技web课堂:SQLServer之HAVING 子句
    SQLServer之GROUP BY语句
    松软科技web课堂:SQLServer之SUM() 函数
    松软科技web课堂:SQLServer之MIN() 函数
    SQLServer之MAX() 函数
    松软科技web课堂:SQLServer之LAST() 函数
    松软科技带你学开发:SQL--FIRST() 函数
    松软科技带你学开发:SQL--COUNT() 函数
    NodeJS初介
  • 原文地址:https://www.cnblogs.com/jirimutu01/p/5556380.html
Copyright © 2011-2022 走看看