技术开发 频道

如何处理任务调度程序的若干问题?


【IT168 开发管理】对于那些运行规则固定的静态任务(如每过30分钟更新缓存),我们当然可以通过Spring配置文件定义调度规则并在Spring容器启动运行调度。但事情并非总是这么简单,在实际的应用中,往往我们需要根据业务数据动态产生任务。举一个例子:在论坛系统中,当发现某些用户发表一些违反规则的内容时,做为一个惩罚手段,往往需要将该用户锁定一段时间。这种功能就需要通过操作业务功能动态地创建任务来完成:在锁定用户后,经过一段时间通过任务自动将其解锁。随着业务系统的运行,任务源源不断地产生,又源源不断地得到执行。

    在实际的应用系统中,任务的执行时间点往往是非常关键的,不允许执行点发生时间漂移(如电力控制系统)。此外,任务的执行往往是与日历相关的(如在本月15日上午10:00断路某个线程),所以Quartz往往是更适合的选择。 

    如何产生任务 

    在实际应用中,有两个不同的创建动态任务的方式: 

    1)业务流程产生型 
    2)扫描线程产生型。 

    前者表示在进行一个业务时,在业务操作过程中生产任务。而后者由一个扫描线程定时查看业务数据库的任务表,并根据业务数据产生任务。 

    在业务流程中产生 

    某个业务需要使用到任务,如果任务的执行点离业务操作的时间不会太长,这时可以直接在业务操作流程过程中就安排好任务。假设在电力传输管理系统中有一个功能,可以将一条传输线路在某段时间内停止供电。安排停电的时间点离业务的操作时间点一般不会太长,因此可以在用户执行线路停电安排的业务时,马上就向Scheduler中注册两个任务:一个是在某一时间点执行断电的任务,另一个任务是在某一时间点,执行恢复供电的任务。 

    当然,考虑到系统重启的情况,可能需要使用持久化任务,以便系统将已经安排的任务在系统重启后能够恢复。 

    由扫描任务周期性产生 

    但在有些情况下,不适合在业务流程时就产生任务,而应当通过定期扫描的功能,根据业务数据动态产生任务。 

    在论坛系统中,为了清除一些无效的注册帐号,可以定义这样的规则:如果账号注册后,在半年内都没有激活帐号,则将这些帐号删除。对于系统来说,引发这个潜在帐号清除任务的业务是用户注册功能,但是我们不应该在注册帐号后就安排一个清除帐号的任务。首先,在注册用户时,并不知道这个帐号在将来是否会满足清除的条件;其次,任务的执行时间点离当前点太远,现在就安排显然“为时过早”,不这将会产生过长的任务列队,打个不太恰当的比方,再离谱的炒房者也不会在打地基时就排队购房。 

    一般来说,像清除帐号这样的任务对执行时间点的要求并不会太高,所以只要安排一个以天为周期定时执行的任务,将当天满足条件的帐号删除即可。 

    现在,我们来考虑另一种比较极端的需求。假设我们对清除帐号的执行时间点有严格的要求,清除帐号的时间点只能精确地发生在一年之后的那个时间点上(356×24×60×60×1000毫秒之后)。这时,定期周期执行的清除任务就不再适用了。因为如果清除任务运行频率过小,就不能满足精确执行点的要求。如果频率过大,则会对业务数据库产生频繁的访问,对数据库的性能产生不良的影响。此外,在需要访问数据库的情况下,单纯从技术上角度上看,任务运行频率本身是受限的:你很难安排秒级的任务。 

    为了保证严格的执行时间点并尽量减小对数据库的影响,可以采用一个扫描任务,定期查询数据库,并为那些在小段时间后就要执行的潜在任务进行动态的安排,如下图所示:


通过扫描动态产生任务

    T0对应一个定时的任务,它负责周期性地扫描业务表,它查找出在未来的一个扫描周期内的将要执行的任务,创建并安排这些任务。通过这种方式可以带来三个好处: 

    (1)降低对数据库的影响:扫描任务自身的运行频率不必太高,如我们可以使扫描任务30分钟运行一次,将30分钟后的所有需要执行的任务都安排好。这样,扫描任务在30分钟之内只会进行一次数据库访问,对数据库的影响微乎其微。 

    (2)减小调度器中任务列队的长度:由于不是将所有潜在任务在很早以后就进行安排,而仅对一个扫描周期内的任务进行安排,调度器中任务列表的长度可以得到有效的控制。 

    (3)保证任务在精确的时间点执行:由于任务提前得到安排,任务的执行时间点可以得到很好的保证。当然,由于扫描任务本身执行的时间消耗,如果任务的运行时间点离扫描任务的周期点很近,它们的执行时间点的精确性可能受到影响。这时,你可以将安排任务的范围调大一些,比如安排两个周期后的所有任务,以取消这种任务执行点和扫描周期点过近的问题。不过,一般情况下,这种影响是可以接受的。 

    如果系统本身存在许多种需要通过扫描任务动态产生的任务,最好通过一张任务表来维护这些任务,而不要将它们分散在不同的业务表中。假设帐号清除任务的条件信息在用户表中,锁定用户任务的信息在锁定用户表中,商品过期任务的信息在商品表中。这时就需要分别为这些任务定义一个扫描任务,或者在扫描任务内遍历所有的相关的业务表。如果和任务有关的业务表非常多(比如100张),为了动态创建任务,扫描任务就会对数据库产生很大的影响。如果通过一张任务表记录所有潜在的任务,并在业务操作过程中动态维护这个任务表,则仅需要一个扫描任务,且扫描任务只需要查询这张任务表就可以了,这可以有效降低创建动态任务对数据库的影响。使用统一任务表的方式也是有代价的,任务有关的业务模块在业务功能之外还需要额外考虑维护任务表中的数据。比如在用户注册模块,用户注册完成后,就向任务表添加一条清除帐号的任务,在用户激活模块,则需要将清除该用户帐号的记录删除掉。

    任务调度对应用程序集群的影响 

    对于有集群要求的Web应用程序来说,如果应用本身有任务调度的功能,就必须在系统设计初期仔细分析任务调度功能是否适合于集群。需要调度的任务按其功能影响的范围,可以分为两种类型: 

    (1)局部影响的任务:局部影响的任务只会对集群节点的服务发生影响。比如静态网页定期生成任务、本地缓存刷新任务(假设未使用分布式缓存),这些任务执行的结果仅会对本节点产生影响,它不会产生全局性的影响,因此应用程序集群部署后,并不会对业务逻辑产生破坏。 

    (2)全局影响的任务:大部分任务的执行结果对于全局是有影响的,比如前面我们所提到的无效帐号清除任务,生成统计数据。这些任务的运行结果对整个系统的影响是全局性的。这种类型的任务不应该和应用程序绑定在一定,应该独立开发和部署,否则会产生重复执行的任务。 

    为了说明集群环境下全局影响的任务如何发生重复性操作问题,我们来看一个具体的例子,集群中拥有两个节点,它们分别以30分钟为周期运行用户解锁的扫描任务,并动态安排30分钟后需要进行用户解锁的任务:

时间 集群节点A 集群节点B
10:10 扫描任务工作  
10:11 安排30分钟后所有帐户解锁任务,安排一个任务,在10:30运行,对X帐号进行解锁。  
10:15   扫描任务工作
10:16   安排30分钟后所有帐户解锁任务,安排一个任务,在10:30运行,对X帐号进行解锁。
10:30 任务运行,对X帐号进行解锁 任务运行,对X帐号进行解锁

可见X帐号被A和B两个节点进行了两次解锁,其中一次是多余的。在这样的场景中,多次运行的任务除了做画蛇添足的事情外,并没有带来什么负面影响。但有些重复执行的任务的影响是有害的,如定期生成统计数据任务、定期转账任务。所以需要将具有全局影响的任务调度程序和业务应用程序分开,只在业务应用程序上实行集群部署,而任务调度程序采用单服务运行的机制,如下图所示:


带任务调度系统的集群部署方案

这样,业务应用程序产生业务数据,任务调度程序对数据库中的业务数据进行处理,保证全局影响的任务只会执行一次。当然,局部影响的任务还应该和业务应用程序一起部署。


Web应用程序中调度器的启动和关闭问题 

    我们知道静态变量是ClassLoader级别的,如果Web应用程序停止,这些静态变量也会从JVM中清除。但是线程则是JVM级别的,如果你在Web应用中启动一个线程,这个线程的生命周期并不会和Web应用程序保持同步。也就是说,即使你停止了Web应用,这个线程依旧是活跃的。正是因为这个很隐晦的问题,所以很多有经验的开发者不太赞成在Web应用中私自启动线程。 

    如果我们手工使用JDK Timer(Quartz的Scheduler),在Web容器启动时启动Timer,当Web容器关闭时,除非你手工关闭这个Timer,否则Timer中的任务还会继续运行! 

    下面通过一个小例子来演示这个“诡异”的现象,我们通过ServletContextListener在Web容器启动时创建一个Timer并周期性地运行一个任务:
代码清单StartCycleRunTask:容器监听器 package com.baobaotao.web; import java.util.Date; import java.util.Timer; import java.util.TimerTask; import javax.servlet.ServletContextEvent; import javax.servlet.ServletContextListener; public class StartCycleRunTask implements ServletContextListener { private Timer timer; public void contextDestroyed(ServletContextEvent arg0) {
②该方法在Web容器关闭时执行 System.out.println(
"Web应用程序启动关闭..."); } public void contextInitialized(ServletContextEvent arg0) {
②在Web容器启动时自动执行该方法 System.out.println(
"Web应用程序启动..."); timer = new Timer();②-1:创建一个Timer,Timer内部自动创建一个背景线程 TimerTask task = new SimpleTimerTask(); timer.schedule(task, 1000L, 5000L); ②-2:注册一个5秒钟运行一次的任务 } } class SimpleTimerTask extends TimerTask {③任务 private int count; public void run() { System.out.println((++count)+"execute task..."+(new Date())); } } 在web.xml中声明这个Web容器监听器: <?xml version="1.0" encoding="UTF-8"?> <web-app> <listener> <listener-class>com.baobaotao.web.StartCycleRunTask</listener-class> </listener> </web-app>
    在Tomcat中部署这个Web应用并启动后,你将看到任务每隔5秒钟执行一次,在控制台上打出以下的信息:


Web应用程序启动,任务周期运行

    运行一段时间后,登录Tomcat管理后台,将对应的Web应用(chapter13)关闭,Tomcat的后台管理界面下图所示:


在Tomcat管理后台关闭相应的Web应用

    转到Tomcat控制台,你将看到虽然Web应用已经关闭,但Timer任务还在我行我素地执行如故——舞台已经拆除,戏子继续表演:


Web应用关闭后,任务依旧继续执行

    我们可以通过改变清单StartCycleRunTask的代码,在contextDestroyed(ServletContextEvent arg0)中添加timer.cancel()代码,在Web容器关闭后手工停止Timer来结束任务。 

    Spring为JDK Timer和Quartz Scheduler所提供的TimerFactoryBean和SchedulerFactoryBean能够和Spring容器的生命周期关联,在Spring容器启动时启动调度器,而在Spring容器关闭时,停止调度器。所以在Spring中通过这两个FactoryBean配置调度器,再从Spring IoC中获取调度器引用进行任务调度将不会出现这种Web容器关闭而任务依然运行的问题。而如果你在程序中直接使用Timer或Scheduler,如不进行额外的处理,将会出现这一问题。 

    小结 

    开发任务调度的应用程序并非象想象中那样简单,在实际应用中,我们需要考虑到动态任务的产生机制,以照顾数据库性能、任务队列长度、任务执行时间点精度度等方方面面的问题。此外,对于需要集群部署的Web应用程序,我们应该将业务应用和任务调度应用分别部署,以避免一个任务多次执行的问题。Spring已经为我们提供了Quartz和JDK Timer和Spring生命周期关联的FactoryBean,我们应该尽量避免在Web应用中直接手工创建Timer或Scheduler,因为Web容器关闭时,这些调度器本身的线程并不会因此而停止。


0
相关文章