TableStore多元索路由探微

表格存储是阿里云提供的一个分布式存储系统,可以用来存储海量结构化、半结构化的数据。多元索引(SearchIndex)可以支持基于属性列的丰富查询类型,帮你挖掘出数据的更多潜能。
多元索引会分布式地将数据打散存储在不同机器上。一般情况下,查询无需关心数据被分配到哪里;但通过指定路由,您可以有的放矢地定向搜索,在指定的一个数据分区上执行查询,而不是所有数据分区,有效提升了查询吞吐量,减少长尾对延迟的影响。
本文抽丝剥茧,介绍分布式模型中路由的原理,如何使用路由来加速查询。

分布式模型和路由

主表和索引数据被分布式地存储到多个分区,分布在多台机器上,为了定位数据被分配到哪个分区,读写时都需要根据路由值进行计算。

分区和路由

海量数据的存储需求、读写延迟和低成本催生了分布式NoSQL存储。购买更大更强的服务器运行数据库作为“纵向扩展(scale up)”,变得越发困难且昂贵;而在服务器集群上进行“横向扩展(scale out)”则备受青睐。分布式系统会对数据分区,分区被分布到多台机器上,根本上解决了单台机器对数据容量的限制。每个分区的数据规模较小,查询性能更高;查询可以并发地在多个分区上执行,提升了吞吐量。
路由,是指定位数据到特定分区的方法。例如,将一张表(Table)的数据划分为3个分区(分区0~2)存储,一个简单的路由规则可以这样设计:PartitionId = PartitionKey % PartitionNum。其中,PartitionId是分区编号;PartitionKey是分区键值;PartitionNum是分区数,在这里为3。
表格存储的主表数据就是按分区键(表格存储中的第一个主键列)为路由值,定位某一行到特定分区。
多元索引数据默认按“所有主键列的序列化值”为路由值来确定分区,也可以由用户指定路由字段的值来分区索引数据。如果用户创建索引时指定了路由字段,会按照该路由字段写入相应分区;查询时也带上路由值,就可以在指定的一个分区执行查询,而不是所有分区,效率更高。

写入

写入一行时,主表数据默认以“分区键”为路由,写入特定分区;索引数据以“所有主键列的序列化值”为路由,写入特定分区。

查询

表格存储中的查询分两种,一种是简单地按照主键列或主键列前缀查询,另一种是按属性列查询。
按主键列/主键列前缀查询:这个过程只涉及主表数据读取。主表数据按照分区键分区,分区内部数据按照主键列的前缀排序。如果按主键列读取(GetRow / BatchGetRow / GetRange),可以直接定位到对应数据分区;
按属性列查询:这个过程涉及索引表数据读取。索引数据默认按照“所有主键列的序列化值”分区。如果按属性列查询,并不知道数据分区在哪里,只能全局范围搜索,查询性能低,资源消耗大。如果用户指定了路由字段,写入和读取时都按照路由字段来定位分区,就可以避免全局范围搜索,查询性能高,资源消耗小。

路由的目的

本文关心的是多元索引中的路由的作用。
如果创建索引时不指定路由字段,默认按“所有主键列的序列化值”为路由,索引数据虽然会被打散到不同分区实现分布式存储,但是查询的时候必须访问所有的分区,效率是很低的;如果创建索引时由用户指定路由字段,查询时也带上路由字段值,则可以为查询带来额外收益。总结起来,路由有以下作用:

  • 定向存储,定向搜索,降低查询延迟,增加查询吞吐
  • 减少系统资源浪费

忠告

别急,事情总有两面。有时候不经过精心设计,会出现严重的数据偏移——某个路由值的行数太多,单个分区的行数太大,查询也变慢,而且可能超过单机容量限制。例如,您为订单表建立多元索引,以商家id(user_id)作为路由字段,普通的商家只会产生少量订单,但有几个“大商家”每天都会产生大量订单,他们的数据所在分区就会急剧膨胀起来。面对这种状况,我们该如何应对?

  • 最好设计多元索引的路由时,就做详尽地评估,避免出现这种情况
  • 为那些“大商家”创建独立的索引,其他商家共用一个索引。

此外,一旦创建索引时指定了路由,以后的查询时都必须带上正确的路由值,否则可能查不到相关数据。如果某天你不想使用路由进行查询了,只能重建索引。

自定义路由的使用

多元索引的自定义路由使用非常方便,只需要两步,详见后面代码实现:

  • 第一步:创建索引时,指定一个或多个路由字段。

您在创建多元索引指定了路由字段后,索引数据的读写都会使用该路由字段进行定位,不能动态改变。如果想使用系统默认路由(即主键列路由)或者重新指定其他字段为路由字段,您需要重建索引。

  • 第二步:索引查询时,在查询请求中指定路由字段值。

查询时使用路由,定向搜索指定数据分区,可以减少长尾对延迟的影响。一般来说,对于自定义路由的查询请求,都要求用户提供路由字段。如果不指定,虽然查询结果是一样的,但会访问无关的数据分区,白白浪费了系统资源,增加访问延迟。

此外,使用自定义路由也有限制——路由字段只能是表格存储的主键列
由于表格存储半结构化的特性,如果路由字段是属性列,可能出现某行没有该属性列,就不知道路由到哪个数据分片了。

代码实现

假设在订单表上建立多元索引,以商家id(user_id)为路由,查询某商家的订单时也需要指定路由值。代码示例如下:

    //1. 创建表
    createtableRequest := new(tablestore.CreateTableRequest)

    tableMeta := new(tablestore.TableMeta)
    tableMeta.TableName = tableName                            //设置表名
    tableMeta.AddPrimaryKeyColumn("order_id", tablestore.PrimaryKeyType_STRING)    //第1主键列: 订单号
    tableMeta.AddPrimaryKeyColumn("user_id", tablestore.PrimaryKeyType_STRING)    //第2主键列: 商家id
    tableOption := new(tablestore.TableOption)
    tableOption.TimeToAlive = -1                            //TTL无限
    tableOption.MaxVersion = 1                                //每列最多1个数据版本
    reservedThroughput := new(tablestore.ReservedThroughput)
    reservedThroughput.Readcap = 0
    reservedThroughput.Writecap = 0
    createtableRequest.TableMeta = tableMeta
    createtableRequest.TableOption = tableOption
    createtableRequest.ReservedThroughput = reservedThroughput    //设置预留读写吞吐量

    _, err := client.CreateTable(createtableRequest)        //创建主表
    if err != nil {
        fmt.Println("Failed to create table with error:", err)
        return
    } else {
        fmt.Println("Create table finished")
    }

    //2. 创建多元索引,指定路由字段
    request := &tablestore.CreateSearchIndexRequest{}
    request.TableName = tableName                            //设置表名
    request.IndexName = indexName                            //设置索引名

    //添加索引字段 (根据业务,您还可以添加更多字段)
    schemas := []*tablestore.FieldSchema{}
    field1 := &tablestore.FieldSchema{
        FieldName:        proto.String("product_name"),  //字段名: 商品名
        FieldType:        tablestore.FieldType_KEYWORD,     //字段类型: KEYWORD类型
        Index:            proto.Bool(true),                 //开启索引
    }
    field2 := &tablestore.FieldSchema{
        FieldName:        proto.String("order_time"),    //字段名: 下单时间
        FieldType:        tablestore.FieldType_LONG,        //字段类型: LONG类型
        Index:            proto.Bool(true),                //开启索引
        EnableSortAndAgg: proto.Bool(true),                //允许排序和聚合
    }
    field3 := &tablestore.FieldSchema{
        FieldName:        proto.String("user_id"),        //字段名: 商家id
        FieldType:        tablestore.FieldType_KEYWORD,        //字段类型: KEYWORD类型
        Index:            proto.Bool(true),                //开启索引
    }
    schemas = append(schemas, field1, field2, field3)

    indexSetting := &tablestore.IndexSetting{                //设置路由字段
        RoutingFields: []string{"user_id"},
    }

    request.IndexSchema = &tablestore.IndexSchema{
        FieldSchemas: schemas,                                //设置索引字段
        IndexSetting: indexSetting,                            //设置索引配置(包含路由字段)
    }

    _, err = client.CreateSearchIndex(request)                //创建多元索引
    if err != nil {
        fmt.Println("Failed to create index with error:", err)
        return
    } else {
        fmt.Println("Create index finished")
    }
    time.Sleep(time.Duration(60) * time.Second)                //等待数据表加载

    //3. 插入一些测试数据
    productNames := []string{"product a", "product b", "product c"}
    userIds := []string{"00001", "00002", "00003", "00004", "00005"}
    for i := 0; i < 100; i++ {
        putRowRequest := new(tablestore.PutRowRequest)
        putRowChange := new(tablestore.PutRowChange)
        putRowChange.TableName = tableName
        putPk := new(tablestore.PrimaryKey)
        putPk.AddPrimaryKeyColumn("order_id", fmt.Sprintf("%d", i))
        putPk.AddPrimaryKeyColumn("user_id", userIds[i%len(userIds)])

        putRowChange.PrimaryKey = putPk
        putRowChange.AddColumn("product_name", productNames[i%len(productNames)])
        putRowChange.AddColumn("order_time", int64(time.Now().Second()))
        putRowChange.SetCondition(tablestore.RowExistenceExpectation_IGNORE)
        putRowRequest.PutRowChange = putRowChange
        _, err := client.PutRow(putRowRequest)
        if err != nil {
            fmt.Println("putrow failed with error:", err)
        }
    }
    time.Sleep(time.Duration(30) * time.Second)                //等待数据同步到多元索引(通常,稳定后延迟在1s~10s级别)

    //4. 查询时,带上路由字段
    searchRequest := &tablestore.SearchRequest{}
    searchRequest.SetTableName(tableName)                    //设置主表名
    searchRequest.SetIndexName(indexName)                    //设置多元索引名
    query := &search.MatchQuery{}                           //设置查询类型为MatchQuery
    query.FieldName = "user_id"                             //设置要匹配的字段
    query.Text = "00002"                                     //设置要匹配的值: 查询user_id="00002"商家的所有订单
    searchQuery := search.NewSearchQuery()
    searchQuery.SetQuery(query)
    searchQuery.SetGetTotalCount(true)            //返回所有匹配到的行
    searchRequest.SetColumnsToGet(&tablestore.ColumnsToGet{ReturnAll:true})    //返回所有的列
    searchRequest.SetSearchQuery(searchQuery)

    routingValue := new(tablestore.PrimaryKey)
    routingValue.AddPrimaryKeyColumn("user_id", "00002")
    searchRequest.AddRoutingValue(routingValue)

    searchResponse, err := client.Search(searchRequest)        //查询
    if err != nil {
        fmt.Println("Failed to search with error:", err)
        return
    }
    fmt.Println("IsAllSuccess: ", searchResponse.IsAllSuccess)     //查看返回结果是否完整
    fmt.Println("TotalCount: ", searchResponse.TotalCount)        //返回所有匹配到的行数
    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))
    }
上一篇:2021年,算法工程师必备的能力是什么?


下一篇:Firefox SVG getBBox方法返回'NS_ERROR_FAILURE'错误分析