宽表设计最佳实践

Lindorm宽表引擎提供PB级存储能力,可以将写入的数据按照主键进行范围分区并均匀分布在每台机器上,同时,Lindorm提供了SQL和索引支持,使用体验上接近于关系型数据库,但Lindorm宽表的底层实际上是基于LSM-Tree存储结构的分布式NoSQL数据库,与关系型数据库存在一些区别。因此在使用Lindorm宽表前,对数据模型、数据分布规律等原理有一个简单的理解可以帮助您更好地使用Lindorm,规避业务建模不合理带来的性能、热点等一系列问题。

数据模型

Lindorm宽表引擎是一个行存储引擎。如果按照以下SQL语句创建一张示例表orders

CREATE TABLE orders (
    channel VARCHAR NOT NULL,       #支付渠道,支付宝,微信等
    id VARCHAR NOT NULL,            #订单id
    ts TIMESTAMP NOT NULL,          #订单事件发生时间
    status VARCHAR,                 #订单状态
    location VARCHAR,               #订单发生地点
    PRIMARY KEY(channel, id, ts)    #主键为channel, id, ts三者联合组成
) WITH (DYNAMIC_COLUMNS='true');    #开启动态列功能,非主键列可以随意写入,无需预定义;

可以得到下表所示的数据模型:

主键

非主键

channel

id

ts

status

location

……

alipay

a0001

1705786502000

0

shanghai

……

alipay

a0002

1705786502001

1

beijing

……

……

……

……

……

……

……

unionpay

u0001

1705786502056

0

hangzhou

……

unionpay

u0002

1705786502068

1

nanjing

……

……

……

……

……

……

……

wechat

w0001

1705786502056

0

shanghai

……

wechat

w0002

1705786502068

0

shanghai

……

……

……

……

……

……

……

主键

一行中的列会分为主键和非主键。主键可以由多个列组成。主键有以下几个特征:

  • 主键的Schema不可修改:主键在创建Lindorm宽表时就已经确定,建表后主键的列不可以增加、删除、更换顺序,更不能修改数据类型。因此,在建表前需要合理规划主键。主键的设计对数据请求性能至关重要。

  • 主键具有唯一性:所有主键共同组成了一行RowKey,RowKey在一张表里是唯一的。因此,通过提供完整的主键即可确定表中的唯一一行。

  • 主键即聚簇索引:在Lindorm宽表中,数据按照主键的顺序存储。例如在示例表orders中,channel列的值相等的行会存放在一起。在channel列的值相等的情况下,再按照id列的值排列。在id列的值相等的情况下,再按照ts排列。

    通过主键即聚簇索引的特性,可以实现更高效的查询。在查询时,Lindorm与MySQL类似,遵循最左匹配原则。您可以指定尽量多的主键等值来缩小查询范围,在设计表的主键时,您可以把常用的等值查询条件放在主键的最左边。如果查询语句中对某个主键设置了范围查询,则最左匹配立即终止,即使其他的主键为等值查询,后续主键的扫描也无法利用存储顺序,需要进行大量的过滤来匹配范围查询。

    以示例表orders为例,查询语句如下:

    -- 需要读取所有channel=alipay且id>a0089的数据来匹配符合ts=1705786502068这个条件的行。
    SELECT * FROM orders WHERE channel=="alipay" AND id > 'a0089' AND ts = 1705786502068;  

    如果未指定最左边的主键,查询其他主键,尽管查询条件中指定了主键,也仍然是全表扫描,如下所示:

    -- 这是一个全表扫查询
    SELECT * FROM orders WHERE id = 'a0089';

    在上述语句中,查询条件指定了一个主键id,但该主键位于所有主键的中间位置,因此系统仍然需要过滤所有的行来匹配id= 'a0089'的行,效率非常低。如果该查询是主要查询,那么在设计该表的主键时,建议将id列放在最靠左的主键中,或为id列创建一张索引表来加速查询。

非主键

Lindorm支持动态定义非主键,即非主键无需定义在Schema中,您可以写入任意列名的非主键,与HBase使用方式类似。详细的使用说明,请参见动态列。同时,Lindorm支持使用通配符来定义非主键列,例如定义*_str列为STRING类型,则所有列名以_str结尾的、数据类型为STRING的列都可以写入。详细介绍请参见通配符列

Lindorm支持非主键列的更新,数据写入时无需指定所有非主键列,但至少需要指定一个非主键列,不支持仅写入主键。由于Lindorm宽表的存储是主键有序,如果没有在非主键列上建立索引,直接将其作为过滤条件会造成全表扫描,此类查询默认会被Lindorm拒绝。因此,如果需要高效查询非主键,您可以限定主键范围,或为非主键列建立索引。

-- 未限定主键,仅使用非主键列作为查询条件,该请求将扫描全表
SELECT * FROM table WHERE location = 'shanghai'; 

-- 限定主键的扫描,会扫描所有channel='alipay'的行,并过滤出location = 'shanghai'的行
SELECT * FROM table WHERE location = 'shanghai' and channel='alipay'; 

数据分布

Lindorm是一个分布式数据库,表中数据会按照主键范围分区,被自动放置在实例的每个节点(每台机器)上。如图所示:

image

在Lindorm中,分区被称作Region。Region里存储了表中的一段数据,所有Region将按照主键范围分布,首尾相接,组成整个表空间。假设写入一行数据:{alipay,a100999,1705786502068},这行数据将存储在Region_3中。当Region_3中的数据增多至超过阈值(默认阈值为8 GB),或系统检测到Region中存在读写热点,就会对Region进行分裂,分裂出来的Region分别为原先Region范围的上半部分和下半部分,系统会根据负载将新的Region分配至不同的服务器,从而达到负载均衡。

image

系统不会保证相同前缀的行一定在同一个分区内,例如,系统无法保证满足channel=alipay的行存储在同一个Region,如果满足channel=alipay条件的行足够多,也会分布在多个Region中。例如上图中channel=alipay的行分布在Region_1Region_2Region_3中。在实际写入过程中,您无需担心某些范围的数据写入过多,也无需在建表时通过PARTITION BY指定分区,无需干预,系统会自动选择合适的分裂点。

预分区

由于Lindorm支持自动分区,因此您在建表时无需定义分区范围。在写入数据的过程中,系统会自动分裂分区。如果您对数据分布有需求,可以在建表时指定预分区个数,系统会将表的Region分布在多台机器上,分散读写。需要注意的是,建表指定的分区个数只是初始表的Region个数,在数据写入过程中,系统仍然会对Region进行分裂。如果要在建表的时候指定预分区,请参见CREATE TABLE

如果您一开始写入量便很大,或计划使用Bulkload批量导入数据,建议您建表时设置适合数据分布的预分区,防止一开始写入时便导致单台服务器超出负载。

  • 如果您使用SQL或HBase API写入数据,可以在建表时指定预分区数量为节点数×4。初始分区并不是数量越多越好,因此建议您根据需求设置合适的分区个数。

  • 如果您使用Bulkload批量导入数据,建议在建表时指定预分区数量为数据量(GB)÷8,以便数据能够分散导入到每个Region中,并且不会导致每个Region的数据量过大而触发分裂。

您还需要注意预分区的Region范围是否符合数据写入规律,否则即使预分区数量再多,写入或导入数据时,数据倾斜至少数几个分区中也会导致性能与预期不符。

热点

在分布式系统中,关键在于确保数据请求能够均匀地分布在每个Region上,这样系统就能够实现水平扩展,承载更多的数据量或请求。然而,当系统出现热点时,可能会导致单台机器成为性能瓶颈。

  • 单行热点:频繁读写单行数据可能会产生单行热点。由于对同一行的请求始终会被发送到同一台服务器上,因此这台服务器的性能上限决定了整个系统的性能上限,此时无法通过水平扩展解决热点问题,只能通过升级单机配置来处理更多的请求,所以在宽表的设计时需要考虑热点问题,尽量避免单行热点的产生。

  • 小范围热点:如果频繁读写一个很小范围内的数据,可能会造成小范围热点。由于Lindorm是按照主键的范围进行分区的,所以小范围的数据很可能分布在同一个分区,那么请求也可能都被发送到同一台服务器上,尽管Lindorm的热点自愈功能能够识别小范围的热点,并通过自动分裂Region将热点Region中的数据分散到不同的节点中,但是您仍然可以选择更加分散的主键来避免小范围请求。以示例表orders为例,您可以对id列使用HASH函数后进行存储,从而尽可能地避免热点问题。

  • 主键递增写热点:如果主键的值是递增的,则代表主键的值始终在增长。由于Lindorm是按照主键的范围进行分区的,即使Region发生分裂,后续写入的数据也只会存储在分裂出的子Region的下半部分,流量无法分散。例如示例表orders中,id是递增的,而满足channel=alipay条件的订单写入量非常大,那么即使Region_3能分裂成Region_3_aRegion_3_b两部分,承担写入流量的也只有Region_3_b。由于递增写热点的问题无法从系统层面解决,因此,建议您在设计主键时尽可能避免主键递增的情况,且主键的第一列不能是递增的值。

    重要

    如果您是使用Cassandra等使用HASH分区设计的系统将数据迁移到Lindorm,需注意,在Cassandra等系统中的第一列为HASH分区键,如果该列的值是递增的,那么经过HASH函数处理后,这些值将会被均匀地分散至每个分区中,但如果把该递增的主键迁移至Lindorm,则会出现严重的热点问题。

主键设计

主键的设计在Lindorm宽表中至关重要,数据分布、数据排序等都与主键有着密切的关系,因此一个好的主键是合理分配宽表资源、正确使用宽表的关键因素。主键设计的建议,请参见如何设计宽表主键

主键的设计要点如下:

  • 主键应尽可能短,只需确保行的唯一即可,不应将JSON、网页内容等放在主键中。

  • 主键的第一列尽量分散,如果是订单ID这类递增的值,可以采用hash(id)+id的方式,或者reverse(id)的方式存储。

  • 主键第一列不能是递增的值,否则会出现严重的热点问题。如果主键中有递增字段无法避免,可以将其放在第二或者第三列主键,保证最左边的主键尽量分散。例如示例表orders第一列主键是channel,不同channelid递增,写入会分散到不同的Region中,消除了单点写入的风险。

  • 主键是查询性能的关键(在没有索引表的情况下),因此主键设计时需要结合主要的查询方式来考虑。

索引表设计

当仅使用主键查询不能满足业务的性能要求时,可以为查询的列创建二级索引,详细介绍,请参见二级索引

二级索引是以索引列为主键建立的索引表,被索引的列实际上是索引表的主键,因此索引列的设计依旧需遵循主键设计原则,分散程度不大、递增的列不适合被索引。

二级索引的查询遵循最左匹配原则,例如为a、b、c三列创建联合索引,查询条件中指定了主键b或c,但未指定最左主键a(类似查询:SELECT * FROM tablename WHERE b=xx;),则该查询仍然是全表扫描。

创建二级索引后数据将同时写入主表和索引表,一次单纯的数据写入会涉及回读主表、写入索引表等多次操作,进而影响主表的写入性能。因此为保证主表的读写性能,二级索引表的数量不宜过多。同时,二级索引的索引列不支持修改,如果已创建的索引表不满足查询需求,只能删除该索引后创建新的索引,因此二级索引比较适合查询相对固定的场景。如果列的数量很多,需要使用索引和组合查询,建议您使用Lindorm搜索索引和列存索引

以下是三种索引的比较说明:

索引类型

适用场景

是否有额外依赖

是否实时可见

二级索引

适用于相对较固定的查询场景。

无。

是。

搜索索引

适用于索引列较多、查询条件组合较多的在线查询场景。

需开通搜索索引功能,并购买搜索引擎节点。

数据需同步至搜索引擎,索引存在一定延迟。详细介绍请参见搜索索引介绍

列存索引

适用于针对某些列进行分析查询的场景。

需开通列存索引功能,并购买计算引擎节点。

数据需转换为列存格式,索引存在一定延迟。详细介绍请参见列存索引

数据写入

数据写入操作要遵循合理的主键设计,以确保数据能够均匀分散在每台服务器上,从而获得最佳性能。Batch写入比单行写入能够节省RPC次数,服务器能够批量处理行写入,更容易达到更大的吞吐。但是通过Batch写入时,并不是行数越多越好,一次Batch的数据过多可能会造成服务器OOM或者Full GC,进而影响服务的稳定性。因此建议Batch写入的行数控制在合理范围内,一个Batch的大小不超过2 MB。

Lindorm同时支持HBase API和SQL两种不同类型的访问写入,两种访问方式不可混用。如果您使用SQL创建了表且每一列都定义了数据类型(例如INT,LONG等),再使用HBase API写入这些列可能会导致无法使用SQL读取。使用SQL创建的表仅支持通过SQL读写,使用HBase API创建的表支持通过SQL访问,您可以使用列映射来添加Schema,具体操作方式请参见使用SQL访问HBase表

数据查询

点查和范围查询

Lindorm宽表有两类常见查询:点查和范围查询。

点查

如果在查询时指定了表的所有主键,则该查询为一个点查,例如:

SELECT * FROM orders WHERE channel='alipay' AND id='a0001' AND ts=1705786502000;
SELECT * FROM orders WHERE channel='alipay' AND id='a0001' AND ts IN (1705786502000, 1705786502222, 1705786502333);
SELECT * FROM orders WHERE channel='alipay' AND id IN ('a0001', 'a0002', 'a0003') AND ts IN (1705786502000, 1705786502222, 1705786502333);
SELECT * FROM orders WHERE channel IN ('alipay', 'wechat', 'unionpay') AND id IN ('a0001', 'a0002', 'a0003') AND ts IN (1705786502000, 1705786502222, 1705786502333);

上述示例语句中,第一条SQL是单行点查,其他SQL语句均是多行点查。第二条SQL指定了多个条件,在Lindorm宽表中会返回多行数据。第三条SQL语句中主键idts分别指定了三个IN条件,因此实际查询时会点查3×3=9行数据,同理,第四条SQL语句会查询3×3×3=27行数据。在点查中IN条件越多,组合条件产生交叉的可能性越大(会产生笛卡尔积),发送到服务器的查询行请求数量越多。在Lindorm宽表中执行多行的点查操作时会一次性返回所有结果,点查的行数越多,服务器需要处理的行数也越多,返回的数据集也越大,越容易造成服务器OOM或Full GC。因此在点查过程中,您需要控制点查的数量,减少IN条件中数据的个数以及IN的组合数量。

Lindorm对批量点查的数量有限制,默认一次最多可执行2000条点查请求,超过则会报错Multi Get Plan query too many rows in one select。如果您有大批量点查的需求,且经过评估内存不会达到瓶颈,可以联系Lindorm技术支持(钉钉号:s0s3eg3)放宽该限制。

范围查询

如果查询条件中未指定主键,或仅指定了部分主键,则该查询为范围查询。例如:

SELECT * FROM orders;
SELECT * FROM orders WHERE channel='alipay';
SELECT * FROM orders WHERE channel='alipay' AND id='1705786502001';
SELECT * FROM orders WHERE channel='alipay' AND id IN ('a0001', 'a0002', 'a0003');
SELECT * FROM orders WHERE channel IN ('alipay', 'wechat', 'unionpay') AND id IN ('a0001', 'a0002', 'a0003');

以上示例SQL语句中均未指定所有主键,均为范围查询。

范围查询的结果是流式返回的,即使是全表扫描,服务器仍然可以通过流式返回的方式返回全部数据,不会造成服务器OOM等问题,因此单次范围查询的条数没有任何限制,您无需在查询语句中添加limit、offset等条件来实现分页或游标。

ORDER BY查询请求

Lindorm宽表的数据是按主键排序的,即使SELECT语句中不添加ORDER BY子句,系统也会按照主键顺序返回查询结果。但如果查询请求命中索引表,则默认按照索引表的顺序返回。同时,索引表中的数据排列为索引列的顺序,所以索引列的顺序决定了该查询的返回顺序。

ORDER BY的列遵循最左匹配原则,如果想要高效地排序,则不能跳过左边的主键字段直接在ORDER BY语句中使用中间的主键字段进行排序。在ORDER BY子句中使用中间的主键或者非主键进行排序时,会涉及大量的排序计算,因此建议您将需要排序的主键放在最左边,或为需要排序的非主键创建二级索引。此外,如果您希望查询结果倒序排列(ORDER BY DESC),建议在建表时添加DESC关键字,可以保证数据的倒序存储,以便获得最佳的查询性能。关于ORDER BY的使用,请参见如何在较大结果集中使用ORDER BY

Count查询请求

Lindorm是基于LSM-Tree存储结构的NoSQL数据库,由于存在多版本更新、删除标记(deletemarker)等数据(即数据可能存在多次修改、删除),因此底层存储并没有在元数据中记录表的行数。如果您想要精确查询表的行数,则需要扫描全表,表越大则耗时越长。如果您只需要获取一张表的预估行数,请参见如何统计表行数

如果Count请求有限定条件,那么查询耗时则取决于限定条件下需要扫描的表数据的多少,例如SELECT count(*) FROM table WHERE channel = 'alipay'会读取所有满足channel = 'alipay'的行进行统计。

数据删除

在Lindorm宽表中删除一行数据时,该行数据不会立刻被清理,而是先写入一个删除标记(deletemaker),在系统执行COMPACTION操作后才会被彻底删除。在数据被彻底清理前,被删除的数据和写入的删除标记都会被查询到,并通过运算逻辑过滤掉,因此大量删除操作会增加deletemaker的数量,影响查询性能。建议您设置TTL(数据有效期)来淘汰不需要的数据,而不是直接删除。如果业务运行过程有大量的删除需求,可以通过缩短COMPACTION操作周期来快速清理被删除的数据和deletemaker。设置TTLCOMPACTION周期的方式,请参见ALTER TABLE

如果使用了二级索引,在主表中更新数据时,二级索引表中更新前的主表数据会被删除,之后再写入新数据。因此,主表数据更新会导致二级索引表中也产生大量的deletemaker,主表的大量更新会影响索引表的查询性能,您可以通过缩短COMPACTION操作周期来减少deletemaker的影响。

重要

缩短COMPACTION操作周期会加重系统负担,请您合理设置。

更多说明

  • Lindorm宽表引擎基于LSM-Tree存储结构,如果您对LSM-Tree结构的多版本、时间戳、TTL等功能比较陌生,很有可能会因为使用不当进而出现写入查询不符合预期的情况,出现此类情况可以参见查询结果不符合预期的常见原因

  • 在使用Lindorm宽表时,您需要时刻关注实例的CPU、网络、磁盘水位等指标,避免资源不足导致性能下降。同时,您需要关注服务器上文件的数量、Region大小、Compaction是否存在积压等,避免其超出Lindorm限制影响数据写入查询。Lindorm使用限制说明请参见配额与限制,监控报警中需要您重点关注的监控项请参见监控报警最佳实践

  • 其他常见报错及解决方式,请参见常见问题