分布式打分—Elastic Stack 实战手册

本文涉及的产品
检索分析服务 Elasticsearch 版,2核4GB开发者规格 1个月
简介: 搜索引擎中的搜索与数据库中,常规的 SELECT 查询语句,都能帮你从一大堆数据中,找到匹配某个特定关键字的数据条目,但是这两者最大的区别在于,搜索引擎能够基于查询和结果的相关性,帮你做好结果集排序,即搜索引擎会将它认为最符合你查询诉求的数据条目,放在最前面,而数据库的 SELECT 语句却做不到。

970X90.png

· 更多精彩内容,请下载阅读全本《Elastic Stack实战手册》

· 加入创作人行列,一起交流碰撞,参与技术圈年度盛事吧

创作人:赵震一

什么是打分

搜索引擎中的搜索与数据库中,常规的 SELECT 查询语句,都能帮你从一大堆数据中,找到匹配某个特定关键字的数据条目,但是这两者最大的区别在于,搜索引擎能够基于查询和结果的相关性,帮你做好结果集排序,即搜索引擎会将它认为最符合你查询诉求的数据条目,放在最前面,而数据库的 SELECT 语句却做不到。

那么搜索引擎是怎么做到的呢?其关键在于打分,搜索引擎在完成关键字匹配后,会基于一定的机制对每条匹配的数据(后称文档)进行打分,得分高的文档表示与本次查询相关度高,就会在最后的结果列表中排在靠前的位置,反之则排名靠后,从而帮助你快速找到你最想要的数据。

下面,我们来向 Elasticsearch 插入一些索引数据:

#删除已有索引
DELETE /my-index-000001

#创建索引,显示在 settings 中指定2个 shard:"number_of_shards": "2"
PUT /my-index-000001
{
  "settings": {
    "number_of_shards": "2",
    "number_of_replicas": "1"
  },
  "mappings": {
    "properties": {
      "title": {
        "type": "text"
      },
      "date": {
        "type": "date"
      },
      "content": {
        "type": "text"
      }
    }
  }
}

#插入记录
PUT /my-index-000001/_doc/1
{
  "title":   "三国志",
  "date":    "2021-05-01",
  "content": "国别体史书"
}

PUT /my-index-000001/_doc/2
{
  "title":   "红楼梦",
  "date":    "2021-05-02",
  "content": "黛玉葬花..."
}

PUT /my-index-000001/_doc/3
{
  "title":   "易中天品三国",
  "date":    "2021-05-03",
  "content": "草船借箭、空城计..."
}

PUT /my-index-000001/_doc/4
{
  "title":   "水浒传",
  "date":    "2021-05-03",
  "content": "梁山好汉被团灭..."
}

PUT /my-index-000001/_doc/5
{
  "title":   "三国演义",
  "date":    "2021-05-03",
  "content": "三国时代,群雄逐鹿..."
}

接下去,我们采用关键词“三国演义”进行搜索:

GET /my-index-000001/_search
{
  "query": {
    "query_string": {
      "query": "三国演义"
    }
  }
}

查看一下返回记录的排序以及打分情况:

{
  "took" : 4,
  "timed_out" : false,
  "_shards" : {
    "total" : 2,
    "successful" : 2,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 3,
      "relation" : "eq"
    },
    "max_score" : 3.1212955,
    "hits" : [
      {
        "_index" : "my-index-000001",
        "_type" : "_doc",
        "_id" : "5",
        "_score" : 3.1212955,
        "_source" : {
          "title" : "三国演义",
          "date" : "2021-05-03",
          "content" : "三国时代,群雄逐鹿..."
        }
      },
      {
        "_index" : "my-index-000001",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.7946176,
        "_source" : {
          "title" : "三国志",
          "date" : "2021-05-01",
          "content" : "国别体史书"
        }
      },
      {
        "_index" : "my-index-000001",
        "_type" : "_doc",
        "_id" : "3",
        "_score" : 0.59221494,
        "_source" : {
          "title" : "易中天品三国",
          "date" : "2021-05-03",
          "content" : "草船借箭、空城计..."
        }
      }
    ]
  }
}

可以看到 title 为“三国演义”的文档排在第一,分数( _score )是3.1212955,其次是”三国志”,分数是0.7946176,最后是“易中天品三国”,分数是0.59221494,其余没有匹配的文档则没有出现,该搜索结果即打分排名基本符合预期。“三国志” 的得分较 ”易中天品三国” 的原因是因为 “三国志” 词语较短。

Elasticsearch 搜索的打分机制

众所周知,Elasticsearch 是以 Lucene 作为其搜索引擎技术的核心基石的。为了适应大数据时代的搜索需求,Elasticsearch 对 Lucene 最大的增强在于,将原本的单机搜索能力扩展到了分布式的集群规模能力,即将原本单机无法支撑的索引数据,水平切分成多个可以独立部署在不同机器上的 Shard,每个 Shard 由独立的 Lucene 实例提供服务,从而以集群的形式对外提供搜索服务。

因此,为了便于理解 Elasticsearch 的分布式搜索的打分机制,我们先来简单回顾下单机情况下 Lucene 是如何打分的。

Lucene 打分机制

当我们向 Lucene 某个索引提交搜索请求后,Lucene 会基于查询完成匹配,并得到一个文档结果集,然后默认基于以下的评分公式,来对结果集中的每个条目计算相关度(评分公式可以基于配置调整)。

其中 q 表示查询,d 表示当前文档,t 表示 q 中的词条,tf(t in d) 是计算词条 t 在文档 d 中的词频,idf(t) 是词条t在整个索引中的逆文档频率。

我们介绍一下最关键的两个概念,即词频( TF )和逆文档频率( IDF )。

词频( TF ):词条在文档中出现的次数

基于特定的 q 和文档 d 来说,词条 t 代指 q 分词后的其中一个词条,t 的词频指该 t 词条在文档 d 中的出现次数,出现次数越多,表示该文档相对于该词关联度更高。

逆文档频率(IDF ):在同一索引中存在该词条的文档数的倒数

包含某个词条的文档数越多,说明这个词条的词频在整个索引中的影响力越弱。

对于该公式的其他各项的含义,本小节不作深入介绍,我们仅需了解,一旦给定查询 q 和文档 d,其得分即为查询中每个词条 t 的得分总和。

而每个词条的得分,一个主要部分是该词条在文档 d 中的词频 ( TF ) 乘以逆文档频率 ( IDF ) 的平方。即词条在文档中出现的频率越高,则得分越高。而索引中存在该词条的文档越少,逆文档频率则越高,表示该词条越罕见,那么对应的分数也将越高。

回顾完 Lucene 的打分机制,我们再回过来看下 Elasticsearch 的搜索及打分机制。

Elasticsearch 打分机制

Elasticsearch 的搜索类型有两种,默认的称为 QUERY_THEN_FETCH。顾名思义,它的搜索流程分为两个阶段,分别称之为 Query 和 Fetch 。

我们来看下 QUERY_THEN_FETCH 的流程:

Query 阶段:

  1. Elasticsearch 在收到客户端搜索请求后,会由协调节点将请求分发到对应索引的每个 Shard 上。
  2. 每个 Shard 的 Lucene 实例基于本地 Shard 内的 TF/IDF 统计信息,独立完成 Shard 内的索引匹配和打分(基于上述公式),并根据打分结果完成单个 Shard 内的排序、分页。
  3. 每个 Shard 将排序分页后的结果集的元数据(文档 ID 和分数,不包含具体的文档内容)返回给协调节点。
  4. 协调节点完成整体的汇总、排序以及分页,筛选出最终确认返回的搜索结果。

Fetch 阶段:

  1. 协调节点根据筛选结果去对应 shard 拉取完整的文档数据
  2. 整合最终的结果返回给用户客户端

分布式打分的权衡

我们再来看一个场景,先重建索引,但是我们将 Shard 建成 3:

#删除已有索引
DELETE /my-index-000001

#创建索引,显示在 settings 中指定3个 shard:"number_of_shards": "3"
PUT /my-index-000001
{
  "settings": {
    "number_of_shards": "3",
    "number_of_replicas": "1"
  },
  "mappings": {
    "properties": {
      "title": {
        "type": "text"
      },
      "date": {
        "type": "date"
      },
      "content": {
        "type": "text"
      }
    }
  }
}

#插入记录
PUT /my-index-000001/_doc/1
{
  "title":   "三国志",
  "date":    "2021-05-01",
  "content": "国别体史书"
}

PUT /my-index-000001/_doc/2
{
  "title":   "红楼梦",
  "date":    "2021-05-02",
  "content": "黛玉葬花..."
}

PUT /my-index-000001/_doc/3
{
  "title":   "易中天品三国",
  "date":    "2021-05-03",
  "content": "草船借箭、空城计..."
}

PUT /my-index-000001/_doc/4
{
  "title":   "水浒传",
  "date":    "2021-05-03",
  "content": "梁山好汉被团灭..."
}

PUT /my-index-000001/_doc/5
{
  "title":   "三国演义",
  "date":    "2021-05-03",
  "content": "三国时代,群雄逐鹿..."
}

然后再次执行相同的搜索:

GET /my-index-000001/_search
{
  "query": {
    "query_string": {
      "query": "三国演义"
    }
  }
}

查看本次搜索结果:

{
  "took" : 6,
  "timed_out" : false,
  "_shards" : {
    "total" : 3,
    "successful" : 3,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 3,
      "relation" : "eq"
    },
    "max_score" : 1.6285465,
    "hits" : [
      {
        "_index" : "my-index-000001",
        "_type" : "_doc",
        "_id" : "3",
        "_score" : 1.6285465,
        "_source" : {
          "title" : "易中天品三国",
          "date" : "2021-05-03",
          "content" : "草船借箭、空城计..."
        }
      },
      {
        "_index" : "my-index-000001",
        "_type" : "_doc",
        "_id" : "5",
        "_score" : 1.1507283,
        "_source" : {
          "title" : "三国演义",
          "date" : "2021-05-03",
          "content" : "三国时代,群雄逐鹿..."
        }
      },
      {
        "_index" : "my-index-000001",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.5753642,
        "_source" : {
          "title" : "三国志",
          "date" : "2021-05-01",
          "content" : "国别体史书"
        }
      }
    ]
  }
}

搜索结果的排名竟然发生了变化,我们期望排第一的”三国演义”排到了第二,得分为1.1507283,而“易中天品三国”竟然得分1.6285465,跃居第一,这并不符合我们的搜索预期。

通过分析上面的 QUERY_THEN_FETCH 流程,我们不难发现:由于分布式系统天然的割裂性质,每个 shard 无法看到全局的统计信息,所以上述第 2 步中每个 Shard 的打分都是基于本地 Shard 内的 TF/IDF 统计信息来完成的。

在大多数的生产环境中,由于数据量多且在每个 Shard 分布均匀,这种方式是没有问题的。但是在极端情况下(如上例),3 个 shard 中的文档数相差较大,那么 IDF 在 3 个 Shard 中所起到的影响将截然不同,即单个 Shard 内打分汇总后的结果,与全局打分汇总的结果会有相当大的出入,造成我们在靠前的分页,搜到原本应该排名靠后的文档。

这也是分布式打分引入的实际问题,那么如何才能解决这类问题呢?

我们曾在上一小节提到,Elasticsearch 的搜索类型其实有两种,除了上面介绍的 QUERY_THEN_FETCH 之外,还有一种是 DFS_QUERY_THEN_FETCH。

DFS 在这里的意思是分布式频率打分,其思想是提前向所有 Shard 进行全局的统计信息搜集,然后再将这些统计信息,随着查询分发到各个 Shard,让各个 Shard 在本地采用全局 TF/IDF 来打分,具体的流程如下:

预统计阶段:

  1. Elasticsearch 在收到客户端搜索请求后,会由协调节点进行一次预统计工作,即先向所有相关 Shard 搜集统计信息

Query 阶段:

  1. 由协调节点整合所有统计信息,将全局的统计信息连同请求一起分发到对应索引的每个 Shard 上。
  2. 每个 Shard 的 Lucene 实例,基于全局的 TF/IDF 统计信息,独立完成 Shard 内的索引匹配和打分(基于上述公式),并根据打分结果,完成单个 Shard 内的排序、分页。
  3. 每个 Shard 将排序分页后的结果集的元数据(文档 ID 和分数,不包含具体的文档内容)返回给协调节点。
  4. 协调节点完成整体的汇总、排序以及分页,筛选出最终确认返回的搜索结果。

Fetch 阶段:

  1. 协调节点根据筛选结果去对应 shard 拉取完整的文档数据
  2. 整合最终的结果返回给用户客户端

综上可见,Elasticsearch 在分布式打分上做了权衡,如果要考虑绝对的精确性,那么需要牺牲一些性能来换取全局的统计信息。

让我们来看下如何切换到 DFS_QUERY_THEN_FETCH,只需在接口 URL 加上search_type=dfs_query_then_fetch

GET /my-index-000001/_search?search_type=dfs_query_then_fetch
{
  "query": {
    "query_string": {
      "query": "三国演义"
    }
  }
}

可以看到,通过这种方式返回的结果又恢复了正常:

{
  "took" : 9,
  "timed_out" : false,
  "_shards" : {
    "total" : 3,
    "successful" : 3,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 3,
      "relation" : "eq"
    },
    "max_score" : 3.7694218,
    "hits" : [
      {
        "_index" : "my-index-000001",
        "_type" : "_doc",
        "_id" : "5",
        "_score" : 3.7694218,
        "_source" : {
          "title" : "三国演义",
          "date" : "2021-05-03",
          "content" : "三国时代,群雄逐鹿..."
        }
      },
      {
        "_index" : "my-index-000001",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.1795839,
        "_source" : {
          "title" : "三国志",
          "date" : "2021-05-01",
          "content" : "国别体史书"
        }
      },
      {
        "_index" : "my-index-000001",
        "_type" : "_doc",
        "_id" : "3",
        "_score" : 0.8715688,
        "_source" : {
          "title" : "易中天品三国",
          "date" : "2021-05-03",
          "content" : "草船借箭、空城计..."
        }
      }
    ]
  }
}

”三国演义“的文档仍排在第一,分数( _score )变成了 3.7694218,其次是”三国志”,分数是1.1795839,最后是”易中天品三国“,分数是0.8715688,其余没有匹配的文档同样没有出现。

另外,根据返回的 took 数据,可以看到耗时较 query_then_fetch
的方式有略为增加,所以这种方式对性能会有折损,在生产环境中建议谨慎使用。

查看得分逻辑

为了在实际开发中了解得分逻辑,从而优化我们的查询条件或索引工作,我们需要关注例如“易中天品三国”为什么分数是 0.8715688,而不是 3.7694218。

我们可以通过在查询中增加 explain 来查看得分的说明信息。

GET /my-index-000001/_search?search_type=dfs_query_then_fetch
{
  "query": {
    "query_string": {
      "query": "三国演义"
    }
  },
  "explain": true
}

通过增加 "explain": true,我们可以看到返回的结果集里增加了大量 _explanation 信息:

{
  "took" : 21,
  "timed_out" : false,
  "_shards" : {
    "total" : 3,
    "successful" : 3,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 3,
      "relation" : "eq"
    },
    "max_score" : 3.7694218,
    "hits" : [
      {
        "_shard" : "[my-index-000001][0]",
        "_node" : "ydZx8i8HQBe69T4vbYm30g",
        "_index" : "my-index-000001",
        "_type" : "_doc",
        "_id" : "5",
        "_score" : 3.7694218,
        "_source" : {
          "title" : "三国演义",
          "date" : "2021-05-03",
          "content" : "三国时代,群雄逐鹿..."
        },
        "_explanation" : {
          "value" : 3.7694218,
          "description" : "max of:",
          "details" : [
            {
              "value" : 3.7694218,
              "description" : "sum of:",
              "details" : [
                {
                  "value" : 0.52763593,
                  "description" : "weight(title:三 in 0) [PerFieldSimilarity], result of:",
                  "details" : [
                    {
                      "value" : 0.52763593,
                      "description" : "score(freq=1.0), computed as boost * idf * tf from:",
                      "details" : [
                        {
                          "value" : 2.2,
                          "description" : "boost",
                          "details" : [ ]
                        },
                        {
                          "value" : 0.5389965,
                          "description" : "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:",
                          "details" : [
                            {
                              "value" : 3,
                              "description" : "n, number of documents containing term",
                              "details" : [ ]
                            },
                            {
                              "value" : 5,
                              "description" : "N, total number of documents with field",
                              "details" : [ ]
                            }
                          ]
                        },
                        {
                          "value" : 0.4449649,
                          "description" : "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:",
                          "details" : [
                            {
                              "value" : 1.0,
                              "description" : "freq, occurrences of term within document",
                              "details" : [ ]
                            },
                            {
                              "value" : 1.2,
                              "description" : "k1, term saturation parameter",
                              "details" : [ ]
                            },
                            {
                              "value" : 0.75,
                              "description" : "b, length normalization parameter",
                              "details" : [ ]
                            },
                            {
                              "value" : 4.0,
                              "description" : "dl, length of field",
                              "details" : [ ]
                            },
                            {
                              "value" : 3.8,
                              "description" : "avgdl, average length of field",
                              "details" : [ ]
                            }
                          ]
                        }
                      ]
                    }
                  ]
                },
                
                {
                  "value" : 1.357075,
                  "description" : "weight(title:演 in 0) [PerFieldSimilarity], result of:",
                  "details" : [
                    {
                      "value" : 1.357075,
                      "description" : "score(freq=1.0), computed as boost * idf * tf from:",
                      "details" : [
                        {
                          "value" : 2.2,
                          "description" : "boost",
                          "details" : [ ]
                        },
                        {
                          "value" : 1.3862944,
                          "description" : "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:",
                          "details" : [
                            {
                              "value" : 1,
                              "description" : "n, number of documents containing term",
                              "details" : [ ]
                            },
                            {
                              "value" : 5,
                              "description" : "N, total number of documents with field",
                              "details" : [ ]
                            }
                          ]
                        },
                        {
                          "value" : 0.4449649,
                          "description" : "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:",
                          "details" : [
                            {
                              "value" : 1.0,
                              "description" : "freq, occurrences of term within document",
                              "details" : [ ]
                            },
                            {
                              "value" : 1.2,
                              "description" : "k1, term saturation parameter",
                              "details" : [ ]
                            },
                            {
                              "value" : 0.75,
                              "description" : "b, length normalization parameter",
                              "details" : [ ]
                            },
                            {
                              "value" : 4.0,
                              "description" : "dl, length of field",
                              "details" : [ ]
                            },
                            {
                              "value" : 3.8,
                              "description" : "avgdl, average length of field",
                              "details" : [ ]
                            }
                          ]
                        }
                      ]
                    }
                  ]
                },
                ...
              ]
            },
            ...
          ]
        }
      },
     ...
    ]
  }
}

通过分析 description 和 details 中信息的描述,我们可以进一步深挖 Elasticsearch 的打分逻辑和我们查询出来的每个文档的得分详情。

创作人简介:
赵震一,程序员,好奇技淫巧,关注大数据与分布式计算。
相关实践学习
使用阿里云Elasticsearch体验信息检索加速
通过创建登录阿里云Elasticsearch集群,使用DataWorks将MySQL数据同步至Elasticsearch,体验多条件检索效果,简单展示数据同步和信息检索加速的过程和操作。
ElasticSearch 入门精讲
ElasticSearch是一个开源的、基于Lucene的、分布式、高扩展、高实时的搜索与数据分析引擎。根据DB-Engines的排名显示,Elasticsearch是最受欢迎的企业搜索引擎,其次是Apache Solr(也是基于Lucene)。 ElasticSearch的实现原理主要分为以下几个步骤: 用户将数据提交到Elastic Search 数据库中 通过分词控制器去将对应的语句分词,将其权重和分词结果一并存入数据 当用户搜索数据时候,再根据权重将结果排名、打分 将返回结果呈现给用户 Elasticsearch可以用于搜索各种文档。它提供可扩展的搜索,具有接近实时的搜索,并支持多租户。
相关文章
|
18天前
|
存储 分布式计算 大数据
HBase分布式数据库关键技术与实战:面试经验与必备知识点解析
【4月更文挑战第9天】本文深入剖析了HBase的核心技术,包括数据模型、分布式架构、访问模式和一致性保证,并探讨了其实战应用,如大规模数据存储、实时数据分析及与Hadoop、Spark集成。同时,分享了面试经验,对比了HBase与其他数据库的差异,提出了应对挑战的解决方案,展望了HBase的未来趋势。通过Java API代码示例,帮助读者巩固理解。全面了解和掌握HBase,能为面试和实际工作中的大数据处理提供坚实基础。
61 3
|
18天前
|
消息中间件 RocketMQ 微服务
RocketMQ 分布式事务消息实战指南
RocketMQ 分布式事务消息实战指南
368 1
|
16天前
|
消息中间件 分布式计算 中间件
秀出天际!阿里甩出的988页分布式微服务架构进阶神仙手册我粉了
秀出天际!阿里甩出的988页分布式微服务架构进阶神仙手册我粉了
|
18天前
|
Java 数据库连接 API
分布式事物【XA强一致性分布式事务实战、Seata提供XA模式实现分布式事务】(五)-全面详解(学习总结---从入门到深化)
分布式事物【XA强一致性分布式事务实战、Seata提供XA模式实现分布式事务】(五)-全面详解(学习总结---从入门到深化)
71 0
|
18天前
|
开发框架 Java 数据库连接
分布式事物【XA强一致性分布式事务实战、Seata提供XA模式实现分布式事务】(五)-全面详解(学习总结---从入门到深化)(下)
分布式事物【XA强一致性分布式事务实战、Seata提供XA模式实现分布式事务】(五)-全面详解(学习总结---从入门到深化)
48 0
|
18天前
|
数据库 微服务
分布式事物【XA强一致性分布式事务实战、Seata提供XA模式实现分布式事务】(五)-全面详解(学习总结---从入门到深化)(上)
分布式事物【XA强一致性分布式事务实战、Seata提供XA模式实现分布式事务】(五)-全面详解(学习总结---从入门到深化)
46 0
|
18天前
|
监控 NoSQL 算法
探秘Redis分布式锁:实战与注意事项
本文介绍了Redis分区容错中的分布式锁概念,包括利用Watch实现乐观锁和使用setnx防止库存超卖。乐观锁通过Watch命令监控键值变化,在事务中执行修改,若键值被改变则事务失败。Java代码示例展示了具体实现。setnx命令用于库存操作,确保无超卖,通过设置锁并检查库存来更新。文章还讨论了分布式锁存在的问题,如客户端阻塞、时钟漂移和单点故障,并提出了RedLock算法来提高可靠性。Redisson作为生产环境的分布式锁实现,提供了可重入锁、读写锁等高级功能。最后,文章对比了Redis、Zookeeper和etcd的分布式锁特性。
183 16
探秘Redis分布式锁:实战与注意事项
|
18天前
|
缓存 应用服务中间件 数据库
【分布式技术专题】「缓存解决方案」一文带领你好好认识一下企业级别的缓存技术解决方案的运作原理和开发实战(多级缓存设计分析)
【分布式技术专题】「缓存解决方案」一文带领你好好认识一下企业级别的缓存技术解决方案的运作原理和开发实战(多级缓存设计分析)
58 1
|
18天前
|
存储 缓存 监控
【分布式技术专题】「缓存解决方案」一文带领你好好认识一下企业级别的缓存技术解决方案的运作原理和开发实战(场景问题分析+性能影响因素)
【分布式技术专题】「缓存解决方案」一文带领你好好认识一下企业级别的缓存技术解决方案的运作原理和开发实战(场景问题分析+性能影响因素)
49 0
|
18天前
|
缓存 监控 负载均衡
【分布式技术专题】「缓存解决方案」一文带领你好好认识一下企业级别的缓存技术解决方案的运作原理和开发实战(数据缓存不一致分析)
【分布式技术专题】「缓存解决方案」一文带领你好好认识一下企业级别的缓存技术解决方案的运作原理和开发实战(数据缓存不一致分析)
49 2