当前版本仍在开发中,尚不被视为稳定版本。最新稳定版请使用 Spring Batch 文档 6.0.2!

Spring Batch 架构

Spring Batch 的设计充分考虑了可扩展性以及多样化的终端用户群体。下图展示了其分层架构,该架构既支撑了系统的可扩展能力,也为最终使用 Spring Batch 的开发者提供了良好的易用性。

Figure 1.1: Spring Batch Layered Architecture
图 1. Spring Batch 分层架构

这个分层架构突出了三个主要的高层组件:Application、Core 和 Infrastructure。Application 层包含开发者基于 Spring Batch 编写的所有批处理作业和自定义代码。Batch Core 层则包含启动和控制批处理作业所需的核心运行时类,其中包括 JobOperatorJobStep 的实现。Application 与 Core 都建立在一套通用基础设施之上。这套基础设施包含常用的 reader、writer 以及服务(例如 RetryTemplate),既供应用开发者使用(例如 ItemReaderItemWriter 等 reader/writer),也供框架核心自身使用(例如 retry,这本身就是一个独立库)。

通用批处理原则与指导建议

在构建批处理解决方案时,应重点考虑以下关键原则、指导建议和通用注意事项。

  • 要记住,批处理架构通常会影响在线架构,反之亦然。设计时应同时兼顾两种架构及其运行环境,并在可能的情况下复用通用构建块。

  • 尽可能简化设计,避免在单个批处理应用中构建过于复杂的逻辑结构。

  • 尽量让数据处理与数据存储在物理位置上保持接近,也就是说,让数据尽可能靠近实际处理它的地方。

  • 尽量减少系统资源消耗,尤其是 I/O。应尽可能多地在内存中完成操作。

  • 要审查应用的 I/O 情况(例如分析 SQL 语句),确保避免不必要的物理 I/O。尤其需要关注以下四类常见问题:

    • 本可只读取一次并缓存或保存在工作存储中的数据,却在每笔事务中反复读取。

    • 在同一事务中,已经读取过的数据又被重复读取。

    • 引发不必要的表扫描或索引扫描。

    • 在 SQL 语句的 WHERE 子句中没有指定关键键值。

  • 不要在一次批处理运行中重复做同一件事。例如,如果为了报表需要进行数据汇总,那么最好在数据初次处理时就同步累加并保存汇总结果,这样报表应用就不必再重复处理同一批数据。

  • 在批处理应用启动之初就分配足够的内存,以避免运行过程中发生耗时的重新分配。

  • 在数据完整性方面要始终以最坏情况为前提。应加入充分的检查机制和记录校验,以保障数据完整性。

  • 在可能的情况下,为内部校验实现校验和机制。例如,平面文件应包含尾记录,用于给出文件中的总记录数以及关键字段的汇总值。

  • 应尽早在接近生产环境的条件下,使用真实规模的数据量规划并执行压力测试。

  • 在大型批处理系统中,备份往往是个挑战,尤其当系统需要与在线应用一起 7x24 小时并发运行时更是如此。在线系统设计中通常会较好地考虑数据库备份,但文件备份同样重要。如果系统依赖平面文件,那么文件备份流程不仅要存在并形成文档,还应定期进行演练和验证。

批处理策略

为了帮助设计和实现批处理系统,应当向设计人员和开发人员提供基础性的批处理应用构建块和模式,例如示例结构图和代码骨架。在开始设计一个批处理作业时,应当先将业务逻辑拆分成一系列步骤,再利用下列标准构建块来实现这些步骤:

  • 转换类应用: 对于每一种来自外部系统或输出给外部系统的文件,都应当有一个转换应用,将其事务记录转换为处理所需的标准格式。这类批处理应用可以部分甚至全部由转换工具模块组成。

  • 校验类应用: 校验应用用于确保所有输入和输出记录都是正确且一致的。校验通常依赖文件头尾、校验和、校验算法以及记录级交叉检查。

  • 抽取类应用: 抽取应用从数据库或输入文件中读取一组记录,根据预定义规则筛选出需要的数据,并将其写入输出文件。

  • 抽取/更新类应用: 抽取/更新应用从数据库或输入文件中读取记录,并根据每条输入记录中的数据对数据库或输出文件进行修改。

  • 处理与更新类应用: 处理与更新应用会对来自抽取应用或校验应用的输入事务进行处理。这个过程通常需要读取数据库以获取处理所需的数据,并可能进一步更新数据库,同时生成后续输出处理所需的记录。

  • 输出/格式化类应用: 输出/格式化应用读取输入文件中的记录,按照标准格式重组数据,并生成一个可用于打印或传输到其他程序、系统的输出文件。

此外,对于无法通过上述构建块实现的业务逻辑,还应提供一个基础应用骨架作为支撑。

除了主要构建块之外,每个应用还可能使用一个或多个标准化的工具步骤,例如:

  • Sort:读取输入文件,并按照记录中的排序键重新排序后生成输出文件。排序通常由标准系统工具完成。

  • Split:读取单个输入文件,并根据某个字段值将每条记录写入多个输出文件中的某一个。拆分既可以定制实现,也可以通过参数驱动的标准系统工具完成。

  • Merge:从多个输入文件中读取记录,并合并生成一个输出文件。合并同样可以由定制程序或参数驱动的标准系统工具完成。

批处理应用还可以按输入来源分类:

  • 数据库驱动型应用:由数据库中读取出的行或值驱动。

  • 文件驱动型应用:由文件中读取出的记录或值驱动。

  • 消息驱动型应用:由消息队列中读取出的消息驱动。

任何批处理系统的基础都是其处理策略。影响策略选择的因素包括:批处理系统的预计规模、与在线系统或其他批处理系统的并发关系,以及可用的批处理窗口。(需要注意的是,随着越来越多企业追求 7x24 连续运行,清晰可划分的批处理窗口正在逐渐消失。)

典型的批处理方式按实现复杂度从低到高通常包括:

  • 在离线模式下,于批处理窗口内执行常规处理。

  • 批处理与在线处理并发执行。

  • 多个不同批次或作业同时并行处理。

  • 分区处理(同时运行同一作业的多个实例)。

  • 以上方式的组合。

这些处理方式中的部分或全部,可能由商业调度器提供支持。

本节余下内容将更详细地讨论这些处理方式。一般来说,批处理过程所采用的提交策略与锁策略取决于具体的处理类型,而在线系统的锁策略也应遵循相同原则。因此,在设计整体架构时,批处理架构绝不能被当作事后补充。

锁策略既可以仅依赖数据库的普通锁机制,也可以在架构中实现一套额外的自定义锁服务。这类锁服务会跟踪数据库中的锁状态(例如把必要信息记录到专用数据库表中),并对请求数据库操作的应用程序授予或拒绝访问权限。为了避免在发生锁冲突时直接终止批处理作业,这套架构还可以进一步实现重试逻辑。

1. 在批处理窗口内执行常规处理 对于运行在独立批处理窗口中的简单批处理过程,如果被更新的数据不会被在线用户或其他批处理过程同时使用,那么并发就不是问题,此时可以在整个批次结束时统一提交一次。

但在大多数情况下,更稳健的方案通常更合适。要意识到,批处理系统会随着时间不断增长,无论是复杂度还是处理的数据量都是如此。如果没有预先设计锁策略,系统又仍然依赖单一提交点,那么后续修改批处理程序往往会非常痛苦。因此,即便是最简单的批处理系统,也应当考虑为重启恢复设计提交逻辑,并参考本节后续更复杂场景中的处理方式。

2. 批处理与在线处理并发执行 如果批处理应用处理的数据也可能被在线用户同时更新,那么它不应长时间锁定这些数据(无论是在数据库中还是文件中),锁定时间最好不要超过几秒。此外,更新应当每处理若干笔事务就提交一次,这样可以尽量缩小其他进程无法访问数据的范围,并缩短数据不可用的持续时间。

为了减少物理锁带来的影响,还可以采用逻辑行级锁,并通过乐观锁模式或悲观锁模式来实现。

  • 乐观锁假设记录冲突发生的概率较低。其典型做法是在会被批处理和在线处理同时访问的数据库表中加入一个时间戳列。应用程序读取某一行准备处理时,会一并读取该时间戳;随后在尝试更新该行时,会把原始时间戳带入 WHERE 子句中。如果时间戳匹配,说明期间没有其他程序修改过该行,于是数据和时间戳都会被更新;如果时间戳不匹配,则说明在读取与更新之间已有其他应用更新了这条记录,因此当前更新不能执行。

  • 悲观锁则假设记录冲突发生的概率较高,因此在读取数据时就需要获取物理锁或逻辑锁。悲观型逻辑锁的一种实现方式,是在数据库表中增加一个专用锁列。应用在读取某行并准备更新时,会先在该锁列上设置标记;只要该标记存在,其他应用在逻辑上就无法再获取该行。等持锁应用更新完这一行之后,再清除该标记,让其他应用可以继续访问。需要注意的是,从最初读取记录到设置标记之间,数据完整性仍然需要得到保证,例如可以通过数据库锁(如 SELECT FOR UPDATE)来实现。还要注意,这种方式本质上仍然会遭遇与物理锁类似的问题,只不过更容易实现超时机制,以便在用户长时间占用锁时自动释放。

这些模式未必天然适合批处理本身,但在批处理与在线处理并发运行的场景下仍然可能适用,例如数据库本身不支持行级锁时。一般而言,乐观锁更适合在线应用,而悲观锁更适合批处理应用。无论何时采用逻辑锁,所有访问这些受保护数据实体的应用都必须使用同一套锁方案。

还要注意,这两种方案都只解决“单条记录加锁”的问题。很多时候,我们需要锁定的是一组在逻辑上相关联的记录。对于物理锁,必须非常谨慎地管理,才能避免潜在死锁;而对于逻辑锁,通常更好的做法是构建一个逻辑锁管理器,让它理解你要保护的逻辑记录组,并确保锁的一致性与无死锁性。这个逻辑锁管理器通常会使用自己的表来处理锁管理、冲突报告、超时控制等问题。

3. 并行处理 并行处理允许多个批次运行或多个作业同时执行,从而缩短整体批处理耗时。只要这些作业不共享同一批文件、数据库表或索引空间,这通常不是问题。如果存在共享,则应通过分区数据等方式来实现隔离。另一种方案是借助控制表构建一个用于维护相互依赖关系的架构模块。控制表中应为每个共享资源保留一行记录,用来标识该资源当前是否正被某个应用使用。批处理架构或并行作业中的应用随后就可以通过查询这张表,判断自己是否能够访问所需资源。

如果数据访问本身不是问题,那么并行处理也可以通过增加线程并发执行来实现。在大型机环境中,传统上通常使用并行作业类别来保证所有进程都能获得足够的 CPU 时间。不管采用何种方式,方案都必须足够稳健,以确保所有运行中的进程都能获得合理的时间片。

并行处理中的其他关键问题还包括负载均衡,以及文件、数据库缓冲池等通用系统资源的可用性。此外,控制表本身也很容易成为一个关键资源瓶颈。

4. 分区处理 通过分区,可以让大型批处理应用的多个实例并发运行,目的在于缩短长时间批处理作业的总执行时长。适合分区的处理过程通常具备这样的特征:输入文件可以被拆分,或者主数据库表可以进行分区,使应用能够分别处理不同的数据集合。

此外,被分区后的处理过程必须被设计成只处理自己被分配到的数据集。分区架构必须与数据库设计和数据库分区策略紧密配合。需要注意的是,数据库分区并不一定意味着物理分区(尽管在大多数情况下这样做是有益的)。下图展示了分区处理的思路:

Figure 1.2: Partitioned Process
图 2. 分区处理

架构应当足够灵活,能够支持分区数量的动态配置。你既要考虑自动配置,也要考虑用户可控配置。自动配置可以基于输入文件大小、输入记录数量等参数来决定。

4.1 分区方法 分区方案的选择必须具体情况具体分析。下面列举了一些可能采用的分区方式:

1. 固定且均匀地拆分记录集

这种方式是把输入记录集拆分成数量均匀的若干份,例如 10 份,每一份正好占总记录集的 1/10。然后由一个批处理/抽取应用实例负责处理其中一份。

要使用这种方式,就必须先进行预处理来拆分记录集。拆分结果通常是一组上下界位置编号,可以作为输入参数传给批处理/抽取应用,从而把它的处理范围限制在对应分片内。

由于需要计算并确定每个分片的边界,预处理本身可能会带来较大开销。

2. 按关键列拆分

这种方式是根据某个关键列(例如地区编码)来拆分输入记录集,并把不同键值对应的数据分配给不同批处理实例。要实现这一点,列值通常可以通过以下两种方式进行分派:

  • 通过分区表分配给某个批处理实例(本节后面会介绍)。

  • 按值区间分配给某个批处理实例,例如 0000-0999、1000-1999 等。

在方案 1 下,新增键值通常意味着要手工重新配置批处理或抽取逻辑,确保这些新值被归入某个特定实例。

在方案 2 下,可以确保所有取值范围都被某个批处理实例覆盖。但每个实例实际处理的数据量会受到列值分布的影响,例如 0000-0999 区间可能包含大量地点,而 1000-1999 区间却很少。因此,在这种方案下,数据范围本身就需要按分区思想进行设计。

无论采用哪一种方式,都很难实现记录在各批处理实例之间的绝对均衡分布,同时也无法动态调整所使用的批处理实例数量。

3. 按视图拆分

这种方式本质上仍然是按关键列拆分,只不过拆分动作发生在数据库层面。它会把记录集拆分成多个视图,每个批处理应用实例在处理时使用其中某一个视图。拆分依据通常是对数据进行分组。

在这种方式下,每个批处理实例都必须被配置为访问某个特定视图,而不是主表。与此同时,一旦新增了新的数据取值范围,也必须把这部分数据纳入某个视图中。由于实例数量一旦变化就需要同步调整视图定义,因此它同样不具备动态配置能力。

4. 增加处理标识列

这种方式是在输入表中增加一个新列,作为处理标识。预处理阶段会先把所有记录标记为“未处理”。在批处理应用取数时,只读取那些仍标记为未处理的记录;一旦某条记录被读取(并加锁),就会被标记为“处理中”。等该记录处理完成后,再将标识更新为“完成”或“错误”。借助这一个额外字段,你无需修改程序就可以启动多个批处理实例,同时还能保证每条记录只会被处理一次。

在这种方式下,表上的 I/O 会动态增加。不过,对于本来就需要更新数据的批处理应用来说,这种影响会相对较小,因为无论如何都需要执行写操作。

5. 将表抽取为平面文件

这种方式是先把数据库表抽取成一个平面文件,然后再把该文件拆分成多个片段,分别作为不同批处理实例的输入。

在这种方式下,把表抽取到文件并进一步拆分所带来的额外开销,可能会抵消多分区并发带来的收益。不过,它可以通过调整文件拆分脚本来实现动态配置。

6. 使用哈希列

这种方案是在用于检索驱动记录的数据库表中增加一个哈希列(键或索引)。这个哈希列包含一个标识,用来决定某一行应由哪个批处理实例负责处理。例如,如果要启动三个批处理实例,那么标识为 “A” 的行由实例 1 处理,标识为 “B” 的行由实例 2 处理,标识为 “C” 的行由实例 3 处理。

相应的记录检索过程会在查询中增加一个额外的 WHERE 子句,用于选出带有特定标识的所有行。向该表插入新记录时,也需要写入这个标识字段,并默认归属于某个实例(例如 “A”)。

可以使用一个简单的批处理应用来更新这些标识,例如在不同实例之间重新分配负载。当新增记录达到一定数量后,就可以运行这个批处理(批处理窗口之外的任意时间均可),把新数据重新分配给其他实例。

如果需要增加更多批处理实例,只需再次运行上述用于重分配标识的批处理应用,即可让现有标识适配新的实例数量。

4.2 数据库与应用设计原则

如果架构要支持运行在分区数据库表之上的多分区应用,并采用关键列分区方式,那么它应当包含一个中心化的分区仓库来保存分区参数。这样做能够提升灵活性,并确保系统可维护性。这个仓库通常由一张单独的表组成,也就是所谓的分区表。

分区表中保存的信息通常是静态的,一般应由 DBA 来维护。对于一个多分区应用,它的每个分区都应在表中对应一行记录。该表通常需要包含如下列:程序 ID、分区号(即该分区的逻辑 ID)、该分区对应数据库关键列的最小值以及最大值。

程序启动时,架构层(更具体地说,是控制处理 tasklet)应当把程序 id 和分区号传给应用。如果采用的是关键列分区方式,那么应用会用这些变量去查询分区表,从而确定自己应处理哪一段数据范围。此外,在整个处理过程中,分区号还必须用于:

  • 附加到输出文件或数据库更新中,以确保后续合并过程能够正确工作。

  • 把正常处理情况写入批处理日志,并把错误上报给架构层的错误处理器。

4.3 尽量减少死锁

当应用并行运行或采用分区处理时,数据库资源争用和死锁都有可能发生。因此,在数据库设计阶段,数据库设计团队必须尽可能消除潜在的争用场景。

同时,开发者也必须确保数据库索引表在设计时兼顾死锁预防与性能表现。

死锁或热点通常容易出现在管理类或架构类表中,例如日志表、控制表和锁表。对于这些表带来的影响,同样必须充分考虑。使用真实场景进行压力测试,对于识别架构中的潜在瓶颈至关重要。

为了尽量降低冲突对数据的影响,架构层在连接数据库或遇到死锁时,应提供相应服务,例如等待并重试机制。这意味着系统内部应具备对特定数据库返回码作出响应的能力,不是立刻报错,而是等待预设时长后再次尝试数据库操作。

4.4 参数传递与校验

对于应用开发者而言,分区架构应当尽量保持透明。与应用在分区模式下运行相关的所有任务,都应由架构层负责完成,包括:

  • 在应用启动之前获取分区参数。

  • 在应用启动之前校验分区参数。

  • 在启动时把参数传递给应用。

校验过程至少应确保以下几点:

  • 应用拥有足够的分区来覆盖完整的数据范围。

  • 分区之间不存在空档。

如果数据库本身也进行了分区,那么还可能需要额外校验,以确保单个应用分区不会跨越多个数据库分区。

此外,架构层还应考虑分区结果的汇总问题。需要重点回答的问题包括:

  • 是否必须等所有分区都执行完成后,才能进入下一个作业步骤?

  • 如果其中一个分区中止,系统应如何处理?