分库分表

1 基本思想之什么是分库分表?
从字面上简单理解,就是把原本存储于一个库的数据分块存储到多个库上,把原本存储于一个表的数据分块存储到多个表上。
2 基本思想之为什么要分库分表?

数据库中的数据量不一定是可控的,在未进行分库分表的情况下,随着时间和业务的发展,库中的表会越来越多,表中的数据量也会越来越大,相应地,数据操作,增删改查的开销也会越来越大;另外,由于无法进行分布式式部署,而一台服务器的资源(CPU、磁盘、内存、IO等)是有限的,最终数据库所能承载的数据量、数据处理能力都将遭遇瓶颈。
3 分库分表的实施策略。

分库分表有垂直切分和水平切分两种。
3.1 何谓垂直切分,即将表按照功能模块、关系密切程度划分出来,部署到不同的库上。例如,我们会建立定义数据库workDB、商品数据库payDB、用户数据库userDB、日志数据库logDB等,分别用于存储项目数据定义表、商品定义表、用户数据表、日志数据表等。
3.2 何谓水平切分,当一个表中的数据量过大时,我们可以把该表的数据按照某种规则,例如userID散列,进行划分,然后存储到多个结构相同的表,和不同的库上。例如,我们的userDB中的用户数据表中,每一个表的数据量都很大,就可以把userDB切分为结构相同的多个userDB:part0DB、part1DB等,再将userDB上的用户数据表userTable,切分为很多userTable:userTable0、userTable1等,然后将这些表按照一定的规则存储到多个userDB上。
3.3 应该使用哪一种方式来实施数据库分库分表,这要看数据库中数据量的瓶颈所在,并综合项目的业务类型进行考虑。
如果数据库是因为表太多而造成海量数据,并且项目的各项业务逻辑划分清晰、低耦合,那么规则简单明了、容易实施的垂直切分必是首选。
而如果数据库中的表并不多,但单表的数据量很大、或数据热度很高,这种情况之下就应该选择水平切分,水平切分比垂直切分要复杂一些,它将原本逻辑上属于一体的数据进行了物理分割,除了在分割时要对分割的粒度做好评估,考虑数据平均和负载平均,后期也将对项目人员及应用程序产生额外的数据管理负担。
在现实项目中,往往是这两种情况兼而有之,这就需要做出权衡,甚至既需要垂直切分,又需要水平切分。我们的游戏项目便综合使用了垂直与水平切分,我们首先对数据库进行垂直切分,然后,再针对一部分表,通常是用户数据表,进行水平切分。
4 分库分表存在的问题。

4.1 事务问题。
在执行分库分表之后,由于数据存储到了不同的库上,数据库事务管理出现了困难。如果依赖数据库本身的分布式事务管理功能去执行事务,将付出高昂的性能代价;如果由应用程序去协助控制,形成程序逻辑上的事务,又会造成编程方面的负担。
4.2 跨库跨表的join问题。
在执行了分库分表之后,难以避免会将原本逻辑关联性很强的数据划分到不同的表、不同的库上,这时,表的关联操作将受到限制,我们无法join位于不同分库的表,也无法join分表粒度不同的表,结果原本一次查询能够完成的业务,可能需要多次查询才能完成。
4.3 额外的数据管理负担和数据运算压力。
额外的数据管理负担,最显而易见的就是数据的定位问题和数据的增删改查的重复执行问题,这些都可以通过应用程序解决,但必然引起额外的逻辑运算,例如,对于一个记录用户成绩的用户数据表userTable,业务要求查出成绩最好的100位,在进行分表之前,只需一个order by语句就可以搞定,但是在进行分表之后,将需要n个order by语句,分别查出每一个分表的前100名用户数据,然后再对这些数据进行合并计算,才能得出结果。



在最近做的一个项目中,由于每天核算的数据量过于庞大,需要把数据库进行分库保存。当数据分散到各个库之后,带来的数据更新操作就会存在一个一致性和完整性的问题。下面是一个典型的场景

假设目前存在三个物理库,现在有一个文件,里面有1W条数据,根据分库的规则,可以把文件里面的数据分到三个库中,现在需要保证这1W条数据要要完整的保存到这三个库里面,并且数据是一致性的,也就是说 三个库里面已导入的数据完全和文件里面的数据一致。

正常情况下,我们先把文件里面的数据按照所属的数据库分成三份,然后针对每一份数据库进行保存,在单库的情况下,可以保证单库的数据完整性。但是三个库要保证一致性,就是非常复杂的一项工作,很有可能第一个库的数据保存成功了,但是后面三个库的数据保存失败了,导致整个文件的里面的数据在数据库里面不完整。

如何解决这种问题,目前想到的有几个办法:

方案1

 使用类似JTA提供的分布式事物机制,也就是说需要相关的数据库提供支持XA的驱动。( XA 是指由 X/Open 组织提出的分布式交易处理的规范)。这个需要依赖特定的数据库厂商,也是比较简单的方案。毕竟复杂的事务管理都可以通过提供JTA服务的厂商和提供XA驱动的数据库厂商来完成。目前大多数实现了JTA的服务器厂商比较多,比如JBOSS,或者开源的JOTM(Java Open Transaction Manager)——ObjectWeb的一个开源JTA实现。但是引入支持XA的数据库驱动会带来很多潜在的问题,在 《java事务设计策略》里面:在Java事务管理中,常常令人困惑的一个问题是什么时候应该使用XA,什么时候不应使用XA。由于大多数商业应用服务器执行单阶段提交(one-phase commit)操作,性能下降并非一个值得考虑的问题。然而,非必要性的在您的应用中引入XA数据库驱动,会导致不可预料的后果与错误,特别是在使用本地事务模型(Local Transaction Model)时。因此,一般来说在您不需要XA的时候,应该尽量避免使用它。”  所以这个是一个可选的方案,也是最简单的一个方案

 

方案2

建立一张文件批次表(放在一个独立的数据库里面),保存待处理的文件批次信息(不是明细数据,简单说的就是要处理的文件名和所在路径),在每次处理文件数据的时候,先往表里面插入一条文件批次信息,并且设置文件的状态为初始状态,在文件中的数据全部成功的保存到三个分库里面之后,在更新文件的批次状态为成功。如果保存到分库的过程中出现异常,文件批次的状态还是初始状态。而后台启动一个定时机制,定时去扫描文件批次状态,如果发现是初始状态,就重新执行文件的导入操作,直到文件完全导入成功。这个方案看起来没有问题,但是可能存在重复导入的情况,比如批次导入到第一个分库成功了,后面两个库失败了,重新导入的话,可能会重复把数据重复导入第一个分库。我们可以在导入之间进行判断,如果导入过,就不进行导入,但是极端的情况,我们无法判断数据是否导入过,也是一个有缺陷的方案,并且如果每次导入之前,都进行数据是否导入的操作,性能会有一些影响。我们也可以通过异常恢复机制来进行,如果发现文件导入失败了,我们删除已经导入入库的流水,但是这也引入了错误处理带来的一致性问题,比如我们已经导入成功2个分库的数据,在导入第三个分库失败的情况下,要删除掉前面两个分库的数据,这也没有办法保证是一致的。

在这个方案里面,我们可以在进行一定的优化,让它看起来运作起来是没有问题的。首先再建立一张子批次表(和文件批次表放在同一个库),在进行处理的时候,我们把大的文件的数据按照分库规则拆成三个子文件,每一个子文件里面的数据对应一个分库。这样就产生三条子批次信息,由于文件批次信息和子批次信息 在同一个库里面,可以保证一致性。这样每个待处理的文件就分成了四条记录,一条主文件批次信息,三条子批次信息,在导入数据之前,这些批次的信息的状态都是初始状态。这样一个文件的导入就分解为三个子文件,分别导入到对应库里面去。对于每个子文件批次,我们可以保证子文件数据的都是在同一个库里面,保证每个子文件里面数据的一致性和完整性,然后导入成功之后,在更新子批次的状态为成功,如果所有的子文件的批次状态都为成功,那么对应的文件批次状态就更新为成功。这样看起来非常完美,解决了问题。但是仔细考虑一下,有一个小的细节问题:子批次信息和一个独立的库,要导入的数据是和子批次信息可能不再一个库,没有办法保证这两个操作是一致性的,也就是说 子文件里面的数据成功的导入到分库,但是可能子批次信息状态没有更新。那子批次信息能不能放在每个分库里面了,这样的话,又回到刚开始提出的问题了(这里面就不解释,可以去自己去想想)。

下面一副图简单的演示的设计思想:

分库分表_第1张图片

 

分库分表_第2张图片

 

方案3

第2个方案的基础上,可以继续加以优化。首先我们保留第二个方案的文件批次信息表和子文件批次信息表,而且我们必须把这两个表放在同一个库里面(这里假设分配到主库),保证我们拆分任务时的一致性。然后在各个分库里面,我们建立一张各个分库的子文件批次表。这个表模型基本上是和主库的子文件批次信息表一样。当拆分任务的时候,先保证主库数据的完整性,也就是产生了一条文件批次信息记录和三条子文件批次记录,然后把这三条子文件批次信息分别复制到对应的分库中的子文件批次信息表里面,然后更新主库的子文件批次信息状态为“已同步”。当然,这个过程是无法保证一致性的。解决方案启动一个定时任务,定期的把主库重点的子文件批次表信息中初始状态的记录 同步到各个分库的子文件批次表里面,这里面可能导致两种情况

1 分库子批次信息表已经存在相同的信息(这个可以通过唯一性主键保证),说明已经同步,直接更新主库的子文件批次信息状态为 “已经同步”

2 分库子批次信息不存在,则往对应的分库insert一条数据,然后更新主库的子文件批次信息状态为 “已经同步”

然后各个分库 就是先导入子文件中的数据,在更新分库的子文件批次表的状态为处理成功 ,这两个操作由于都是分库的上的操作,可以保证一致性。最后,在更新主库的子批次信息表的状态为 “处理成功”。同样,更新主库的子批次信息状态如果失败,可以采取类似的定时机制,同步分库子文件批次信息表和主库的子文件批次信息表的状态。通过这种努力重试型机制,保证了主库中的子文件批次表和分库的子文件批次表是一致的。等所有的主库子文件批次信息表状态全部更新为“处理成功”,则文件批次状态就更新为“处理成功”。

相比第二个方案,我们在两个库里面增加了数据的同步,用这种机制,保证了主库分库数据的一致性。

这里简单的介绍一下第二个方案的简单实现细节:

首先是数据库之间表结构关联关系

分库分表_第3张图片

 

 下面用脚本的方式简单的演示一下这个过程

我们假设有四个库,一个主库MAIN,三个字库SUB1,SUB2,SUB3

MAIN库两张表:

FILE_BATCH_NO,主要关注status状态 I(初始)->S(成功)

SUB_BATCH_NO,主要关注status状态 I(初始)->R(同步成功)->S(处理成功)

SUB库两张表

DATA_DEAIL:保存明细数据,也就是业务逻辑主要处理的表

SUB_BATCH_NO:主要关注status状态,I(初始)->S(处理成功)

 1 拆分文件批次的过程

begin
 
declare  file_name,batch_no,sub_batch_no;
 
insert  into  MAIN.FILE_BATCH_INFO(id,file_name,batch_no,status) values (seq.FILE_BATCH_INFO,#file_name#,#batch_no#, 'I' );
 
insert  into  MAIN.SUB_BATCH_INFO(id,file_name,main_batch_no,status) values (seq.SUB_BATCH_INFO,#file_name#,#batch_no#,#sub_batch_no#, 'I' );
insert  into  MAIN.SUB_BATCH_INFO(id,file_name,main_batch_no,status) values (seq.SUB_BATCH_INFO,#file_name#,#batch_no#,#sub_batch_no#, 'I' );
insert  into  MAIN.SUB_BATCH_INFO(id,file_name,main_batch_no,status) values (seq.SUB_BATCH_INFO,#file_name#,#batch_no#,#sub_batch_no#, 'I' );
 
commit ;
 
end ;

 

2 同步MAIN库的子批次信息到分库的各个SUB库中对应的子批次信息表,同步成功,更新MAIN库对应的子批次信息状态为同步成功。

##分库的操作,从MAIN库SUB_BATCH_INFO表中获取对应的数据插入到SUB1库里面
begin  transaction  in  SUB1
declare  file_name,batch_no,sub_batch_no;
 
select  SUB_BATCH_INFO.ID into  SUB_ID from   MAIN.SUB_BATCH_INFO where  SUB_BATCH_INFO.DATA_BASE = SUB1
//判断分库数据是否存在,存在就返回 true
if( select  * from  SUB1.SUB_BATCH_INFO where  SUB_ID = SUB_BATCH_INFO.ID)
   return  SUCCESS
insert  into  SUB1.SUB_BATCH_INFO(id,file_name,main_batch_no,status) values (SUB_ID,#file_name#,#batch_no#,#sub_batch_no#, 'I' );
commit ;
 
end ;
 
 
##SUB1库的操作完成之后,开始进行MAIN库SUB_BATCH_INFO表对应的 update 操作
begin  transaction  in  MAIN
 
declare  SUB_ID;
## R代表已经同步的状态,这里面可以判断status的状态,不过意义不大
update   MAIN.SUB_BATCH_INFO set  status = 'R'  where  ID = SUB_ID
 
commit ;
end ;

 上面只是一个SUB库的操作,如果有多个库,循环进行操作。如果某一个库没有同步成功,有定时恢复机制。定时恢复机制的对应的SQL就是从MAIN中提取出是状态的SUB_BATCH_INFO记录,重复进行上述处理的过程

 

3 SUB库处理子批次信息,对流水进行保存,然后更新SUB库对应的SUB_BATCH_INFO记录状态为处理成功。然后在更新MAIN库的对应的SUB_BATCH_INFO记录状态为成功。

 

 这里的情况一样,就是SUB库和MAIN库也存在状态同步的问题,这里也需要一个定时对MAIN库的 SUB_BATCH_INFO表状态进行同步更新

 

4 判断MAIN库对应的SUB_BATCH_INFO所有状态是否已经为成功,如果成功,更新MAIN库的FILE_BATCH_NO 的状态为成功。

 

在这四个过程中,需要三个定时器。有两个定时器保证MAIN库和SUB库之间的数据一致性问题,另外一个定时器负责异步更新MAIN库 批次和子批次的一致性问题。

 

对于第三个方案,可以抽取出通用的逻辑,来解决后续类似的场景。比如根据条件,删除各个分库中满足条件的流水,或者批量更新各个分库中满足条件的流水。我们可以把这些作为一个任务来抽象出来,一个具体的任务由N个子任务组成(N为分库的个数),系统要保证N个子任务要么全部成功,要么全部失败,不允许部分成功。我们可以在方案三的思想上,建立总任务表和子任务表,文件导入的处理只是其中的一个任务类型而已,批量删除,批量更新以及其他类似的操作,都可以当做具体的任务类型。

 

4 第四种方案就是经典的分布式事务设计中的 两阶段提交思想。两阶段提交的有三个重要的子操作:准备提交,提交,回滚。

继续拿文件导入来举例子,各个分库作为一个事务参与者 , 我们需要设计各个分库的准备提交操作,提交,回滚操作。

准备提交阶段:各个分库可以把要处理的文件明细保存到一张临时表里面,并且记住这一次事务中上下文信息。

提交阶段:把这一次事务上下文中对应的临时表数据同步到对应的明细表中

回滚阶段:删除本次事务相关的临时表流水信息。

通过设计一个两阶段的提交的事务管理器,我们可以在导入文件的时候启动一个分布式事务,生成一个事务上下文(这个上下文信息要保存到数据库里面),然后在调用各个子参与者的时候,需要把这个上下文信息传递下去,分库先进行准备工作(就是保存明细到临时表),如果成功,就返回准备成功。等所有的参与者成功了,事务管理器就提交这个事务,这个分库完成提交动作,把数据从临时表插入到正式表。如果某一个准备操作失败,所有的分库执行回滚操作,删除导入的流水。

这里面最重要的就是,如果某分库准备阶段返回成功,那么提交一定要成功,否则只能做数据订正或者人工处理了。这个是在两阶段中事务中没有办法解决。

对于不同的操作,要设计对应的准备提交,提交,回滚操作,开发量比较大,而且分布式事务管理器的实现也需要一定的功底。

 

上面四种方案,能够保证完整性和一致性的只有第三种和第四种方案。其实这两种方案的设计思想是一致的。就是通过努力重试以及异步确认进行的。严格的说,第三种方案会有一定的问题,因为在整个处理过程中,只能保证最终一致性,而没有办法保证ACID里面的孤立性。因为存在部分提交的情况,而这一些数据有可能后续会进行回滚。不过可以就第三种方案在进行优化,加上一个锁机制,不过扩展下来就比较复杂了。



数据库横向分表的一些问题


今天看了“Database Sharding at Netlog, with MySQL and PHP”一文,和去年我们讨论扩展的思路很类似(不过这种分布式扩展,计算,存储的思路都很类似),但是这片文章的作者是在日益爆炸式增长的用户数据下实践的分享,因此这里将文中的一些思想记录下来分享一下。

       Netlog拥有4000万活跃用户,每个月有超过5000万的独立用户访问网站,每个月有5亿多的PV。数据量应该算是比较大的。作者是Jurriaan Persyn,他从一个开发者角度而非DBA或者SA角度来谈Netlog是如何通过数据切分来提高网站性能,横向扩展数据层的。原文在:http://www.jurriaanpersyn.com/archives/2009/02/12/database-sharding-at-netlog-with-mysql-and-php/

 

       首先,还是先谈到关于数据库在数据日益庞大的情况下一个演变过程。

第一阶段:读写同在一台数据库服务器

 

 

分库分表_第4张图片

 

 

第二阶段:读写分离(可以解决读写比例均衡或者读居多的情况,但是带入了数据复制同步的问题)

 

 

 

分库分表_第5张图片

 

      

第三阶段:部分数据独立部署结合读写分离。(部分数据根据其业务独立性情况,可以将所有的数据独立存储到数据库服务器,分担数据读写压力,前提是要求数据具有较高的业务独立性)

 

 

 

分库分表_第6张图片

 

 

       第四阶段:数据分拆结合读写分离(三阶段的增强)

 

 

 

分库分表_第7张图片

      

       第五阶段:问题出现,分拆也无法解决数据爆炸性增长,同时读写处于同等比例。

 

分库分表_第8张图片

 

 

       解决问题两种方式:DB Scale up DB Scale out。前者投入以及后期扩展有限,因此需要进行数据切分。

 

分库分表_第9张图片

 

 

       上图就是将photo的数据切分到了10台数据库服务器上。

 

       切分数据的两个关键点:

1.  如何根据存储的数据内容判断数据的存储归属,也就是什么是内容的分区主键。

2.  采用什么算法可以根据不同的主键将内容存储到不同的分区中。

 

分区主键的选择还是要根据自身的业务场景来决定,Netblog选择的是用户ID

采用什么方式将分区主键映射到对应的分区可以通过以下四种方式:

1.  根据数据表来切分。(前提就是数据独立性较强,和前面提到的三阶段类似)

2.  基于内容区间范围的分区。(就好比前1000个用户的信息存储在A服务器,1000-2000存储在B服务器)

3.  采用Hash算法结合虚拟节点的方式。(这类在memcached等等分布式场景中最常见,其实也是一个难点),缺点就是在于动态增加存储节点会导致数据部分或者全部失效。

4.  目录式的分区。最简单也是最直接的方式,key和分区的对应关系被保存,通过查找目录可以得到分区信息。适合扩展,就是增加查询损耗。

 

如何将数据分布的尽量均匀,如何平衡各个服务器之间的负载,如何在新增存储机器和删除存储机器的时候不影响原有数据,同时能够将数据均摊,都是算法的关键。在分布式系统中DHTDistribute Hash Table)被很多人研究,并且有很多的论文是关于它的。

 

数据的横向切分给应用带来的问题:

1.  跨区的数据查询变得很困难。(对于复杂的关联性数据查询无法在一个请求中完成)

2.  数据一致性和引用完整性较难保证。(多物理存储的情况下很难保证兼顾效率、可用性、一致性)

3.  数据分区之间的负载均衡问题。(数据本身的不均衡性,访问和读写的不均衡性都会给数据分区的负载均衡带来困难)

4.  网络配置的复杂性。(需要保证服务器之间的大数据量频繁的交互和同步)

5.  数据备份策略将会变得十分复杂。

解决这些问题当前已经有的一些开源项目:

1.  MySql Cluster,解决读写分离问题已经十分成熟。

2.  MySql Partitioning,可以将一个大表拆分为很多小表,提高访问速度,但是限制与这些小表必须在同一台服务器上。

3.  HSCALESpock Proxy都是建立与MySql Proxy基础上的开源项目,MySql Proxy采用LUA脚本来进行数据分区。

4.  HiveDBMySql分区框架的java实现。

5.  另外还有HyperTable,HBase,BigTable等等。

 

Netblog几个需求:

1.              需要灵活的可扩展性。对于存储增加减少需要能够动态的及时实施,因为数据量增长很快,如果策略会导致数据失效或者部署需要重新启动,则就不能满足需求。

2.              不想引入全新的数据层和与原有系统不匹配的抽象层,因为并不是所有数据都需要切分,仅仅在需要的情况下通过API的方式来透明切分数据。

3.              分区的主键需要可配置。

4.              需要封装API,对开发人员透明数据切分的工作。

 

      Netblog Sharding的实现

 

 

分库分表_第10张图片

 

上图就是NetblogSharding的结构图,主要分成了三部分:ShardSharddbSharddbhostShard就是一个表,里面存放了部分用户数据。Sharddb是一个表的组合就像一个虚拟的DBSharddbhost是具体的存储分区。ShardSharddb可以根据负载的情况被移动到不同的host中去。

       对于Shard的管理,Netblog采用的是目录查询的管理方式。目录信息也存储在MySql中,同时会通过互备,Memcache,集群来确保安全性和高效性。

       Shard Table API采用了多一层的映射模式来适合各种不同属性的查询情况。数据和记录在数据库中存储除了UserID以外还有对应的ItemIDItemID的作用就是定义了具体获取数据的字段信息,例如关联照片表时,ItemID就是PhotoId,关联视频表时,ItemID就是videoID

       一个获取用户id26博客信息的范例:

1Where is user 26?

   User 26 is on shard 5.

2On shard 5; Give me all the $blogIDs ($itemIDs) of user 26.

That user's $blogIDs are: array(10,12,30);

3On shard 5; Give me all details about the items array(10,12,30) of user 26.

Those items are: array(array('title' => "foo", 'message' => "bar"), array('title' => "milk", 'message' => "cow"));

 

对于Shard的管理Netblog采取的措施主要有这些:

1.  服务器之间的负载均衡根据用户数,数据库文件大小,读写次数,cpu load等等作为参数来监控和维护。根据最后的结果来迁移数据和分流数据。

2.  移动数据时会监控用户是否在操作数据,防止不一致性。

3.  对于数据库的可用性,采用集群,master-mastermaster-slave复制等手段。

 

最后通过三种技术来解决三个问题:

 

1.  Memcached解决shard多次查询的效率问题。

根据上面的范例可以看到,一次查询现在被分割成为了三部分:shard查询,item查询,最终结果查询。通过memcached可以缓存三部分内容,由前到后数据的稳定性以及命中率逐渐降低,同时通过结合有效期(内容存储时效)和修改更新机制(add,update,delete触发缓存更新),可以极大地解决效率问题。甚至通过缓存足够信息减少大量的数据库交互。

 

2.  并行计算处理。

由于数据的分拆,有时候需要得到对于多Shard数据处理的结果汇总,因此就会将一个请求分拆为多个请求,分别交由多个服务器处理,最后将结果汇总。(类似于Map-reduce

 

3.  采用Sphinx全文搜索引擎解决多数据分区数据汇总查询,例如察看网站用户的最新更新情况或者最热门日至。这个采用单独系统部署,通过建立全局信息索引,来查询数据情况。

 

以上是技术上的全部内容,作者在最后的几个观点十分值得学习,同时也不仅仅限于数据切分,任何框架设计都可以参考。

 

“Don't do it, if you don't need to!" (37signals.com)

"Shard early and often!" (startuplessonslearned.blogspot.com)

 

看起来矛盾的两句话,却是说出了对于数据切分的一些考虑。

首先在没有必要的情况下就不要考虑数据切分,切分带来的复杂性直接影响可用性,可维护性和一致性。在能够采用Scale up的情况下,可以选择Scale up降低框架复杂度。

另一方面,如果发现了业务增长情况出现必须要扩展的趋势,那么就要尽早着手去实施和规划扩展的工作,并且在切分和扩展过程中要不断地去优化和重构。

 

后话:

       其实任何架构设计首要就是简单直接,不过度设计,不滥竽充数。其实就是要平衡好:可用性、高效性、一致性、可扩展性这四者之间的关系。良性循环、应时应事作出取舍和折中。用的好要比学会用更重要,更关键。




你可能感兴趣的:(MySQL)