内容持续更新中…
在做商品库存扣减时,难免会出现库存并的情况,比如:
web-1对库存count的数据量做了修改之后,并没有办法告知web-2库存的真实情况,就导致了商品超卖的情况;
在数据库领域处理此问题通常有两种机制来保证:
悲观并发控制
:对并发的资源加锁控制,一个典型的例子是读取一行数据之前先将其锁住,确保只有拿到锁的线程能够对数据进行修改;乐观并发控制
:首先假设这种情况不可能发生,如果源数据在读写当中被修改,那么更新操作将会失败。应用程序接下来决定该如何解决冲突。例如:可以重试或将情况报告给用户。ES是如何来处理并发的呢?
ES是分布式的,当文档创建、更新或删除时,新版本的文档必须复制到其它节点。ES也是异步和并发的,这意味着复制请求被并行发送,并且到达目的地也是乱序的。ES需要一种保证文档的旧版本不会覆盖新的版本。
Es中每个文档都会有一个版本号的字段,该字段每当文档被修改时递增。ES使用这个_version号来确保变更得到顺序的执行。如果旧版本的文档在新版本文档之后到达,它可以被简单的忽略。
// 修改文档时可以指定version,如果当前的doc文档的version!=1时,修改将失败;
PUT /website/blog/1?version=1
{
"title": "My first blog entry",
"text": "Starting to get the hang of this..."
}
思考:如何保证每个分片内的版本号不会出现乱序呢?
这主要是因为ES的所有写操作是都是由主分片来完成的,因为这些操作是需要同步到其它的副本分片上去。所以实现了并发更新的统一入口,如果是任意一个节点都有更新能力,那可能会造成分片与分片之间的某些doc版本冲突的问题;
当创建一个文档的时候,文档会被存储到一个主分片中,ES如何知道一个文档应该存放到哪个分片呢?(假设我们有2个分片的情况下)
首先这肯定不是随机的,否则将来要获取文档的时候我们就无处可寻了。实际上,这个过程是根据下面这个公式计算的:
shard = hash(routing) % number_of_primary_shards
number_of_primary_shards:你指定索引的分片数;
routing:默认是文档的_id,也可以设置成一个自定义的值;
这也诠释了我们在创建索引的时候就需要确定好主分片的数量并且永远不会改变这个数量:如果数量变了,那么之前路由的值都会无效,文档再也找不到了。
所有的文档API( get 、 index 、 delete 、 bulk 、 update 以及 mget )都接受一个叫做routing的路由参数,通过这个参数我们可以自定义文档到分片的映射。
假设有一个集群由3个节点组成,它包含一个叫blogs的索引,有2个主分片,每个主分片有2个副本分片。相同分片的副本不会放在同一节点中;
我们可以发送请求到集群中的任意节点,每个节点都能力处理任意请求。每个节点都知道集群中任一文档的位置,所以可以直接将请求转发到需要的节点上面。比如:将请求发送到node1,我们可以将其称为“协调节点”;
注:当发送请求的时候, 为了扩展负载,更好的做法是轮询集群中所有的节点。
新建、索引和删除 请求都是“写”操作, 必须在主分片上面完成之后才能被复制到相关的副本分片;
以下是在主副分片和任何副本分片上面成功新建,索引和删除文档所需要的步骤顺序:
- 客户端向Node1发送新建、索引或者删除请求。
- 节点使用文档的 _id 确定文档属于分片 0 。请求会被转发到 Node 3,因为分片 0 的主分片目前被分配在 Node 3 上。
- Node 3 在主分片上面执行请求。如果成功了,它将请求并行转发到 Node 1 和 Node 2 的副本分片上。一旦所有的副本分片都报告成功, Node 3 将向协调节点报告成功,协调节点向客户端报告成功。
在客户端收到成功响应时,文档变更已经在主分片和所有副本分片执行完成,变更是安全的。
在上面这个过程中会用到两个参数(只做了解):
可以从主分片或者从其它任意副本分片检索文档;
以下是从主分片或者副本分片检索文档的步骤顺序:
1、客户端向 Node 1 发送获取请求。
2、节点使用文档的 _id 来确定文档属于分片 0 。分片 0 的副本分片存在于所有的三个节点上。 在这种情况下,它将请求转发到 Node 2 。
3、Node 2 将文档返回给 Node 1 ,然后将文档返回给客户端。
在处理读取请求时,协调结点在每次请求的时候都会通过轮询所有的副本分片来达到负载均衡。
ES更新文档的底层是标识旧文档为删除状态,然后新增一条文档;
以下是部分更新一个文档的步骤:
- 客户端向 Node 1 发送更新请求。
- 它将请求转发到主分片所在的 Node 3 。
- Node 3 从主分片检索文档,修改 _source 字段中的 JSON ,并且尝试重新索引主分片的文档。 如果文档已经被另一个进程修改,它会重试步骤 3 ,超过 retry_on_conflict 次后放弃。
- 如果 Node 3 成功地更新文档,它将新版本的文档并行转发到 Node 1 和 Node 2 上的副本分片,重新建立索引。 一旦所有副本分片都返回成功, Node 3 向协调节点也返回成功,协调节点向客户端返回成功。
基于文档的复制
当主分片把更改转发到副本分片时, 它不会转发更新请求。 相反,它转发完整文档的新版本。请记住,这些更改将会异步转发到副本分片,并且不能保证它们以发送它们相同的顺序到达。 如果Elasticsearch仅转发更改请求,则可能以错误的顺序应用更改,导致得到损坏的文档。
mget 和 bulk API 的模式类似于单文档模式。区别在于协调节点知道每个文档存在于哪个分片中。 它将整个多文档请求分解成 每个分片 的多文档请求,并且将这些请求并行转发到每个参与节点。
2.5.1 mget
以下是使用单个 mget 请求取回多个文档所需的步骤顺序:
- 客户端向 Node 1 发送 mget 请求。
- Node 1 为每个分片构建多文档获取请求,然后并行转发这些请求到托管在每个所需的主分片或者副本分片的节点上。一旦收到所有答复, Node 1 构建响应并将其返回给客户端。
2.5.2 bulk
bulk API 按如下步骤顺序执行:
- 客户端向 Node 1 发送 bulk 请求。
- Node 1 为每个节点创建一个批量请求,并将这些请求并行转发到每个包含主分片的节点主机。
- 主分片一个接一个按顺序执行每个操作。当每个操作成功时,主分片并行转发新文档(或删除)到副本分片,然后执行下一个操作。 一旦所有的副本分片报告所有操作成功,该节点将向协调节点报告成功,协调节点将这些响应收集整理并返回给客户端。
bulk API 为什么是这种奇怪的格式:
在批量请求中引用的每个文档可能属于不同的主分片, 每个文档可能被分配给集群中的任何节点。这意味着批量请求 bulk 中的每个 操作 都需要被转发到正确节点上的正确分片。
如果单个请求被包装在 JSON 数组中,那就意味着我们需要执行以下操作:
- 将 JSON 解析为数组(包括文档数据,可以非常大)
- 查看每个请求以确定应该去哪个分片
- 为每个分片创建一个请求数组
- 将这些数组序列化为内部传输格式
- 将请求发送到每个分片
这是可行的,但需要大量的 RAM 来存储原本相同的数据的副本,并将创建更多的数据结构,Java虚拟机(JVM)将不得不花费时间进行垃圾回收。
相反,Elasticsearch可以直接读取被网络缓冲区接收的原始数据。 它使用换行符字符来识别和解析小的 action/metadata 行来决定哪个分片应该处理每个请求。
这些原始请求会被直接转发到正确的分片。没有冗余的数据复制,没有浪费的数据结构。整个请求尽可能在最小的内存中处理。
一个CRUD操作只对单个文档进行处理,可以通过index/type/id来表示文档在集群中哪个分片上,但是,搜索需要一种更加复杂的执行模型因为我们不知道查询会命中哪些文档,这些文档有可能在集群的任何分片上。
找到所有的匹配文档仅仅完成事情的一半,在search接口返回一个page结果之前,多分片中的结果必须组合成单个排序列表。为此,搜索是包含两个过程,我们称为query then fetch(查询 获取);
在初始查询阶段时,查询会广播到索引中的每一个分片(主分片或者副本分片)。每个分片在本地执行搜索并构建一个匹配文档的“优先队列”(可以理解为存放结果的队列);
3.1.1 优化队列
一个优先队列仅仅是一个存放匹配结果的有序列表。队列的大小取决于分页参数from和size。比如:
GET /_search
{
“from”: 90, // es中的from表示:从第多少条之后开始匹配;
“size”: 10
}
那将会创建一个大小为100的队列;
- 客户端发送一个 search 请求到 Node 3 , Node 3 会创建一个大小为 from + size 的空优先队列。
- Node 3 将查询请求转发到索引的每个主分片或副本分片中。每个分片在本地执行查询并添加结果到大小为 from + size 的本地有序优先队列中。
- 每个分片返回各自优先队列中所有文档的 ID 和排序值给协调节点,也就是 Node 3 ,它合并这些值到自己的优先队列中来产生一个全局排序后的结果列表。
一个索引可以由一个或几个主分片组成, 所以一个针对单个索引的搜索请求需要能够把来自多个分片的结果组合起来。 针对 multiple 或者 all 索引的搜索工作方式也是完全一致的—仅仅是包含了更多的分片而已。
查询阶段标识哪些文档满足搜索请求,但是我们仍然需要取回这些文档。这是取回阶段的任务;
分布式阶段由以下步骤构成:
- 协调节点辨别出哪些文档需要被取回并向相关的分片提交多个 GET 请求。
- 每个分片加载并 丰富 文档,如果有需要的话,接着返回文档给协调节点。
- 一旦所有的文档都被取回了,协调节点返回结果给客户端。
3.2.1 深分布问题
先查后取的过程支持用 from 和 size 参数分页,但是这是 有限制的 。 要记住需要传递信息给协调节点的每个分片必须先创建一个 from + size 长度的队列,协调节点需要根据 number_of_shards * (from + size) 排序文档,来找到被包含在 size 里的文档。
所以使用from和size时,需要尽量业务层面避免深分页问题;
scroll 查询 可以用来对 Elasticsearch 有效地执行大批量的文档查询,而又不用付出深度分页那种代价。
先来说明Scroll的原理:
3.3.1 使用方式
GET /old_index/_search?scroll=1m (1)
{
"query": { "match_all": {}},
"sort" : ["_doc"], (2)
"size": 1000 (3)
}
(1) 保持游标查询窗口一分钟
(2) 如果你对scroll取出的数据顺序没有要求的话,则可以对“_doc”进行排序,es对这种排序做了优化。
(3) 尽管我们指定字段 size 的值为1000,我们有可能取到超过这个值数量的文档。 当查询的时候, 字段 size 作用于单个分片,所以每个批次实际返回的文档数量最大为 size * number_of_primary_shards
这个查询的返回结果包括一个字段_scroll_id,它是一个base64编码的长字符串。我们再查询的时候传递_scroll_id字段;
GET /_search/scroll
{
"scroll": "1m", (1)
"scroll_id" : "xxxxxxxid" (2)
}
(1)每次都尽量去刷新它的过期时间;
(2)上一次请求返回的id;
本节将会了解到:
ES的全文检索是通过一个叫做倒排索引来实现的,什么是倒排索引呢?
比如有两段文本:
1 | How are you?
2 | are you ok?
倒排索引包含一个有序列表,列表包含所有文档出现过的不重复个体,或称为 词项 ,对于每一个词项,包含了它所有曾出现过文档的列表:
Term | Doc1 | Doc2 |
---|---|---|
are | 1 | 2 |
how | 1 | |
you | 1 | 2 |
ok | 2 |
当我们检索you的时候,可以看到会查出Doc1和Doc2的id;
不变性
倒排索引被写入磁盘后是不可改变的,它永远不会修改。不变性有重要的价值:
当然,一个不变的索引也有不好的地方。主要事实是它是不可变的! 你不能修改它。如果你需要让一个新的文档 可被搜索,你需要重建整个索引。这要么对一个索引所能包含的数据量造成了很大的限制,要么对索引可被更新的频率造成了很大的限制。
上面提到了索引的不可变性,那ES又是如何来更新索引的呢?
段和提交点
Elasticsearch 基于 Lucene, 这个 java 库引入了 按段搜索 的概念。 每一 段 本身都是一个倒排索引但 索引 在 Lucene 中除表示所有 段 的集合外, 还增加了 提交点 的概念;
新的文档首先被添加到内存索引缓存中,然后写入到一个基于磁盘的段;
逐段搜索会以如下流程进行工作:
当一个查询被触发,所有已知的段按顺序被查询。词项统计会对所有段的结果进行聚合,以保证每个词和每个文档的关联都被准确计算。 这种方式可以用相对较低的成本将新文档添加到索引。
删除和更新
段是不可改变的,所以既不能从把文档从旧的段中移除,也不能修改旧的段来进行反映文档的更新。 取而代之的是,每个提交点会包含一个 .del 文件,文件中会列出这些被删除文档的段信息。
当一个文档被 “删除” 时,它实际上只是在 .del 文件中被 标记 删除。一个被标记删除的文档仍然可以被查询匹配到, 但它会在最终结果被返回前从结果集中移除。
文档更新也是类似的操作方式:当一个文档被更新时,旧版本文档被标记删除,文档的新版本被索引到一个新的段中。 可能两个版本的文档都会被一个查询匹配到,但被删除的那个旧版本文档在结果集返回前就已经被移除。
上面提到的按段提交引来了另外一个问题,就是段被提交后(写入磁盘后)才允许开启被搜索的能力,这样延迟过高;
磁盘在这里成为了瓶颈。提交(Commiting)一个新的段到磁盘需要一个 fsync 来确保段被物理性地写入磁盘,这样在断电的时候就不会丢失数据。 但是 fsync 操作代价很大; 如果每次索引一个文档都去执行一次的话会造成很大的性能问题。
我们需要的是一个更轻量的方式来使一个文档可被搜索,这意味着 fsync 要从整个过程中被移除。
处理方案:在内存索引缓冲区中的文档会被写入到一个新的段中。但是这里新段会被先写入到文件系统缓存—这一步代价会比较低,稍后再被刷新到磁盘—这一步代价比较高。不过只要文件已经在缓存中, 就可以像其它文件一样被打开和读取了。
refresh API
在 Elasticsearch 中,写入和打开一个新段的轻量的过程叫做 refresh 。 默认情况下每个分片会每秒自动刷新一次。这就是为什么我们说 Elasticsearch 是 近 实时搜索: 文档的变化并不是立即对搜索可见,但会在一秒之内变为可见。
POST /_refresh 刷新(Refresh)所有的索引
POST /blogs/_refresh 只刷新(Refresh) blogs 索引
在生产环境中,如果你需要导入大批量数据,可以先将refresh功能关闭,待导入完成后再开启;
如果没有用 fsync 把数据从文件系统缓存刷(flush)到硬盘,我们不能保证数据在断电甚至是程序正常退出之后依然存在。为了保证 Elasticsearch 的可靠性,需要确保数据变化被持久化到磁盘。
在 动态更新索引,我们说一次完整的提交会将段刷到磁盘,并写入一个包含所有段列表的提交点。Elasticsearch 在启动或重新打开一个索引的过程中使用这个提交点来判断哪些段隶属于当前分片。
即使通过每秒刷新(refresh)实现了近实时搜索,我们仍然需要经常进行完整提交来确保能从失败中恢复。但在两次提交之间发生变化的文档怎么办?我们也不希望丢失掉这些数据。
Elasticsearch 增加了一个 translog ,或者叫事务日志,在每一次对 Elasticsearch 进行操作时均进行了日志记录。通过 translog ,整个流程看起来是下面这样:
translog 提供所有还没有被刷到磁盘的操作的一个持久化纪录。当 Elasticsearch 启动的时候, 它会从磁盘中使用最后一个提交点去恢复已知的段,并且会重放 translog 中所有在最后一次提交后发生的变更操作。
translog 也被用来提供实时 CRUD 。当你试着通过ID查询、更新、删除一个文档,它会在尝试从相应的段中检索之前, 首先检查 translog 任何最近的变更。这意味着它总是能够实时地获取到文档的最新版本。
flush API
这个执行一个提交并且截断 translog 的行为在 Elasticsearch 被称作一次 flush 。 分片每30分钟被自动刷新(flush),或者在 translog 太大的时候也会刷新。请查看 translog 文档 来设置,它可以用来 控制这些阈值:
flush API 可以被用来执行一个手工的刷新(flush):
POST /blogs/_flush
POST /_flush?wait_for_ongoing
Translog 有多安全?
translog 的目的是保证操作不会丢失。这引出了这个问题: Translog 有多安全?
在文件被 fsync 到磁盘前,被写入的文件在重启之后就会丢失。默认 translog 是每 5 秒被 fsync 刷新到硬盘, 或者在每次写请求完成之后执行(e.g. index, delete, update, bulk)。这个过程在主分片和复制分片都会发生。最终, 基本上,这意味着在整个请求被 fsync 到主分片和复制分片的translog之前,你的客户端不会得到一个 200 OK 响应。
在每次请求后都执行一个 fsync 会带来一些性能损失,尽管实践表明这种损失相对较小(特别是bulk导入,它在一次请求中平摊了大量文档的开销)。
但是对于一些大容量的偶尔丢失几秒数据问题也并不严重的集群,使用异步的 fsync 还是比较有益的。比如,写入的数据被缓存到内存中,再每5秒执行一次 fsync 。
这个行为可以通过设置 durability 参数为 async 来启用:
PUT /my_index/_settings
{
“index.translog.durability”: “async”,
“index.translog.sync_interval”: “5s”
}
这个选项可以针对索引单独设置,并且可以动态进行修改。如果你决定使用异步 translog 的话,你需要 保证 在发生crash时,丢失掉 sync_interval 时间段的数据也无所谓。请在决定前知晓这个特性。
如果你不确定这个行为的后果,最好是使用默认的参数( “index.translog.durability”: “request” )来避免数据丢失。
由于自动刷新流程每秒会创建一个新的段 ,这样会导致短时间内的段数量暴增。而段数目太多会带来较大的麻烦。 每一个段都会消耗文件句柄、内存和cpu运行周期。更重要的是,每个搜索请求都必须轮流检查每个段;所以段越多,搜索也就越慢。
Elasticsearch通过在后台进行段合并来解决这个问题。小的段被合并到大的段,然后这些大的段再被合并到更大的段。
段合并的时候会将那些旧的已删除文档从文件系统中清除。被删除的文档(或被更新文档的旧版本)不会被拷贝到新的大段中。
启动段合并不需要你做任何事。进行索引和搜索时会自动进行,它的流程:
1、 当索引的时候,刷新(refresh)操作会创建新的段并将段打开以供搜索使用。
2、 合并进程选择一小部分大小相似的段,并且在后台将它们合并到更大的段中。这并不会中断索引和搜索。
3、 新的段被刷新(flush)到了磁盘。 ** 写入一个包含新段且排除旧的和较小的段的新提交点。新的段被打开用来搜索。老的段被删除。
默认1s将数据存入系统缓存中并开启一个新的段;
translog默认每5秒做一次持久化,如果说5s内段未被持久化一磁盘,一样会造成数据丢失;