【大数据高并发核心场景实战】 数据持久化层 - 查询分离

上一章中我们介绍到冷热分离,旨在快速交付。但是他仍存在一些问题,并不是完美的方案,比如限制了业务的操作,必须再特定的业务场景下(冷数据不允许修改、冷数据查询慢、不适合复杂查询)。本章将介绍新的方案,支持千万数据的快速查询。

1. 业务场景

适用场景

  1. 数据查询缓慢(数据量大导致、数据聚合时调用外部系统过多导致等)
  2. 写数据效率尚可
  3. 所有数据都可能修改(若存在冷数据,可使用上一章的冷热分离方案)

基本思路:将更新的数据放在主数据库里,而查询的数据放在另外一个专门针对搜索的存储系统里。主库单表查询,无关联无外键,所以写数据无压力。数据查询通过一个专门处理大数据量的查询引擎来解决。

这里有同学可能会提到数据库读写分离,这种情况下在千万级别数据量下的速度提升并不大,并且只能解决数据库查询慢的问题,不能解决其他如查询详情时调用外部系统耗时长导致的查询慢问题。

核心问题

  1. 如何触发查询分离?
  2. 如何实现查询分离?
  3. 查询数据如何存储
  4. 查询数据如何使用
  5. 历史数据如何迁移

2. 查询分离

2.1 如何触发查询分离

1)修改业务代码,写入同时同步更新查询数据

同步更新示意图
图2-1: 同步更新查询数据示意图

2)修改业务代码,在写入常规数据后,异步更新查询数据

异步更新示意图
图2-2: 异步更新查询数据示意图

3)监控数据库日志,如有数据变更,则更新查询数据

优点是不会影响业务代码。

监控日志更新示意图
图2-3: 监控数据库日志更新查询数据示意图

优缺点对比

优缺点对比表
图2-4: 三种触发方式优缺点对比表

针对优缺点总结适用场景

适用场景总结
图2-5: 三种方法适用场景总结

2.2 如何实现查询分离

这里以方法二,业务代码异步更新查询数据的方式为例讲解实现方式,这个方法需要考虑以下几个问题:

  1. 写操作较多且线程太多时,需要加以控制,否则太多线程最终会拖垮JVM
  2. 创建查询数据的线程出错时,如何自动重试?如何标识更新失败的数据?
  3. 多线程并发时,需要解决很多并发场景

针对以上问题,可以考虑使用MQ来解决:在短时间线程过多时,将任务暂存到MQ中间件进行削峰处理;业务失败时可自动重新发送消息重试。

MQ解决方案示意图
图2-6: MQ解决方案架构示意图

具体方案

  1. 写操作时,主数据表添加标识 NeedUpdateQueryData=true,MQ消息简单,只是一个信号来告知更新数据,不包含更新的数据ID(如果包含业务信息,就需要考虑更多的幂等和消息丢失等问题)
  2. 消费者获取信号后,先批量查询待更新的主数据,然后批量更新查询数据,更新完成后将查询数据的主数据标识 NeedUpdateQueryData 更新为 false
  3. 若存在多个消费者同时有迁移动作的情况,就涉及并发性问题,这与前一场景冷热分离中的并发性处理逻辑类似,这里不再赘述

消息的时序性问题

  • 生产者1 将数据A修改为A1,发送消息Q1
  • 生产者2 将数据A1修改为A2,发送消息Q2
  • 消费者1 收到Q1,查询数据为A1(此时消费者2收到Q2,将数据A2迁移到缓存),A1迁移到缓存

即消费者查询数据库数据后,在未迁移数据时被后触发的消费者线程更新了迁移了更新的数据,而后先消费的消费者会将后消费消费者的迁移更新掉,导致缓存本该后迁移记录丢失。

解决方法:消费者查询 NeedUpdateQueryData=true 数据的同时查询 lastUpdateTime 作为乐观锁字段进行更新。

2.3 查询数据如何存储

常用的两个中间件是 MongoDB 和 ES,选择取决于团队成员的技术结构。我们团队选择的是 ES。

特性维度 MongoDB Elasticsearch
数据模型 文档型数据库,类似JSON,结构灵活 搜索引擎,擅长处理非结构化文本数据
核心优势 高性能读写、灵活的数据模型、横向扩展 强大的全文检索、复杂查询和数据分析
查询场景 适合精确查询、范围查询、事务和聚合操作 适合模糊匹配、全文搜索、多条件复杂检索
写入性能 写入速度较快,支持高并发写入 写入吞吐量通常低于MongoDB,但近实时搜索(秒级)
读取性能 精确查询和聚合操作性能优秀 复杂搜索和全文检索性能卓越
事务支持 支持多文档ACID事务 不支持事务,保证最终一致性
资源消耗 磁盘占用通常更小(高压缩存储引擎) 磁盘和内存消耗相对较高
扩展性 支持分片集群,需手动配置 天生分布式,开箱即用,自动分片
管理维护 集群配置相对复杂,需要专业知识 管理相对简单,有完善的监控工具
适用场景 Web应用后端、用户画像、设备监控 搜索引擎、日志分析、实时监控
不适用场景 复杂的全文搜索需求 需要强事务一致性的场景
学习成本 中等,查询语法相对简单 较高,查询DSL较复杂
社区生态 成熟稳定,社区活跃 生态丰富,插件众多
成本考量 通常存储成本更低 资源消耗大,总体成本可能更高

2.4 查询数据如何使用

ES自带查询API,在业务代码中直接调用ES即可。这里涉及到一个场景:缓存和数据库数据不一致的问题

两种解决思路

  1. 在查询数据更新到最新前,不允许用户查询(在数据同步完成前,强制查询走主数据源如MySQL,而不是ES)
  2. 给用户提示"当前数据为2s前的数据,如发现数据不准确可尝试刷新",通常用户都能接受

2.5 历史数据迁移

当前方案中,只需要把所有历史数据加上标识 NeedUpdateQueryData=true,程序就会自动处理。

2.6 MQ+ES 整体方案

  1. 业务数据修改后,触发异步线程数据同步
  2. 触发异步方式使用MQ(解耦、削峰)
  3. 查询数据到ES(适合大数据量的复杂查询)
  4. 查询数据同步到ES会有一定延时,用户可能查询到旧数据,需给用户提示
  5. 历史数据迁移,只需把所有历史数据的标识改成true,系统会自动批量同步到ES
整体方案示意图
图2-7: MQ+ES整体架构方案示意图

这个整体方案看似简单,但有一些陷阱必须注意。下面着重介绍使用Elasticsearch时的注意事项。

3. ElasticSearch注意事项

Elasticsearch的使用要点:

  1. 如何使用Elasticsearch设计表结构?
  2. Elasticsearch的存储结构
  3. Elasticsearch如何修改表结构?
  4. Elasticsearch的准实时性
  5. Elasticsearch可能丢数据
  6. Elasticsearch分页

3.1 如何使用Elasticsearch设计表结构

Elasticsearch基于索引设计,无法像MySQL那样使用join查询,所以查询数据时需要把每条主数据及关联子表的数据全部整合在一条记录中。

下面以常见的订单业务类讲解如何设计ES表结构:

订单数据结构
图3-1: 订单业务数据结构示意图

虽然订单数据在关系型数据库中涉及多表,但使用Elasticsearch存储数据时不会设计多个表,而是将所有表的相关字段数据汇集在一个Document中,即一个完整的文档结构:

{
  "order_ID": "o2020103115214521",
  "order_invoice": {},
  "user": {
    "user_ID": "U1099",
    "user_name": "YiHuiComeOn"
  },
  "order_product_item": [
    {
      "product_name": "乒乓球拍",
      "product_count": 1,
      "product_price": 149
    },
    {
      "product_name": "纸巾",
      "product_count": 2,
      "product_price": 1.4
    }
  ],
  "total_amount": 20
}

习惯关系型数据库的同学可能会有疑惑:为什么汇聚到同一document中?为什么ES不需要关联查询?这就涉及到ES特殊的存储结构。

3.2 Elasticsearch的存储结构

3.2.1 Lucene和MySQL的概念对照

Lucene是一个索引系统,此处把Lucene与MySQL的一些概念做简单对照:

Lucene与MySQL概念对照
图3-2: Lucene与MySQL概念对照表

3.2.2 无结构文档的倒排索引

假设有一些无结构文档数据:

无结构文档
图3-3: 无结构文档示例

简单倒排索引后的结果:

简单倒排索引结果
图3-4: 无结构文档倒排索引结果

无结构的文档经过简单的倒排索引后,字典表主要存放关键字,而倒排表存放该关键字所在的文档ID。业务数据通常不是无结构的文档内容,而是有结构的数据,此时如何倒排索引呢?

3.2.3 有结构文档的倒排索引

更复杂的例子:每个Doc都有多个Field,Field有不同的值(包含不同的Term,Term是经过文本分析处理后不可再分割的最小单位)。

有结构文档示例
图3-5: 有结构文档示例

倒排表

  1. 性别倒排索引
性别倒排索引
图3-6: 性别字段倒排索引示例
  1. 年龄倒排索引
年龄倒排索引
图3-7: 年龄字段倒排索引示例
  1. 武功倒排索引
武功倒排索引
图3-8: 武功字段倒排索引示例

由此可见,有结构的文档经过倒排索引后,字段中的每个值都是一个关键字,存放在Term Dictionary中,且每个关键字都有对应地址指向所在文档。

3.2.4 ES的Document如何定义结构和字段格式

设计ES的Document结构时,不需要像MySQL那样关联表,而是把所有相关数据汇集在一个Document中。直接将3.1节中订单的JSON文档转成一个ES文档(SQL中的子表数据在Elasticsearch中以嵌入式对象格式存储):

{
  "mappings": {
    "doc": {
      "properties": {
        "order_ID": {
          "type": "text"
        },
        "order_invoice": {
          "type": "nested"
        },
        "order_product_item": {
          "type": "nested",
          "properties": {
            "product_name": {
              "type": "text"
            }
          }
        },
        "total_amount": {
          "type": "long"
        },
        "user": {
          "properties": {
            "user_ID": {
              "type": "text"
            },
            "user_name": {
              "type": "text"
            }
          }
        }
      }
    }
  }
}

至此,大家已经了解了Elasticsearch表结构的设计。在实际业务中,主数据修改表结构时,ES也要求修改文档结构,这时该怎么办?

3.3 Elasticsearch如何修改表结构

  • ES支持直接添加新字段
  • 因为修改字段的类型会导致索引失效,所以ES不支持修改原字段类型

Elasticsearch底层基于Lucene,Lucene的倒排索引一旦创建就是不可变的。就像印刷好的书籍,你不能直接修改某一页的排版,只能重新印刷一本。

  • 如果想修改字段的映射(表结构),需要新建一个索引,然后使用Elasticsearch的reindex功能将旧索引复制到新索引中
POST /_reindex
{
  "source": {"index": "products_old"},
  "dest": {"index": "products_new"}
}

reindex功能会使旧索引失效,直接重命名字段时可以使用alias索引功能

注意:通常不会直接删除旧字段,常用做法是新版本项目代码兼容旧数据,在项目稳定运行后,再考虑清理旧字段。

3.4 陷阱一:Elasticsearch是准实时的吗

当更新数据至Elasticsearch且返回成功提示时,通过Elasticsearch查询返回的数据可能不是最新的。

这个过程涉及Elasticsearch的Shard(分片),以及Lucene Index、Segment、Document三者之间的关系。

Elasticsearch的一个Shard就是一个Lucene Index,每一个Lucene Index由多个Segment构成。

分片(Shard)结构图

分片结构图
图3-9: Elasticsearch分片结构示意图

Index、Segment、Document三者之间的关系

三者关系图
图3-10: Index、Segment、Document关系图

数据索引的过程详解

  1. 当新的Document被创建时,数据首先会存放到新的Segment中,同时旧Document会被删除,并在原来的Segment上标记删除标识。当Document被更新时,旧版Document会被标识为删除,并将新版Document存放在新的Segment中

  2. Shard收到写请求时,请求会被写入Translog中,然后Document被存放在Memory Buffer中

写请求处理
图3-11: Elasticsearch写请求处理流程

注意:Memory Buffer 不会被查询到

  1. 每隔1秒(默认设置),Refresh操作被执行一次,Memory Buffer中的数据会被写入一个Segment,并存放在File System Cache中,这时新数据就可以被搜索到了
Refresh操作示意图
图3-12: Refresh操作数据刷新流程

通俗理解整个过程

名词解释

  • Document:ES中的基本数据单元,相当于一条记录
  • Segment:Lucene索引的基本单元,是不可变的
  • Memory Buffer:临时存储新文档的内存区域
  • Translog:记录所有写操作的日志文件
  • Refresh:将内存中的数据写入新Segment并使其可搜索的操作
  • File System Cache:操作系统级别的磁盘缓存

流程解释

  1. 新数据到达:先登记到Translog,再放到Memory Buffer
  2. 定期刷新:每1秒将Memory Buffer中的数据写入Segment,放到File System Cache
  3. 此时数据可被搜索

通过以上数据索引过程的说明,可以发现Elasticsearch并不是实时的,而是有1秒延时。解决方案是提示用户查询的数据会有一定延时。

3.5 陷阱二:Elasticsearch宕机恢复后,数据丢失

上一小节中提及每隔1秒Memory Buffer中数据会被刷到Segment中,此时数据可被用户搜索到,但没有持久化,一旦系统宕机,数据就会丢失。

如何防止数据丢失?使用Lucene中的Commit操作解决这个问题。

Commit操作方法:先将多个Segment合并保存到磁盘中,再进行持久化标记。

但commit有两个问题:

  1. 会占用IO资源,使得commit期间数据查询变慢
  2. 无法解决数据保存时,在translog写完还未写入文件系统缓存情况的数据丢失

translog持久化到磁盘需要执行fsync操作,具体实现方法有两种:

  1. index.translog.durability设置成request,缺点是耗费资源,性能差一些
  2. index.translog.durability设置为async,每隔index.translog.sync_interval时间执行一次fsync

配置建议

# 方案A:金融级安全(不能丢任何数据)
PUT /my_index/_settings
{
  "index.translog.durability": "request"
}

# 方案B:普通业务(可容忍少量数据丢失)
PUT /my_index/_settings
{
  "index.translog.durability": "async",
  "index.translog.sync_interval": "5s"
}

实践总结

根据业务需求选择策略

业务类型 推荐配置 解释
金融交易 durability: request 数据绝对不能丢失
电商订单 durability: async, sync_interval: 1s 可容忍极短时间延迟
日志分析 durability: async, sync_interval: 5s 丢几条日志没关系

记住:没有完美的方案,只有适合你业务需求的方案!

3.6 陷阱三:分页越深,查询效率越低

Elasticsearch的读操作流程主要分为两个阶段:Query Phase、Fetch Phase。

  1. Query Phase:协调节点先把请求分发到所有分片,每个分片在本地查询后建一个结果集队列,将Document ID以及搜索分数存放在队列中,再返回给协调节点,协调节点建全局队列,归并所有结果集并进行全局排序

Tips:在Elasticsearch查询过程中,如果search方法带有from和size参数,Elasticsearch集群需要给协调节点返回分片数×(from+size)条数据,然后在单机上进行排序,最后给客户端返回size大小的数据。比如客户端请求10条数据,有3个分片,那么每个分片会返回10条数据,协调节点最后会归并30条数据,但最终只返回10条数据给客户端。

Elasticsearch读操作示意图
图3-13: Elasticsearch读操作两阶段流程
  1. Fetch Phase:协调节点先根据结果集里的Document ID向所有分片获取完整的Document,然后所有分片返回完整的Document给协调节点,最后协调节点将结果返回给客户端

比如有5个分片,需要查询排序序号从10000到10010(from=10000,size=10)的结果,每个分片返回给协调节点计算的数据量是10010条。这是为了防止其他分片中没有数据,考虑最坏情况10010条数据都在自己分片上,进而把10010条数据全部给协调节点去聚合计算。

也就是说,协调节点需要在内存中计算10010×5=50050条记录,所以用户分页越深查询速度会越慢,分页并不是越多越好。

那如何更好地解决Elasticsearch分页问题呢?为了控制性能,可以使用Elasticsearch中的max_result_window进行配置,这个数据默认为10000,当from+size > max_result_window时,Elasticsearch将返回错误。

如果用户确实有深度翻页的需求,使用Elasticsearch中search_after的功能也能解决,只是无法实现跳页(这样分片可以利用游标条件过滤部分数据,从而减少数据计算的数量提升查询速度)。

举例,查询结果按照订单总金额分页,上一页最后一个订单的总金额total_amount是10,那么下一页的查询示例代码如下:

{
  "query": {
    "bool": {
      "must": [
        {
          "term": {
            "user.user_name.keyword": "YiHuiComeOn"
          }
        }
      ],
      "must_not": [],
      "should": []
    }
  },
  "from": 0,
  "size": 2,
  "search_after": ["10"],
  "sort": [
    {
      "total_amount": "asc"
    }
  ],
  "aggs": {}
}

至此,Elasticsearch的一些要点就介绍完了。MQ也有一些要点,比如确保时序、确保重试、确保消息重复消费不会影响业务,以及确保消息不丢失等,后续各章节会有相应的场景描述,这里就不再展开了。

4. 小结

查询分离这个解决方案虽然能解决一些问题,但也要认识到它的不足:

  1. 使用Elasticsearch存储查询数据时,要接受一些局限性:有一定延时,深度分页不能自由跳页,有丢数据的可能性
  2. 主数据量越来越大后,写操作还是慢,到时还是会出问题。比如工单数据,虽然已经去掉所有外键,但当数据量上亿时,插入还是会有问题
  3. 主数据和查询数据不一致时,如果业务逻辑需要查询数据保持一致性呢?查询数据同步到最新数据会有约2秒延时,某些业务场景下用户可能无法接受

架构"没有银弹",不能期望一个解决方案既能覆盖所有的问题,还能实现最小的成本损耗。

如果碰到一个场景不能接受上面某个或某些不足时,该怎么解决?接着看后面的章节。

posted @ 2025-10-29 11:45  yihuiComeOn  阅读(88)  评论(0)    收藏  举报