AnalyticDB: Real-time OLAP Database System at Alibaba Cloud

写在前面

本文是对阿里巴巴analyticDB论文的研读结果,里面加入了自己的一些理解和疑惑,有不准确的地方,请告知并见谅,原始论文地址为:http://www.vldb.org/pvldb/vol12/p2059-zhan.pdf,在本文最后注明了引用和借鉴内容的出自连接。

概述

背景

  • 数据规模快速增长
  • 需要处理的数据类型越来越多
  • 低延时(查询相应时间不超过1秒)分析领域OLAP数据库越来越重要
  • 尤其ad-hoc 复杂查询的使用越来越普遍
    我们希望OLAP数据库技能支持低延时的数据查询还能同时支持高并发和高写入吞吐量,并且还能支持处理多种类型的数据:结构化和复杂数据结构(例如:json,vector和texts)。

本文主要对阿里巴巴开发的analyticDB的设计及特性进行说明。
AnalyticDB的特性包括:

  • 异步构建全列索引 这样可以保证在ad-hoc场景下的复杂查询的低延时。
  • 扩展的行列混合存储 可以实现对结构化和复杂类型的数据的快速检索。
  • 读写分离架构 能够同时满足对大数据量的高并发读和高吞吐写。
  • 存储感知(storage-aware)的优化器和执行引擎 充分利用盘古存储系统以及索引的优势进一步降低查询延时。
    AnalyticDB已经在阿里云向用户提供服务。据官方宣传文档对其性能描述如下:
  • 能够处理100万亿行记录以及10PB大小的数据
  • analyticDB的吞吐量可以达到:1000W writes/s和10W queries/s
  • 并可以将复杂查询在1s以内执行完毕

1. 简介

AnalyticDB是一款OLAP数据库产品,主要用于PB级别数据的实时分析查询,并且支持高并发和低延时,目前AnalyticDB的集群规模是 阿里云2000+物理机器。
在阿里云上它服务了多个行业的外部客户,包括:电子商务、金融、物流、公共交通、气象分析,娱乐,等等,以及阿里巴巴集团内部业务。
一个优秀的OLAP数据库需要具备的特质如下:

  • 低延时:所有查询都需要具有比较低的查询延时。
  • 数据新鲜:能够立即或者快速看到写入的数据,而不能像传统大数据T+1数据处理方案一样,看到的永远是昨天的数据。
  • 灵活性:能够支持ad-hoc场景的各种复杂查询。
  • 低成本:在成本投入上要可行。
  • 高扩展:当集群服务能力达到瓶颈的时候,能够通过添加节点进行快速横向扩展。
  • 高可用: 允许集群中数个节点宕机,而不影响正常服务的提供。

analyticDB需要大规模数据(10 PB+的数据量, 数十万张表和数万亿行记录)上完成客户发来的海量分析型查询,面临的挑战如下:

  • 当今用户面对着比以前更复杂的分析场景,单仍希望保证查询的低延迟。AnalyticDB的用户来自不同的领域,他们的分析需求千差万别,经常变化,这就导致很难对这些用户的多样的和复杂的查询进行优化。这些查询种类繁多,包括:全表扫描、点查(point-lookpu query)、多表关联以及同时对多列进行过滤等。虽然可以通过索引直接提升查询性能,但是传统的创建索引一般都是对预先指定的列进行创建索引,但是在这种查询种类繁多,查询语句复杂的场景下,预建索引是不可行的。

  • 现在的复杂分析场景要求能够支持各种查询、各种类型的数据但是要有一个统一的对用户友好的数据存储层。传统的方式中OLAP查询和点查分别使用不同的存储层,即:列储存和行存储。此外,超过一半的用户的数据都具有复杂的数据类型,如文本、JSON,向量和其他多媒体类型。因此存储层应该能够对多种数据类型进行快速检索,从而可以有效地支持对结构化数据和复杂类型的数据的查询。

  • 系统在处理实时低延时查询的同时还需要处理成每秒数千万次的写入。传统的设计是在同一个处理路径下同时处理读和写,这样可以保证写入一旦提交,马上就能够被读到最新提交的数据。但是,这样的设计不适合我们所说的场景,因为这种设计会导致读写相互影响。因此需要仔细权衡查询延时、写吞吐和数据的可见性。

analyticDB做了如下设计来解决上述问题:

高效索引管理
AnalyticDB具备一个高效索引引擎,通过如下两种关键的设计来实现低延迟查询,但是只引入了极少量的额外开销。

  • 首先,AnalyticDB为每张表创建全列索引(为所有列都创建索引),从而显著提升ad-hoc场景复杂查询的性能。并进一步提供了基于实时过滤比率进行索引路径选择的机制(e filter-ratio-based index path selection mechanism),以避免滥用index而导致性能下降。
  • 其次AnalyticDB禁止更新关键路径上的大型索引,因此在非高峰期对索引进行异步构建。AnalyticDB还维护了一个轻量级sortedindex来最小化异步索引构建对增量数据(在新构建索引之前新写入的数据)查询产生的影响。

针对结构化和复杂数据类型而设计的存储层 analyticDB底层存储提供行列混合存储。通过利用磁盘对于连续数据快速读写的特性,可以对olap和点查场景同时提供还不错的读写性能。存储层进一步提供了对复杂数据类型的支持(包括index)从而提供了search资源(例如:json,vector和text)和结构化数据的能力。
读写分离 为了同时支持高吞吐量写和低延迟查询,系统采用了读写分离架构,即:整个系统由读节点(read node)和写节点(write node)独立单独提供服务,这两类节点相互隔离,互不影响,因此可以独立伸缩。写节点会将写入持久化到可靠的分布式存储系统-盘古(pangu)中。在读节点上通过版本校验机制确保在查询时能够读到最新的数据。

增强的优化器和执行引擎 AnalyticDB对优化器和执行引擎进行了增强,从而进一步减小查询延时并提高并发性。具体来说,AnalyticDB实现了存储感知(storage-aware)的SQL优化器,该优化器采用两项技术:

  • 根据存储特征生成最佳执行计划
  • 在CBO中通过高效实时采样技术来进行准确的Cost评估

AnalyticDB还为行列混合存储设计了一个高性能的向量执行引擎(这里使用到了基于simd的向量化计算技术,对此技术感兴趣可以参考我的文章:如何编写向量化代码)来提高计算密集型分析查询的效率。

2.系统对比

AnalyticDB是阿里研发的用于大规模实时分析的云服务平台。这里我们先做一次AnalyticDB与其他系统的宏观对比。

OLTP 数据库
MySQL和PostgreSQL等OLTP数据库旨在提供事务性查询,这些查询一般都是点查,每次查询会涉及一行或几行。因此OLTP数据库的存储引擎都是行存储并且通过B+树构建索引来加速查询。但行存储不适合分析型查询,因为这种查询只需要部分列,所以行存会导致读放大(大幅增加读I/O)。此外,OLTP数据库通常在写数据处理路径上同步更新索引,这种方式会严重降低写吞吐并增大读延时。

OLAP数据库
为了提高分析查询效率,有许多如Vertica,Teradata DB 和Greenplum 这样的OLAP数据库。Vertica没有像常规数据库那样在列上构建常规索引,而是采用projection技术来提高查询性能,它只保持某列的最小/最大的值,但是这样会在查询时产生低效的数据裁剪,导致查询延迟较高。TeradataDB和Greenplum都采用了列存储并允许用户指定针对特定列构建索引,但是他们都具有如下两个限制:

  • 他们在修改数据时,都会同步修改索引,这种方式在全列索引的情况下不可行(会造成写延时和写放大)。
  • 列存储在执行点查的时候会导致很多随机I/O。

大数据系统
随着Map-Reduce模型的出现,像hive和spark-sql这种通过整个多台服务器计算能力分布式批处理引擎流行起来。但是在这些产品上执行的查询被认为是“离线查询”。因为这种查询的执行会持续几分钟甚至数小时,因此不适合进行实时查询。impala将“离线”查询转换为交互式查询,impala通过使用pipeline处理模型和列存储将普通的查询延时降低到秒级。但是,impala没有列索引(只有最小/最大值统计),所以它不能很好的处理复杂查询。

实时OLAP系统
近实时olap系统包括:Druid 和 Pinot ,两者都采用列存储, 都基于位图构建倒排索引,例如:Pinot会在所有列上构建索引,而Druid会在维度列上构建索引。若Druid上的查询的过滤条件没有在维度列上,会导致很高的查询延时。Druid和Pinot都会在写数据的时候同步更新索引,这样就直接对写入性能产生了负面影响。除此之外,两者都不支持一些重要的查询类型,如:join,update和delete, 并且由于他们都是采用列存储,所以点查效率低下。

云分析服务
最近出现了许多云分析服务,如:Amazon Redshift和Google BigQuery。 Amazon Redshift是完全托管的云数据库服务。它使用列存储和大规模并行处理(MPP)将查询分布在多个节点之间。典型的Redshift群集具有两个或多个计算节点,这些节点通过leader节点进行协调。
与RedShift相比,AnalyticDB中具有独立的read node和write node,实现了读写分离的架构。 Google BigQuery是Google核心技术之一Dremel的外部实现,该技术包括两点:1.实现高性能存储的列式存储技术;2.树形拓扑结构,该拓扑结构可以在数千台服务器间实现秒级查询分发和结果聚合。这与AnalyticDB不同,BigQuery采用了高效索引引擎和DAG执行框架。

3. 系统设计

AnalyticDB运行在飞天系统之上,飞天系统是阿里云从2009年开发的大规模、通用、高可靠的基础计算设施。飞天系统管理了成千上万的物理机器上的所有资源,并持续支持了许多阿里云服务,比如搜索、计算和存储。AnalyticDB主要利用了飞天系统的两个核心组件,即:盘古(一个可靠的分布式存储系统)和伏羲(资源管理器和作业调度器),如图1所示。


图1 AnalyticDB架构图

在本节中,我们介绍AnalyticDB中至关重要的设计选择,包括数据模型和系统架构。

3.1 数据模型和查询语言

AnalyticDB遵循标准的关系数据模型,即表中记录的schema是固定的。此外,还支持许多复杂数据类型,例如text,JSON和vector,以满足应用程序不断增长的分析需求(在第4.1.1和4.2.2节中有详细介绍)。
AnalyticDB遵循ANSI SQL:2003标准 ,并提供了一些额外的feature,例如分区指定和复杂类型数据操作。

3.2 表分区

在AnalyticDB中,每个表只能有两级分区,即主分区和辅助分区。图2说明了一个示例DDL,该示例创建了一个具有两级分区的表,即,列ID上的主分区具有50个分区,而dob上的辅助分区具有12个分区。主分区基于用户指定列的哈希,因此,行在所有主分区之间分配,以最大程度地提高并发性。实际上,可以选择任何值分布均匀的列作为主分区列,从而使每个分区保持平衡。此外,用户可以选择性指定辅助分区(称为子分区)。辅助分区是一个列表分区,其阈值定义了最大分区数,用于自动保留和回收数据。通常,代表时间(例如,日,周或月)的列将被选作辅助分区列,因此,同一时间间隔内的行被分组为一个分区。一旦分区数超过阈值,最早的分区将被自动丢弃。
注:若无特别说明,后续文章中出现的分区都默认指主分区。


创建分区表的DDL语句

3.3 总体架构

图1显示了AnalyticDB的系统架构。AnalyticDB中主要有三种类型的节点,即Coordinator,写节点和读节点。

  • Coordinator:通过JDBC / ODBC连接收集来自客户端的请求(写和查),并将其分派给相应的写节点和读节点。并负责为读节点指定其需要服务的partitions。
  • Write Node: 写节点,负责处理写操作(例如INSERT,DELETE,UPDATE),并将SQL语句刷新到盘古存储系统中以实现持久性。写节点由可以进一步细分为master节点和worker节点,master节点只能有一个,并负责为各个worker节点分配其需要服务的partitions,并管理worker与partitions之间的对应关系,当某个worker宕机后,master会负责将其服务的partitions均分到其他的workers上。当master宕机,会从剩余的worker中选举出一个新的master。
  • Read Node:读节点,负责处理查询(例如SELECT)。

通过这种方式,写节点和读节点彼此解耦(读写分离,在3.4节中有详细介绍)。伏羲系统利用所有这些节点中的可用资源为异步任务执行提供计算worker。此外,AnalyticDB还提供了一个在计算worker上运行的PipeLine模式的执行引擎(如图3所示)。


系统中的数据以column block(称为page)为单位从存储系统流到客户端。所有数据处理都是在内存中,并通过网络在不同stage之间进行pipeline化。该pipeline workflow使AnalyticDB能够高吞吐和低延时的处理用户的复杂查询。

3.4 读写分离

传统的OLAP数据库将读处理和写处理耦合在一起,即只要请求到达,数据库实例照单全收,而不做读和写的区分。因此,所有并发请求共享一个资源池,彼此影响。在查询和写入并发性都很高的情况下,这种设计会引起资源争并导致性能急剧下降。为了解决这个问题,AnalyticDB提出了一种读/写分离的架构。写节点仅负责写请求,读节点仅负责查询请求。这两种类型的节点彼此隔离,因此可以在完全不同的执行路径中处理写入和查询。详细读写分离架构如下图所示:



图中Read Node上的Detail是后面所说的明细文件(Detail File),表中的数据会被分为多个分区,每个分区对应一个Detail File。

3.4.1 高吞吐写入

AnalyticDB会将其中的一个写节点作为作为master,其他的写节点作为worker,他们通过基于zookeeper构建的锁服务相互协作。当写节点第一次启动时,有master节点为每个worker节点指派其需要负责的partitions,并将worker与其服务的partitions的对应关系记录下来(3.2节所述)。Coordinator会基于这份配置信息将写请求分发到相应的worker节点上。当一个写请求到达时,Coordinator首先解析SQL并识别到这是一个写请求,然后将这个请求分发到相应的写节点。每个写节点都作为一个接收SQL语句的内存buffer并且周期性的将这些sql语句作为log刷新到pangu(类似于在传统数据库中的log writer线程)。一旦buffer被完全flush到盘古,节点就会返回一个version(即:日志序列号:LSN)给Coordinators,Coordinators就会返回给用户一个写入提交成功的信息。当盘古的日志文件数量达到一定规模,AnalyticDB将会在伏羲中启动多个MapReduce工作将提交的日志执行,完成实际的数据写入工作,例如:写入实际数据和索引(在第4节会对该过程进行详细描述)。

3.4.2 实时读

coordinators为每个读节点分配了多个partition,其中具有相同哈希值的partition位于一个节点中。可以看出写节点与partitions之间的关系是由master节点指定,而读节点与partititions之间的关系是由Coordinator节点指定。图4显示了多个读节点之间的这种partition置放算法,在生产环境中通过配合使用存储感知优化器(5.1节会介绍)可以避免80%的数据重分布操作。

读节点之间的数据置放

此外,考虑到并发性和可靠性,我们还实现了partition的数据副本(在第3.4.3节中有详细介绍)。
每个读节点启动时从盘古加载初始partition,并定期从相应写节点获取后续的数据更新,然后将更新应用与读节点的本地数据副本,这些副本不会被写回到盘古。读节点选择从写节点中持续不断的拉取数据,而不是从盘古中拉取,是为了减少同步延迟。因此,写节点在此充当了高速缓存,以服务来自不同读节点副本并发地拉取更新数据的请求。
由于读节点需要从写节点远程获取最近的写入,因此读节点为用户提供了两个可见性级别:
1.实时可见(realtime):在写入后可以立即读取数据
2.延时可见(bounds-staleness):在写入操作固定延迟后最新数据可见。
为了维持较低的查询延迟,AnalyticDB默认使用延迟可见级别,这在大多数OLAP方案中都是可以接受的。对于要求高可见性的用户,可以启用实时读取,但是这会导致读节点和写节点之间的数据同步问题。
我们使用版本验证机制来解决此问题。具体来说,每个主分区都与写节点上的版本号相关联。当针对一个partition的多次写入请求被刷新之后,写节点将增加该partition的版本,并将此值附加到响应消息中。图5以一个读写请求序列为例,说明了这个过程:
图5 实时读流程

用户将一条记录写入表中(步骤1和2),然后立即发送一个查询来对刚写入的那条记录进行查询。当coordinator收到此查询后,会将查询和先前缓存的版本号(前面已经说了:version会作为响应信息返回给Coordinator,通过图中的步骤3返回,假设返回的版本号为V1)一起发送给相应的读节点(第4步)。对于每个partition,读节点会将其本地版本(表示为V2)与V1进行比较。如果V1不大于V2,则节点可以直接执行查询。否则,它必须从写节点中提取最新数据(步骤5)更新本地副本后,再执行查询。通过执行上述操作,我们可以确保读取节点和写入节点之间的数据可见性以进行实时查询。
但是,如果读节点向写节点发出了pull请求并等待所需要的数据,则延迟将过高。我们通过用写节点push的方式来替换读节点pull的方式实现了对查询延时的优化。当写节点观察到新写入的数据时,它们会主动将其与版本一起推送到相应的读节点。
注:由于存在很多协调节点,所以某个协调节点上缓存的版本可能不是最新版本。

3.4.3 可靠性和可扩展性

可靠性 AnalyticDB为写节点和读节点提供了高可靠性。对于写节点,当worker出现故障时,master会将该worker上的partitions均分到其他可用的worker节点上。当master发生故障时,将从活动的worker中选出新的master。对于读节点,用户可以指定副本数(例如,默认情况下为2),并且同一节点的不同副本会部署在不同的物理计算机上(在第3.5节中详细介绍)。当读节点在处理查询时出现失败时,coordinator会自动将该查询重新发送给其他副本,这个处理过程对用户是透明的。请注意,当有读节点从写节点中拉取数据的时候,写节点出现了故障,读节点不会因写节点的故障而被block住。如果读节点无法与写节点建立连接,则读节点可以直接从Pangu读取数据(尽管会导致更高的延迟)并继续执行(图5中的步骤6)。
可伸缩性 AnalyticDB还保证了写节点和读节点的可伸缩性。添加新的写节点后,master将调整表partitions的放置位置,以确保负载平衡。新的partition的分布信息将会更新到ZooKeeper,coordiantor可以根据新的partition分布信息分发后续的写请求。读节点的可伸缩性以相似的方式运行,但是对于读节点来说,是由coordinator节点进行partitions分布位置的调整。

3.5 集群管理

AnalyticDB的群集管理旨在服务于多租户。一个集群中可能存在许多AnalyticDB实例。我们设计并实现了一个名为Gallardo的集群管理组件,该组件利用CGroups技术隔离了不同AnalyticDB实例之间的资源(CPU核心,内存,网络带宽),并保证了它们的稳定性。创建新的AnalyticDB实例时,Gallardo为其分配所需的资源。在分配期间,Gallardo仔细地将不同的角色(Coordinator,写节点,读节点)和读节点的副本放置在不同的物理机中,以遵守可靠性要求。请注意,Gallardo与伏羲没有冲突。

  • Gallardo:负责在不同的AnalyticDB实例之间分配和隔离资源。
  • 伏羲:整合所有AnalyticDB实例中的空闲可用资源用于计算任务。

4. 存储

AnalyticDB采用行列混合存储,可以支持结构化数据和其他复杂的数据类型,例如JSON和vector。

4.1 物理数据存储

本节首先介绍AnalyticDB中的数据布局和元数据,然后说明如何处理数据。
4.1.1 行列混合存储
AnalyticDB的一个设计目标是同时支持OLAP查询和点查。 OLAP查询通常会查询一个宽表中的一小部分列。列存储由于其高效的数据压缩和较少的I/O而非常适合这种查询,但对于需要访问一个或几个整行的点查却难以满足其需求。在点查情况下,行存储的性能更好,但是,它会增加OLAP查询的成本。
为了解决这个难题,AnalyticDB提出了行列混合存储,如图6所示。


每个表分区中的数据都保存在一个文件(我们称之为:明细文件 detail file)中,该文件分为多个行组(row group)。每个行组包含固定数量的行。在一个行组中,来自同一列的所有值都组织成为一个数据块(data block),并且所有数据块都按顺序存储。数据块是基本操作单位(例如,用于读取和缓存),并有助于实现较高的压缩率以节省存储空间。这种混合设计能够以可接受的开销来平衡OLAP查询和点查(point-lookup query)。与列存储类似,混合存储仍然按列对数据进行聚类,这种方式有利于OLAP查询。尽管整个列位于不同行组内的多个数据块中,但只需要少量顺序搜索即可获取所有数据。通过对生产环境的观察和测试,这种开销只占不到总查询延迟的5%。对于点查,由于特定行的所有列都存储在同一行组中,因此还可以保持良好的性能。在将各个列组装成行的时候仅涉及短距离的顺序查找,而不涉及到单纯列存储中的交叉分段查找,因此性能还是可以的。
复杂数据类型 行列混合存储适用于短列,例如:数字和短字符串类型,但不适用于复杂数据类型(例如JSON和vector),因为这种类型的数据的大小可变且通常很大。若将这些行分组成固定行数的行组可能会产生异常的巨大块。为了解决此问题,为复杂类型的数据设计了固定大小的存储模型。它利用了另一级别的块,即FBlock,每个块的固定大小为32KB。一个数据块(前面所说的data block)(具有30,000行)会进一步将其行分配到多个FBlock中,在数据块中存储指向这些FBlock的指针。这样,数据块仍然具有固定的行数,并且所有FBlock都存储在单独的文件中,如图7所示:

但是,FBlock中包含的行数可以少于一(即:部分行)。为了支持快速搜索,我们在数据块中为每个FBlock维护一个块条目,如图7的左侧所示。每个条目都包含两个标识符,即相应FBlock的开始行和结束行。一行可以被分为多个连续的FBlock。例如,在图7中,FBlock1和FBlock2分别存储行[0,99]和[99,200],指示行99被分为两个FBlock。要访问它,我们首先从数据块中扫描块条目以找到相关的FBlock(例如FBlock1和FBlock2),然后从两个FBlock中分别获取该行的一部分,并将两个部分行进行拼接。

4.1.2 元数据

每列都有一个单独的详细元文件(detail meta file),称之为详细元文件,详细元文件中记录相应的列的元数据信息,这些元数据可加速对该列中大量数据的检索。如图6所示。详细元文件很小(一般小于1MB),并缓存在内存中以便频繁访问。每列的元数据由四个部分组成:

  • Header。包括版本号,详细元文件的长度以及一些统计信息。
  • 列统计信息。包括行数,NULL值数,cardinality,SUM,MAX和MIN 值。优化器根据这些信息来生成最佳执行计划。
  • 字典。对于cardinality较少(小于1024)的列(其实我认为cardinality较少也就是说列中的数据分布不均匀),AnalyticDB采用字典编码,数据文件里保存字典号码。从而节省存储空间。
  • block map。该map中的key是数据块的标识,value的内容是对应的数据块在明细文件中的位置以及数据块的长度。通过block map可以快速访问到存储在明细文件中的data block。

4.1.3 数据操作

AnalyticDB的底层存储采用Lamda架构(请参见图8),低层存储中包含基线数据(baseline data )和增量数据(incremental data)。 基线数据存储历史数据,包括索引数据和行列数据。 增量数据保留新写入的数据,增量数据种不包含fullindex,而采用比较简单的排序索引(在第4.2.5节中有详细说明)。仅在读节点从写节点拉取并replay新写入的日志时,才会产生增量数据,因此增量数据只会在读节点上。 并且增量数据与基线数据的数据和元数据的格式是完全一致的。


查询执行
为了支持UPDATE,我们使用delete bit-set记录已删除数据的行ID。通过写时复制(Copy-on-write)技术支持MVCC(多版本并发控制)。当更新或删除一行时,delete bit-set会生成一个快照并且连同该快照对应的版本号一起存储在内存map中,用于后续查询。该delete bit-set会被分成小的压缩段,因此快照可以共享未更改的段以节省空间。此外,若已经创建了快照的新版本,一旦没有查询在运行,最旧的版本将被删除。算法1、2和3解释了如何遵循图8中的步骤对基线数据和增量数据执行INSERT,DELETE和FILTER查询。


执行查询的详细过程如下:

  • 首先给出一个版本号,根据给出的版本号,我们可以找到对应的delete bit-set对象。
  • 其次从对应的delete bit-set中找出符合过滤条件的ID集合,我们称之为:Set(deleted_ID)。
  • 再次从基线数据和增量数据中找到符合条件的ID集合,我们称之为Set(ID)。其中基线数据中符合条件的ID是通过全索引(倒排索引)获得的,增量数据中符合条件的ID是通过Sorted 索引获得的。
  • 最后我们从数据种符合条件的ID集合中减去已经删除的符合条件的集合,就可以得到最后的结果,即最终结果就是:result=Set(ID-Set(deleted_ID)。

合并基线数据和增量数据
随着新数据的不断写入,增量数据的查询速度会大大降低。 因此,将异步启动build过程从而将增量数据合并到基线数据中。 在此过程中,删除的记录将被忽略,并相应地创建一个新索引。 如图9所示:


合并过程如下:当build过程开始时,我们使当前增量数据不可变,并创建另一个新的增量数据实例来处理新到达的数据。 在build过程完成之前,所有的查询将在旧的基线数据,陈旧的增量数据和新的增量数据这三种数据上执行。 一旦合并完成,生成了新版本的基准数据,就可以安全地删除旧的基基线数据和陈旧的增量数据。 此时,新的基线数据和新的增量数据将用于后续查询。
注:合并基线数据和增量数据的整个过程是在Read Node上执行的。

4.2 索引管理

索引是几乎所有数据库中提高查询性能的关键组件。但是,现有的索引方法不能完全满足OLAP的要求。例如,B+树在进行树节点分裂时的更新成本很高,因此仅在经过精心选择相应的列,尽最大可能避免B+树的节点分裂,B+树类型的索引性能才可以满足需求。像Druid 这样的系统在更多的列上选择使用基于位图的倒排索引,但仅适用于某些特定的数据类型(例如String)。最近,随着对复杂类型数据(例如JSON,向量和文本)的查询的需求日益增加,我们还应该支持这些类型的索引。而且,大多数系统都是在写数据的时候同步构建索引,这样就严重的限制了写性能。
因此,我们设计并实现了一个索引引擎,该引擎可为结构化和复杂类型的数据构建索引,而不会影响写入吞吐。该引擎在所有列上建立索引以完全支持ad-hoc查询,并通过异步构建索引的方式,从写路径中完全去掉了索引构建的工作。我们进行了几种复杂的设计,以最大程度地减少存储开销并提高性能。

4.2.1 基于索引的过滤查询

分区中的每一列具有倒排索引,并将倒排索引存储在单独的文件中。在倒排索引中,索引键是原始列中的值,而该值是相应行ID列表。根据第4.1.1节,由于每个行组都有固定数量的行,因此我们可以通过其id轻松找到行。通过使用全列索引(在所有列上都构建索引),AnalyticDB能够支持高性能的ad-hoc查询。图10提供了一个SQL过滤示例,其中包含有关结构化数据和复杂类型数据的过滤条件。对于每种条件,索引引擎都会对其相应的索引进行过滤,并获得部分结果,即一组符合过滤条件的行ID。之后,所有部分结果将通过交集,并集,差集等操作合并为最终结果。要合并这些结果,大多数数据库中通常使用二路归并,但是这会占用大量内存,并且并发性较低。为了减轻这种影响,我们改用了K路归并,以确保对大型数据集的亚秒级查询延迟。


索引选择
在所有列上过度使用索引过滤有时可能会降低查询性能。例如,对于两个条件A和B,如果A的部分结果远小于B的部分结果,则比较高效的方式是:首先获得A的部分结果,然后在A的部分结果上适用条件B进一步过滤。而不应该分别基于条件A和条件B获得两个部分结果,然后合并它们。因此AnalyticDB提出了一种运行时基于过滤比率的索引路径选择机制(runtime filter-ratio-based index path selection mechanism),该机制评估每个条件的过滤比率,以确定是否在运行时使用相应的索引。过滤比率定义为:符合条件的行数(从索引中检索)除以总行数(从元数据中检索)。 AnalyticDB根据每个索引的查询条件的过滤比率进行升序排序,来决定使用的索引的顺序。在一个条件过滤完成之后,如果所有已处理的过滤条件的综合过滤比率(将每个查询条件的过滤比例依次相乘)足够小(例如,小于总行的百分之一),则此过程终止,并且所有先前获得的部分结果进行K路归并,获得一部分符合条件的行。后续查询会直接在这些行上执行后续的过滤条件,而不是采用相应的列索引进行全表过滤。

4.2.2 复杂数据类型的索引

JSON
插入JSON对象时,我们会将分层的JSON属性展平为多个列,然后为每个列构建倒排索引。例如,给定JSON对象{id, product_name, properties {color, size}},,将其展平后对应的列为:ID,product_name,properties.color和properties.size,为每个列均建立一个索引。我们使用PForDelta算法来压缩每个索引键下的行ID。此外,JSON对象可能包含数千个属性(即数千个索引)。我们将一个json对象的所有索引打包在一个文件中,以限制文件数量。有了索引,AnalyticDB可以直接以json的格式来声明过滤条件,进而获取相关数据,这比从磁盘读取和解析JSON数据块效率更高。

Full-text
对于全文数据,AnalyticDB通过存储更多信息来扩展倒排索引,包括词频和从文档到词的映射。然后,我们使用流行的TF(术语频率)/ IDF(反向文档频率)得分来计算查询和数据库中文本之间的相似度。只有分数高于阈值的那些对象才返回给用户。

vector
特征向量是许多计算机视觉任务(例如对象/场景识别和机器学习)的常见组件,其中可以通过训练AI模型从图像中提取高维向量。 两个对象的相似性可以通过其特征向量的距离来度量。 在查询向量数据时,用户始终需要最近邻居搜索(NNS),目的是在数据库中查找最接近查询点的对象。
比较正式的解释是:可以将NNS(q)定义为在数据库的同一列中存储的所有的向量集合Y中查找出来的一个向量y,该向量与给定的向量q之间的距离最短。AnalyticDB支持使用SQL语句指定不同的相似度d,例如欧氏距离和余弦距离。


NNS算法的最暴力的实现方法是依次扫描数据库中的所有向量,然后将每个向量与目标向量进行距离计算,最后返回前k个结果。为了避免这种暴力查找,我们的实现整合了产品量化(PQ)和邻近图(k-NNG)算法。 PQ和k-NNG是经过验证的有效的近似NNS方法,可以以高概率提取NN(或k-NN)。 PQ通过分解向量空间的方法实现了较小的索引,而k-NNG通过其有效的,连通性良好的图索引而拥有更好的搜索性能和准确性。 AnalyticDB会根据内存资源以及用户的准确性和效率要求,为矢量数据自适应地选择最合适的索引。

4.2.3 节省索引空间

AnalyticDB使用一种自适应方法来减小索引大小。对于索引中的每个键值对,AnalyticDB都会根据其空间消耗自动选择使用bitmap或整数数组来保存其值。例如,如果索引值为[1,2,8,12],则位图(2个字节)比整数数组(4个字节)更节省空间。但是,如果索引值为 [1,12,35,67],则整数数组(4个字节)比位图(9个字节)更节省空间。通过采用这种方法,总索引大小可以减少50%。 AnalyticDB还允许用户禁用特定列上的索引,进一步节省索引空间。

4.2.4 异步索引构建

AnalyticDB每秒处理数千万写请求,因此在写操作中构建全列索引是不可行的。AnalyticDB的索引引擎以异步的方式构建索引。当写节点将写入日志刷新到Pangu时,写入操作结束(第3.4.1节)。索引引擎会定期在后台为新写入的数据构建倒排索引,并将其与现有的完整索引合并。 这种异步方式完全避免了同步构建索引的开销,同时保证了查询效率和写入吞吐量。构建和合并索引的过程通过许多Map-Reduce任务来完成。借助伏羲,这些任务可在AnalyticDB集群内的非高峰时段自动并发运行,因此带来的额外开销是可接受的。
表1 显示了AnalyticDB和Greenplum(基于列存储的OLAP数据库系统)分别对1TB数据构建全列索引的对比数据。



我们看到AnalyticDB仅为索引使用了0.66TB的额外空间,这比Greenplum中的2.71TB小得多。尽管AnalyticDB将索引建立时间增加了一倍,但此异步过程不会影响在线读写的性能。如表1所示,1TB的实时插入时间,Greenplum是AnalyticDB的四倍。具体的测试数据在第6张中进行了说明。

4.2.5 增量数据的索引

异步构建索引的采用还存在另一个问题:在新索引构建完成之前,增量数据是没有索引的,因此对增量数据只能通过全部扫描进行查询,这种方式会导致查询延时增大。为了解决这个问题,索引引擎在读节点中为增量数据构建单独的排序索引。排序索引实际上是增量数据块中行ID的数组。如图11所示:



对于升序排序索引,第i个元素Ti(Ti是数组中第i个元素上存储的值,按照图中所示就是id)表示数据块中的第i个最小值位于id等于Ti的行中。这样,对于增量数据的查找就变成了二分查找,从而将查找的时间复杂度从O(n)降低到O(log n)。为了存储排序索引,我们在每个数据块中分配一个附加的header。由于一个数据块中有3万行,并且每行id都是short int类型,因此header的大小(即排序索引)大约只有60KB。在刷新数据块之前,索引引擎将构建排序索引并将其转储到文件的开头。该构建过程在读节点中本地执行,并且非常轻量级。

4.2.6 查询条件的索引缓存

传统数据库在内存中缓存索引(以索引页的粒度),以减少昂贵的磁盘I / O。 AnalyticDB不仅提供了索引页粒度的高速缓存,还提供了更强大的查询条件缓存。查询条件缓存将查询条件(例如,id <123)作为键,并将查询结果(即行ID的集合)作为值。这样在对相同查询条件反复查询时,就可以避免对全部索引反复查找。当查询条件缓存未命中时,我们可以再通过索引进行查找。
使用查询缓存的一个挑战是用户条件不断急剧变化,从而导致频繁的缓存未命中。但是,我们观察到这不会对整体缓存效率产生太大影响:
1)结果较大的条件很少,并且不会频繁更改(例如,WHERE city ='Beijing'),因此它们的缓存可以持续很长时间;
2)结果规模较小的条件是巨大的,并且变化很大(例如WHERE用户ID = XXX),但是可以低成本重新计算它们的结果。
总之,AnalyticDB通常可以很好地缓存昂贵的计算结果以节省资源,而对于轻量的计算结果,即使未命中,也可以很快进行重新计算。这确保了索引缓存的有效性。

5. 优化器和执行引擎

在本节中,我们将讨论优化器和执行引擎采用的新颖的优化方案,它们将进一步降低查询延迟并提高并发性。

5.1 优化器

AnalyticDB优化器针对需要极短响应时间和高并发性的实时在线分析同时提供了CBO(基于成本的优化)和RBO(基于规则的优化)两种优化方式。
AnalyticDB包含了丰富的关系代数转换规则,从而确保可以选择最优的查询执行计划。这些规则包括:

  • 基本优化规则:裁剪,下推/合并,重复数据删除,恒定折叠/谓词派生;
  • 针对不同的join的探测优化规则:BroadcastHashJoin,RedistributedHashJoin,NestLoopIndexJoin,聚合,JoinReorder,GroupBy下推,Exchange下推,Sort下推等;
  • 高级优化规则:通用表表达式

除了上述通用CBO / RBO之外,AnalyticDB还开发了两个关键功能,即存储感知优化和有效实时采样。

5.1.1 存储感知执行计划优化

执行下推。执行下推是将SQL中可以依赖存储能力的关系代数计算进行提取,将查询计划等价转换为两部分,一部分在计算层执行,一部分下推给存储层执行。由于原有查询计划中并没有明确的界限来分隔两部分,因此需要依赖存储层本身的计算能力,通过关系代数的等价转换规则,将其分离。执行下推在很多分布式数据库中都有类似的实现,但下推算子的极致基本都是以单列条件的AND操作为主,其他算子如函数、JOIN等都在计算层实现。这主要是由于其并未实现存储层向上注册计算能力的逻辑,默认认为存储层最多只能做单列或者组合条件的过滤。

AnalyticDB引入了一种STARs模型作为执行下推的框架,通过将异构数据源的执行能力按照关系代数的维度进行抽象,将存储的能力特征化为其所能处理的关系代数的能力。在优化器完成初步的分布式执行计划后,利用动态规划的方式针对不同的数据源将适合下推给存储执行的关系代数算子进行封装,转化为对应的存储的API调用。如图12所示:



于此同时,STARs框架还加入了代价的计算,也就是说并非简单的依赖存储的能力进行执行下推,而是在对其进行关系代数能力抽象的同时,对其执行的代价进行量化。在进行动态规划时,将代价和执行能力同时作为参考因素,避免盲目的下推导致性能变差。

Join下推
数据重分布是分布式数据库执行计划的另一个重要特点。它与传统数据库中的数据库不同,主要是由于数据的物理分布特性与集群时的逻辑语义不匹配而引起的。
例如,在SQL语句“ SELECT T.tid,count(*)FROM T JOIN S ON T.sid = S.sid GROUP BY T.tid”中,基于表T和S是否基于同一字段进行hash以及两个表的partitions是否位于同一读取节点(第3.4.2节),AnalyticDB选择最佳的join下推策略。避免数据重分布非常重要,因为数据重分布的成本非常高,这涉及序列化,反序列化,网络开销等。在表T和S没有基于同一字段hash的情况下,AnalyticDB可以从底层存储中获取T和S的大小,从而可以很明确的指导对哪个表进行shuffle性能更好。如前所述,优化器会计算所有可能执行计划的成本。通过这种方式,AnalyticDB实现了针对不同数据特征的最佳执行计划。
基于索引的join和聚合
将Join变为查找现有索引,全索引的设计进一步消除了构建哈希的开销。当调整Join的顺序时,如果大多数Join列是分区列且具有索引,优化器会避免使用BushyTree,而更倾向选择LeftDeepTree。采用LeftDeepTree,AnalyticDB能更好地利用现有索引。另外优化器还会下推谓词和聚合。比如count和过滤查询可以直接基于索引计算。所有这些优化方式都降低了查询延迟,同时提高集群利用率,从而使得AnalyticDB能轻松支持高并发。(请参见图13)。

5.1.2 高效实时采样

代价评估是CBO的基础统计信息是优化器在做基于代价查询优化所需的基本信息,通常包括有关表、列和索引等的统计信息。传统数据仓库仅收集有限的统计信息,例如列上典型的最常值(MFV)。商业数据库为用户提供了收集统计信息的工具,但这通常取决于DBA的经验,依赖DBA来决定收集哪些统计数据,并依赖于服务或工具供应商。
上述方法收集的统计数据通常都是静态的,它可能需要在一段时间后,或者当数据更改达到一定程度,来重新收集。但是,随着业务应用程序变得越来越复杂和动态,预定义的统计信息收集可能无法以更有针对性的方式帮助查询。例如,用户可以选择不同的聚合列和列数,其组合可能会有很大差异。但是,在查询生成之前很难预测这样的组合。因此,很难在统计收集时决定正确统计方案。但是,此类统计信息可帮助优化器做出正确决定。
AnalyticDB设计了一个查询驱动的动态统计信息收集机制来解决此问题。守护程序动态监视传入的查询工作负载和特点以提取其查询模式,并基于查询模式,分析缺失和有益的统计数据。在此分析和预测之上,异步统计信息收集任务在后台执行。这项工作旨在减少收集不必要的统计数据,同时使大多数即将到来的查询受益。对于前面提到的聚合示例,收集多列统计信息通常很昂贵,尤其是当用户表有大量列的时候。根据我们的动态工作负载分析和预测,可以做到仅收集必要的多列统计信息,同时,优化器能够利用这些统计数据来估计聚合中不同选项的成本并做出正确的决策。
总而言之:传统的数据库需要人为指定收集的信息,而一旦查询模式发生变化,也需要人为调整,AnalyticDB可以动态地根据传入的查询语句来调整需要收集的统计信息,并且能通过智能预测尽可能少的收集数据。

5.2 Execution Engine

AnalyticDB提供了一个通用的pipeline模式执行引擎,以及在该引擎之上的DAG(有向无环图)运行框架。它适用于低延迟和高吞吐的工作负载。AnalyticDB的列式执行引擎能够充分利用底层的行列混合存储。与行式执行引擎相比,当前的向量化执行引擎更加缓存友好,能避免将不必要的数据加载到内存中。



PipeLine模式的执行引擎

与许多 OLAP 系统一样,AnalyticDB在运行时利用代码生成器(CodeGen) 来提高 CPU 密集型计算的性能。AnalyticDB的CodeGen基于 ANTLR ASM来动态生成表达式的代码树。同时此 CodeGen 引擎还将运行时因素纳入考虑,让AnalyticDB能在Task级别根据不同的硬件特性选择最优执行方案。例如,如果集群中CPU支持 AVX-512指令集,AnalyticDB通过生成字节码使用SIMD来提高性能。在此之外,通过整合内部数据表示形式,在存储层和执行引擎之间,AnalyticDB是能够直接对序列化二进制数据进行操作,而不是Java 对象。这有助于消除序列化和反序列化的开销,这在大数据量shuffle时可能会节约20%以上的时间。

6. 测试评估

在本节中,我们将在实际工作负载和TPC-H基准测试中对AnalyticDB进行评估,以展示AnalyticDB在不同类型的查询下的性能及其写入能力。

6.1 测试环境

实验是在由八台物理计算机组成的群集上进行的,每台物理计算机都具有Intel Xeon Platinum 8163 CPU(@ 2.50GHz),300GB内存和3TB SSD硬盘。所有这些机器都通过10Gbps以太网连接。我们在此集群中包含4个Coordinator,4个写节点和32个读节点。
实际工作负载 我们在生产中使用两个真实表进行评估。第一个表称为“Users”,该表使用用户ID作为其主键,并且具有64个主分区(没有辅助分区)。第二个表称为“Orders”,它使用订单ID作为其主键,并具有64个主分区和10个辅助分区。这两个表通过用户标识相互关联。我们使用实际用户生成的三种查询(如表2所示),这三种查询包含了:scan,点查到多表join。请注意,所有三个查询都包含交易时间,即时间戳列。这是因为Druid必须具有一个timestamp列作为分区键,并且查询时不指定timestamp列,将会非常缓慢。

对比的系统 我们将AnalyticDB与四个OLAP系统进行了比较:PrestoDB,Spark-SQL ,Druid和Greenplum。 Greenplum在所有列上都有索引;Druid不支持数字列上的索引;PrestoDB和Spark-SQL将数据保留在Apache ORC(优化后的列式文件)文件中,并且任何列上都没有索引。所有系统均以其默认配置运行。请注意,Druid不支持JOIN之类的复杂查询,因此无法执行表2中的大多数TPC-H查询和Q3。因此,我们在相应的实验中将其省略。在所有实验中,并发数都是指并发运行的查询数。

6.2实际负载

本节首先介绍对1TB数据和10TB数据的查询性能,然后说明写吞吐量。

6.2.1 查询1TB数据

我们生成了一个1TB的数据集来运行表2中的三个查询。图14和图15分别显示了AnalyticDB,PrestoDB,Druid,Spark-SQL和Greenplum的查询延迟数据。可以看出,与其他系统相比,AnalyticDB的延迟至少降低了一个数量级。



Q1. 借助索引引擎,AnalyticDB避免了全表扫描和排序,这与PrestoDB和Spark-SQL不同。特别是,AnalyticDB将ORDER BY和LIMIT的运算符分配到每个二级分区,该二级分区保存交易时间列的索引。由于索引是有序的,因此每个分区仅遍历索引即可获得想要的行ID,该ID仅涉及数十个索引条目。尽管Greenplum也在所有列上建立索引,但是它无法将它们用于ORDER BY运算符并执行完整扫描,因此比AnalyticDB慢得多。Druid使用交易时间作为range的分区列,在此列上执行ORDER BY时,Druid会从最大range的分区进行过滤。它比Greenplum具有更好的性能,但仍比AnalyticDB慢,因为它仍会扫描该分区中的所有行。
Q2. 在我们的数据集中,满足o_trade_time,o_trade_prize和o_seller_ID条件的的行数分别为306340963、209994127和210408。如果没有索引支持,PrestoDB和Spark-SQL必须扫描所有行以进行过滤。 Druid和Greenplum与PrestoDB和Spark-SQL相比可以获得更好的性能,因为它们可以利用索引列快速查找。但是,Druid仅能在字符串类型的列上建立索引。 Greenplum的索引可用于所有列,但必须顺序过滤多个条件,并且没有针对未改变过滤条件的查询进行缓存。与它们相比,AnalyticDB直接并行扫描三列上的索引,并缓存符合条件的行ID(第4.2.6节)。因此,具有相同条件的后续查询可以快速返回结果。
Q3。如图14和图15所示,在不同的并发级别下,Q3的50/95%延迟高于Q1和Q2。这是因为Q3是一个更为复杂的查询,即结合了GROUP BY和ORDER BY运算符的多表join扫描。尽管由于查询复杂性,延迟会稍高,但是AnalyticDB仍然可以确保其性能表现是最优的。尤其是AnalyticDB将join运算符转换为等价的子查询,并利用索引来完成这些子查询。它进一步利用索引来执行GROUP BY和ORDER BY运算符,并避免了构建hashmap的开销。 Greenplum比AnalyticDB慢,因为它付出了更多的开销用来构建hashmap。为了公平起见,我们还以hash join的模式评估AnalyticDB,并且与Greenplum的性能表现相当。

6.2.2 查询10TB数据

我们进一步生成了一个更大的10TB数据集,并提高了并发水平。由于在大型数据集中,其他系统比AnalyticDB慢得太多,因此在此就不在展示测试数据了。
图16说明了对1TB和10TB数据的三个查询的TP50的延迟数据。我们可以看到,对于Q1和Q2,在不同的并发级别下,延迟都能维持在数百毫秒以内。对于Q3,并发为200时的延迟远高于并发为40的延迟。原因是8台计算机下的计算能力已达到饱和。具体来说,在64个主分区和10个辅助分区下,实际并发线程在200个并发下可能达到128,000个。这八台机器上总共有48×8 = 384个CPU内核。由于Q3占用大量计算资源,因此由于频繁的上下文切换而导致性能下降,从而导致高延迟。



从图16中可以看到,在不同并发级别下10TB数据的变化趋势与1TB数据的变化趋势相似。也就是说,尽管数据大小增加,性能也不会受到显着影响。 10 TB的查询延迟仅是1 TB的两倍,这是因为AnalyticDB首先在索引中搜索行ID,并且只需要获取符合条件的行。借助索引缓存可以进一步提升查询效率,并降低了总体开销。总而言之,AnalyticDB的性能受表大小的影响很小,但更多地取决于索引的计算以及符合条件的行的数量。

6.2.3 写吞吐量

为了评估AnalyticDB的写性能,我们将每条记录的大小设置为500byte,并将这些记录插入Orders表。表3说明了写吞吐量(每秒写请求):



得益于读/写分离的架构和异步索引构建,写吞吐量随着写节点数量的增加几乎呈线性增长,直到盘古达到饱和为止。当写入节点的数量达到10个时,写入吞吐量为625,000,带宽对应于大约300 MB / s。请注意,AnalyticDB在异步构建索引时,能够保证分布不会影响查询效率和写入吞吐量(如4.2.4节中的表1所述)。

6.3 TPC-H Benchmark

我们生成1TB数据用于TPC-H评估。图17说明了AnalyticDB,PrestoDB,Spark-SQL和Greenplum之间的性能比较。 AnalyticDB在22个查询中有20个查询的运行时间最短,并且比第二好的查询(即Greenplum)快2倍。与Spark-SQL相比,AnalyticDB采用pipeline处理模型和索引,这比基于stage的处理模型要快。 PrestoDB也采用流水线处理,但缺少列索引。尽管Greenplum同时具有pipeline处理和全列索引,但是AnalyticDB还具备四个其他优势:

  • AnalyticDB使用行列混合存储,而Greenplum使用列存储。常见的TPC-H查询涉及大约一半的列,因此AnalyticDB可以使用单个I/O来获取一行的多个列。
  • 与Greenplum中基于统计的查询执行计划优化相比,AnalyticDB基于运行时成本的索引路径选择(使用实际的中间结果)会选择更优的查询执行计划。
  • AnalyticDB将K路归并与复合谓词下推结合在一起。
  • AnalyticDB集成了矢量化执行引擎,并将优化的CodeGen应用于所有运算符和表达式。对于查询2,AnalyticDB比PrestoDB和Greenplum慢,因为它为多表join选择了不同的join顺序。

7. 结论

AnalyticDB具有高效的索引引擎,可以为所有列异步构建索引,这有助于提高查询性能并隐藏索引构建开销。 经过精心设计,全列索引仅多消耗66%的存储空间。 AnalyticDB扩展了行列混合存储,以支持结构化数据和其他复杂类型的数据可能涉及的复杂查询。 为了同时提供高吞吐量写和高并发查询,AnalyticDB遵循读/写分离。 此外,我们增强了AnalyticDB中的优化器和执行引擎,以充分利用我们的存储和索引的优势。
从测试结果来,AnalyticDB的性能要由于同类其他olap产品。

参考与引用

http://www.vldb.org/pvldb/vol12/p2059-zhan.pdf
https://blog.csdn.net/yunqiinsight/article/details/100512749

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容

  • ORA-00001: 违反唯一约束条件 (.) 错误说明:当在唯一索引所对应的列上键入重复值时,会触发此异常。 O...
    我想起个好名字阅读 4,867评论 0 9
  • 【什么是大数据、大数据技术】 大数据,又称巨量资料,指的是所涉及的数据资料量规模巨大到无法在合理时间内通过传统的应...
    kimibob阅读 2,692评论 0 51
  • 一、 设计理念 1.空间换时间 1)多级缓存,静态化 客户端页面缓存(http header中包含Expires/...
    帅T阅读 3,585评论 1 15
  • 今天看到一位朋友写的mysql笔记总结,觉得写的很详细很用心,这里转载一下,供大家参考下,也希望大家能关注他原文地...
    信仰与初衷阅读 4,699评论 0 30
  • 原文出处: 杨步涛的博客 一、 设计理念 1. 空间换时间1) 多级缓存,静态化客户端页面缓存(http head...
    CookieziSui阅读 2,482评论 0 48