使用 Quartz 调度器遇到的一些问题

最近,线上运行的定时任务出现调度失败。调整了相关参数后,虽然遏制了调度失败的情况,却导致任务调度的延迟极度增加。同时还观察到,在多机部署的环境中,负载极不均匀,于是深入代码排查一番。

TL; DR

  1. 不要使用 Spring 提供的 org.springframework.scheduling.quartz.SchedulerFactoryBean#setTaskExecutor 方法自定义工作线程池
  2. 要注意 Spring 与 Quartz 的部分配置默认值不一致,如 org.quartz.threadPool.class
  3. 一般情况下使用 Quartz 提供的 SimpleThreadPool 配合参数 org.quartz.threadPool.threadCount 调整最大线程数即可
  4. 如果需要自定义工作线程池,则必须直接实现 org.quartz.spi.ThreadPool 接口,并一定要实现 blockForAvailableThreads 方法,支持阻塞等待
  5. 适当设置 org.quartz.scheduler.batchTriggerAcquisitionMaxCount 参数,使用批量能力降低 DB 交互带来的延迟

任务调度失败

问题现象

接用户反馈,一个一直正常运行的定时任务,在确认没有进行任何变动的情况下,却突然不再被调度了。经过确认,确实没有找到该任务的任何改动痕迹。因此怀疑问题是出在 Quartz 调度引擎上。

找出原因

看到这个现象,第一反应是 Quartz 里的 Trigger 异常了。因为 Quartz 对于所有状态是异常的 Trigger 都不会再次调度,表现为没有任何操作但是任务不执行。于是查看 QRTZ_TRIGGERS 表,筛选过后果然看到了 STATEERRORTRIGGER。根据 TRIGGER_NAMETRIGGER_GROUP 确定为用户反馈的任务所对应的触发器。

确定了问题是 TRIGGER_STATE 后,就要找到状态变化的点。先查看出现问题时的业务日志,可以看到有这样的记录穿插其中:

[2021-04-16 16:14:08.001][|][quartz-scheduler_QuartzSchedulerThread] ERROR o.s.s.q.LocalTaskExecutorThreadPool:runInThread:84 - Task has been rejected by TaskExecutor
 org.springframework.core.task.TaskRejectedException: Executor [java.util.concurrent.ThreadPoolExecutor@ef3310e[Running, pool size = 50, active threads = 50, queued tasks = 100, completed tasks = 20]] did not accept task: org.quartz.core.JobRunShell@5841bfb6
         at org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor.execute(ThreadPoolTaskExecutor.java:324)
         at org.springframework.scheduling.quartz.LocalTaskExecutorThreadPool.runInThread(LocalTaskExecutorThreadPool.java:80)
         at org.quartz.core.QuartzSchedulerThread.run(QuartzSchedulerThread.java:398)
 Caused by: java.util.concurrent.RejectedExecutionException: Task org.quartz.core.JobRunShell@5841bfb6 rejected from java.util.concurrent.ThreadPoolExecutor@ef3310e[Running, pool size = 50, active threads = 50, queued tasks = 100, completed tasks = 20]
         at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2063)
         at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:830)
         at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1379)
         at org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor.execute(ThreadPoolTaskExecutor.java:321)
         … 2 common frames omitted
 [2021-04-16 16:14:08.001][|][quartz-scheduler_QuartzSchedulerThread] ERROR o.quartz.core.QuartzSchedulerThread:run:403 - ThreadPool.runInThread() return false!

从日志里可以看到,有一些定时任务被线程池拒绝了。而我们的线程池中有 50 个工作线程,100 个等待队列。也就是说这台机器短时间内承担了 150 个以上的调度任务,就会导致线程池无法承受如此多的任务而导致后续的任务被拒绝执行。因此猜测这部分被拒绝执行的任务导致了 Quartz 的 Trigger 状态异常。

于是查找相关代码,可以看到有一共有四处修改状态为 ERROR 的代码。其中由于 retrieveJob 导致的错误暂时排除,因为 DB 中 Trigger 的数据都是高度相似的。如果是这里出了问题,那么一定是大批量的出现错误,而非个别任务。继续查找,找到如下逻辑:

JobRunShell shell = null;
try {
    // 创建触发器执行环境
    shell = qsRsrcs.getJobRunShellFactory().createJobRunShell(bndle);
    shell.initialize(qs);
} catch (SchedulerException se) {
    qsRsrcs.getJobStore().triggeredJobComplete(triggers.get(i), bndle.getJobDetail(), Trigger.CompletedExecutionInstruction.SET_ALL_JOB_TRIGGERS_ERROR);
    continue;
}

// 提交执行并检查提交结果
if (qsRsrcs.getThreadPool().runInThread(shell) == false) {
    // this case should never happen, as it is indicative of the
    // scheduler being shutdown or a bug in the thread pool or
    // a thread pool being used concurrently - which the docs
    // say not to do...
    getLog().error("ThreadPool.runInThread() return false!");
    qsRsrcs.getJobStore().triggeredJobComplete(triggers.get(i), bndle.getJobDetail(), Trigger.CompletedExecutionInstruction.SET_ALL_JOB_TRIGGERS_ERROR);
}

从这里可以看到,如果任务提交到线程池的时候失败了,那么就会导致任务被标记为 ERROR。而这刚好符合我们看到的日志现象。由于线程池等待队列满导致的提交异常这种情况只会在等待队列撑满之后才会发生,刚好符合大部分任务没有问题但少部分任务失败的情况,于是确定了问题的原因。

深入分析

虽然找到了调度失败的原因是由于线程池满引起的,但是经过计算,这并不是设计中应该出现的。因为在设计中,业务一共有三台机器分别调度任务,而总任务数量在 300~400 左右。期望中每台机器都应承载 1/3 左右的业务量,也就是约 100~140 个任务左右。在这种情况下,理论上每台机器的线程池都应刚好能满足业务需要的,不应该出现这种情况。而通过查看日志,会发现实际上大部分任务都由一台机器执行,另外两台只分配到了少量任务。这种倾斜直接导致了任务量最大的机器数量爆表,导致线程池处理不及时,进而导致任务提交失败,被 Quartz 标记为异常,停止调度。

误入歧途

既然找到了问题在于 Quartz 调度任务的速度太快,线程池来不及处理,于是想到通过参数配置降低 Quartz 调度线程投递任务的速度,并尝试调升队列缓冲来解决这个问题。通过阅读调度器的代码,找到如下逻辑:

@Override
public void run() {
    int acquiresFailed = 0;

    while (!halted.get()) {
        try {
            // do some process states check

            int availThreadCount = qsRsrcs.getThreadPool().blockForAvailableThreads();
            if(availThreadCount > 0) { // will always be true, due to semantics of blockForAvailableThreads...

                List<OperableTrigger> triggers;

                long now = System.currentTimeMillis();

                clearSignaledSchedulingChange();
                try {
                    triggers = qsRsrcs.getJobStore().acquireNextTriggers(
                            now + idleWaitTime, Math.min(availThreadCount, qsRsrcs.getMaxBatchSize()), qsRsrcs.getBatchTimeWindow());
                    acquiresFailed = 0;
                    if (log.isDebugEnabled())
                        log.debug("batch acquisition of " + (triggers == null ? 0 : triggers.size()) + " triggers");
                } catch (JobPersistenceException jpe) {
                    // do exception process
                    continue;
                } catch (RuntimeException e) {
                    // do exception process 
                    continue;
                }

                if (triggers != null && !triggers.isEmpty()) {

                    now = System.currentTimeMillis();
                    long triggerTime = triggers.get(0).getNextFireTime().getTime();
                    long timeUntilTrigger = triggerTime - now;
                    while(timeUntilTrigger > 2) {
                        synchronized (sigLock) {
                            if (halted.get()) {
                                break;
                            }
                            if (!isCandidateNewTimeEarlierWithinReason(triggerTime, false)) {
                                try {
                                    // we could have blocked a long while
                                    // on 'synchronize', so we must recompute
                                    now = System.currentTimeMillis();
                                    timeUntilTrigger = triggerTime - now;
                                    if(timeUntilTrigger >= 1)
                                        sigLock.wait(timeUntilTrigger);
                                } catch (InterruptedException ignore) {
                                }
                            }
                        }
                        if(releaseIfScheduleChangedSignificantly(triggers, triggerTime)) {
                            break;
                        }
                        now = System.currentTimeMillis();
                        timeUntilTrigger = triggerTime - now;
                    }

                    // do some check

                    if(goAhead) {
                        try {
                            List<TriggerFiredResult> res = qsRsrcs.getJobStore().triggersFired(triggers);
                            if(res != null)
                                bndles = res;
                        } catch (SchedulerException se) {
                            // do exception process
                            continue;
                        }

                    }

                    for (int i = 0; i < bndles.size(); i++) {
                        TriggerFiredResult result =  bndles.get(i);
                        TriggerFiredBundle bndle =  result.getTriggerFiredBundle();
                        Exception exception = result.getException();

                        // do some check

                        JobRunShell shell = null;
                        try {
                            shell = qsRsrcs.getJobRunShellFactory().createJobRunShell(bndle);
                            shell.initialize(qs);
                        } catch (SchedulerException se) {
                            qsRsrcs.getJobStore().triggeredJobComplete(triggers.get(i), bndle.getJobDetail(), CompletedExecutionInstruction.SET_ALL_JOB_TRIGGERS_ERROR);
                            continue;
                        }

                        if (qsRsrcs.getThreadPool().runInThread(shell) == false) {
                            // this case should never happen, as it is indicative of the
                            // scheduler being shutdown or a bug in the thread pool or
                            // a thread pool being used concurrently - which the docs
                            // say not to do...
                            getLog().error("ThreadPool.runInThread() return false!");
                            qsRsrcs.getJobStore().triggeredJobComplete(triggers.get(i), bndle.getJobDetail(), CompletedExecutionInstruction.SET_ALL_JOB_TRIGGERS_ERROR);
                        }

                    }

                    continue; // while (!halted)
                }
            } else { // if(availThreadCount > 0)
                // should never happen, if threadPool.blockForAvailableThreads() follows contract
                continue; // while (!halted)
            }

            long now = System.currentTimeMillis();
            long waitTime = now + getRandomizedIdleWaitTime();
            long timeUntilContinue = waitTime - now;
            synchronized(sigLock) {
                try {
                    if(!halted.get()) {
                        // QTZ-336 A job might have been completed in the mean time and we might have
                        // missed the scheduled changed signal by not waiting for the notify() yet
                        // Check that before waiting for too long in case this very job needs to be
                        // scheduled very soon
                        if (!isScheduleChanged()) {
                            sigLock.wait(timeUntilContinue);
                        }
                    }
                } catch (InterruptedException ignore) {
                }
            }

        } catch(RuntimeException re) {
            getLog().error("Runtime error occurred in main trigger firing loop.", re);
        }
    } // while (!halted)

    // drop references to scheduler stuff to aid garbage collection...
    qs = null;
    qsRsrcs = null;
}

这段是 Quartz 调度线程的主要逻辑,注意其中的这段:

triggers = qsRsrcs.getJobStore().acquireNextTriggers(
                            now + idleWaitTime, Math.min(availThreadCount, qsRsrcs.getMaxBatchSize()), qsRsrcs.getBatchTimeWindow());

这里可以看到,Quartz 每次从 DB 拉取的任务数量与线程池的可用线程数和一个参数 MaxBatchSize 有关。根据查阅到的一些文章,说此处 Quartz 会每次拉取线程数个任务。因此怀疑这里是由于工作线程池的线程数量过大,导致任务都被一台机器拉取,导致的负载不均衡。于是尝试调小线程池的参数,来缓解拉取任务过多的问题。这里要说下,这个怀疑是错误的!让我误入歧途。具体原因会在后面说明。

问题“解决“

通过调整此处线程池的参数,任务执行出错的问题再也没有出现。本以为所有的问题就这样解决了,没想到却引来了另一个更棘手的问题:调度延迟。

任务调度延迟

问题现象

接用户反馈,部分每分钟执行一次的定时任务,出现了同一分钟内执行两次的情况。在任务的执行历史里可以明显看到 12:01:09 有一次执行,12:01:22 的时候又出现了一次执行。

找出原因

由于 Quartz 调度器本身是不太容易犯重复调度这样的错误的,因此第一时间考虑是任务调度延迟导致的。通过观察用户的定时任务,发现实际上该任务在 11:59:02 12:01:09 和 12:01:22 分别调度一次。因此可以确定,12:01 的两次调度,一次为正常的调度,一次为 12:00 应当调度的任务但是因为某些原因延迟了。这样才导致整个调度看起来就像是在 12:01 的之后重复调度了一次。

那么现在的问题就在于调度延迟了。由于之前比较激进的调整过线程池的参数,因此这里还是主要考虑在线程池周边找问题。通过调大线程池的参数,可以明显的发现总体调度延迟飞速降低。但是在测试的过程中,还是在日志里发现了一点之前忽略的小问题:

[2021-04-16 20:27:00.051][|][threadPoolTaskExecutor-1] Execute task|1|1
[2021-04-16 20:27:00.100][|][threadPoolTaskExecutor-2] Execute task|1|2
[2021-04-16 20:27:00.157][|][threadPoolTaskExecutor-3] Execute task|1|3
[2021-04-16 20:27:00.213][|][threadPoolTaskExecutor-4] Execute task|1|4
[2021-04-16 20:27:00.268][|][threadPoolTaskExecutor-5] Execute task|1|5
[2021-04-16 20:27:00.323][|][threadPoolTaskExecutor-6] Execute task|1|6
[2021-04-16 20:27:00.379][|][threadPoolTaskExecutor-7] Execute task|1|7
[2021-04-16 20:27:00.437][|][threadPoolTaskExecutor-8] Execute task|1|8
[2021-04-16 20:27:00.492][|][threadPoolTaskExecutor-9] Execute task|1|9
[2021-04-16 20:27:00.554][|][threadPoolTaskExecutor-10] Execute task|1|10
[2021-04-16 20:27:00.610][|][threadPoolTaskExecutor-11] Execute task|1|11
[2021-04-16 20:27:00.667][|][threadPoolTaskExecutor-12] Execute task|1|12
[2021-04-16 20:27:00.721][|][threadPoolTaskExecutor-13] Execute task|1|13
[2021-04-16 20:27:00.775][|][threadPoolTaskExecutor-14] Execute task|1|14
[2021-04-16 20:27:00.831][|][threadPoolTaskExecutor-15] Execute task|1|15
[2021-04-16 20:27:00.885][|][threadPoolTaskExecutor-16] Execute task|1|16
[2021-04-16 20:27:00.937][|][threadPoolTaskExecutor-17] Execute task|1|17
[2021-04-16 20:27:00.991][|][threadPoolTaskExecutor-18] Execute task|1|18
[2021-04-16 20:27:01.044][|][threadPoolTaskExecutor-19] Execute task|1|19
[2021-04-16 20:27:01.098][|][threadPoolTaskExecutor-20] Execute task|1|20
[2021-04-16 20:27:01.152][|][threadPoolTaskExecutor-21] Execute task|1|21
[2021-04-16 20:27:01.206][|][threadPoolTaskExecutor-22] Execute task|1|22
[2021-04-16 20:27:01.261][|][threadPoolTaskExecutor-23] Execute task|1|23
[2021-04-16 20:27:01.315][|][threadPoolTaskExecutor-24] Execute task|1|24
[2021-04-16 20:27:01.372][|][threadPoolTaskExecutor-25] Execute task|1|25
[2021-04-16 20:27:01.427][|][threadPoolTaskExecutor-26] Execute task|1|26
[2021-04-16 20:27:01.481][|][threadPoolTaskExecutor-27] Execute task|1|27
[2021-04-16 20:27:01.536][|][threadPoolTaskExecutor-28] Execute task|1|28
[2021-04-16 20:27:01.590][|][threadPoolTaskExecutor-29] Execute task|1|29
[2021-04-16 20:27:01.644][|][threadPoolTaskExecutor-30] Execute task|1|30
[2021-04-16 20:27:01.698][|][threadPoolTaskExecutor-31] Execute task|1|31
[2021-04-16 20:27:01.757][|][threadPoolTaskExecutor-32] Execute task|1|32
[2021-04-16 20:27:01.811][|][threadPoolTaskExecutor-33] Execute task|1|33
[2021-04-16 20:27:01.866][|][threadPoolTaskExecutor-34] Execute task|1|34
[2021-04-16 20:27:01.920][|][threadPoolTaskExecutor-35] Execute task|1|35
[2021-04-16 20:27:01.973][|][threadPoolTaskExecutor-36] Execute task|1|36
[2021-04-16 20:27:02.031][|][threadPoolTaskExecutor-37] Execute task|1|37
[2021-04-16 20:27:02.086][|][threadPoolTaskExecutor-38] Execute task|1|38
[2021-04-16 20:27:02.139][|][threadPoolTaskExecutor-39] Execute task|1|39
[2021-04-16 20:27:02.194][|][threadPoolTaskExecutor-40] Execute task|1|40
[2021-04-16 20:27:02.247][|][threadPoolTaskExecutor-41] Execute task|1|41
[2021-04-16 20:27:02.301][|][threadPoolTaskExecutor-42] Execute task|1|42
[2021-04-16 20:27:02.354][|][threadPoolTaskExecutor-43] Execute task|1|43
[2021-04-16 20:27:02.407][|][threadPoolTaskExecutor-44] Execute task|1|44
[2021-04-16 20:27:02.460][|][threadPoolTaskExecutor-45] Execute task|1|45
[2021-04-16 20:27:02.513][|][threadPoolTaskExecutor-46] Execute task|1|46
[2021-04-16 20:27:02.567][|][threadPoolTaskExecutor-47] Execute task|1|47
[2021-04-16 20:27:02.620][|][threadPoolTaskExecutor-48] Execute task|1|48
[2021-04-16 20:27:02.680][|][threadPoolTaskExecutor-49] Execute task|1|49
[2021-04-16 20:27:02.734][|][threadPoolTaskExecutor-50] Execute task|1|50

可以看到,日志里每两个任务的启动时间,都间隔了 50ms 左右。这个时间并不在预期中。按照设想,Quartz 应该一次性拿可用线程数个任务,然后批量提交才对。如果是批量提交到线程池,正常情况下不应有这么大的启动延迟。就算是因为 CPU 调度的原因,在这台 4C 的机器上,前四个任务的启动时间也不应有特别巨大的偏差才对。而且这台测试机为了避免干扰,除了必要的 SSH 等服务外,没有运行任何其他服务,也不存在 CPU 或内存争抢的情况。

为了确认猜想,使用相同的参数创建了相同的线程池,并使用循环批量提交任务,来测试线程池内线程的启动延迟:

ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
threadPoolTaskExecutor.setCorePoolSize(150);
threadPoolTaskExecutor.setMaxPoolSize(300);
threadPoolTaskExecutor.setQueueCapacity(150);
threadPoolTaskExecutor.initialize();

int count = 0;
while (count < 200) {
    threadPoolTaskExecutor.execute(() -> {
        log.info("Execute start at {}", System.currentTimeMillis());
        try {
            Thread.sleep(2_000);
        } catch (InterruptedException e) {
            log.error("Interrupted exception while execute task!", e);
            Thread.interrupted();
        }
        log.info("Execute end at {}", System.currentTimeMillis());
    });

    count++;
}

经过测试,得到的日志输出是这样的:

[2021-04-17 11:29:06.360][|][ThreadPoolTaskExecutor-1] Execute start
[2021-04-17 11:29:06.361][|][ThreadPoolTaskExecutor-2] Execute start
[2021-04-17 11:29:06.371][|][ThreadPoolTaskExecutor-6] Execute start
[2021-04-17 11:29:06.371][|][ThreadPoolTaskExecutor-5] Execute start
[2021-04-17 11:29:06.372][|][ThreadPoolTaskExecutor-7] Execute start
[2021-04-17 11:29:06.369][|][ThreadPoolTaskExecutor-4] Execute start
[2021-04-17 11:29:06.362][|][ThreadPoolTaskExecutor-3] Execute start
[2021-04-17 11:29:06.374][|][ThreadPoolTaskExecutor-8] Execute start
[2021-04-17 11:29:06.375][|][ThreadPoolTaskExecutor-9] Execute start
[2021-04-17 11:29:06.382][|][ThreadPoolTaskExecutor-11] Execute start
[2021-04-17 11:29:06.382][|][ThreadPoolTaskExecutor-10] Execute start
[2021-04-17 11:29:06.382][|][ThreadPoolTaskExecutor-12] Execute start
[2021-04-17 11:29:06.384][|][ThreadPoolTaskExecutor-13] Execute start
[2021-04-17 11:29:06.384][|][ThreadPoolTaskExecutor-14] Execute start
[2021-04-17 11:29:06.385][|][ThreadPoolTaskExecutor-15] Execute start
[2021-04-17 11:29:06.385][|][ThreadPoolTaskExecutor-16] Execute start
[2021-04-17 11:29:06.386][|][ThreadPoolTaskExecutor-18] Execute start
[2021-04-17 11:29:06.386][|][ThreadPoolTaskExecutor-17] Execute start
[2021-04-17 11:29:06.392][|][ThreadPoolTaskExecutor-19] Execute start
[2021-04-17 11:29:06.393][|][ThreadPoolTaskExecutor-20] Execute start
[2021-04-17 11:29:06.393][|][ThreadPoolTaskExecutor-21] Execute start
[2021-04-17 11:29:06.394][|][ThreadPoolTaskExecutor-22] Execute start
[2021-04-17 11:29:06.394][|][ThreadPoolTaskExecutor-23] Execute start
[2021-04-17 11:29:06.395][|][ThreadPoolTaskExecutor-24] Execute start
[2021-04-17 11:29:06.395][|][ThreadPoolTaskExecutor-25] Execute start
[2021-04-17 11:29:06.396][|][ThreadPoolTaskExecutor-26] Execute start
[2021-04-17 11:29:06.396][|][ThreadPoolTaskExecutor-27] Execute start
[2021-04-17 11:29:06.397][|][ThreadPoolTaskExecutor-28] Execute start
[2021-04-17 11:29:06.397][|][ThreadPoolTaskExecutor-29] Execute start
[2021-04-17 11:29:06.398][|][ThreadPoolTaskExecutor-30] Execute start
[2021-04-17 11:29:06.398][|][ThreadPoolTaskExecutor-31] Execute start
[2021-04-17 11:29:06.399][|][ThreadPoolTaskExecutor-32] Execute start
[2021-04-17 11:29:06.400][|][ThreadPoolTaskExecutor-33] Execute start
[2021-04-17 11:29:06.400][|][ThreadPoolTaskExecutor-34] Execute start
[2021-04-17 11:29:06.400][|][ThreadPoolTaskExecutor-35] Execute start
[2021-04-17 11:29:06.401][|][ThreadPoolTaskExecutor-36] Execute start
[2021-04-17 11:29:06.402][|][ThreadPoolTaskExecutor-37] Execute start
[2021-04-17 11:29:06.402][|][ThreadPoolTaskExecutor-38] Execute start
[2021-04-17 11:29:06.402][|][ThreadPoolTaskExecutor-39] Execute start
[2021-04-17 11:29:06.403][|][ThreadPoolTaskExecutor-40] Execute start
[2021-04-17 11:29:06.410][|][ThreadPoolTaskExecutor-42] Execute start
[2021-04-17 11:29:06.411][|][ThreadPoolTaskExecutor-41] Execute start
[2021-04-17 11:29:06.411][|][ThreadPoolTaskExecutor-43] Execute start
[2021-04-17 11:29:06.412][|][ThreadPoolTaskExecutor-44] Execute start
[2021-04-17 11:29:06.412][|][ThreadPoolTaskExecutor-45] Execute start
[2021-04-17 11:29:06.413][|][ThreadPoolTaskExecutor-46] Execute start
[2021-04-17 11:29:06.421][|][ThreadPoolTaskExecutor-47] Execute start
[2021-04-17 11:29:06.422][|][ThreadPoolTaskExecutor-48] Execute start
[2021-04-17 11:29:06.424][|][ThreadPoolTaskExecutor-49] Execute start
[2021-04-17 11:29:06.424][|][ThreadPoolTaskExecutor-50] Execute start

可以看到,在相同的一台四核的机器上,除了最开始启动较慢外,余下的任务启动时间的偏差基本在 1ms 内50 个任务全部启动也只用了 60ms 左右。前 150 个任务的启动时间总过也就相差 200ms 左右。这个结果表明,定时任务调度时一定还有其他耗时操作穿插其间,导致任务的提交本身存在延迟,才导致了前 50 个任务的启动时间偏差接近 3s。

深入分析

于是回到代码本身,深入分析 Quartz 的任务调度和提交逻辑。参考前面提供的 QuartzSchedulerThread 主调度线程的代码,我们可以把整个调度过程进行一个阶段划分:

  1. 等待工作线程池出现可用线程
  2. 根据可用线程数和最大批量大小从 DB 中取出将要触发的 Trigger
  3. 循环等待,直到离最近一个要出发的 Trigger 的触发时间还有 2ms
  4. 更新 DB 设置当前批次 Trigger 的触发状态
  5. 提交 Trigger 到执行线程池

其中 2 4 两步需要访问 DB 更新 Trigger 的状态,并且需要获取 TRIGGER_ACCESS 锁。按照访问 DB 的一般耗时估算,一次 DB 操作大约会消耗 5~10ms 的时间,那么这两步的 DB 操作大约每次会消耗 15~20ms 左右的时间,刚好与我们之前观察到的 50ms 的延迟接近。

可是按照正常逻辑,此处 Quartz 应该是批量去取 Trigger,应该每一批 Trigger 消耗 50ms 才是正常现象。现在每一个 Trigger 都间隔了 50ms,说明此处的批量处理一定出现了问题。于是单独深入批量拉取 Trigger 这段代码,发现之前被忽略的 maxBatchSize 有问题。由于理所当然的认为该选项的值一定会比较大,不太可能成为瓶颈,之前就没有仔细追踪这个参数。现在经过追溯,发现该值在 org.quartz.core.QuartzSchedulerResources 中存在一个默认值 1。也就是说如果没有特殊配置的话,这里的批量永远是单个拉取。这也说明了,之前的猜测是完全错误的。负载不均匀导致队列爆掉并不是因为 Quartz 批量过大导致的,而是另有原因。

尝试修复

既然找到了这个变量漏设置了值,那就修改这里的默认值,再进行测试。这里这个变量值需要修改 Quartz 的配置文件,增加 org.quartz.scheduler.batchTriggerAcquisitionMaxCount 配置项。这里修改成 50 进行测试,配合线程池的 core size 150,应该可以达到每批次处理 50 个 Trigger 的目的。修改后的日志如下:

[2021-04-17 13:45:00.051][|][threadPoolTaskExecutor-1] Execute task|1|1
[2021-04-17 13:45:00.108][|][threadPoolTaskExecutor-2] Execute task|1|2
[2021-04-17 13:45:00.173][|][threadPoolTaskExecutor-3] Execute task|1|3
[2021-04-17 13:45:00.231][|][threadPoolTaskExecutor-4] Execute task|1|4
[2021-04-17 13:45:00.288][|][threadPoolTaskExecutor-5] Execute task|1|5
[2021-04-17 13:45:00.346][|][threadPoolTaskExecutor-6] Execute task|1|6
[2021-04-17 13:45:00.403][|][threadPoolTaskExecutor-7] Execute task|1|7
[2021-04-17 13:45:00.462][|][threadPoolTaskExecutor-8] Execute task|1|8
[2021-04-17 13:45:00.518][|][threadPoolTaskExecutor-9] Execute task|1|9
[2021-04-17 13:45:00.574][|][threadPoolTaskExecutor-10] Execute task|1|10
[2021-04-17 13:45:00.629][|][threadPoolTaskExecutor-11] Execute task|1|11
[2021-04-17 13:45:00.687][|][threadPoolTaskExecutor-12] Execute task|1|12
[2021-04-17 13:45:00.741][|][threadPoolTaskExecutor-13] Execute task|1|13
[2021-04-17 13:45:00.795][|][threadPoolTaskExecutor-14] Execute task|1|14
[2021-04-17 13:45:00.849][|][threadPoolTaskExecutor-15] Execute task|1|15
[2021-04-17 13:45:00.915][|][threadPoolTaskExecutor-16] Execute task|1|16
[2021-04-17 13:45:00.971][|][threadPoolTaskExecutor-17] Execute task|1|17
[2021-04-17 13:45:01.026][|][threadPoolTaskExecutor-18] Execute task|1|18
[2021-04-17 13:45:01.081][|][threadPoolTaskExecutor-19] Execute task|1|19
[2021-04-17 13:45:01.135][|][threadPoolTaskExecutor-20] Execute task|1|20

奇怪的事情发生了,这个参数的修改完全没有起作用,Quartz 还是一个个任务调度的,而不是批量调度。同时在为了测试专门开启的调度引擎的 Debug 日志中也发现了相关日志:

[2021-04-17 13:45:00.076][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 1 triggers
[2021-04-17 13:45:00.135][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 1 triggers
[2021-04-17 13:45:00.201][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 1 triggers
[2021-04-17 13:45:00.258][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 1 triggers
[2021-04-17 13:45:00.315][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 1 triggers
[2021-04-17 13:45:00.373][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 1 triggers
[2021-04-17 13:45:00.431][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 1 triggers
[2021-04-17 13:45:00.488][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 1 triggers
[2021-04-17 13:45:00.545][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 1 triggers
[2021-04-17 13:45:00.600][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 1 triggers

从这里可以看出,调度器确实每次只取了一个 Trigger 进行触发。这就很奇怪了,明明我们已经修改了最大批量值的参数,却看起来像没有生效。考虑到这里的批量值是由两个参数决定的,那么问题一定出在二者之间

  1. 线程池可用线程数有问题,获取到的数据不是设置的 150 个线程
  2. maxBatchSize 参数设置没有生效,仍然为 1

由于这两个参数在调度器之外比较用非入侵的方式检测,于是决定直接上 IDEA 用远程调试来解决:

在调度线程批量拉取 Trigger 的地方设置断点,通过查看两个变量的值,发现了问题。这里可以清晰的看到 maxBatchSize 已经变为我们设置的 50 了。但是 availThreadCount 却不是预想中的 150,而是 1。于是检查一下这个线程池:

可以看到,此处用来接收任务的线程池居然是 LocalTaskExecutorThreadPool 类型的线程池,而不是我们在配置里提供给 Quartz 的。而在初始化时提供给 Quartz 的线程池变成了该线程池内部的一个 executor。那这个线程池是哪来的呢?跟进去看下:

可以看到这个线程池是 spring 对于我们实际使用的线程池的一个封装。这个线程池内部的 taskExecutor 才是真正的由我们配置的执行任务的线程池。而这个封装中,由于内部的 executor 是不确定的,所以为了简单,此处的可用线程数查询居然永久返回 1。至此问题的根源找到了:因为 LocalTaskExecutorThreadPool 封装了我们提供给 Quartz 的线程池,且该封装在获取可用线程数时永远返回 1,导致了我们修改 maxBatchSize 没有生效。

再次深入

那么这里其实还有个问题,就是这个 LocalTaskExecutorThreadPool 是从何而来,为何没有直接使用我们构造好的 ThreadPoolTaskExecutor 呢?

经过追踪,发现这个线程池的来源在这里:

org.springframework.scheduling.quartz.SchedulerFactoryBean

可以看到,这是 Spring 对于 Quartz 初始化的一个封装。在这个封装中,如果 Spring 发现我们尝试使用自己配置的一个任务执行器的话,就会默认用 LocalTaskExecutorThreadPool 来做一个包装,除非在配置文件中配置 org.quartz.threadPool.class 显示的覆盖这个参数。而通过阅读配置,我发现这里确实是没有设置值。通过参考 Quartz 本身的初始化过程,猜测当时没有设置这个值的原因是因为 Quartz 本身默认会使用 SimpleThreadPool 来运行任务,因此考虑这个设置是多余的,于是没有进行设置。但是在考虑的过程中却忽略了 Spring 这块,导致实际的效果与预期不一致。

真相大白

至此,这里无法进行批量调度导致调度延迟的各个原因终于复出水面。在这个问题中,由于多个配置之间的互相作用最终导致了这个结果。主要有以下几点:

  1. maxBatchSize 的默认值为 1
  2. LocalTaskExecutorThreadPool 对于可用线程数永远返回 1
  3. Quartz 的默认线程池是 SimpleThreadPool,但 Spring 在使用了自定义工作线程池的情况下默认为 LocalTaskExecutorThreadPool
  4. org.quartz.threadPool.class 的默认行为与 Quartz 匹配,但与 Spring 不匹配

这里针对 3 多说几句。后面经过阅读代码,发现 Quartz 调度线程需要的 ThreadPool 并不是通常使用的 java.util.concurrent.ThreadPoolExecutor 而是 Quartz 自行封装的 org.quartz.spi.ThreadPool。因此,当我们要求 Spring 使用我们自定义的 ThreadPoolExecutor 来执行工作任务时,Spring 别无选择,必须对我们提供的 ThreadPoolExecutor 做一次封装,才能提供给 Quartz 调度线程使用。而一开始在编写代码时对这里没有深入研究则埋下了一个大坑。

因此这里的解决方案也很简单,直接去掉自定义线程池使用 Quartz 提供的 SimpleThreadPool 来处理工作负载即可。而且这里修改为 Quartz 自身的 SimpleThreadPool 来处理工作负载后,原先的调度异常的问题也自然而然的解决了。具体原因见总结。修改配置后再进行测试,看到这样的日志输出:

[2021-04-17 15:50:09.821][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 0 triggers
[2021-04-17 15:50:33.989][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 50 triggers
[2021-04-17 15:51:01.551][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 50 triggers
[2021-04-17 15:51:02.948][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 50 triggers
[2021-04-17 15:51:04.009][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 6 triggers
[2021-04-17 15:51:04.723][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 44 triggers
[2021-04-17 15:51:06.241][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 50 triggers
[2021-04-17 15:51:07.614][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 26 triggers
[2021-04-17 15:51:08.500][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 31 triggers
[2021-04-17 15:51:09.448][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 43 triggers
[2021-04-17 15:51:10.600][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 31 triggers
[2021-04-17 15:51:11.564][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 19 triggers
[2021-04-17 15:51:11.884][|][quartz-scheduler_QuartzSchedulerThread] DEBUG o.quartz.core.QuartzSchedulerThread:run:291 - batch acquisition of 0 triggers

[2021-04-17 15:51:00.810][|][quartz-scheduler_Worker-5] Execute task|1|5
[2021-04-17 15:51:00.806][|][quartz-scheduler_Worker-3] Execute task|1|3
[2021-04-17 15:51:00.810][|][quartz-scheduler_Worker-20] Execute task|1|20
[2021-04-17 15:51:00.810][|][quartz-scheduler_Worker-21] Execute task|1|21
[2021-04-17 15:51:00.813][|][quartz-scheduler_Worker-22] Execute task|1|22
[2021-04-17 15:51:00.814][|][quartz-scheduler_Worker-6] Execute task|1|6
[2021-04-17 15:51:00.810][|][quartz-scheduler_Worker-9] Execute task|1|9
[2021-04-17 15:51:00.814][|][quartz-scheduler_Worker-12] Execute task|1|12
[2021-04-17 15:51:00.810][|][quartz-scheduler_Worker-17] Execute task|1|17
[2021-04-17 15:51:00.814][|][quartz-scheduler_Worker-13] Execute task|1|13
[2021-04-17 15:51:00.818][|][quartz-scheduler_Worker-26] Execute task|1|26
[2021-04-17 15:51:00.814][|][quartz-scheduler_Worker-23] Execute task|1|23
[2021-04-17 15:51:00.809][|][quartz-scheduler_Worker-4] Execute task|1|4
[2021-04-17 15:51:00.814][|][quartz-scheduler_Worker-10] Execute task|1|10
[2021-04-17 15:51:00.809][|][quartz-scheduler_Worker-18] Execute task|1|18
[2021-04-17 15:51:00.814][|][quartz-scheduler_Worker-8] Execute task|1|8
[2021-04-17 15:51:00.809][|][quartz-scheduler_Worker-19] Execute task|1|19
[2021-04-17 15:51:00.814][|][quartz-scheduler_Worker-14] Execute task|1|14
[2021-04-17 15:51:00.807][|][quartz-scheduler_Worker-7] Execute task|1|7
[2021-04-17 15:51:00.807][|][quartz-scheduler_Worker-16] Execute task|1|16
[2021-04-17 15:51:00.814][|][quartz-scheduler_Worker-11] Execute task|1|11
[2021-04-17 15:51:00.807][|][quartz-scheduler_Worker-15] Execute task|1|15
[2021-04-17 15:51:00.814][|][quartz-scheduler_Worker-1] Execute task|1|1
[2021-04-17 15:51:00.816][|][quartz-scheduler_Worker-24] Execute task|1|24
[2021-04-17 15:51:00.806][|][quartz-scheduler_Worker-2] Execute task|1|2
[2021-04-17 15:51:00.816][|][quartz-scheduler_Worker-25] Execute task|1|25
[2021-04-17 15:51:00.827][|][quartz-scheduler_Worker-27] Execute task|1|27
[2021-04-17 15:51:00.828][|][quartz-scheduler_Worker-31] Execute task|1|31
[2021-04-17 15:51:00.828][|][quartz-scheduler_Worker-29] Execute task|1|29
[2021-04-17 15:51:00.828][|][quartz-scheduler_Worker-32] Execute task|1|32
[2021-04-17 15:51:00.829][|][quartz-scheduler_Worker-30] Execute task|1|30
[2021-04-17 15:51:00.829][|][quartz-scheduler_Worker-28] Execute task|1|28
[2021-04-17 15:51:00.830][|][quartz-scheduler_Worker-33] Execute task|1|33
[2021-04-17 15:51:00.831][|][quartz-scheduler_Worker-35] Execute task|1|35
[2021-04-17 15:51:00.829][|][quartz-scheduler_Worker-34] Execute task|1|34
[2021-04-17 15:51:00.832][|][quartz-scheduler_Worker-36] Execute task|1|36
[2021-04-17 15:51:00.834][|][quartz-scheduler_Worker-37] Execute task|1|37
[2021-04-17 15:51:00.834][|][quartz-scheduler_Worker-38] Execute task|1|38
[2021-04-17 15:51:00.836][|][quartz-scheduler_Worker-41] Execute task|1|41
[2021-04-17 15:51:00.837][|][quartz-scheduler_Worker-39] Execute task|1|39
[2021-04-17 15:51:00.837][|][quartz-scheduler_Worker-40] Execute task|1|40
[2021-04-17 15:51:00.837][|][quartz-scheduler_Worker-42] Execute task|1|42
[2021-04-17 15:51:00.838][|][quartz-scheduler_Worker-43] Execute task|1|43
[2021-04-17 15:51:00.838][|][quartz-scheduler_Worker-44] Execute task|1|44
[2021-04-17 15:51:00.839][|][quartz-scheduler_Worker-45] Execute task|1|45
[2021-04-17 15:51:00.843][|][quartz-scheduler_Worker-48] Execute task|1|48
[2021-04-17 15:51:00.843][|][quartz-scheduler_Worker-46] Execute task|1|46
[2021-04-17 15:51:00.853][|][quartz-scheduler_Worker-49] Execute task|1|49
[2021-04-17 15:51:00.857][|][quartz-scheduler_Worker-47] Execute task|1|47
[2021-04-17 15:51:00.862][|][quartz-scheduler_Worker-50] Execute task|1|50

第一段调度器的日志显示现在已经成功的使用批量的方式来进行 Trigger 的拉取了,每次拉取 50 个。在拉取了 150 个任务之后,由于线程都已在执行任务,所以只有 6 个空闲线程释放,因此拉取了 6 个任务。

第二段是实际的任务执行日志。可以看到在这段执行过程中,大约有 25 个任务在 20ms 内启动完成,50 个任务全部启动也只消耗了约 50ms 的时间,已经比原先大大提升。

同时,对于负载不均匀的问题,在修改前,三台机器的负载为:1:2:497。修改之后再次测试,变为:100:151:249。负载不均匀的问题已经基本解决。

总结

注意事项

通过这次问题排查,认识到了一些在 Quartz 搭配 Spring 使用时需要注意的问题:

  1. 尽量不要使用自定义线程池,而是使用 Quartz 实现的 SimpleThreadPool
  2. 搭配使用时,不光要留意 Quartz 本身对于配置项的默认值处理,也要注意 Spring 对于配置项的处理
  3. Quartz 的调度误差是由多方面原因造成的,需要综合考虑配置项之间的相互作用逻辑
  4. 分析问题时需要严格测试,而不能想当然的进行调整

调度异常&负载不均原因

另外再来看一下最开始遇到的调度异常&负载不均的问题。综合后续的所有分析可知,在默认情况下,Quartz 的调度逻辑是这样的:

可以看到在这个默认配置下,由于存在一个阻塞等待可用线程org.quartz.simpl.SimpleThreadPool#blockForAvailableThreads)的操作,可以保证当本机的线程全部繁忙的时候,本机的调度线程不会去获取锁,自然而然的让别的实例来接手余下的任务调度工作。同时,由于这里的阻塞等待,保证了当本机线程全忙的时候不会有任何新任务进入,也就保证了不会出现提交失败的问题。

而当使用自定义线程池的时候,调度流程变成了这样:

在这个工作模式下,阻塞等待没有了。就算自定义线程池中的线程全忙、队列已满,此处还是会返回有一个可用线程。所以此时,调度线程的工作模式就变成了,不管线程池是否爆满,始终抢夺任务并继续提交。正是因为这里没有判定线程池是否满的逻辑,导致了最初的提交失败。而且由于不再等待可用线程,这里的循环变成了 加锁->拉 Trigger->提交 的循环,从而导致最初最先启动的线程在抢锁的过程中有极大的优势(没抢到锁会强制等待,而等待的时候锁已经又被最先启动的线程抢了),从而导致负载的严重失衡。

所以为了解决提交失败和负载不均的问题,最简单的方法是不要使用自定义线程池,而是通过 org.quartz.threadPool.class 设置使用 SimpleThreadPool,并根据机器负载调节线程数量。如果一定要使用自定义线程池的话,则必须实现 Quartz 提供的 org.quartz.spi.ThreadPool 接口,并实现阻塞等待可用线程的逻辑。千万不能直接使用 Spring 提供的 LocalTaskExecutorThreadPool

调度延迟产生的原因

根据本次分析的过程可以看出,Quartz 的调度延迟主要由以下几个方面组成:

  1. 同步加锁延迟。当 Quartz 使用DB作为集群持久化存储的场景下,为了保证调度秩序,避免重复调度或漏调度,每一次 DB 操作都要进行加锁。而每次调度时,需要先从 DB 读取要触发的 Trigger 信息并更新状态为 ACQUIRED,然后在将要触发时更新状态到 EXECUTING,之后才会向工作线程池提交任务。虽然在读取 Trigger 信息的时候大部分操作都不是批量进行的,但是加锁操作是针对一批任务的。考虑到正常的 DB 读写耗时在 5~10ms 左右,如果不进行批量操作的话,按照 50 个任务一批计算,每一批的加锁时间就白白增加了 250~500ms。因此,进行批量拉取可以有效的减小 DB 加锁操作带来的调度延迟。同时,考虑到锁的竞争会导致线程等待,批量加锁操作还可以有效的降低锁争抢,提升整体效率。如果业务场景对调度延迟比较敏感,应考虑使用性能更好的独立 DB 实例,并尽量降低 Quartz 实例到 DB 之间的网络延迟;
  2. 批量执行排队延迟。如果同时触发的任务数量超过了总集群的线程池上限,则一定会被分为多个批次进行触发,这时就会出现排队延迟。这里每个批次间的排队延迟将取决于任务的执行速度。单个任务执行的越慢则越靠后被触发的批次延迟越大。当总线程数耗尽时,下一次调度一定会等到有线程释放才会开始。理想情况下所有任务都应在一个批次内做完。因此应根据工作负载的情况,对 Quartz 集群进行扩容,增加总线程数,来避免任务进入排队。但是由于成本限制,这个方式往往很难实施。而且在实际使用的过程中,偶尔会出现任务本身的执行耗时不可控的情况。因此在实际使用的过程中,需要根据实际的业务情况,合理调节批量参数和工作线程数。当然在调节工作线程数量时还需要需要考虑到机器实际的负载能力,不能无谓的提高,反而导致负载不均衡的情况出现。同时应当考虑采取异步机制,并通过消息队列等异步化手段,将任务快速分发下去。再结合快速扩容机制,在峰值来临前对执行器进行扩容,来进一步降低延迟。这里推荐异步化任务执行器而不是大量扩容 Quartz 集群的主要原因在于,Quartz 集群本身是一个有状态的集群,相对来说逻辑复杂任务较重,且存在集群锁,不适合进行快速扩缩容。而在需要大批量调度的场景下,我们的工作负载往往都是无状态逻辑简单轻量化的小任务。这种任务最适合配合 K8S 等集群进行快速扩容支撑峰值,并在谷时缩容降低成本;
  3. CPU调度延迟。当调度线程将任务提交给工作线程池后,工作线程池中的线程最终还需要依赖 CPU 时间片来执行,若调度机器的 CPU 负载较高,或线程池数量不合理,那么线程在真正被 CPU 执行前将会有较大等待。为了尽量减少这里的执行延迟,应将工作线程池部署在 CPU 核数较多且负载不高的机器上。同时应该合理设置工作线程数量,避免一次性触发过多任务。

AWS Lightsail 修改 DB 参数

AWS 推出的 Lightsail Database 是目前比较实惠的托管 DB 方案。不过相对来说,Lightsail 的控制面板功能较少,很多参数无法修改。经过搜索发现,其实我们有很多参数可以调,只是需要通过命令行的方式来调整。

准备工作

安装 aws-cli 工具

https://aws.amazon.com/cli/

https://lightsail.aws.amazon.com/ls/docs/en_us/articles/lightsail-how-to-set-up-and-configure-aws-cli

Linux:

sudo apt-get install awscli

MacOS:

brew install awscli

Python(通用):

pip install awscli

设置 Access Key

https://lightsail.aws.amazon.com/ls/docs/en_us/articles/lightsail-how-to-set-up-access-keys-to-use-sdk-api-cli

先在 AWS 控制台新建用户或 Key:

https://console.aws.amazon.com/iam/home#/users

然后执行:

aws configure

按照提示依次输入:

AWS Access Key ID 控制台中创建的 Key
AWS Secret Access Key 控制台中创建的 Key 对于的 Secret
Default region name 可用区,https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.RegionsAndAvailabilityZones.html
Default output format 输出格式,建议 json

获取现有设置

aws lightsail get-relational-database-parameters --relational-database-name DatabaseName > current_params.json

注意 DatabaseName 替换为创建 DB 时设置的名称。就是 Lightsail 控制面板里显示的那个。

执行成功后打开 json 文件,可以看到所有变量。

注意每个变量有几个属性:

Allowed values 允许的变量范围
Apply method 变量的生效时间。immediate 表示立即生效,pending-reboot 表示重启后生效
Apply type 底层引擎支持的生效方式。dynamic 动态,可以立即生效,static 静态,必须重启后才能生效
Data type 数据类型
Description 变量描述
Is modifiable 能否修改
Parameter name 变量名

这里我们用最大连接数举例:

{
    "allowedValues": "1-100000",
    "applyMethod": "pending-reboot",
    "applyType": "dynamic",
    "dataType": "integer",
    "description": "The number of simultaneous client connections allowed.",
    "isModifiable": true,
    "parameterName": "max_connections",
    "parameterValue": "{DBInstanceClassMemory/12582880}"
}

可以看到,最大连接数是一个动态变量,整型,可修改。默认是实例内存大小/12582880,也就是 1G 内存约 80 个链接。实际比这个数值少,没有具体深究。

修改设置

找到了对于的参数,就可以修改了。修改参数使用的指令是:

aws lightsail update-relational-database-parameters --relational-database-name DatabaseName --parameters "parameterName=ParameterName,parameterValue=NewParameterValue,applyMethod=ApplyMethod"

DatabaseName 是实例名,ParameterName 替换为要修改的变量,NewParameterValue 替换为变量的值,ApplyMethod 替换为想要的生效方式。

比如,我们修改最大连接数到 1000,重启后生效,对应的命令为:

aws lightsail update-relational-database-parameters --relational-database-name DatabaseName --parameters "parameterName=max_connections,parameterValue=1000,applyMethod=pending-reboot"

成功后会收到这样的响应:

{
    "operations": [
        {
            "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
            "resourceName": "DatabaseName",
            "resourceType": "RelationalDatabase",
            "createdAt": 1570000000.000,
            "location": {
                "availabilityZone": "ap-northeast-1a",
                "regionName": "ap-northeast-1"
            },
            "isTerminal": true,
            "operationDetails": "",
            "operationType": "UpdateRelationalDatabaseParameters",
            "status": "Succeeded",
            "statusChangedAt": 1570000000.000
        }
    ]
}

看到 status Succeeded 就是设置成功啦!

官方文档:

https://lightsail.aws.amazon.com/ls/docs/en_us/articles/amazon-lightsail-updating-database-parameters

 

在腾讯云 TKE 上使用 Helm 的几个小坑

首先,大家都懂,gcr.io 是连不上的,需要使用阿里云镜像或别的镜像

helm init --upgrade -i registry.cn-hangzhou.aliyuncs.com/google_containers/tiller:v2.13.0 --stable-repo-url https://kubernetes.oss-cn-hangzhou.aliyuncs.com/chart --service-account tiller

如果没注意已经部署了,可以先删除:

kubectl delete deployment tiller-deploy --namespace=kube-system

然后,helm install 可能会报没有权限:

Error: release xxx failed: namespaces "default" is forbidden: User "system:serviceaccount:kube-system:default" cannot get namespaces in the namespace "default"

这时候需要新建账户处理权限问题

kubectl create serviceaccount --namespace kube-system tiller
kubectl create clusterrolebinding tiller-cluster-rule --clusterrole=cluster-admin --serviceaccount=kube-system:tiller
kubectl patch deploy --namespace kube-system tiller-deploy -p '{"spec":{"template":{"spec":{"serviceAccount":"tiller"}}}}'

注意这里给的权限比较高,高级玩家可以用 yaml 自定义权限,这里不再详细说明。

apiVersion: v1
kind: ServiceAccount
metadata:
  name: tiller
  namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
  name: tiller
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
  - kind: ServiceAccount
    name: tiller
    namespace: kube-system

然后来说说 Volumes 挂载

这个地方在控制台是没有直接入口的,需要通过修订 YAML 实现。有两种方式,一是通过控制台直接编辑 YAML,二是用 kubectl 自行 patch。注意这里修改的是 deployment 的 YAML

我这里使用 NFS 的方式,需要添加的段是:

spec:
  template:
    spec:
      volumes:
      - name: data
        nfs:
          path: /
          server: 10.0.x.x

最后关于访问方式,在 Service 面板,腾讯云提供了更新访问操作的入口,从这里直接修改就可以。

参考文章:

http://lizhe.name/node/357

https://www.jianshu.com/p/53bbf1f86b8a

https://ezmo.me/2017/09/24/helm-quick-toturial/

IPv6 地址处理时的小 Bug

众所周知,IPv6 地址中可以用 ::  来省略一连串的 0,于是,在某些情况下,如果 0 被过度合并了,就会导致地址解析的问题。

最近在 AWS 上启用 IPv6 的时候就遇到了这个情况,AWS 分配给我的地址段是:

2406:da14:5c9::/56

可以看出,AWS 实际给我分配的地址是:

2406:da14:5c9:0000::/56

但是由于连续 0 的省略策略,导致 5c9 之后的真正可以被划分的部分被省略了。于是在 AWS 的系统中,子网划分页面就显示成了这样:

2406:da14:5__::/64

按照正常逻辑应该是这样:

2406:da14:5c9:00__:/56

结果当然就悲剧了。这里我除了填 c9 之外,任何的值都会导致划分出的子网不在上面的地址段内,导致划分失败。

所幸的是 AWS 的系统可以随意的解绑地址段,然后重新分配。于是重新分配之后就好了。

不过这里也是对我们的一个提醒:由于 IPv6 地址书写的特殊性,连续 0 会被缩写,那么在进行地址读取和判断的时候,就一定要先做 ::  的展开,再去做判断哦。

 

Tomcat 在处理 Cookie 的时候的几个小坑

今天在代码中调用 HttpServletRequest  对象的 getCookies()  方法时,发现实际得到的 Cookie 数量与提交的不符。实际提交了 17 个 Cookie,但是获取到的只有 14 个。

经过排查,发现如果调用 getHeaders(“Cookie”)  方法,获取原始的 Cookie 串,是可以拿到正确的 17 个 Cookie 组成的字符串的。于是确认应该是 Tomcat 在处理 Cookie 的时候进行了过滤。

经过一番搜索,发现了这个文档:

http://tomcat.apache.org/tomcat-7.0-doc/config/systemprops.html

其中,跟 Cookie 相关的参数有:

org.apache.tomcat.util.http. ServerCookie.ALLOW_EQUALS_IN_VALUE If this is true Tomcat will allow ‘=‘ characters when parsing unquoted cookie values. If false, cookie values containing ‘=‘ will be terminated when the ‘=‘ is encountered and the remainder of the cookie value will be dropped.

If not specified, the default value specification compliant value of false will be used.

org.apache.tomcat.util.http. ServerCookie.ALLOW_HTTP_SEPARATORS_IN_V0 If this is true Tomcat will allow HTTP separators in cookie names and values.

If not specified, the default specification compliant value of false will be used.

org.apache.tomcat.util.http. ServerCookie.ALLOW_NAME_ONLY If this is false then the requirements of the cookie specifications that cookies must have values will be enforced and cookies consisting only of a name but no value will be ignored.

If not specified, the default specification compliant value of false will be used.

org.apache.tomcat.util.http. ServerCookie.ALWAYS_ADD_EXPIRES If this is true Tomcat will always add an expires parameter to a SetCookie header even for cookies with version greater than zero. This is to work around a known IE6 and IE7 bug that causes IE to ignore the Max-Age parameter in a SetCookie header.

If org.apache.catalina.STRICT_SERVLET_COMPLIANCE is set to true, the default of this setting will be false, else the default value will be true.

org.apache.tomcat.util.http. ServerCookie.FWD_SLASH_IS_SEPARATOR If this is true then the / (forward slash) character will be treated as a separator. Note that this character is frequently used in cookie path attributes and some browsers will fail to process a cookie if the path attribute is quoted as is required by a strict adherence to the specifications. This is highly likely to break session tracking using cookies.

If org.apache.catalina.STRICT_SERVLET_COMPLIANCE is set to true, the default of this setting will be true, else the default value will be false.

org.apache.tomcat.util.http. ServerCookie.PRESERVE_COOKIE_HEADER If this is true Tomcat will ensure that cookie processing does not modify cookie header returned by HttpServletRequest.getHeader().

If org.apache.catalina.STRICT_SERVLET_COMPLIANCE is set to true, the default of this setting will be true, else the default value will be false.

org.apache.tomcat.util.http. ServerCookie.STRICT_NAMING If this is true then the requirements of the Servlet specification that Cookie names must adhere to RFC2109 (no use of separators) will be enforced.

If org.apache.catalina.STRICT_SERVLET_COMPLIANCE is set to true, the default of this setting will be true, else the default value will be false.

看了一下相关参数,跟我这个现象有关的是这两个选项:

org.apache.tomcat.util.http. ServerCookie.ALLOW_HTTP_SEPARATORS_IN_V0
org.apache.tomcat.util.http. ServerCookie.ALLOW_NAME_ONLY

于是按照这个选项去搜索,发现了这样一篇文章:

http://www.cnblogs.com/princessd8251/articles/4172103.html

其中详细说明了第一个选项的作用:

在网上查了下,cookie有以下版本:

版本0:由Netscape公司制定的,也被几乎所有的浏览器支持。Java中为了保持兼容性,目前只支持到版本0,Cookie的内容中不能包含空格,方括号,圆括号,等于号(=),逗号,双引号,斜杠,问号,@符号,冒号,分号。

版本1:根据RFC 2109(http://www.ietf.org/rfc/rfc2109.txt)文档制定的. 放宽了很多限制. 上面所限制的字符都可以使用. 但为了保持兼容性, 应该尽量避免使用这些特殊字符。

Tomcat 具体的过滤源码里面包含了这些字符:

如果 org.apache.tomcat.util.http.ServerCookie.FWD_SLASH_IS_SEPARATOR 设为 true (默认 False),’/’也会被作为http分隔符

如果 org.apache.tomcat.util.http.ServerCookie.ALLOW_HTTP_SEPARATORS_IN_V0 设为 true (默认 False),tomcat将会允许cookie的name和value使用http分隔符

tomcat源码中的http分隔符:’\t’, ‘ ‘, ‘\”‘, ‘(‘, ‘)’, ‘,’, ‘:’, ‘;’, ‘<', '=', '>‘, ‘?’, ‘@’, ‘[‘, ‘\\’, ‘]’, ‘{‘, ‘}’

然后第二个选项就很好理解了,如果 Cookie 的 Value 是空,那么直接干掉。

于是,我们传的 Cookie 是这样的:

aaa=@abcdefg

Tomcat 先做了一次截断,将 @ 和后面的内容都去掉了;然后做了一次判断,发现 aaa 这个 Cookie 只有 name 没有 value,于是直接干掉了。

到此真相大白。

 

如果想要解决这个问题,有两个方法:

1. 在 Tomcat 的配置文件 catalina.properties  中设置 org.apache.tomcat.util.http.ServerCookie.ALLOW_HTTP_SEPARATORS_IN_V0=true

2. 在代码中使用 request.getHeader(“Cookie”) 取出原始的 Cookie 串,自行处理。

Nginx 诡异 SSL_PROTOCOL_ERROR 问题排查

这两天在检查一台 Nginx 配置的时候,遇到了一个极端诡异的问题。一段很通用的配置,配在这个服务器上,就会 100% 导致 Chrome 报 ERR_SSL_PROTOCOL_ERROR 。但是这段配置非常的通用,是用 Mozilla 提供的工具生成的。

而且在 iPhone 的 Safari 上访问又是完全正常的,服务器日志也看不到任何错误。看到的请求相应码也是完全正确的 200 。

先贴出配置:

# https://mozilla.github.io/server-side-tls/ssl-config-generator/
    listen 443 ssl http2;

    # certs sent to the client in SERVER HELLO are concatenated in ssl_certificate
    ssl_certificate /path/to/signed_cert_plus_intermediates;
    ssl_certificate_key /path/to/private_key;
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:50m;
    ssl_session_tickets off;


    # modern configuration. tweak to your needs.
    ssl_protocols TLSv1.2;
    ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256';
    ssl_prefer_server_ciphers on;

可以看到是在 Mozilla 网站上选择  和 Modern 生成出来的配置。

在测试过程中,排查了各种问题,包括但不限于 SSL 证书问题,HTTP Basic Auth 问题,http2 问题等等,然而都没有解决这个现象。

一次偶然的尝试,发现只要注释掉我给这个 server 特殊配置的这段逻辑,使用服务器通用的 ssl.ngx 文件中的 SSL 配置,就不会出现问题。于是开始先使用 ssl.ngx 文件中的配置,然后逐行替换,来查找具体出现问题的配置。

终于,当我将配置中的这行加上时,问题出现了:

ssl_session_tickets off;

于是以这个配置作为关键字搜索,找到了这么一篇文章:

https://community.letsencrypt.org/t/errors-from-browsers-with-ssl-session-tickets-off-nginx/18124/5

I’m posting this here both because this question was recently asked and because it took me many hours of troubleshooting to figure out the issue as while I found several references to the problem on Google, no one seemed to have a real solution. So here it is:

ssl_session_tokens off breaks if it’s not set the same for all ssl-enabled server{} blocks. So if you have 2 server configurations and and you have ssl_server_tokens set to on in one (which is the default so it counts even if you omit it) and set to off in another, it will break the one where it’s set to off in certain browsers. The easiest way to resolve this, unless you have multiple http{} blocks, is to just set it to off in the http{} block. I have not tested to see if you you can have different settings in different http{} blocks as I haven’t had need to set up more than one http{} block.

For others looking for this issue, I want to add that Chrome will respond with: ERR_SSL_PROTOCOL_ERROR while Firefox responds with: SSL_ERROR_RX_UNEXPECTED_NEW_SESSION_TICKET and curl responds with: gnutls_handshake() failed: An unexpected TLS packet was received. IE seemed to work, surprisingly.

简单翻译一下,这里是说,如果你的 nginx 开了多个 https 的 server,其中某些 server 没有配置 ssl_server_tokens off; ,而有些 server 配置了这个选项,那么就会导致没有手动 off 的 server 采用默认值 on,而手动 off 掉的 server 采用 off。这种情况会导致 nginx 和浏览器之间的握手出现问题,从而导致 Chrome 报出 ERR_SSL_PROTOCOL_ERROR ,FireFox 则会报出 SSL_ERROR_RX_UNEXPECTED_NEW_SESSION_TICKET 。

那么解决方法也很简单,只要在所有的 server 块统一这个配置就好了。要么都设置为 on,要么都设置为 off,问题解决。目前没有尝试多个 http 块隔离两个 server,建议还是将这个配置统一一下。

 

 

MySQL 分区表的一些问题

最近在使用 MySQL 分区表的时候,研究了一下多列 Range 分区,也就是

PARTITION BY RANGE COLUMNS(`a`, `b`, `c`) (
    PARTITION p1 VALUES LESS THAN (0, 0, MAXVALUE),
    PARTITION p2 VALUES LESS THAN (10, 10, MAXVALUE),
    PARTITION p3 VALUES LESS THAN (20, 20, MAXVALUE)
)

在多列的情况下,MySQL 的分区策略和单列略有不同,这也是比较坑的地方,查遍所有文档都没人提到。。。

先说说单列 Range 分区。比如,如果这么写:

PARTITION BY RANGE(`a`) (
    PARTITION p1 VALUES LESS THAN (0),
    PARTITION p2 VALUES LESS THAN (10),
    PARTITION p3 VALUES LESS THAN (20)
)

那么,p1 中的数据是 a 值小于 0 的,注意,是小于,不包括 0 。然后,p2 中的数据是 a 值在 [0, 10) 之间的,注意右边是开区间,不包括 10 。同样的,p3 中的数据是 a 值在 [10, 20) 之间的,不包括 20 。

也就是说,如果有这么一条数据:

INSERT INTO test_table (`a`, `b`, `c`) VALUES (10,10,20);

由于 a=10,所以会落入 p3 分区。

再来看多列分区,使用第一个多列分区语句,执行 INSERT,会发现,数据插入到了 p2 分区,而不是想象中的 p3 分区。

这里么的原因,就涉及到 MySQL 内部的比较了。当使用单列分区时,MySQL 的比较方法是:

if a < 0  then p1
if a < 10 then p2
if a < 20 then p3

当采用多列分区的时候,比较方法就相应的变成了:

if (a,b,c) < (0 , 0 , MAXVALUE) then p1
if (a,b,c) < (10, 10, MAXVALUE) then p2
if (a,b,c) < (20, 20, MAXVALUE) then p3

那咱们再来看看直接执行这个比较会怎么样:

mysql> SELECT 10 < 10;
+---------+
| 10 < 10 |
+---------+
|       0 |
+---------+
1 row in set (0.01 sec)

mysql> SELECT 9 < 10;
+--------+
| 9 < 10 |
+--------+
|      1 |
+--------+
1 row in set (0.01 sec)

mysql> SELECT (10,10) < (10,10);
+-------------------+
| (10,10) < (10,10) |
+-------------------+
|                 0 |
+-------------------+
1 row in set (0.00 sec)

mysql> SELECT (10,9) < (10,10);
+------------------+
| (10,9) < (10,10) |
+------------------+
|                1 |
+------------------+
1 row in set (0.00 sec)

惊喜来了!(10,10) < (10,10) 毫不意外的被判定为 false ,但是 (10,9) < (10,10) 确是 true 的!

再来一些尝试:

mysql> SELECT (11,9) < (10,10);
+------------------+
| (11,9) < (10,10) |
+------------------+
|                0 |
+------------------+
1 row in set (0.00 sec)

mysql> SELECT (9,11) < (10,10);
+------------------+
| (9,11) < (10,10) |
+------------------+
|                1 |
+------------------+
1 row in set (0.01 sec)

mysql> SELECT (9,10) < (10,10);
+------------------+
| (9,10) < (10,10) |
+------------------+
|                1 |
+------------------+
1 row in set (0.01 sec)

惊呆了,(9,11) < (10,10) 居然也是 true !

来,实际测试一下:

CREATE TABLE `test_table` (
    `a` INT(20) NOT NULL,
    `b` INT(11) NOT NULL
) ENGINE=INNODB DEFAULT CHARSET=UTF8MB4
PARTITION BY RANGE COLUMNS(`a`, `b`) (
    PARTITION p1 VALUES LESS THAN (0, 0),
    PARTITION p2 VALUES LESS THAN (10, 10),
    PARTITION p3 VALUES LESS THAN (20, 20)
);

INSERT INTO `test_table` VALUES (10,10);
INSERT INTO `test_table` VALUES (10,9);
INSERT INTO `test_table` VALUES (9,11);

执行之后发现,第一条记录毫不意外的在 p3 ,但是第二条记录和第三条记录却都在 p2 !

那么这时候执行查询会发生什么呢?

mysql> SELECT * FROM `test_table`;
+----+----+
| a  | b  |
+----+----+
| 10 |  9 |
|  9 | 11 |
| 10 | 10 |
+----+----+
3 rows in set (0.00 sec)

mysql> EXPLAIN PARTITIONS SELECT * FROM `test_table` WHERE a=10;
+------+-------------+------------+------------+------+---------------+------+---------+------+------+-------------+
| id   | select_type | table      | partitions | type | possible_keys | key  | key_len | ref  | rows | Extra       |
+------+-------------+------------+------------+------+---------------+------+---------+------+------+-------------+
|    1 | SIMPLE      | test_table | p2,p3      | ALL  | NULL          | NULL | NULL    | NULL |    3 | Using where |
+------+-------------+------------+------------+------+---------------+------+---------+------+------+-------------+
1 row in set (0.00 sec)

mysql> EXPLAIN PARTITIONS SELECT * FROM `test_table` WHERE b=10;
+------+-------------+------------+------------+------+---------------+------+---------+------+------+-------------+
| id   | select_type | table      | partitions | type | possible_keys | key  | key_len | ref  | rows | Extra       |
+------+-------------+------------+------------+------+---------------+------+---------+------+------+-------------+
|    1 | SIMPLE      | test_table | p1,p2,p3   | ALL  | NULL          | NULL | NULL    | NULL |    5 | Using where |
+------+-------------+------------+------------+------+---------------+------+---------+------+------+-------------+
1 row in set (0.00 sec)

可以看到,当我们使用多列中的第一列做查询时,MySQL 会识别出 p1 分区一定没有数据,所以优化中直接去掉了这个分区,但是搜索了 p2 p3 两个分区。

这也是疑惑点之一,按照 MySQL 的规则,似乎 p2 也一定不会有数据,为啥要搜 p2

再来看下面一个查 b 的语句,会发现根本没有用分区,直接全分区搜索。。。看来 MySQL 也知道可能有一些 b 值并不是存在相应的分区中,需要全表扫描。

具体原因可能需要深入分析 MySQL 源码,这里就先说这么一个需要注意的现象,防止踩坑。。。

nginx rewrite 的一个小坑

今天在配置 Nginx 的时候写了这么一个 location

location /a {
    rewrite /a/(.*) /$1 break;
    ...
}

然后发现当我直接访问 /a 的时候,rewrite 并没有生效,后端收到的还是 /a 而不是我想象中的 / 。想了想可能是结尾 / 的问题,于是这样改:

location /a {
    rewrite /a(.*) $1 break;
    ...
}

结果新的问题来了,由于这样匹配到的 $1 是空的,所以 Nginx 报错了,the rewritten URI has a zero length

所以这种情况下只好这么写:

location /a/ {
    rewrite /a(.*) $1 break;
    ...
}

注意第一行的末尾 / 。这种情况下,访问 /a 会被 Nginx 自动重定向到 /a/ ,然后重写之后的 uri 就是 /,问题解决。

Nginx 配置 ECC RSA 双证书

Config Nginx for parallel ECC and RSA Certificate

先决条件

Nginx 1.11.0 以上

OpenSSL 1.0.2 以上

申请证书

首先申请 ECC 证书,这个不多说,很多方法都可以,大部分 CA 现在也都可以签署。生成 CSR 的命令是:

openssl ecparam -out 证书名.key -name prime256v1 -genkey && openssl req -new -key 证书名.key -nodes -out 证书名.csr

拿到证书之后,还是像之前一样将中级 CA 拼接在证书后面,得到给 Nginx 使用的 domain-cert.crt

配置 Nginx

首先是将两个证书链都加入 Nginx 的配置文件:

ssl_certificate     example.com.rsa.crt;
ssl_certificate_key example.com.rsa.key;

ssl_certificate     example.com.ecdsa.crt;
ssl_certificate_key example.com.ecdsa.key;

如果要使用 CT 的话有两种方法:

1. 将两个证书的 CT 信息放到同一目录,并做如下设置:

ssl_ct on;
ssl_ct_static_scts /path/to/sct/dir;

这样 Nginx CT 模块会自动在这个目录下查找相应证书的 CT 信息并发送

2. 可以单独配置每个证书的 CT 文件:

ssl_ct on;

ssl_certificate example.com.rsa.crt;
ssl_certificate_key example.com.rsa.key;
ssl_ct_static_scts xample.com.rsa.scts;

ssl_certificate example.com.ecdsa.crt;
ssl_certificate_key example.com.ecdsa.key;
ssl_ct_static_scts example.com.ecdsa.scts;

然后问题来了。很多盆友这么配置之后可能发现用 Chrome 之类的明明支持 ECC 的浏览器却并没有用 ECC 证书。这是为什么呢?

问题就出在  ssl_ciphers  这个配置项上面。

如果我们用各种网上推荐的配置,需要注意顺序问题。以 Cloud Flare 的配置为例:

#https://github.com/cloudflare/sslconfig/blob/master/conf
ssl_protocols               TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers                 EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
ssl_prefer_server_ciphers   on;

注意到这里面的算法都是优先使用 RSA 的,所以服务器和浏览器协商出来的一定是 RSA ,这就导致 Nginx 会自动发送 RSA 证书链给浏览器。

这里可以用 openssl 验证一下:

# openssl ciphers -V 'EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5' | column -t
0xC0,0x2F  -  ECDHE-RSA-AES128-GCM-SHA256    TLSv1.2  Kx=ECDH  Au=RSA    Enc=AESGCM(128)  Mac=AEAD
0xC0,0x2B  -  ECDHE-ECDSA-AES128-GCM-SHA256  TLSv1.2  Kx=ECDH  Au=ECDSA  Enc=AESGCM(128)  Mac=AEAD
0xC0,0x27  -  ECDHE-RSA-AES128-SHA256        TLSv1.2  Kx=ECDH  Au=RSA    Enc=AES(128)     Mac=SHA256
0xC0,0x23  -  ECDHE-ECDSA-AES128-SHA256      TLSv1.2  Kx=ECDH  Au=ECDSA  Enc=AES(128)     Mac=SHA256
0xC0,0x13  -  ECDHE-RSA-AES128-SHA           SSLv3    Kx=ECDH  Au=RSA    Enc=AES(128)     Mac=SHA1
0xC0,0x09  -  ECDHE-ECDSA-AES128-SHA         SSLv3    Kx=ECDH  Au=ECDSA  Enc=AES(128)     Mac=SHA1
0x00,0x9C  -  AES128-GCM-SHA256              TLSv1.2  Kx=RSA   Au=RSA    Enc=AESGCM(128)  Mac=AEAD
0x00,0x3C  -  AES128-SHA256                  TLSv1.2  Kx=RSA   Au=RSA    Enc=AES(128)     Mac=SHA256
0x00,0x2F  -  AES128-SHA                     SSLv3    Kx=RSA   Au=RSA    Enc=AES(128)     Mac=SHA1
0xC0,0x30  -  ECDHE-RSA-AES256-GCM-SHA384    TLSv1.2  Kx=ECDH  Au=RSA    Enc=AESGCM(256)  Mac=AEAD
0xC0,0x2C  -  ECDHE-ECDSA-AES256-GCM-SHA384  TLSv1.2  Kx=ECDH  Au=ECDSA  Enc=AESGCM(256)  Mac=AEAD
0xC0,0x28  -  ECDHE-RSA-AES256-SHA384        TLSv1.2  Kx=ECDH  Au=RSA    Enc=AES(256)     Mac=SHA384
0xC0,0x24  -  ECDHE-ECDSA-AES256-SHA384      TLSv1.2  Kx=ECDH  Au=ECDSA  Enc=AES(256)     Mac=SHA384
0xC0,0x14  -  ECDHE-RSA-AES256-SHA           SSLv3    Kx=ECDH  Au=RSA    Enc=AES(256)     Mac=SHA1
0xC0,0x0A  -  ECDHE-ECDSA-AES256-SHA         SSLv3    Kx=ECDH  Au=ECDSA  Enc=AES(256)     Mac=SHA1
0x00,0x9D  -  AES256-GCM-SHA384              TLSv1.2  Kx=RSA   Au=RSA    Enc=AESGCM(256)  Mac=AEAD
0x00,0x3D  -  AES256-SHA256                  TLSv1.2  Kx=RSA   Au=RSA    Enc=AES(256)     Mac=SHA256
0x00,0x35  -  AES256-SHA                     SSLv3    Kx=RSA   Au=RSA    Enc=AES(256)     Mac=SHA1
0xC0,0x12  -  ECDHE-RSA-DES-CBC3-SHA         SSLv3    Kx=ECDH  Au=RSA    Enc=3DES(168)    Mac=SHA1
0xC0,0x08  -  ECDHE-ECDSA-DES-CBC3-SHA       SSLv3    Kx=ECDH  Au=ECDSA  Enc=3DES(168)    Mac=SHA1
0x00,0x0A  -  DES-CBC3-SHA                   SSLv3    Kx=RSA   Au=RSA    Enc=3DES(168)    Mac=SHA1

会看到第一个选择就是使用 RSA,而不是椭圆曲线 ECDSA。

再来验证一下 Mozilla 给出的配置:

# openssl ciphers -V 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS' | column -t 
0xC0,0x2B  -  ECDHE-ECDSA-AES128-GCM-SHA256  TLSv1.2  Kx=ECDH  Au=ECDSA  Enc=AESGCM(128)  Mac=AEAD
0xC0,0x2F  -  ECDHE-RSA-AES128-GCM-SHA256    TLSv1.2  Kx=ECDH  Au=RSA    Enc=AESGCM(128)  Mac=AEAD
0xC0,0x2C  -  ECDHE-ECDSA-AES256-GCM-SHA384  TLSv1.2  Kx=ECDH  Au=ECDSA  Enc=AESGCM(256)  Mac=AEAD
0xC0,0x30  -  ECDHE-RSA-AES256-GCM-SHA384    TLSv1.2  Kx=ECDH  Au=RSA    Enc=AESGCM(256)  Mac=AEAD
0x00,0x9E  -  DHE-RSA-AES128-GCM-SHA256      TLSv1.2  Kx=DH    Au=RSA    Enc=AESGCM(128)  Mac=AEAD
0x00,0x9F  -  DHE-RSA-AES256-GCM-SHA384      TLSv1.2  Kx=DH    Au=RSA    Enc=AESGCM(256)  Mac=AEAD
0xC0,0x23  -  ECDHE-ECDSA-AES128-SHA256      TLSv1.2  Kx=ECDH  Au=ECDSA  Enc=AES(128)     Mac=SHA256
0xC0,0x27  -  ECDHE-RSA-AES128-SHA256        TLSv1.2  Kx=ECDH  Au=RSA    Enc=AES(128)     Mac=SHA256
0xC0,0x09  -  ECDHE-ECDSA-AES128-SHA         SSLv3    Kx=ECDH  Au=ECDSA  Enc=AES(128)     Mac=SHA1
0xC0,0x28  -  ECDHE-RSA-AES256-SHA384        TLSv1.2  Kx=ECDH  Au=RSA    Enc=AES(256)     Mac=SHA384
0xC0,0x13  -  ECDHE-RSA-AES128-SHA           SSLv3    Kx=ECDH  Au=RSA    Enc=AES(128)     Mac=SHA1
0xC0,0x24  -  ECDHE-ECDSA-AES256-SHA384      TLSv1.2  Kx=ECDH  Au=ECDSA  Enc=AES(256)     Mac=SHA384
0xC0,0x0A  -  ECDHE-ECDSA-AES256-SHA         SSLv3    Kx=ECDH  Au=ECDSA  Enc=AES(256)     Mac=SHA1
0xC0,0x14  -  ECDHE-RSA-AES256-SHA           SSLv3    Kx=ECDH  Au=RSA    Enc=AES(256)     Mac=SHA1
0x00,0x67  -  DHE-RSA-AES128-SHA256          TLSv1.2  Kx=DH    Au=RSA    Enc=AES(128)     Mac=SHA256
0x00,0x33  -  DHE-RSA-AES128-SHA             SSLv3    Kx=DH    Au=RSA    Enc=AES(128)     Mac=SHA1
0x00,0x6B  -  DHE-RSA-AES256-SHA256          TLSv1.2  Kx=DH    Au=RSA    Enc=AES(256)     Mac=SHA256
0x00,0x39  -  DHE-RSA-AES256-SHA             SSLv3    Kx=DH    Au=RSA    Enc=AES(256)     Mac=SHA1
0xC0,0x08  -  ECDHE-ECDSA-DES-CBC3-SHA       SSLv3    Kx=ECDH  Au=ECDSA  Enc=3DES(168)    Mac=SHA1
0xC0,0x12  -  ECDHE-RSA-DES-CBC3-SHA         SSLv3    Kx=ECDH  Au=RSA    Enc=3DES(168)    Mac=SHA1
0x00,0x16  -  EDH-RSA-DES-CBC3-SHA           SSLv3    Kx=DH    Au=RSA    Enc=3DES(168)    Mac=SHA1
0x00,0x9C  -  AES128-GCM-SHA256              TLSv1.2  Kx=RSA   Au=RSA    Enc=AESGCM(128)  Mac=AEAD
0x00,0x9D  -  AES256-GCM-SHA384              TLSv1.2  Kx=RSA   Au=RSA    Enc=AESGCM(256)  Mac=AEAD
0x00,0x3C  -  AES128-SHA256                  TLSv1.2  Kx=RSA   Au=RSA    Enc=AES(128)     Mac=SHA256
0x00,0x3D  -  AES256-SHA256                  TLSv1.2  Kx=RSA   Au=RSA    Enc=AES(256)     Mac=SHA256
0x00,0x2F  -  AES128-SHA                     SSLv3    Kx=RSA   Au=RSA    Enc=AES(128)     Mac=SHA1
0x00,0x35  -  AES256-SHA                     SSLv3    Kx=RSA   Au=RSA    Enc=AES(256)     Mac=SHA1
0x00,0x0A  -  DES-CBC3-SHA                   SSLv3    Kx=RSA   Au=RSA    Enc=3DES(168)    Mac=SHA1

在这份配置中可以看到,每种套件中使用椭圆曲线部分都被排在了 RSA 前面,所以能尽量协商出支持椭圆曲线的算法。

或者我们还可以更激进一些:

# openssl ciphers -V 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK' | column -t
0xC0,0x2B  -  ECDHE-ECDSA-AES128-GCM-SHA256  TLSv1.2  Kx=ECDH        Au=ECDSA  Enc=AESGCM(128)    Mac=AEAD
0xC0,0x2C  -  ECDHE-ECDSA-AES256-GCM-SHA384  TLSv1.2  Kx=ECDH        Au=ECDSA  Enc=AESGCM(256)    Mac=AEAD
0xC0,0x23  -  ECDHE-ECDSA-AES128-SHA256      TLSv1.2  Kx=ECDH        Au=ECDSA  Enc=AES(128)       Mac=SHA256
0xC0,0x09  -  ECDHE-ECDSA-AES128-SHA         SSLv3    Kx=ECDH        Au=ECDSA  Enc=AES(128)       Mac=SHA1
0xC0,0x24  -  ECDHE-ECDSA-AES256-SHA384      TLSv1.2  Kx=ECDH        Au=ECDSA  Enc=AES(256)       Mac=SHA384
0xC0,0x0A  -  ECDHE-ECDSA-AES256-SHA         SSLv3    Kx=ECDH        Au=ECDSA  Enc=AES(256)       Mac=SHA1
0xC0,0x2F  -  ECDHE-RSA-AES128-GCM-SHA256    TLSv1.2  Kx=ECDH        Au=RSA    Enc=AESGCM(128)    Mac=AEAD
0xC0,0x30  -  ECDHE-RSA-AES256-GCM-SHA384    TLSv1.2  Kx=ECDH        Au=RSA    Enc=AESGCM(256)    Mac=AEAD
0x00,0x9E  -  DHE-RSA-AES128-GCM-SHA256      TLSv1.2  Kx=DH          Au=RSA    Enc=AESGCM(128)    Mac=AEAD
0x00,0xA2  -  DHE-DSS-AES128-GCM-SHA256      TLSv1.2  Kx=DH          Au=DSS    Enc=AESGCM(128)    Mac=AEAD
0x00,0xA3  -  DHE-DSS-AES256-GCM-SHA384      TLSv1.2  Kx=DH          Au=DSS    Enc=AESGCM(256)    Mac=AEAD
0x00,0x9F  -  DHE-RSA-AES256-GCM-SHA384      TLSv1.2  Kx=DH          Au=RSA    Enc=AESGCM(256)    Mac=AEAD
0xC0,0x27  -  ECDHE-RSA-AES128-SHA256        TLSv1.2  Kx=ECDH        Au=RSA    Enc=AES(128)       Mac=SHA256
0xC0,0x13  -  ECDHE-RSA-AES128-SHA           SSLv3    Kx=ECDH        Au=RSA    Enc=AES(128)       Mac=SHA1
0xC0,0x28  -  ECDHE-RSA-AES256-SHA384        TLSv1.2  Kx=ECDH        Au=RSA    Enc=AES(256)       Mac=SHA384
0xC0,0x14  -  ECDHE-RSA-AES256-SHA           SSLv3    Kx=ECDH        Au=RSA    Enc=AES(256)       Mac=SHA1
0x00,0x67  -  DHE-RSA-AES128-SHA256          TLSv1.2  Kx=DH          Au=RSA    Enc=AES(128)       Mac=SHA256
0x00,0x33  -  DHE-RSA-AES128-SHA             SSLv3    Kx=DH          Au=RSA    Enc=AES(128)       Mac=SHA1
0x00,0x40  -  DHE-DSS-AES128-SHA256          TLSv1.2  Kx=DH          Au=DSS    Enc=AES(128)       Mac=SHA256
0x00,0x6B  -  DHE-RSA-AES256-SHA256          TLSv1.2  Kx=DH          Au=RSA    Enc=AES(256)       Mac=SHA256
0x00,0x38  -  DHE-DSS-AES256-SHA             SSLv3    Kx=DH          Au=DSS    Enc=AES(256)       Mac=SHA1
0x00,0x39  -  DHE-RSA-AES256-SHA             SSLv3    Kx=DH          Au=RSA    Enc=AES(256)       Mac=SHA1
0x00,0x9C  -  AES128-GCM-SHA256              TLSv1.2  Kx=RSA         Au=RSA    Enc=AESGCM(128)    Mac=AEAD
0x00,0x9D  -  AES256-GCM-SHA384              TLSv1.2  Kx=RSA         Au=RSA    Enc=AESGCM(256)    Mac=AEAD
0x00,0x3C  -  AES128-SHA256                  TLSv1.2  Kx=RSA         Au=RSA    Enc=AES(128)       Mac=SHA256
0x00,0x3D  -  AES256-SHA256                  TLSv1.2  Kx=RSA         Au=RSA    Enc=AES(256)       Mac=SHA256
0x00,0x2F  -  AES128-SHA                     SSLv3    Kx=RSA         Au=RSA    Enc=AES(128)       Mac=SHA1
0x00,0x35  -  AES256-SHA                     SSLv3    Kx=RSA         Au=RSA    Enc=AES(256)       Mac=SHA1
0x00,0x6A  -  DHE-DSS-AES256-SHA256          TLSv1.2  Kx=DH          Au=DSS    Enc=AES(256)       Mac=SHA256
0xC0,0x32  -  ECDH-RSA-AES256-GCM-SHA384     TLSv1.2  Kx=ECDH/RSA    Au=ECDH   Enc=AESGCM(256)    Mac=AEAD
0xC0,0x2E  -  ECDH-ECDSA-AES256-GCM-SHA384   TLSv1.2  Kx=ECDH/ECDSA  Au=ECDH   Enc=AESGCM(256)    Mac=AEAD
0xC0,0x2A  -  ECDH-RSA-AES256-SHA384         TLSv1.2  Kx=ECDH/RSA    Au=ECDH   Enc=AES(256)       Mac=SHA384
0xC0,0x26  -  ECDH-ECDSA-AES256-SHA384       TLSv1.2  Kx=ECDH/ECDSA  Au=ECDH   Enc=AES(256)       Mac=SHA384
0xC0,0x0F  -  ECDH-RSA-AES256-SHA            SSLv3    Kx=ECDH/RSA    Au=ECDH   Enc=AES(256)       Mac=SHA1
0xC0,0x05  -  ECDH-ECDSA-AES256-SHA          SSLv3    Kx=ECDH/ECDSA  Au=ECDH   Enc=AES(256)       Mac=SHA1
0x00,0x32  -  DHE-DSS-AES128-SHA             SSLv3    Kx=DH          Au=DSS    Enc=AES(128)       Mac=SHA1
0xC0,0x31  -  ECDH-RSA-AES128-GCM-SHA256     TLSv1.2  Kx=ECDH/RSA    Au=ECDH   Enc=AESGCM(128)    Mac=AEAD
0xC0,0x2D  -  ECDH-ECDSA-AES128-GCM-SHA256   TLSv1.2  Kx=ECDH/ECDSA  Au=ECDH   Enc=AESGCM(128)    Mac=AEAD
0xC0,0x29  -  ECDH-RSA-AES128-SHA256         TLSv1.2  Kx=ECDH/RSA    Au=ECDH   Enc=AES(128)       Mac=SHA256
0xC0,0x25  -  ECDH-ECDSA-AES128-SHA256       TLSv1.2  Kx=ECDH/ECDSA  Au=ECDH   Enc=AES(128)       Mac=SHA256
0xC0,0x0E  -  ECDH-RSA-AES128-SHA            SSLv3    Kx=ECDH/RSA    Au=ECDH   Enc=AES(128)       Mac=SHA1
0xC0,0x04  -  ECDH-ECDSA-AES128-SHA          SSLv3    Kx=ECDH/ECDSA  Au=ECDH   Enc=AES(128)       Mac=SHA1
0x00,0x88  -  DHE-RSA-CAMELLIA256-SHA        SSLv3    Kx=DH          Au=RSA    Enc=Camellia(256)  Mac=SHA1
0x00,0x87  -  DHE-DSS-CAMELLIA256-SHA        SSLv3    Kx=DH          Au=DSS    Enc=Camellia(256)  Mac=SHA1
0x00,0x84  -  CAMELLIA256-SHA                SSLv3    Kx=RSA         Au=RSA    Enc=Camellia(256)  Mac=SHA1
0x00,0x45  -  DHE-RSA-CAMELLIA128-SHA        SSLv3    Kx=DH          Au=RSA    Enc=Camellia(128)  Mac=SHA1
0x00,0x44  -  DHE-DSS-CAMELLIA128-SHA        SSLv3    Kx=DH          Au=DSS    Enc=Camellia(128)  Mac=SHA1
0x00,0x41  -  CAMELLIA128-SHA                SSLv3    Kx=RSA         Au=RSA    Enc=Camellia(128)  Mac=SHA1

可以看到在这份配置中,我几乎将所有的支持椭圆曲线的套件都提到了最前面,来加大协商出支持椭圆曲线算法的套件的可能性。

综上,推荐的配置如下:

ssl_ciphers 'EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+ECDSA+AES128:EECDH+aRSA+AES128:RSA+AES128:EECDH+ECDSA+AES256:EECDH+aRSA+AES256:RSA+AES256:EECDH+ECDSA+3DES:EECDH+aRSA+3DES:RSA+3DES:!MD5';
ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS';
ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK';

三者任选其一即可。

 

参考:

https://imququ.com/post/ecc-certificate.html

https://www.zeroling.com/nginx-kai-qi-https-shuang-zheng-shu-zhi-nan/

https://n4l.pw/nginx-dual-certificates.html

https://imququ.com/post/certificate-transparency.html

https://seryo.net/use-ecc-algorithms-issued-a-certificate-request-file.Seryo

MySQL 隐式转化整理

前几天在微博上看到一篇文章:价值百万的 MySQL 的隐式类型转换感觉写的很不错,再加上自己之前也对MySQL的隐式转化这边并不是很清楚,所以就顺势整理了一下。希望对大家有所帮助。

当我们对不同类型的值进行比较的时候,为了使得这些数值「可比较」(也可以称为类型的兼容性),MySQL会做一些隐式转化(Implicit type conversion)。比如下面的例子:

mysql> SELECT 1+'1';
        -> 2
mysql> SELECT CONCAT(2,' test');
        -> '2 test'

很明显,上面的SQL语句的执行过程中就出现了隐式转化。并且从结果们可以判断出,第一条SQL中,将字符串的“1”转换为数字1,而在第二条的SQL中,将数字2转换为字符串“2”。

MySQL也提供了CAST()函数。我们可以使用它明确的把数值转换为字符串。当使用CONCA()函数的时候,也可能会出现隐式转化,因为它希望的参数为字符串形式,但是如果我们传递的不是字符串呢:

mysql> SELECT 38.8, CAST(38.8 AS CHAR);
        -> 38.8, '38.8'
mysql> SELECT 38.8, CONCAT(38.8);
        -> 38.8, '38.8'

隐式转化规则

官方文档中关于隐式转化的规则是如下描述的:

If one or both arguments are NULL, the result of the comparison is NULL, except for the NULL-safe <=> equality comparison operator. For NULL <=> NULL, the result is true. No conversion is needed.

  • If both arguments in a comparison operation are strings, they are compared as strings.
  • If both arguments are integers, they are compared as integers.
  • Hexadecimal values are treated as binary strings if not compared to a number.
  • If one of the arguments is a TIMESTAMP or DATETIME column and the other argument is a constant, the constant is converted to a timestamp before the comparison is performed. This is done to be more ODBC-friendly. Note that this is not done for the arguments to IN()! To be safe, always use complete datetime, date, or time strings when doing comparisons. For example, to achieve best results when using BETWEEN with date or time values, use CAST() to explicitly convert the values to the desired data type.A single-row subquery from a table or tables is not considered a constant. For example, if a subquery returns an integer to be compared to a DATETIME value, the comparison is done as two integers. The integer is not converted to a temporal value. To compare the operands as DATETIME values, use CAST() to explicitly convert the subquery value to DATETIME.
  • If one of the arguments is a decimal value, comparison depends on the other argument. The arguments are compared as decimal values if the other argument is a decimal or integer value, or as floating-point values if the other argument is a floating-point value.
  • In all other cases, the arguments are compared as floating-point (real) numbers.

翻译为中文就是:

  • 两个参数至少有一个是 NULL 时,比较的结果也是 NULL,例外是使用 <=> 对两个 NULL 做比较时会返回 1,这两种情况都不需要做类型转换
  • 两个参数都是字符串,会按照字符串来比较,不做类型转换
  • 两个参数都是整数,按照整数来比较,不做类型转换
  • 十六进制的值和非数字做比较时,会被当做二进制串
  • 有一个参数是 TIMESTAMPDATETIME,并且另外一个参数是常量,常量会被转换为 timestamp
  • 有一个参数是 decimal 类型,如果另外一个参数是 decimal 或者整数,会将整数转换为 decimal 后进行比较,如果另外一个参数是浮点数,则会把 decimal 转换为浮点数进行比较
  • 所有其他情况下,两个参数都会被转换为浮点数再进行比较

注意点

安全问题:假如 password 类型为字符串,查询条件为 int 0 则会匹配上。

mysql> select * from test;
+----+-------+-----------+
| id | name  | password  |
+----+-------+-----------+
|  1 | test1 | password1 |
|  2 | test2 | password2 |
+----+-------+-----------+
2 rows in set (0.00 sec)

mysql> select * from test where name = 'test1' and password = 0;
+----+-------+-----------+
| id | name  | password  |
+----+-------+-----------+
|  1 | test1 | password1 |
+----+-------+-----------+
1 row in set, 1 warning (0.00 sec)

mysql> show warnings;
+---------+------+-----------------------------------------------+
| Level   | Code | Message                                       |
+---------+------+-----------------------------------------------+
| Warning | 1292 | Truncated incorrect DOUBLE value: 'password1' |
+---------+------+-----------------------------------------------+
1 row in set (0.00 sec)

相信上面的例子,一些机灵的同学可以发现其实上面的例子也可以做sql注入。

假设网站的登录那块做的比较挫,使用下面的方式:

SELECT * FROM users WHERE username = '$_POST["username"]' AND password = '$_POST["password"]'

如果username输入的是 a’ OR 1=’1 ,那么password随便输入,这样就生成了下面的查询:

SELECT * FROM users WHERE username = 'a' OR 1='1' AND password = 'anyvalue'

就有可能登录系统。其实如果攻击者看过了这篇文章,那么就可以利用隐式转化来进行登录了。如下:

mysql> select * from test;
+----+-------+-----------+
| id | name  | password  |
+----+-------+-----------+
|  1 | test1 | password1 |
|  2 | test2 | password2 |
|  3 | aaa   | aaaa      |
|  4 | 55aaa | 55aaaa    |
+----+-------+-----------+
4 rows in set (0.00 sec)

mysql> select * from test where name = 'a' + '55';
+----+-------+----------+
| id | name  | password |
+----+-------+----------+
|  4 | 55aaa | 55aaaa   |
+----+-------+----------+
1 row in set, 5 warnings (0.00 sec)

之所以出现上述的原因是因为:

mysql> select '55aaa' = 55;
+--------------+
| '55aaa' = 55 |
+--------------+
|            1 |
+--------------+
1 row in set, 1 warning (0.00 sec)

mysql> select 'a' + '55';
+------------+
| 'a' + '55' |
+------------+
|         55 |
+------------+
1 row in set, 1 warning (0.00 sec)

下面通过一些例子来复习一下上面的转换规则:

mysql> select 1+1;
+-----+
| 1+1 |
+-----+
|   2 |
+-----+
1 row in set (0.00 sec)

mysql> select 'aa' + 1;
+----------+
| 'aa' + 1 |
+----------+
|        1 |
+----------+
1 row in set, 1 warning (0.00 sec)

mysql> show warnings;
+---------+------+----------------------------------------+
| Level   | Code | Message                                |
+---------+------+----------------------------------------+
| Warning | 1292 | Truncated incorrect DOUBLE value: 'aa' |
+---------+------+----------------------------------------+
1 row in set (0.00 sec)

把字符串“aa”和1进行求和,得到1,因为“aa”和数字1的类型不同,MySQL官方文档告诉我们:

When an operator is used with operands of different types, type conversion occurs to make the operands compatible.

查看warnings可以看到隐式转化把字符串转为了double类型。但是因为字符串是非数字型的,所以就会被转换为0,因此最终计算的是0+1=1

上面的例子是类型不同,所以出现了隐式转化,那么如果我们使用相同类型的值进行运算呢?

mysql> select 'a' + 'b';
+-----------+
| 'a' + 'b' |
+-----------+
|         0 |
+-----------+
1 row in set, 2 warnings (0.00 sec)

mysql> show warnings;
+---------+------+---------------------------------------+
| Level   | Code | Message                               |
+---------+------+---------------------------------------+
| Warning | 1292 | Truncated incorrect DOUBLE value: 'a' |
| Warning | 1292 | Truncated incorrect DOUBLE value: 'b' |
+---------+------+---------------------------------------+
2 rows in set (0.00 sec)

是不是有点郁闷呢?

之所以出现这种情况,是因为+为算术操作符arithmetic operator 这样就可以解释为什么ab都转换为double了。因为转换之后其实就是:0+0=0了。

再看一个例子:

mysql> select 'a'+'b'='c';
+-------------+
| 'a'+'b'='c' |
+-------------+
|           1 |
+-------------+
1 row in set, 3 warnings (0.00 sec)

mysql> show warnings;
+---------+------+---------------------------------------+
| Level   | Code | Message                               |
+---------+------+---------------------------------------+
| Warning | 1292 | Truncated incorrect DOUBLE value: 'a' |
| Warning | 1292 | Truncated incorrect DOUBLE value: 'b' |
| Warning | 1292 | Truncated incorrect DOUBLE value: 'c' |
+---------+------+---------------------------------------+
3 rows in set (0.00 sec)

现在就看也很好的理解上面的例子了吧。a+b=c结果为1,1在MySQL中可以理解为TRUE,因为’a’+’b’的结果为0,c也会隐式转化为0,因此比较其实是:0=0也就是true,也就是1.

第二个需要注意点就是防止多查询或者删除数据

mysql> select * from test;
+----+-------+-----------+
| id | name  | password  |
+----+-------+-----------+
|  1 | test1 | password1 |
|  2 | test2 | password2 |
|  3 | aaa   | aaaa      |
|  4 | 55aaa | 55aaaa    |
|  5 | 1212  | aaa       |
|  6 | 1212a | aaa       |
+----+-------+-----------+
6 rows in set (0.00 sec)

mysql> select * from test where name = 1212;
+----+-------+----------+
| id | name  | password |
+----+-------+----------+
|  5 | 1212  | aaa      |
|  6 | 1212a | aaa      |
+----+-------+----------+
2 rows in set, 5 warnings (0.00 sec)

mysql> select * from test where name = '1212';
+----+------+----------+
| id | name | password |
+----+------+----------+
|  5 | 1212 | aaa      |
+----+------+----------+
1 row in set (0.00 sec)

上面的例子本意是查询id为5的那一条记录,结果把id为6的那一条也查询出来了。我想说明什么情况呢?有时候我们的数据库表中的一些列是varchar类型,但是存储的值为‘1123’这种的纯数字的字符串值,一些同学写sql的时候又不习惯加引号。这样当进行select,update或者delete的时候就可能会多操作一些数据。所以应该加引号的地方别忘记了。

关于字符串转数字的一些说明

mysql> select 'a' = 0;
+---------+
| 'a' = 0 |
+---------+
|       1 |
+---------+
1 row in set, 1 warning (0.00 sec)

mysql> select '1a' = 1;
+----------+
| '1a' = 1 |
+----------+
|        1 |
+----------+
1 row in set, 1 warning (0.00 sec)

mysql> select '1a1b' = 1;
+------------+
| '1a1b' = 1 |
+------------+
|          1 |
+------------+
1 row in set, 1 warning (0.00 sec)

mysql> select '1a2b3' = 1;
+-------------+
| '1a2b3' = 1 |
+-------------+
|           1 |
+-------------+
1 row in set, 1 warning (0.00 sec)

mysql> select 'a1b2c3' = 0;
+--------------+
| 'a1b2c3' = 0 |
+--------------+
|            1 |
+--------------+
1 row in set, 1 warning (0.00 sec)

从上面的例子可以看出,当把字符串转为数字的时候,其实是从左边开始处理的。

  • 如果字符串的第一个字符就是非数字的字符,那么转换为数字就是0
  • 如果字符串以数字开头
  • 如果字符串中都是数字,那么转换为数字就是整个字符串对应的数字
  • 如果字符串中存在非数字,那么转换为的数字就是开头的那些数字对应的值

如果你有其他更好的例子,或者被隐式转化坑过的情况,欢迎分享。

参考资料

  • http://dev.mysql.com/doc/refman/5.7/en/cast-functions.html
  • https://blog.eood.cn/mysql_params
  • http://dev.mysql.com/doc/refman/5.7/en/type-conversion.html

转自:http://www.cnblogs.com/rollenholt/p/5442825.html