深入浅出TableStore翻页

表格存储是阿里云提供的一个分布式存储系统,可以用来存储海量结构化、半结构化的数据。数据存储后就需要查询出来满足业务需求,但是有时候一次请求可以返回的数据量有限,不能返回完所有的数据,那这个时候就需要通过多次查询来返回需要的数据,这就是我们接下来要讲的“翻页”。
      表格存储中的数据分为两部分,一部分是主表(Table),只支持按主键或主键前缀查询,另一部分是多元索引(SearchIndex),支持按属性列查询,所以主表的翻页仅支持按主键或主键前缀查询时的翻页,而多元索引的翻页可以支持在任意属性列上查询后的翻页。
     常见的翻页形式有两种,一种是可以直接跳页,但是不能深度翻页,另一种可以深度翻页,但不能直接跳页。表格存储基于上述原理提供了三种翻页方式,每种方式各有千秋,都不是万能的。本文将逐一讲解每种的功能和原理,帮你做出适合自身业务的正确选择。他们包括:

  1. GetRange翻页:基于主表,只能按主键查询,只能连续翻页
  2. token深度翻页:基于多元索引,可以按属性列查询,只能连续翻页
  3. 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。
深入浅出TableStore翻页

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))                                                //返回的行数                                        
上一篇:Firefox SVG getBBox方法返回'NS_ERROR_FAILURE'错误分析


下一篇:分析: GetBuffer, ReleaseBuffer, GetBufferSetLength(转载)