在这篇博文中,我们将深入研究Cassandra 3.0的全新物化视图功能。我们将看到它是如何在内部实现的,您应该如何使用它来充分利用其性能以及需要避免哪些警告。
本文中Cassandra == Apache Cassandra™, 物化视图是Materialized Views译文
为什么是物化视图?
Cassandra数据模型的关键点之一是非规范化,即复制数据以便更快地访问。以牺牲磁盘空间以获取更低读取延迟。
如果您的数据本质上是不可变的(如时间序列数据/传感器数据),那么您可以轻松上手,非常迷人。
但是,需要非规范化的可变数据总是痛点。一般来说,人们最终采取以下策略:
- 对不可变数据进行非规范化处理
-
对于可变数据,要么:
- 接受将它们标准化,接受额外的读开销,但不关心变动
- 非规范化处理,但接受先读后写,再处理更新这些开销
- 因为非规范化大多数时候用于不同的读取模式,你可以依靠第三方索引解决方案(如Datastax Enterprise Search或Stratio Lucene-based secondary index 或SASI二级索引)作业
对于经常变化的数据两种解决方案都不理想,因为它会给开发人员带来很多开销(在客户端额外读取或同步更新数据)
物化视图的目的是为了减轻开发者的痛苦,但它不会奇迹般地解决了非规范化的所有开销。
物化视图创建语法
以下是创建物化视图的语法:
CREATE MATERIALIZED VIEW [IF NOT EXISTS] keyspace_name.view_name AS
SELECT column1, column2, ...
FROM keyspace_name.base_table_name
WHERE column1 IS NOT NULL AND column2 IS NOT NULL ...
PRIMARY KEY(column1, column2, ...)
在第一个视图中,很明显物化视图需要一个基表。物化视图,从概念上讲,仅仅是另一种方式来呈现基表数据,具有不同的主键,不同的访问模式。
细心的读者应该注意到where子句 column1 IS NOT NULL AND column2 IS NOT NULL ...。当然,此子句保证将用作视图主键的所有列都不为null。
创建物化视图的一些约束项
- AS SELECT column1, column2, … 子句让你选择其中的列基表要复制到视图中。基表主键列会被包含在内的。
- WHERE column1 IS NOT NULL AND column2 IS NOT NULL … 自居保证了视图的主键列没有空列
- PRIMARY KEY(column1, column2, …) 子句中应该包含基表的所有主键列,再加上至多有一个列,它不能是基本表的主键的一部分,这些主键列顺序并不重要,可自行抉择
一例胜千言
CREATE TABLE user(
id int PRIMARY KEY,
login text,
firstname text,
lastname text,
country text,
gender int
);
CREATE MATERIALIZED VIEW user_by_country
AS SELECT * //denormalize ALL columns
FROM user
WHERE country IS NOT NULL AND id IS NOT NULL
PRIMARY KEY(country, id);
INSERT INTO user(id,login,firstname,lastname,country) VALUES(1, 'jdoe', 'John', 'DOE', 'US');
INSERT INTO user(id,login,firstname,lastname,country) VALUES(2, 'hsue', 'Helen', 'SUE', 'US');
INSERT INTO user(id,login,firstname,lastname,country) VALUES(3, 'rsmith', 'Richard', 'SMITH', 'UK');
INSERT INTO user(id,login,firstname,lastname,country) VALUES(4, 'doanduyhai', 'DuyHai', 'DOAN', 'FR');
SELECT * FROM user_by_country;
country | id | firstname | lastname | login
---------+----+-----------+----------+------------
FR | 4 | DuyHai | DOAN | doanduyhai
US | 1 | John | DOE | jdoe
US | 2 | Helen | SUE | hsue
UK | 3 | Richard | SMITH | rsmith
SELECT * FROM user_by_country WHERE country='US';
country | id | firstname | lastname | login
---------+----+-----------+----------+-------
US | 1 | John | DOE | jdoe
US | 2 | Helen | SUE | hsue
在上面的例子中,我们希望按国家代码查找用户,因此加入the WHERE country IS NOT NULL子句。我们还需要包含原始表的主键(AND id IS NOT NULL)
视图的主键由国家/地区作为分区键组成。由于同一个国家/地区可能有许多用户,因此我们需要将用户ID添加为clustering列以区分它们。
WHERE xxx IS NOT NULL子句的基本原理是保证基表中的空值不会被非规范化为视图。例如,未设置其国家/地区的用户将不会被复制到视图中,主要是因为SELECT * FROM user_by_country WHERE country = null没有意义,因为country是主键的一部分。此外,将来,您可以使用除IS NOT NULL之外的其他子句,主要使用用户定义函数来过滤要非规范化的数据。
约束的基本原理(基表的所有主键列,加上最多一个不属于基表主键的列)是为了避免主键的空值。
例:
CREATE MATERIALIZED VIEW user_by_country_and_gender
AS SELECT * //denormalize ALL columns
FROM user
WHERE country IS NOT NULL AND gender IS NOT NULL AND id IS NOT NULL
PRIMARY KEY((country, gender),id)
INSERT INTO user(id,login,firstname,lastname,country,gender) VALUES(100,'nowhere','Ian','NOWHERE',null,1);
INSERT INTO user(id,login,firstname,lastname,country,gender) VALUES(100,'nosex','Jean','NOSEX','USA',null);
在上面的示例中,用户' NOWHERE '和' nosex '无法非规范化到视图中,因为作为视图主键一部分列是null。
技术实现
A 物化视图更新步骤
以下是在基表中插入/更新/删除数据时的操作顺序
- 如果设置了系统属性cassandra.mv_enable_coordinator_batchlog,则协调器将创建批处理日志
- 协调者发送修改至所有副本,并等待一致性级别要求的ack数
- 每个副本正在获取要在基表中插入/更新/删除的分区上的本地锁
- 每个副本都在基表的分区上执行本地读取
-
每个副本使用以下语句创建本地批处理日志:
- DELETE FROM user_by_country WHERE country ='old_value'
- INSERT INTO user_by_country(country,id,...)VALUES('FR',1,...)
- 每个副本都异步执行批处理日志。对于批处理日志中的每个语句,它使用CL = ONE针对配对的视图副本(稍后解释)执行
- 每个副本在本地执行基表的修改(mutation)
- 每个副本都释放基表分区上的本地锁
- 如果本地修改成功,则每个副本都会向协调器发送ack
- 如果协调器接收到与一致性级别要求一样多的ack,则客户端确认该修改(mutation)是成功的
- 可选的,如果设置了系统属性cassandra.mv_enable_coordinator_batchlog,并且如果QUORUM确认的是由所接收的协调,协调者的batchlog会被删掉
B 配对视图副本定义
在详细解释一些技术步骤的基本原理之前,让我们定义什么是配对视图副本。以下是源代码中的正式定义:
The view natural endpoint is the endpoint which has the same cardinality as this node in the replication factor.
The cardinality is the number at which this node would store a piece of data, given the change in replication factor.
如果keyspace 的复制策略是NetworkTopologyStrategy,我们在计算基数时过滤环以仅包含本地数据中心中的节点。基数是副本索引位置的意思
例如,如果我们有以下ring环:
- A,T1 - > B,T2 - > C,T3 - > A.
对于令牌T1,在RF = 1时,将包括A,因此T1的A的基数为1.对于令牌T1,在RF = 2时,将包括B,因此T1的B的基数为2.
对于令牌T3,在RF = 2时,将包括A,因此T3的A的基数为2。对于基表令牌为T1且视图标记为T3的视图,节点之间的配对将为:
- A写入C(对于T1,A的基数为1,对于T3,C的基数为1)
- B写入A(对于T1,B的基数为2,对于T3,A的基数为2)
- C写入B(对于T1,C的基数为3,对于T3,B的基数为3)
C本地锁定基表分区
读者应该想知道为什么每个副本都需要在基表分区上获取本地锁,因为锁定很昂贵。此锁定的原因是为了保证在基表分区上进行并发更新时的视图更新一致性。
假设我们在用户(id = 1)上有2个并发更新,其原始国家/地区为英国:
- UPDATE ... SET country ='US'WHER id = 1
- UPDATE ... SET country ='FR'WHER id = 1
如果没有本地锁定,我们的视图将发生穿插修改
用户(id = 1)现在在视图表中有2个条目(country =' US '和country =' FR '),这是不对的。
使用本地锁定修复此问题
实际上,操作的顺序1)读取基表数据2)删除视图旧分区3)插入视图新分区必须要原子执行,因此需要锁定
D 本地batchlog用于视图异步更新
在每个副本上创建的用于视图更新的本地batchlog可确保即使出现故障(例如,因为视图副本暂时关闭),最终也会提交视图更新。
使用一致性级别ONE是因为每个基表副本负责更新其配对的视图副本,因此一致性级别ONE就足够了。
此外,每个配对视图副本的更新是异步执行的,例如,副本在处理到基表更新之前不会阻塞并等待确认。本地batchlog保证在出现错误时自动重试。
E 视图数据一致性级别
客户端在基表上请求的一致性级别得到遵守,例如,如果需要QUORUM(RF = 3),协调器只有在从基表副本收到2个ack时才会确认成功写入。在这种情况下,客户端确保基表更新在3副本中至少2个副本上进行了持久化
视图表的一致性保证较弱。与上面的例子中,我们只有该视图将被更新,保证最终上3副本中至少2副本以上进行持久化
与基表相比,一致性保证的主要区别在于最终一致(异步本地批处理日志,非强一致)。在协调器接收基表副本2个ACK的时候,我们都不能肯定 view表已经更新上至少2个副本。
F 协调者批处理日志
系统属性cassandra.mv_enable_coordinator_batchlog仅对某些边界情况有帮助。并不能防范所有边界case,同时花费高额代价,协调者batchlog一般情况下没意义,参数cassandra.mv_enable_coordinator_batchlog被默认禁用。
性能考虑
与正常突变相比,具有物化视图的基表上的mutation将产生以下额外成本:
- 基表分区上的本地锁定
- 基表分区上的本地read-before-write
- 物化视图的本地批处理日志
- 可选地,协调器批处理日志
实际上,大多数性能热点都是由本地先读后写入引起的,但这个只开销一次,并不取决于与表关联的视图个数。
但是,增加视图数量会对群集范围的写入吞吐量产生影响,因为对于每个基表更新,您将向群集添加额外的(DELETE + INSERT)* nb_of_views负载。
话虽如此,比较普通表和具有视图的表之间的原始写吞吐量是没有意义的。比较手动非规范化表(使用已记录的批处理客户端)和使用物化视图的同一表之间的写入吞吐量更为明智。在这种情况下,具有物化视图的自动服务器端方案明显胜出,因为:
- 它为先前读写节省了网络流量
- 它为已记录的非规范化表mutation批量节省了网络流量
- 它消除了开发人员不得不保持与基表同步的非规范化表的痛苦
值得一提的另一个性能考虑因素是热点。与手动非规范化方案类似,如果您的视图分区键选择不当,您最终会在群集中出现热点。我们的用户表的一个简单示例是创建物化视图user_by_gender
// THIS IS AN ANTI-PATTERN !!!!
CREATE MATERIALIZED VIEW user_by_gender
AS SELECT * FROM user
WHERE id IS NOT NULL AND gender IS NOT NULL
PRIMARY KEY(gender, id)
根据上面的观点,所有用户最终只会有2个分区:男性和女性。您当然不希望群集中存在此类热点。
现在,物化视图和二级索引相比,读性能如何?
根据二级索引的实现,读取性能可能会有所不同。如果执行scatter-gather操作,则读取性能将与数据中心/集群中的节点数密切相关。
注:二级索引会发起全集群节点查询。
读取操作也总是由两个不同的读取路径组成:
- 读取磁盘上的索引以查找相关的主键
- 从C *中读取源数据
话虽这么说,很明显物化视图会给你更好的读取性能,因为读取是直接的,只需一步即可完成。我们的想法是您在写入时支付开销以获得读取时的增益。
实际上,在查询方面,物化视图与高级二级索引实现相比松散,因为只允许精确匹配,远程扫描(给我的用户在“UK”和“US”之间的国家/地区)将破坏您的读取性能。
物化视图和操作
在本章中,我们将讨论在操作方面实现物化视图的影响。
Repair & hints
- 可以独立于其基表修复视图
- 如果基表被修复,由于基于mutation的修复(通过写入路径进行修复,与正常修复不同),视图也将被修复
- 对视图的读取修复行为与正常的读取修复相似
- 基表上的读取修复也将修复视图
- 基表上的提示重放将触发相关视图的更新
schema:
- 物化视图可以修改为任何标准表(压缩,压缩,......)。使用ALTER MATERIALIZED VIEW命令
- 您不能从实例化视图使用的基表中删除列,即使此列不是视图主键的一部分
- 您可以向基表添加新列,其初始值将在关联视图中设置为null
- 你不能删除基表,你必须先删除所有相关的视图
The shadowable view tombstone
对于那些只想了解深层内部的人来说,这部分纯粹是技术性的。你可以放心地跳过它
在物化视图的开发过程中,一些问题出现在墓碑和查看时间戳上。我们来看这个例子:
CREATE TABLE base (a int, b int, c int, PRIMARY KEY (a));
CREATE MATERIALIZED VIEW view AS
SELECT * FROM base
WHERE a IS NOT NULL
AND b IS NOT NULL
PRIMARY KEY (a, b);
//Insert initial data
INSERT INTO base (a, b, c) VALUES (0, 0, 1) USING TIMESTAMP 0;
//1st update
UPDATE base SET b = 1 USING TIMESTAMP 2 WHERE a = 0;
//2nd update
UPDATE base SET b = 0 USING TIMESTAMP 3 WHERE a = 0;
ts是时间戳的简写
在初始数据插入时,视图将包含此行:pk = (0,0), row_ts=0, c=1@ts0
在1st更新,视图状态是:
- pk=(0,0), row@ts0, row_tombstone@ts2, c=1@ts0 (DELETE FROM view WHERE a=0 AND b=0)
- pk=(0,1), row@ts2, c=1@ts0 (INSERT INTO view … USING TIMESTAMP 2)
行(0,0)在逻辑上不再存在,因为行逻辑删除时间戳>行时间戳,到目前为止一直很好。在第二次更新时,视图状态为: - pk=(0,0), row@ts3, row_tombstone@ts2, c=1@ts0 (INSERT INTO view …)
- pk=(0,1), row@ts2, row_tombstone@ts3, c=1@ts0 (DELETE FROM view WHERE a=0 AND b=1)
由于我们将b重新设置为0,因此再次重新插入视图行(0,0),但每列的时间戳不同。((a,b) = (0,0)@ts3 but c=1@ts0 因为没有修改列c。
问题是,现在,如果你读取视图分区(0,0),列c值将被旧行墓碑@ts2遮蔽,所以 SELECT * FROM view WHERE a=0 AND b=0将返回(0, 0,null)这是错误的......
一个天真的解决方案是在第二次更新后将列c时间戳升级到3,例如pk=(0,0), row@ts3, row_tombstone@ts2, c=1@ts3
但是,如果以后有另一个UPDATE,UPDATE base SET c=2 USING TIMESTAMP 1 WHERE a=0 AND b=0 该如何做?如果我们遵循先前的规则,我们将在视图中为列c设置时间戳为1,它将被先前的值覆盖( (c=1@ts3)…
开发团队提出了一个解决方案:shadowable tombstone!有关详细信息,请参阅CASSANDRA-10261。
源代码注释中可隐式墓碑(shadowable tombstone)的正式定义是:
如果行时间戳(primaryKeyLivenessInfo().timestamp())低于删除时间戳,则仅可存在shadowable row tombstone。也就是说,如果一行具有带时间戳A的可阴影墓碑,并且对具有时间戳B的那一行进行更新,使得B> A,那么该shadowable tombstone将被该更新“遮蔽”。目前,shadowable row deletions的唯一用途是物化视图,请参阅CASSANDRA-10261。
有了这个实现,在1st更新,视图状态是:
- pk=(0,0), row@ts0, shadowable_tombstone@ts2, c=1@ts0 (DELETE FROM view WHERE a=0 AND b=0)
- pk=(0,1), row@ts2, c=1@ts0 (INSERT INTO view … USING TIMESTAMP 2)
在第二次更新时,视图状态变为: - pk=(0,0), row@ts3, shadowable_tombstone@ts2, c=1@ts0 (INSERT INTO view …)
- pk=(0,1), row@ts2, shadowable_tombstone@ts3, c=1@ts0 (DELETE FROM view WHERE a=0 AND b=1)
现在,当读取视图分区(0,0)时,由于可隐藏的逻辑删除 (ts2)被新的行时间戳(ts3)遮蔽,因此即使其时间戳(ts0)比可隐式墓碑时间戳(ts2)低,也会读出列c值
简而言之:
- 如果可阴影的墓碑时间戳>行时间戳,则可阴影的墓碑表现得像普通的墓碑
- 如果可阴影的墓碑时间戳<行时间戳,请忽略此可隐藏的逻辑删除(就像它不存在一样)
译至原文
微信群和钉钉群交流
为了营造一个开放的 Cassandra 技术交流,我们建立了微信群和钉钉群,为广大用户提供专业的技术分享及问答,定期在国内开展线下技术沙龙,专家技术直播,欢迎大家加入。
微信群:
钉钉群
钉钉群入群链接:https://c.tb.cn/F3.ZRTY0o