ShardingSphere 采用一套自动化的执行引擎,负责将路由和改写完成之后的真实 SQL 安全且高效发送到底层数据源执行。 它不是简单地将 SQL 通过 JDBC 直接发送至数据源执行;也并非直接将执行请求放入线程池去并发执行。它更关注平衡数据源连接创建以及内存占用所产生的消耗,以及最大限度地合理利用并发等问题。 执行引擎的目标是自动化的平衡资源控制与执行效率。
从资源控制的角度看,业务方访问数据库的连接数量应当有所限制。 它能够有效地防止某一业务操作过多的占用资源,从而将数据库连接的资源耗尽,以致于影响其他业务的正常访问。 特别是在一个数据库实例中存在较多分表的情况下,一条不包含分片键的逻辑 SQL 将产生落在同库不同表的大量真实 SQL ,如果每条真实SQL都占用一个独立的连接,那么一次查询无疑将会占用过多的资源。
从执行效率的角度看,为每个分片查询维持一个独立的数据库连接,可以更加有效的利用多线程来提升执行效率。 为每个数据库连接开启独立的线程,可以将 I/O 所产生的消耗并行处理。为每个分片维持一个独立的数据库连接,还能够避免过早的将查询结果数据加载至内存。 独立的数据库连接,能够持有查询结果集游标位置的引用,在需要获取相应数据时移动游标即可。
以结果集游标下移进行结果归并的方式,称之为流式归并,它无需将结果数据全数加载至内存,可以有效的节省内存资源,进而减少垃圾回收的频次。 当无法保证每个分片查询持有一个独立数据库连接时,则需要在复用该数据库连接获取下一张分表的查询结果集之前,将当前的查询结果集全数加载至内存。 因此,即使可以采用流式归并,在此场景下也将退化为内存归并。
一方面是对数据库连接资源的控制保护,一方面是采用更优的归并模式达到对中间件内存资源的节省,如何处理好两者之间的关系,是 ShardingSphere 执行引擎需要解决的问题。 具体来说,如果一条 SQL 在经过 ShardingSphere 的分片后,需要操作某数据库实例下的 200 张表。 那么,是选择创建 200 个连接并行执行,还是选择创建一个连接串行执行呢?效率与资源控制又应该如何抉择呢?
针对上述场景,ShardingSphere 提供了一种解决思路。 它提出了连接模式(Connection Mode)的概念,将其划分为内存限制模式(MEMORY_STRICTLY)和连接限制模式(CONNECTION_STRICTLY)这两种类型。
使用此模式的前提是,ShardingSphere 对一次操作所耗费的数据库连接数量不做限制。 如果实际执行的 SQL 需要对某数据库实例中的 200 张表做操作,则对每张表创建一个新的数据库连接,并通过多线程的方式并发处理,以达成执行效率最大化。 并且在 SQL 满足条件情况下,优先选择流式归并,以防止出现内存溢出或避免频繁垃圾回收情况。
使用此模式的前提是,ShardingSphere 严格控制对一次操作所耗费的数据库连接数量。 如果实际执行的 SQL 需要对某数据库实例中的 200 张表做操作,那么只会创建唯一的数据库连接,并对其 200 张表串行处理。 如果一次操作中的分片散落在不同的数据库,仍然采用多线程处理对不同库的操作,但每个库的每次操作仍然只创建一个唯一的数据库连接。 这样即可以防止对一次请求对数据库连接占用过多所带来的问题。该模式始终选择内存归并。
内存限制模式适用于 OLAP 操作,可以通过放宽对数据库连接的限制提升系统吞吐量; 连接限制模式适用于 OLTP 操作,OLTP 通常带有分片键,会路由到单一的分片,因此严格控制数据库连接,以保证在线系统数据库资源能够被更多的应用所使用,是明智的选择。
ShardingSphere 最初将使用何种模式的决定权交由用户配置,让开发者依据自己业务的实际场景需求选择使用内存限制模式或连接限制模式。
这种解决方案将两难的选择的决定权交由用户,使得用户必须要了解这两种模式的利弊,并依据业务场景需求进行选择。 这无疑增加了用户对 ShardingSphere 的学习和使用的成本,并非最优方案。
这种一分为二的处理方案,将两种模式的切换交由静态的初始化配置,是缺乏灵活应对能力的。在实际的使用场景中,面对不同 SQL 以及占位符参数,每次的路由结果是不同的。 这就意味着某些操作可能需要使用内存归并,而某些操作则可能选择流式归并更优,具体采用哪种方式不应该由用户在 ShardingSphere 启动之前配置好,而是应该根据 SQL 和占位符参数的场景,来动态的决定连接模式。
为了降低用户的使用成本以及连接模式动态化这两个问题,ShardingSphere 提炼出自动化执行引擎的思路,在其内部消化了连接模式概念。 用户无需了解所谓的内存限制模式和连接限制模式是什么,而是交由执行引擎根据当前场景自动选择最优的执行方案。
自动化执行引擎将连接模式的选择粒度细化至每一次 SQL 的操作。 针对每次 SQL 请求,自动化执行引擎都将根据其路由结果,进行实时的演算和权衡,并自主地采用恰当的连接模式执行,以达到资源控制和效率的最优平衡。 针对自动化的执行引擎,用户只需配置 maxConnectionSizePerQuery 即可,该参数表示一次查询时每个数据库所允许使用的最大连接数。
执行引擎分为准备和执行两个阶段。
顾名思义,此阶段用于准备执行的数据。它分为结果集分组和执行单元创建两个步骤。
结果集分组是实现内化连接模式概念的关键。执行引擎根据 maxConnectionSizePerQuery 配置项,结合当前路由结果,选择恰当的连接模式。 具体步骤如下:
将 SQL 的路由结果按照数据源的名称进行分组。
通过下图的公式,可以获得每个数据库实例在 maxConnectionSizePerQuery
的允许范围内,每个连接需要执行的 SQL 路由结果组,并计算出本次请求的最优连接模式。
在 maxConnectionSizePerQuery 允许的范围内,当一个连接需要执行的请求数量大于 1 时,意味着当前的数据库连接无法持有相应的数据结果集,则必须采用内存归并; 反之,当一个连接需要执行的请求数量等于 1 时,意味着当前的数据库连接可以持有相应的数据结果集,则可以采用流式归并。
每一次的连接模式的选择,是针对每一个物理数据库的。也就是说,在同一次查询中,如果路由至一个以上的数据库,每个数据库的连接模式不一定一样,它们可能是混合存在的形态。
通过上一步骤获得的路由分组结果创建执行的单元。 当数据源使用数据库连接池等控制数据库连接数量的技术时,在获取数据库连接时,如果不妥善处理并发,则有一定几率发生死锁。 在多个请求相互等待对方释放数据库连接资源时,将会产生饥饿等待,造成交叉的死锁问题。
举例说明,假设一次查询需要在某一数据源上获取两个数据库连接,并路由至同一个数据库的两个分表查询。 则有可能出现查询 A 已获取到该数据源的 1 个数据库连接,并等待获取另一个数据库连接;而查询 B 也已经在该数据源上获取到的一个数据库连接,并同样等待另一个数据库连接的获取。 如果数据库连接池的允许最大连接数是 2,那么这 2 个查询请求将永久的等待下去。下图描绘了死锁的情况。
ShardingSphere 为了避免死锁的出现,在获取数据库连接时进行了同步处理。 它在创建执行单元时,以原子性的方式一次性获取本次 SQL 请求所需的全部数据库连接,杜绝了每次查询请求获取到部分资源的可能。 由于对数据库的操作非常频繁,每次获取数据库连接时时都进行锁定,会降低 ShardingSphere 的并发。因此,ShardingSphere 在这里进行了 2 点优化:
避免锁定一次性只需要获取 1 个数据库连接的操作。因为每次仅需要获取 1 个连接,则不会发生两个请求相互等待的场景,无需锁定。 对于大部分 OLTP 的操作,都是使用分片键路由至唯一的数据节点,这会使得系统变为完全无锁的状态,进一步提升了并发效率。 除了路由至单分片的情况,读写分离也在此范畴之内。
仅针对内存限制模式时才进行资源锁定。在使用连接限制模式时,所有的查询结果集将在装载至内存之后释放掉数据库连接资源,因此不会产生死锁等待的问题。
该阶段用于真正的执行 SQL,它分为分组执行和归并结果集生成两个步骤。
分组执行将准备执行阶段生成的执行单元分组下发至底层并发执行引擎,并针对执行过程中的每个关键步骤发送事件。 如:执行开始事件、执行成功事件以及执行失败事件。执行引擎仅关注事件的发送,它并不关心事件的订阅者。 ShardingSphere 的其他模块,如:分布式事务、调用链路追踪等,会订阅感兴趣的事件,并进行相应的处理。
ShardingSphere 通过在执行准备阶段的获取的连接模式,生成内存归并结果集或流式归并结果集,并将其传递至结果归并引擎,以进行下一步的工作。
执行引擎的整体结构划分如下图所示。