编者按:本文系InfoQ中文站向陈天的约稿,这是AWS系列文章的第三篇。以后会有更多文章刊出,但并无前后依赖的关系,每篇都自成一体。读者若要跟随文章来学习AWS,应该至少注册了一个AWS账号,事先阅读过当期所介绍服务的简介,并在AWS management console中尝试使用过该服务。否则,阅读的效果不会太好。DynamoDB看似简单,用起来则相当麻烦。本文将探讨如何进行DynamoDB的数据库(表)设计,如何使用 hash key / range key 使得查询更方便快捷?如何缓冲对DynamoDB的突发读写,如何自动scale up / scale down。
DynamoDB 是 AWS 独有的完全托管的 NoSQL Database。它的思想来源于 Amazon 2007 年发表的一篇论文: Dynamo: Amazon’s Highly Available Key-value Store 。在这篇论文里,Amazon 介绍了如何使用 commodity hardware 来打造高可用、高弹性的数据存储,这篇文章影响了很多 NoSQL 数据库的设计,如 cassandra / riak,也最大程度地将 consistent hashing 这个概念从学术界引入了工业界。欲理解 DynamoDB,首先要理解 consistent hashing。consistent hashing 的原理如下图所示:
它的概念是:我有一个足够大的keyspace(2的160次方,比较一下:IPv6是2的128次方),我们记作X,然后将X放在一个环形的空间里划分成大小相等的Y个 partition,依次循环排列(如图),每个 partition 由一个vnode(riak的概念)管理,当你有M个database server(node),Y个vnode再平均映射到M个node上。当数据要插入时,将其主键(hash key)映射到K中的一个地址(addr),对应到某个vnode,再进一步对应到某个node,如果这个数据需要N个replica,则将数据写入addr(vnode a),addr + 1(vnode b), … ,add + N(vnode n)。在这种设置下,M就是你的shards,N是replica。以后添加新的node时,映射发生变化,只需要把相应的变化了的vnode迁移到新的node上即可。在这种结构下,sharding/replica对程序员基本上是透明的。
在 DynamoDB 中,为持久化,一份数据被写入到三个 replica 中。至于需要多少个 partition(或者说 shard),则由开发者为每个 DynamoDB table 设置的读写属性来确定。对于一个有三个 partition,三个 replica 的表,数据是这样分布的:
DynamoDB 组织数据的基本单元是 table,类似于 MongoDB 的 collection,或者 RDBMS 里的 table,每个 table 里可以有任意数量的 item,类似于 MongoDB 的 document,或者 RDBMS 里的 row。而每个 item,必须有一个 hash key,用于做 consistent hashing,还可以有一个可选的 range key,用于查询。如果 range key 存在,则 hash key 可以重复,hash key + range key 唯一确定一个 item 即可;如果 range key 不存在,则 hash key 必须唯一。
我们知道,一个 hash key 只会存在于同一个 partition 上,因此,相同 hash key 不同 range key 的 item 也会在同一个 partition。如果 hash key 选择不好,容易产生过热的 partition,影响效率。
对于 hash key,查询的动作很单一,就是 Get,或者 BatchGet,在同一个 partition 内部,数据是按照 range key 进行排序的。
如果你要使用 hash key / range key 以外的字段进行查询,你必须为要查询的字段创建索引(LocalIndex,Global Secondary Index)。DynamoDB 里面的 Global Secondary Index 实质上是一张表,只不过和主表联动更新而已。
很多用户在第一次接触 DynamoDB 时,已经有了很多其他 DB 的工作经验,这时候,照搬已有的经验去使用 DynamoDB,会遇到很多挑战。
在 MySQL 或者 MongoDB 里,同一个 db client 的若干次写请求是排队的,这个特性会导致应用程序的一些 race condition 在 DB 层面被掩盖了。比较经典的问题是 Session Store。大部分的应用框架都会使用 DB 来存储 Session,然后在每个 request 到来时刷新 Session。其中,第一次访问的 request 和用户登录的 request 处理起来会特别一些,前者创建 Session,后者会在 Session 中附加用户信息,而其它的 request 对于 Session 而言,只是刷新过期时间而已。框架一般会将这些操作区分开来,比如,在 nodejs 的 express 框架中,Session Store 定义了几种基本的操作: get
, set
和 touch
。 get
用于获取 Session, set
用于创建/更新(replace)Session, touch
用于刷新(update timestamp)Session。很多 DB 的实现采用偷懒的方式,不定义 touch
,将刷新的行为和更新的行为等同起来,这会导致同一个用户的多个并发的 request 进入 Session 更新的 race condition,如果恰巧其中的一个 request 是处理用户登录,那么用户登录在 SessionStore 里所做的操作有可能会被其他 request 的操作覆盖,导致登录不成功:
get.set.....done // 登录 .......get.set.....done // 刷新
这个问题在 express-mysql-session
和 express-mongo-session
里都存在,只不过被数据库的串行访问给掩盖了:
get.set.....done ................get.set.....done
但 DynamoDB 的任何访问都是无状态的,它没有 Connection 的概念,所有的操作都是一个 HTTPS 请求。这是为何同样逻辑的代码(见: connect-dynamo
,使用 DynamoDB 就会触发 race condition。这并非 DynamoDB 的毛病,只不过 DynamoDB 帮我们将这种问题显现出来而已。
初学 DynamoDB 的用户,在使用 updateItem 时,胸毛上一定有万千个草泥马在奔腾。MySQL 的 update
语句简单直观,MongoDB 的 $set
操作稍稍有些绕,但逻辑还算比较正常,读一下手册,立刻知道该怎么写。可是 Amazon 不给 DynamoDB 好好地整 API,下面的查询代码在初次相见时,有一种如鲠在喉的感觉:
{ TableName: 'AwesomeTable', Key: { id: { S: 'AwesomeID' } }, UpdateExpression: 'SET #attrName =:attrValue', ExpressionAttributeNames: { '#attrName': 'attr_to_be_updated' }, ExpressionAttributeValues: { ':attrValue': { S: 'value_to_be_updated'} } }
更要命的是,API 复杂也就罢了, API 文档 还不给你好好写,初学者如无帮助,根本无法使用。
在 DynamoDB 里,要想根据索引查询数据,必须显式指定 IndexName
,这也是很多从其它 DB 迁移过来的用户感觉很绕的地方:
{ TableName: 'AwesomeTable', IndexName: EXPIRES_INDEX, KeyConditionExpression: '#hash_key = :v_hkey AND #range_key < :v_rkey', ExpressionAttributeNames: {'#hash_key': '<hash-key>', '#range_key': '<range-key>'}, ExpressionAttributeValues: { ':v_hkey': {'S': '<some-value>'}, ':range_key': {'N': <some-num>.toString()} }, ProjectionExpression: '<fields-you-want-to-projected>' }
另外,查询的时候 hashkey 是必须的,rangekey 是可选的。DynamoDB 通过 hashkey 确定在哪个 partion 下查询,然后通过 range key 再在该 partition 下进一步筛选。由于 hashkey 的特性,你只能进行相等的判断,不能使用大于、小于、或者 begin with。更丰富的查询操作只能通过 range key 来实现。这一点也给从 MySQL / MongoDB 阵营过来的用户带来很大程度的不适,原本会写的查询,在 DynamoDB 中突然不知道怎么处理了。
如果能够掌握更新和查询的别扭语法,那么其他的数据库操作都不在话下。很多语言在 DynamoDB 直接的 SDK 上提供了一层封装,可以更优雅地用人性化的方式使用 DynamoDB,多多少少也减轻了使用上的痛苦。
由于 DynamoDB 与众不同的计费模式,使其使用方式和其它 DB 也有显著不同。其它 DB,你预先估计好容量,准备好 cluster,然后为整体使用的服务器资源买单,同时需要为下一次容量扩展做准备;而 DynamoDB 你只需要设置预估的读写容量,系统会帮你自动分配资源保证这个容量。在其它 DB 里,设计容量往往是峰值容量,在低谷期这些容量被大大地浪费了;而在 DynamoDB 里,你可以动态调整容量使其和系统一起伸缩。这是个讨厌的工作,它意味着工程师再也不能写完代码往运维那里一扔,说:「兄弟,抗不扛得住,看你了」;而是在代码中考虑系统要如何运维。
DynamoDB 增删改查等操作都能够返回使用 ConsumedCapacity
,这样你可以了解目前还剩多少剩余的容量,进行「动态」调整。这个调整不是即时的,有一定的时间代价,因为 DynamoDB 需要根据 consistent hashing 把 key 重新分布。因此,调整最好是指数级的,以减少调整的频次。
另外,DynamoDB 可以累积一定的读写容量来帮你应对突发状况。如果你的写容量是 100/s,系统突然瞬间涌入过多的请求,达到 200/s,只要你有累积的容量,DynamoDB 会帮你短时间撑过这个突发访问。这让你的系统在应对波动上变得简单。
这个特性可以被很好的利用。比如你的数据表要每天定期做一些数据整理活动。如果在 MySQL 里,你在凌晨1点自动一次性整理,但在 DynamoDB 下,这么做意味着你得规划好这次访问的容量,免得过载。简化的办法是,把清理活动均匀分布在凌晨 0~5 点的每个半小时。这样,你可以使用 1/10 的容量完成这个任务,并且可以利用累积的容量,把这一容量需求进一步降低到 1/15。
比如你要读取 1M 数据,分 10 次读,每次 100k,400s 读完,250/s,你可以用 150~200 左右的读容量,而非 2500/s,这样会大大节省费用。
本文先讲到这里,下一篇讲讲 hashKey 和 rangeKey 如何选择和定义,以便充分利用 DynamoDB 的特性。另外,会讲一些使用的场景。
感谢魏星对本文的策划与审校。
给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ,@丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入InfoQ读者交流群 (已满),InfoQ读者交流群(#2) )。