表格存储是阿里云提供的一个分布式存储系统,可以用来存储海量结构化、半结构化的数据。数据存储后就需要查询出来满足业务需求,但是有时候一次请求可以返回的数据量有限,不能返回完所有的数据,那这个时候就需要通过多次查询来返回需要的数据,这就是我们接下来要讲的“翻页”。
表格存储中的数据分为两部分,一部分是主表(Table),只支持按主键或主键前缀查询,另一部分是多元索引(SearchIndex),支持按属性列查询,所以主表的翻页仅支持按主键或主键前缀查询时的翻页,而多元索引的翻页可以支持在任意属性列上查询后的翻页。
常见的翻页形式有两种,一种是可以直接跳页,但是不能深度翻页,另一种可以深度翻页,但不能直接跳页。表格存储基于上述原理提供了三种翻页方式,每种方式各有千秋,都不是万能的。本文将逐一讲解每种的功能和原理,帮你做出适合自身业务的正确选择。他们包括:
- GetRange翻页:基于主表,只能按主键查询,只能连续翻页
- token深度翻页:基于多元索引,可以按属性列查询,只能连续翻页
- offset+limit浅度翻页:基于多元索引,可以按属性列查询,支持跳页
GetRange
表格存储表中的行按主键进行从小到大排序,GetRange会范围读取指定主键区间内的行,每次GetRange请求实现了“翻出一页”的效果。
基于主键的GetRange直接扫描主表,不需要创建索引,在使用上有以下要求:
- 区间的起点和终点是有效的主键或者是由 INF_MIN 和 INF_MAX 类型组成的虚拟点,虚拟点的列数必须与主键相同。
- 需要指定一个或多个返回列名。如果某一行的主键属于读取范围,但不包含指定返回的列,请求结果不包含该行;如果不指定列名,返回完整的行。
- 需要指定读取的方向,可以为主键从小到大的正序(默认),或者从大到小的逆序。
- 需要指定返回的最大行数。
服务端每次请求最多扫描一个分区,在以下情况下结束该操作,即使该区间内仍未未返回的数据:
- 一次只扫描区间范围内的一个分区;
- 返回的行数据大小之和达到 4 MB;
- 返回的行数等于 5000;
- 返回的行数等于最大返回行数;
GetRange请求的返回结果中还包含下一条未读数据的主键,应用程序可以使用该返回值作为下一次 GetRange 操作的起始点继续读取。如果下一条未读数据的主键为空,表示读取区间内的数据全部返回。
GetRange翻页基于主键范围直接读取主表,不需要创建索引,不存在回表问题。
那么基于主表的GetRange是如何实现的呢?
主表的每一行都有主键唯一确定该行,每个主键包括多个主键列(1~4列)。主键的第一列称为分区键,分区键用于对主表数据进行范围分区。每个分区会分布式地调度到不同机器上进行服务,而服务端会根据分区键值定位到数据所在机器。当使用GetRange进行范围查询实现翻页时,用户指定分区键范围和扫描顺序,服务端直接定位到该范围起始的机器并返回结果。
如下图所示,假设inclusive_start_primary_key=180,exclusive_end_primary_key=250,按主键有小到大扫描,则将略过worker 1,顺次扫描worker 2和worker 3。第1次GetRange在扫描完分区2后就返回,如果需要继续读取该范围数据,则需要发起第2次GetRange。
offset + limit
GetRange只能简单地按照主键进行读取,每次GetRange实现“翻出一页”。然而,如果要实现更复杂的查询,如多字段ad-hoc查询、模糊查询、全文检索、排序、范围查询、嵌套查询、空间查询等,你需要在主表上创建多元索引。
翻页页数较少时,即浅度翻页,您可以使用offset+limit的方式。
优点:
- 支持连续翻页,也支持跳页
- 分页结果顺序稳定。如果sort条件相同,返回行按主表主键列有序
缺点:
- 只能浅层次翻页,因为会消耗大量内存和CPU,造成系统不稳定。具体约束条件如下——
约束条件:
- limit <= 100。这是因为,如果需要返回的列没有创建索引,则需要从TableStore主表中BatchGetRow反查得到,max_limit默认和BatchGetRow行数上限保持一致。
- offset >= 0,偏移从0开始计数
- offset + limit <= 2000,限制翻页不能太深。
类似主表数据,索引数据也会被分片,每个分片存储在独立的数据节点上。offset+limit查询所涉及的所有分片,各分片本地排序,收集结果再全局排序,空间复杂度为O(offset+limit)*(1+NumberOfPartitions)。如果是深度翻页,offset取值非常大,消耗大量内存,甚至导致OOM;同时分布式地扫倒排链、本地排序、全局排序也会增加CPU负载。
token
既然深度翻页会对系统带来这么巨大的冲击,自然要想办法优化。治标的方案可以是业务限流、优化最小堆排序算法等,但时间复杂度和空间复杂度不可能做到本质改善。换一种思路呢?如果业务可以限制翻页时只允许连续翻页,即不许跳页,每次翻页忽略“已经翻过的offset条数据”,只需排序limit条数据。而每次请求翻出的一页数据量不会太大(最多limit条),排序规模一下就从offset+limit降到limit,性能也提升上去了。多元索引的token翻页就采用了这个办法。每次请求如果没能全部响应所有数据,都会返回一个token,token中包含上次请求响应最后一条数据的断点和排序条件,下一次请求只需要在这个断点之后查找,既根本上减少翻页对系统的冲击,又保证了返回顺序的稳定性。
当用户索引非常大时,使用offset+limit可能造成OOM和系统不稳定,事实上offset+limit的限制也约束了这种方式不可以翻页过深。翻页超过了offset+limit的限制时,就要使用token。与offset+limit的翻页方式相同,也需要广播查询请求到每个数据分片,本地排序返回后,再全局排序。然而,排序规模不是O(offset+limit),而是O(limit),因为token中封装了前一次查询的最后一条数据的Sort字段取值,因此大大降低了系统资源开销。
token的秘密在于,封装了以下信息:
- Sort条件:指定Sort条件,在第一次连续翻页时由用户设置。如果Sort条件不包含PrimaryKeySort(按照主键列排序),则系统会自动追加PrimaryKeySort到Sort条件后面,以保证结果稳定性。
- 查询断点查询断点**的行。本地排序和全局排序就只需维护大小为O(limit),而非O(offset+limit)的内存,时间复杂度也降低了,响应变快。
优点:
- 相对offset+limit方式消耗系统资源较少,可以进行深度翻页
- 分页结果顺序稳定
缺点:
- 只能连续翻页,不可跳页。这个问题需要靠业务权衡选择。
代码示例
GetRange翻页
主表上范围查询,每次返回最多10条数据,GetRange实现。
getRangeRequest := &tablestore.GetRangeRequest{}
rangeRowQueryCriteria := &tablestore.RangeRowQueryCriteria{}
rangeRowQueryCriteria.TableName = tableName //设置表名
startPK := new(tablestore.PrimaryKey)
startPK.AddPrimaryKeyColumnWithMinValue("pk1")
endPK := new(tablestore.PrimaryKey)
endPK.AddPrimaryKeyColumnWithMaxValue("pk1")
rangeRowQueryCriteria.StartPrimaryKey = startPK //设置范围查询起始点(闭区间)
rangeRowQueryCriteria.EndPrimaryKey = endPK //设置范围查询结束点(开区间)
rangeRowQueryCriteria.Direction = tablestore.FORWARD //设置范围查询方向,FORWARD按照主键由大到小排序;BACKWARD按照主键由小到大排序
rangeRowQueryCriteria.MaxVersion = 1 //每列最多返回1个数据版本
rangeRowQueryCriteria.Limit = 10 //每次请求最多返回10行
getRangeRequest.RangeRowQueryCriteria = rangeRowQueryCriteria
getRangeResp, err := client.GetRange(getRangeRequest) //GetRange范围查询
for {
if err != nil {
fmt.Println("get range failed with error:", err)
break
}
if len(getRangeResp.Rows) > 0 { //如果本次GetRange有至少1行返回,则打印各行
for _, row := range getRangeResp.Rows {
fmt.Println("range get row with key", row.PrimaryKey.PrimaryKeys[0].Value)
}
}
if getRangeResp.NextStartPrimaryKey == nil { //如果本次GetRange扫描完指定范围,结束循环
break
} else { //如果本次GetRange尚未扫描完指定范围,启动下一次GetRange
getRangeRequest.RangeRowQueryCriteria.StartPrimaryKey = getRangeResp.NextStartPrimaryKey
getRangeResp, err = client.GetRange(getRangeRequest)
}
}
offset + limit浅度翻页
基于多元索引,翻出第10~19条数据
searchRequest := &tablestore.SearchRequest{}
searchRequest.SetTableName(tableName) //设置表名
searchRequest.SetIndexName(indexName) //设置索引名
query := &search.MatchQuery{} //设置查询类型为MatchQuery
query.FieldName = "Col_Keyword" //字段名为"Col_Keyword"
query.Text = "hangzhou" //字段值为"hangzhou"
searchQuery := search.NewSearchQuery()
searchQuery.SetQuery(query)
searchQuery.SetOffset(10) //设置offset为10
searchQuery.SetLimit(10) //设置limit为10,表示最多返回10条数据
searchQuery.SetSort(&search.Sort{ //设置Sort条件:按"Col_Long"字段值逆序排列
[]search.Sorter{
&search.FieldSort{
FieldName: "Col_Long",
Order: search.SortOrder_DESC.Enum(),
},
},
})
searchRequest.SetSearchQuery(searchQuery)
searchResponse, err := client.Search(searchRequest) //Search查询
if err != nil { //判断异常
fmt.Printf("%#v", err)
return
}
fmt.Println("IsAllSuccess: ", searchResponse.IsAllSuccess) //查看返回结果是否完整
fmt.Println("RowCount: ", len(searchResponse.Rows)) //返回的行数
for _, row := range searchResponse.Rows {
jsonBody, err := json.Marshal(row)
if err != nil {
panic(err)
}
fmt.Println("Row: ", string(jsonBody)) //打印返回的行(不设置columnsToGet,默认只返回主键)
}
token深度翻页
基于多元索引,每页10条数据,连续翻页
query := &search.TermQuery{
FieldName: "Col_Keyword",
Term: "hangzhou",
}
searchRequest := &tablestore.SearchRequest{}
searchRequest.SetTableName(tableName) //设置表名
searchRequest.SetIndexName(indexName) //设置索引名
searchQuery := search.NewSearchQuery()
searchQuery.SetQuery(query)
searchQuery.SetLimit(10) //无需指定offset,只用指定每页条数limit,因为是连续翻页
searchQuery.SetGetTotalCount(false) //不返回匹配的总行数,提前终止扫倒排链,加速查询
searchQuery.SetSort(&search.Sort{ //第1次翻页指定“Sort条件”:按"Col_Long"字段值逆序排列
[]search.Sorter{
&search.FieldSort{
FieldName: "Col_Long",
Order: search.SortOrder_DESC.Enum(),
},
},
})
searchRequest.SetSearchQuery(searchQuery)
searchResponse, err := client.Search(searchRequest) //第1次Search翻页
if err != nil {
fmt.Printf("%#v", err)
return
}
rows := searchResponse.Rows
for searchResponse.NextToken != nil {
searchQuery.SetToken(searchResponse.NextToken)
searchResponse, err = client.Search(searchRequest) //第n次Search翻页(n>1)
if err != nil {
fmt.Printf("%#v", err)
return
}
for _, r := range searchResponse.Rows {
rows = append(rows, r)
}
}
fmt.Println("IsAllSuccess: ", searchResponse.IsAllSuccess) //查看返回结果是否完整
fmt.Println("RowsSize: ", len(rows)) //返回的行数