【译】Cassandra数据模型

原创
2016/12/21 18:53
阅读数 88

        本文是英文贴的翻译,可以直接查看英文原文
        选择正确的数据模型正是使用Cassandra最困难的一部分。如果诸位有相关开发经验,就会发现CQL虽然看起来很熟悉,但是使用起来却完全不同。
        本文将说明设计Cassandra schema的基本准则。在项目中遵守它们不仅有马上可见的益处,而且可以保证在今后扩展节点时Cassandra的性能保持线性增长。

无效的准则

        有关系型数据库经验的开发者经常会将之前的经验带入Cassandra。为了避免在这些并不重要的事(对Cassandra而言)上耗费时间,先指出一些无效准则:

减少写库数量

        虽然在Cassandra中写数据并不完全免费,但是非常非常廉价。Cassandra天生就是为了高速率的写而优化设计的。在Cassandra中几乎总是应该使用冗余的写来提高读取效率。请记住,读比写昂贵很多并且更难以调优。

减少数据复制

        别害怕,Cassandra所做的事情就是复制数据。磁盘空间通常是最廉价的资源(比起CPU、memory、磁盘IO、网络等等),整个Cassandra正是架构在这个事实之上。你常常需要复制数据来提升读的效率。
另外Cassandra没有JOINs语句(当然,在分布式设计中你不会真的想使用它!)

基本准则

        你的数据模型应遵循如下两个高层级目标:
1. 让数据在集群(cluster)中尽可能的均匀分布
2. 从尽可能少的分区(partition)中查找数据
        上面的两条是最最重要的准则。你应该首选要学会在项目中如何做好这两点。

均匀分布数据

        一般来说,希望集群中的各个节点数据量尽可能相等。在Cassandra中,数据通过分区键(partition key)来分区存储。分区键即主键(PRIMARY KEY)的第一个元素。
        分区键决定了数据存放在哪个node上。所以数据能够均匀分布的关键就在于:选择一个好的主键

最小化分区查找

        分区包含的是有着相同分区键的数据行。当你发起一个查询时,请从尽可能少的分区中读取数据。
这条准则的重要性在于各个分区可能位于不同的节点。协调者(coordinator)将向各个节点发起不同命令。这将带来额外的负荷与延迟。
        即使所要查询的分区都位于一个节点,跨分区的查询也要昂贵许多。

矛盾?

        既然应该从尽可能少的分区中查询数据,那为何不将所有数据都存到一个分区中?因为这违反了第一条准则。
        可以看到这两条准则会相互矛盾,所以实际项目中常常需要权衡。

从查询来构造模型

        实现最小化分区查找的方法就是使数据模型完全切合你的查询。不要从关系、对象等等来倒推模型,从查询入手!
第一步. 确定真正需要支持什么查询
        试着决定你真正需要支持的查询。尽量考虑那些甚至一开始没想到的需求。例如:
        * 按照属性分组
        * 按照属性排序
        * 根据条件过滤
        * 在结果集中去重
        为了追求效率,查询条件的变化经常导致数据模型的变化。
第二步. 尽可能的让你的查询只读取一个分区
        实践中这常常意味着你需要为每一个查询单独建立一张表。换种说法,每张表意味着为你不同的查询事先准备好了答案。如果需要不同的答案,那就建立不同的表。这就是对读的优化。
        永远记住,数据复制是常规操作。你的许多表可能包含重复的数据。

示例

        用一些小例子来看看上面这些准则的最佳实践。

示例一 用户查询

        最顶层的需求是“我们有一些用户,并且需要查询他们”。设计步骤如下:
        1. 细化查询
比如需要按用户名或者email地址查询。每一种查询方式都需要得到用户的所有详细信息。
        2. 按每次只查一个分区的目标来建表
        既然两种查询都需要得到用户的全信息,那就建两张表:

CREATE TABLE users_by_username (
    username text PRIMARY KEY,
    email text,
    age int
)
 
CREATE TABLE users_by_email (
    email text PRIMARY KEY,
    username text,
    age int
)

        回顾一下上面提到的两条准则:
        数据均匀分布?数据将按照不同的用户进行分区,满足。
        最小化分区读?每个查询只读一个分区,满足。
        现在假设我们需要按照上面提到的无效目标进行优化,数据模型将会变成这样:

CREATE TABLE users (
    id uuid PRIMARY KEY,
    username text,
    email text,
    age int
)
 
CREATE TABLE users_by_username (
    username text PRIMARY KEY,
    id uuid
)
 
CREATE TABLE users_by_email (
    email text PRIMARY KEY,
    id uuid
)

        这种数据模型确实是均匀分布的,但是它的缺点是需要查询两个分区。:(

示例二 用户组

        现在来改变一下示例一中的顶层需求:用户是按组进行划分的,我们需要按照组来查找用户
        1. 细化查询条件
        我们需要查找特定组中的所有用户信息,不关心排序。
        2. 按每次只查一个分区的目标来建表
        如何将一个组放到一个分区中 ? 这需要用到复合主键:

CREATE TABLE groups (
    groupname text,
    username text,
    email text,
    age int,
    PRIMARY KEY (groupname, username)
)

        这里的主键包含组名和用户名:组名作为分区键,用户名作为聚合键(clustering key)。这样一个组就完全属于同一个分区。
        在特定组中,数据以用户名为序。查询语句很简单:

SELECT * FROM groups WHERE groupname = ?

         很容易看出这种设计满足第二条准则。但是并不是那么满足第一条。如果我们有着大量的小组数量,每个里面有着少量百用户,这时数据才会均匀分布。
        但是如果仅仅有着一个组,里面包含着大量用户,那么这个组所在的节点(或该节点的复制组)将会承担所有负载。

        如果需要将数据更加均匀的分布,我们需要使用一些策略。首先需要在主键中增加一列,使用联合分区键:

CREATE TABLE groups (
    groupname text,
    username text,
    email text,
    age int,
    hash_prefix int,
    PRIMARY KEY ((groupname, hash_prefix), username)
)

        新增加的hash_prefix是用户名的hash的前缀。比如,可以是hash对4取模的第一个字节。hash_prefix和groupname组成了联合分区键。这导致了一个组的所有用户会分布在四个分区中。
        我们的数据变得更均匀了,但是现在一个查询将要跨越四个分区。这就上文所提到矛盾的例子。你需要从你的具体使用环境中找到平衡。
        如果查询十分频繁,而且你的每个分组不是特别大,将对4取模改为对2取模可能是一个好的选择。
        反过来说,如果查询不频繁,并且要查询的分组会特别大,将4改为10将会更合适。

        在这个例子中,我们在每个分区中复制了相同用户的所有信息。你可能会尝试着这样做来减少数据复制:

CREATE TABLE users (
    id uuid PRIMARY KEY,
    username text,
    email text,
    age int
)
 
CREATE TABLE groups (
    groupname text,
    user_id uuid,
    PRIMARY KEY (groupname, user_id)
)

        这种做法中,如果说一个分组有着1000个用户,那么查询时我们就要读取1001个分区!如果读取频繁,那么这种做法是极端不可取的。
        另一方面,如果按组读取的频率极低,但是更新用户信息(用户名)特别频繁,此时这种做法倒还有可取之处。
        记住设计数据模型时将读/写频率考虑进去!

示例三 用户组和入组时间

        现在在上个例子中加入入组时间,一次读取x个新入组用户。新表如下:

CREATE TABLE group_join_dates (
    groupname text,
    joined timeuuid,
    username text,
    email text,
    age int,
    PRIMARY KEY (groupname, joined)
)

        这里使用了timeuuid(类似于timestamp,但是两条记录不会相同)作为聚合列。在一个组(分区)中,数据按照用户入组时间排列。查询语句如下:

SELECT * FROM group_join_dates
    WHERE groupname = ?
    ORDER BY joined DESC
    LIMIT ?

        这样做可以有效的保证查询效率。但是总是使用ORDER BY并不是最高效的。更高效的做法表结构如下:

CREATE TABLE group_join_dates (
    groupname text,
    joined timeuuid,
    username text,
    email text,
    age int,
    PRIMARY KEY (groupname, joined)
) WITH CLUSTERING ORDER BY (joined DESC)

        下面是能为查询效率带来那么一点轻微提升的查询:

SELECT * FROM group_join_dates
    WHERE groupname = ?
    LIMIT ?

        前一个例子中,因为在组变得过大时数据将不会均匀分布,所以我们用某种随机方式来进行分区(用户名hash)。在这个例子中,我们可以利用时间段来进行分区。比如:

CREATE TABLE group_join_dates (
    groupname text,
    joined timeuuid,
    join_date text,
    username text,
    email text,
    age int,
    PRIMARY KEY ((groupname, join_date), joined)
) WITH CLUSTERING ORDER BY (joined DESC)

        这次利用了入组时间作为复合分区键,每一天将开始一个新的分区。当查询x个新用户的时候,我们首先查询当天的分区,然后是昨天,直到结果集包含x个用户。这样在得到最终结果前可能要查询多个分区。
        为了最小化查询分区数量,应该尝试选择一个合适的时间范围,使得你的查询只会查到一个或两个分区。比如说业务上经常要查的是10个最新的用户,同时每个组每天会增加三个人左右。
        合适的做法是将4天作为一个时间段,而不是一天。可以截取时间戳到合适的长度。如:

now = time()
four_days = 4 * 24 * 60 * 60
shard_id = now - (now % four_days)

总结

        本文的基本原则适用于目前所有的Cassandra版本,很可能也同样适用于未来的版本。其他的一些数据模型面临的问题(如tombstone),当然也需要考虑,但不同的是它们不太可能出现在Cassandra的未来版本中。

        还有Cassandra的其他一些特性(如 集合、用户自定义类型、 静态列)也有助于减少查询分区数目。设计时请别忘了它们。
        想更深入的学习,请看这里。祝各位好运!!

展开阅读全文
1
0 收藏
分享
加载中
更多评论
打赏
0 评论
0 收藏
1
分享
返回顶部
顶部