数据建模
关联关系处理

在关系型数据库中,可以通过主外键进行关联关系的处理,并通过ACID事务功能保证数据的一致性。
但es不支持主外键,并且只支持ACIDic事务。
数据间的关联关系可以通过如下方式处理:
以user和blogs为例:
user{id,name,age…};blogs{user_id,content,post_time}
- 应用层联结
就是客户端程序自己写代码处理关联的数据。
当有根据user的name查询user的blogs需求时,就先查user,获取到user的id,再根据user_id查blogs
- 非规范化数据(冗余存储)
即blogs中添加user_name字段,这种适用于根据name查询blogs这种需求非常常见的时候。
不好的一点是,当某user更新自己name时,应用程序同时也要对blogs表中的所有user_name字段进行更新,但是这并不会耗很大性能
- 并发控制
es不能保证多个文档更新时的ACID事务一致性,只能保证单个文档更新时的ACID事务一致性
通过并发控制来实现对有关联关系的文档的更新时的事务一致性。
并发控制的方式有全局锁,文档锁,树锁

并发控制
  • 全局锁:是对整个索引库上锁。可以完全避免并发问题。全局锁可以保证在某一时间点上只允许一个进程来进行变更动作。因为大多数的变更实际上只涉及少量的文档,所以不会造成长时间的阻塞。
    全局锁的获取实际上是创建一个文档,这个文档是创建在要加锁的index下的自带的type:lock类型下。在进行相关联的多个文档的更新前,先如此地创建一个文档,如果文档创建成功,则获取到了锁,在更新后,再删除这个文档,释放锁。如果文档创建失败(个人猜测,内部机制是,当在lock类型下创建文档时,内部会先检查lock类型下的文档数目_count>=1?,是的话则说明其它进程已经占用了锁,则直接返回创建失败),则需等待重试
PUT /fs_idx/lock/global/_create        #创建并获取一个全局锁
DELETE /fs_idx/lock/global             #释放全局锁

但是也有可能遇到较长时间的阻塞,而对系统性能造成大幅度的限制。可以通过更细粒度的锁来减少阻塞。

  • 文档锁:
    只对要更新的文档体加锁。
    同样实现方式是,针对每一个要进行更新的文档,在lock类型下_create创建相应的加锁文档。如果创建成功,则获取了要更新的文档的锁,可以继续对文档进行更新。最后DELETE掉之前创建的锁文档,释放锁。
    其_create创建锁文档加锁的内部实现是:检测要加锁的文档是否在lock类型下已经存在一个锁文档,如果已经存在,则说明要加锁的文档正被其它进程占用,则会返回锁文件创建失败,加锁失败。
    首先通过scroll search(游标)获取到所有要更新的文档的id,然后通过批量_bulk对每个文档创建一个锁文档,即批量获取锁。如果获得了所有文档的锁,则进行批量更新。
POST /my_index/_refresh

GET  /my_index/lock/_search?scorll=1m    # 获取要更新的文档所属类型的锁
{
    "sort" : ["_doc"],
    "query" : {
        "match" :{ "process_id": 123 }
    }
}

GET /my_index/lock/_bulk       #创建锁
{"create":{"_id":1}}           #_id为要加锁的文档的id
{"process_id":123}             #process_id用来唯一标识要加锁的文档所在的类型
{"create":{"_id":1}}
{"process_id":123}

#更新文档
PUT /my_index/_bulk
{"index":{"_type":"user","_id":1}}
{"name":"newname"}
{"index":{"_type":"blogs","_id":111}}
{"user_name":"newname"}

#释放锁
PUT /my_index/lock/_bulk
{"delete":{"_id":1}}        #_id为锁文档的id
{"delete":{"_id":2}}
但只要有一个创建锁的操作失败,就必需重试锁的获取操作。但是此时已经有一些文档被加了锁,所以如果用原来的方式获取锁,则一定会再次出现失败,这时需要用脚本来处理。
if ( ctx._source.process_id != process_id ) { #process_id为传递的参数
  assert false;     #assert false 将引发异常,导致更新失败。
}
ctx.op = 'noop';     #将 op 从 update 更新到 noop 防止更新请求作出任何改变,但仍返回成功
  • 树锁(”lock_type” : “execlusive”)
    在文件目录文档中,可以通过锁定目录树的一部分,而不是每个文档,来实现并发控制
    详参:解决并发问题
嵌套对象

通过加锁控制并发,来支持有关联关系的文档更新时的事务一致性,也有一些缺点。比如发生死锁时如何检测和处理?另外当非规范化成为很多项目的很好的选择,那么采用锁方案的需求会带来复杂的实现逻辑。
es提供了两个模型来作为替代方案:嵌套对象和父子关系

  • 嵌套对象是将有关联关系的数据存储在一个文档中,因为ES支持单个文档的ACIDic,如此则更新时不会出现不一致及并发问题。
  • 嵌套对象作为根对象的一个域,域类型为:nested。
    比如,订单与订单明细:通常情况下会将订单明细作为订单的一个属性,类型设计为对象数组。
    但是因为在lucene中没有对象数据的概念,因此lucene会将对象数组扁平化存储。
    details:[{“name”:”lily”,”age”:18},{“name”:”jhon”,”age”:19}],会被存储为:
    { “details.name” : [ “lily” , “jhon” ] , “details.age” : [ 18 , 19 ] }
    如此便失去了lily与18的联系。当被索引后,被查询时。
    因此es设计了嵌套对象nested类型,这种类型的数据中的每一个对象会作为一个独立文档存储,与根对象平级存储,但是对外是隐藏的。
{"order_time":"2018-01-01"...},                     #根对象
{  "details.name" :  "lily"  , "details.age" : 18  },       
{  "details.name" :  "jhon"  , "details.age" : 19 }
  • 嵌套对象的映射格式
{ "mappings" : { "order" : { "properties" : { 
    "order_time" : { "type" : "string" },
    "details" : {             #订单明细
        "type" : "nested" ,
        "properties"  :  {
            "product_name" : { "type" : "string" },
            "price" : { "type" : "double" }
        }
    } } }  }
扩容设计

大多数项目都是起步于一个小规模,而非初始数据量即达到PB级别。因此在项目最开始的很长一段时间里使用ES的默认配置即可。
由小规模集群增长为大规模集群的过程几乎完全自动化并且无痛。由大规模集群增长为超大规模集群需要一些规划和设计,但还是相对地无痛。

  • 横向扩容(增加分片数、增加副本数)
    横向扩容应当分阶段进行,为下一阶段准备好足够的资源。只有当你的数据和请求量进入到了下一个阶段,你才有时间思考需要作出哪些改变来达到这个阶段。
    增加副本数,并不能增加索引容量。副本是故障转移、提高集群对查询请求的处理能力和速度
  • 纵向扩容:增加节点数
时序数据 & 基于用户的数据

虽然Elasticsearch 的默认设置会伴你走过很长的一段路,但为了发挥它最大的效用,需要考虑数据是如何流经系统的。 以下为两种常见得数据流

  • 时序数据(时间驱动相关性,例如日志或社交网络数据流),
  • 基于用户的数据(拥有很大的文档集但可以按用户或客户细分)
扩容的单元:分片

应用程序的请求只同es的索引进行交互,es的一个索引可以由多个分片组成,每个es的分片实际是一个lucene引擎。
对于单节点单分片零副本的索引,要扩容,一是增加分片数(手动重新索引),二是增加副本数(自动)

别名方式扩容

索引的别名有

指定过滤和..的路由

有一种情况:多个小论坛的数据(forumA,forumB…),字段基本一致,但是每个论坛的数据量均非常小。
初期,完全可以将这些数据全部索引到同一个索引库中。但让这些数据有规则地落到同一个分片上,方式如下:
- 设置一个forum_name字段(论坛名称)
- 为每个论坛设置一个索引别名,在别名中指定过滤和路由规则

PUT /alias/a_forumA/
{
    "routing" : {  }
    "query" : {
        "filter": { "forum_name" : "forumA"}
    }
}

当出现某个论坛forumA的用户增长频率较快,数据量骤增时,可以用如下方式解决:
- 指定routing=forumA,让其所有数据

容量规划:如何预分配分片数

新手小白或许会这样想:我不知道这个索引的数据量将来会增长到多大,而索引的分片数又不能更改,因此为了保险起见,将分片数设置为1000…

一个分片并不是没有代价的:

  • 一个分片即为一个lucene引擎,会消耗一定数量的CPU、内存、文件句柄
  • 每一个搜索请求都需要命中索引中的所有分片,如果一个索引又多个分片在同一个节点上,那么就会出现这多个分片竞争使用同一个节点的资源,造成查询性能低
  • 评分查询的评分的词项统计是基于分片的,如果数据量小,但分片过多,会造成很低的相关度

那么,在进入某阶段后,如何确定本阶段的预分配的分片数呢?

  • 首先要确定单个分片的容量(所能处理的用户请求量)
    在单节点一个分片零副本的环境下,模拟生产环境的请求,包括索引实际的数据、和运行生产环境下的查询、聚合请求。
    即复制生产环境下的索引的配置,并将生产环境下的客户端请求全部压缩到单个分片上,直到它挂掉(比如:响应时间超过50ms)时,的用户并发请求量
  • 当前数据及请求总量加上一部分的预期的增长/单个分片的容量,即得需要的分片数

当出现timeout等问题时,扩容并不应作为你的第一步:

  • 先检查有没有低效查询(比如模糊查询)、内存不足、或则开启了swap?
  • 优化你的查询,比如将本不需评分的bool查询,改为flilter。
指定高性能机器:热点问题

社交网站的数据总是对最近时间点的数据的查询最频繁,形成热点。
可以用以下步骤提高性能:

  • 创建别名idx_current,类别为search,指向最近一个月或者最近三个月的所有索引
  • 配置一台或者数台高性能机器,将idx_current所包含的索引的分片转移到这些机器上