在近期的工作中,使用了 DynamoDB 作为一部分服务的数据库,因此整理了一些相关的知识,并结合项目实际使用过程中遇到的一些问题与大家分享下。
简介
DynamoDB 是一款由 AWS(Amazon Web Services) 提供的 全托管 模式的 NoSQL 服务,AWS 进行运维。DynamoDB 可以存储文档或者键值对类型的数据,号称可以以非常低的延迟支持 任何级别的负载 ,同时有着较完善的权限管理系统。
为什么选择 DynamoDB:
- 简化运维:全托管模式,减少创建、维护数据库的开销;跨区域的分区存储,数据更安全;
- 自动扩容:数据量的增长对数据库性能没有太多影响
- 灵活的表结构:只需要定义分区键即可正常使用,随时增加减少字段
- 事件流:流结合 Lambda 可以非常方便的实现一些功能
基本内容
- 表(Table)
- 文档(Document)
- 属性(Attribute)
- 分区键(Partition Key/Hash Key, 记为 PK,必需,数据类型必须是基本类型)
- 排序键(Sort Key/Range Key, 记为 SK,可选)
- 数据类型:
- 基本类型: number, string, binary, Boolean, and null
- 文档: list, map
- 集合: set
DynamoDB 的数据集合是一张表,表里的每一条数据就是一个文档,文档中可以存储基本类型的数据(BOOLEAN, NUMBER, STRING)或者一些复杂的数据(MAP, LIST, SET),可以嵌套较为深的层级。在创建一张表的时候,需要指定一个基本类型的属性值作为 PK,可以再指定一个基本类型的属性值作为 SK。PK 的作用是将数据分散到不同的分区(Partition)里,构建无序的哈希索引,SK的作用是将同一个分区中的数据按照一定的顺序排列起来,便于查找。SK 中可以使用 ==, <, >=, <=, begins with, between, contains, in
等函数来进行较为丰富的查询操作,PK 必须使用准备的值进行查询。
与 SQL 的 异同
SQL | NoSQLj |
---|---|
Optimized for storage | Optimized for compute |
Normalized/relational | Denormalized/Hierarchical |
Ad hoc queries | Instantiated view |
Scale vertically | Scale horizontally |
Good for OLAP | Built for OLTP at scale |
SQL 是 为存储进行优化 的,通过存储范式化的数据,尽量减少了存储空间的使用,同时构建了非常强大的查询能力,可以通过新建不同的表对数据库容量进行扩张,适用于 OLAP 应用。
DynamoDB 是一种 NoSQL 的数据库针, 对 计算资源进行了优化 ,以存储空间换计算时间。数据表中直接存储了非范式化的数据,可以直接拿出来使用。其中数据可以进行水平扩张,建表时无需指定确定的表结构,适用于 OLTP 应用。
索引
DynamoDB 共有两种索引,一种是本地二级索引(Local Secondary Index,简称 LSI),一种是全局二级索引(Global Secondary Index,简称 GSI)
本地二级索引 (LSI)
本地二级索引和原表共用一份 Partition, 使用同一个 PK, 共用这一个 PK 的存储空间,上限为 10GB。LSI 提供了不同的筛选方式和排序方式,选用不同的属性作为 SK 可以提高查询的灵活性。
全局二级索引 (GSI)
全局二级索引几乎相当于一个全新的表,除了与原表共用同一个表名之外,GSI 使用了新的 Partition Key 和 Sort Key, 有自己独立的 Partition, 计算读写数量时也与原表相互独立。GSI 会在原表发生改变的时候,通过流(Stream)将更新同步到 GSI 中。一个表可以建立 20 个全局二级索引。推荐使用全局二级索引,避免本地二级索引引起的读写容量的竞争,同时可以重新定义 PK 和 SK,支持更多的查询模式
读一致性
DynamoDB 由于是数据库云服务出身,因此在设计中着重考虑了数据的 可用性和 分区容忍性。从 CAP(Consistency, Availability, Partitioning)理论的角度来讲,DynamoDB 是一个 AP 的数据库。
在 DynamoDB 中,共有两个部分会涉及到读一致性的问题:主表读和GSI读。
主表读过程会产生一致性问题的原因是 DynamoDB 的 Partition 是有 三个副本的(实际为可用区,详细内容可查询“AWS区域和终端节点及可用区”),默认情况下写入到两个副本中即认为写入成功,因此在高频读取时也有一定的概率读到未完成写入的数据。可以通过配置 GetItem, Scan 等的操作一致性为 强一致性来避免这个问题,但可能会带来更高的延迟(10ms级别)和吞吐量开销(强一致性为最终一致性吞吐量开销的二倍)。
GSI 读写会产生一致性的原因是 GSI 和主表实际是 两个不同的存储,写入到主表的数据会通过流同步到GSI,这个过程会存在一定的延时(10ms级别),因此在读取 GSI 中的数据时,不能设置为强一致性。
表结构设计
对于关系型数据库来说,我们只需要将数据模型范式化之后,分别建表,在查询时使用 SQL 进行关联即可实现不同的查询方式,更适合于查询方式复杂多变的系统。 而 DynamoDB 是需要提前考虑好一些查询方式,才能更好的设计表结构,否则可能会在使用的过程中遇到一些不便。
DynamoDB 限于底层结构设计,进行表扫描(Scan)的操作非常耗时,对于较大的表来说基本上是 不可接受的时间消耗,因此需要根据 DynamoDB 的特点对表结构进行巧妙的设计,以实现需要的查询组合。因此,设计 DynamoDB 的第一步便是列举出可能的查询方式。当我们有了一组查询方式之后,便可以开始进行表结构的设计。
基本查询
在设计出查询方式之后,便可以优先关注使用量最多,查询方式最简单的查询需要。以下为主表中几种常见的查询方式:
- 使用 PK 直接查询。可以查出该分区键下的所有数据,适合于 一对多关系。例如查询一个用户下的多个订单记录,这时 PK 为用户的 ID
- 使用 PK + 完整 SK 进行查询。如果一个 PK 下的数据比较多,可以结合 SK 来实现更为精确的查询。例如查询一个用户下的一个特定的订单,这时 PK 为用户的 ID,SK 为订单的ID。
- 使用 PK + SK (==, <, >=, <=, begins with, between, contains, in) 进行查询。如果一个 PK 下数据比较多,同时每一类 SK 上有通用的前缀,可以用
begins with
来进行查询。例如查询用户的订单使用用户 ID 和 SK 前缀order:
进行查询。
索引重载
索引重载是使用 RDS 的用户最容易感到不适应的一部分,这意味着属性名不一定是一个有实际含义的名称,其内容也可能包含多个种类。AWS 的 Principal Technologist, Rick Houlihan 曾说,一个设计良好的 DynamoDB 应该只有一张表。这显然与我们对 RDS 数据的认识完全不一样,RDS 倾向于细粒度的划分每个实体,为实体及实体之间的关系建立相关的表。如果说我们一个应用只有一张表,那么意味着用户、订单、商品、发票等数据会共用一个表定义,表的结构可能类似下面这个样子:
PK | SK(GSI-PK) | DATA(GSI-SK) | DATA1 | DATA2 |
---|---|---|---|---|
用户ID | profile | xx | xx | xx |
用户ID | order-1 | yy | xx | xx |
用户ID | order-2 | zz | xx | xx |
用户ID | invoice-1 | vv | xx | xx |
商品ID | detail | xx | xx | xx |
可以使用 #User#{ID} 和 #PRODUCT#{ID}这样的方式在 ID 中带上该条记录的类型,SK 也可以采用类似的设计方式,这样一方面我们的可以更好的辨识一条数据的类型,另一方面当 PK 在 GSI 中作为 SK 时,可以使用 begins with
查出同一类的数据。
因为 DynamoDB 可以自动扩容,所设计好这一个表之后,我们便可以方便的对该表创建 GSI,开启备份,使用同样的查询、数据插入的命令来完成各种各样的操作,同时因为使用同一个读写容量,所以会减少读写容量的浪费。新建不同的表来存储这些内容,其实新建的表与该表也不会太大的差异,使用同一张表反而更加方便。
在使用 PK 和 SK 进行 Query 之后,还可以使用 Filter 进行数据的筛选,这些筛选只能使用在基础数据类型上面,因此如果存储 list, map, set 时可能会简化一些操作,但在筛选时遇到一些问题。这种时候就可以利用 sk 的 begins with
查询功能,将数据拆分开来方便筛选。
稀疏索引
创建 GSI 时,我们会重新选择一个基础类型的字段作为主键,这时,如果部分数据并不包含这个字段,那么这些数据就不会被添加到这个 GSI 索引中来。因此我们可以利用这个特性为不同的查询模式来创建不同 GSI,以减少 GSI 中数据的数量。
PK | SK(GSI-PK) | DATA(GSI-SK) | DATA1 | DATA2 |
---|---|---|---|---|
用户ID | profile | xx | xx | xx |
用户ID | order-1 | yy | xx | xx |
用户ID | order-2 | zz | xx | xx |
用户ID | invoice-1 | vv | xx | xx |
商品ID | detail | xx | xx |
如上表中我们以 DATA2 来建立 GSI,那么最后一条和商品相关的数据便不会进入到 GSI 中。
组合键
组合键也是一个非常有用的方式,如果有一些字段必须复合起来使用,并且会作出相对复杂的筛选条件,那么我们便可以考虑采用组合键来处理。如以下示例:
Opponent | Date | GameId | Status | Host |
---|---|---|---|---|
Alice | 2014-10-02 | d9bl3 | DONE | David |
Carol | 2014-10-08 | o2pnb | IN_PROGRESS | Bob |
Bob | 2014-09-30 | 72f49 | PENDING | Alice |
Bob | 2014-10-03 | b932s | PENDING | Carol |
Bob | 2014-10-03 | ef9ce | IN_PROGRESS | David |
我们希望达成类似 SELECT * FROM Game WHERE Opponent = 'Bob' ORDER BY DATE DESC FILTER ON Status='PENDING'
的查询,如果 Bob 的数据有上千条,但 IN_PROGRESS 的数量只有一条,那使用 Query + Filter 的结果是需要查到这千条数据,再从中过滤出需要的数据来。但如果我们使用组合键 StatusDate = Status + Date, 如下示例:
Opponent | StatusDate | GameId | Host |
---|---|---|---|
Alice | DONE_2014-10-02 | d9bl3 | David |
Carol | IN_PROGRESS_2014-10-08 | o2pnb | Bob |
Bob | IN_PROGRESS_2014-09-30 | 72f49 | Alice |
Bob | PENDING_2014-10-03 | b932s | Carol |
Bob | PENDING_2014-10-03 | ef9ce | David |
那我们便可以直接查到我们需要的这两条数据来,我们也可以继续使用 Filter 再进行过滤。同时会有一些特殊的要求,在不使用组合键时,如果进行查询会是一个相当大的挑战。
分级数据
与组合键类型,分级数据意味着该字段中的数据不是一个简单的数据,也会是一个拼起来的数据,最简单的例子是地域信息。比如四川省成都市,可以存储为 #SICHUAN#CHENGDU
,如果后面有多少级均可以继续接在后面。通过这种方式便把一个层级很深的数据转化成了一个简单的数据,同时使用 begins with
便可以查某个区划下的所有信息。
本地开发
DynamoDB 是 AWS 的云服务,可以使用 AWS 提供的 aws-cli, 通过命令行来完成常见的表操作及数据操作。最为常用的功能是结合其它命令一起进行查询及数据分析,或者清除表中的数据。同时,如果在开发过程中需要使用 DynamoDB,依然需要付出相应的服务费用,使用 DynamoDB Local 可以减少这部分开销。
CLI 示例
aws dynamodb create-table \
--table-name music \
--attribute-definitions \
AttributeName=artist,AttributeType=S \
AttributeName=song_title,AttributeType=S \
--key-schema \
AttributeName=artist,KeyType=HASH \
AttributeName=song_title,KeyType=RANGE \
--provisioned-throughput \
ReadCapacityUnits=10,WriteCapacityUnits=5
aws dynamodb describe-table --table-name music | grep TableStatus
aws dynamodb put-item \
--table-name music \
--item \
'{"artist": {"S": "No One You Know"}, "song_title": {"S": "Call Me Today"}, "album_title": {"S": "Somewhat Famous"}, "awards": {"N": "1"}}'
aws dynamodb get-item --consistent-read \
--table-name music \
--key '{ "artist": {"S": "Acme Band"}, "song_title": {"S": "Happy Day"}}'
aws dynamodb update-item \
--table-name music \
--key '{ "artist": {"S": "Acme Band"}, "song_title": {"S": "Happy Day"}}' \
--update-expression "SET album_title = :newval" \
--expression-attribute-values '{":newval":{"S":"Updated Album Title"}}' \
--return-values ALL_NEW
aws dynamodb update-table \
--table-name music \
--attribute-definitions AttributeName=album_title,AttributeType=S \
--global-secondary-index-updates \
"[{\"Create\":{\"IndexName\": \"album_title-index\",\"KeySchema\":[{\"AttributeName\":\"album_title\",\"KeyType\":\"HASH\"}], \
\"ProvisionedThroughput\": {\"ReadCapacityUnits\": 10, \"WriteCapacityUnits\": 5 },\"Projection\":{\"ProjectionType\":\"ALL\"}}}]"
aws dynamodb query \
--table-name music \
--index-name album_title-index \
--key-condition-expression "album_title = :name" \
--expression-attribute-values '{":name":{"S":"Somewhat Famous"}}'
aws dynamodb query \
--table-name music \
--key-condition-expression "artist = :name" \
--expression-attribute-values '{":name":{"S":"Acme Band"}}'
aws dynamodb scan --table-name music --filter-expression 'attribute_exists(artist)'
aws dynamodb delete-table --table-name music
// 使用 jq 获取某个字段并取得不重复值
aws dynamodb scan \
--table-name music \
--index-name business-index \
--projection-expression some_data \
| jq '.Items[] | .data.S' | sort --unique
DynamoDB Local
如果在本地进行开发,可以使用 DynamoDB Local, 一种方式是通过 DynamoDBLocal 的 jar 包直接运行,另一种方式是使用 Docker 运行 DynamoDB 的镜像。命令为
docker run -p 8000:8000 -v $(pwd)/data:/data/ amazon/dynamodb-local:1.11.477 -jar DynamoDBLocal.jar -sharedDb -dbPath /data
可以使用 DynamoDB GUI 作为图形化的管理工具或者直接使用 aws-cli 加上参数 --endpoint-url http://localhost:8000
进行管理。
Stream
启用 DynamoDB Stream 之后,DynamoDB 中的数据在发生变化时,均会发送相应的事件流,默认会记录新旧数据,可以据进建立事件系统或者采取进一步的操作。比如可以使用 Lambda 接收 Stream 并将数据填充到 ElasticSearch 中便于进行复杂的查询,或者同步一些数据到其它的表中,或者进行一些聚合计算,均可以通过 DynamoDB Stream 方便的完成。
Tips
- Truncate 操作:DynamoDB 不支持 Truncate 操作,最简单的办法是删表重建即可;如果需要删掉一部分数据,可以写脚本用 scan 查出 PK 的列表逐个进行删除;还可以设置好表的过期时间,让这批数据定期失效即可。
- JavaScript 有两个类库,一种使用了 DynamoDB Json,其中包括了数据的类型,需要调用相关的 marshal 和 unmarshal 方法来转换成标准的 Json
- DynamoDB 中批量操作有 25 的数据量限制,部分情况下需要手动分批分成多个请求来处理,部分类库提供了自动分批的功能
- DynamoDB 无法写入空值、空字符串,会直接忽略相应字段
- 非基本类型的数据可以当作 String 进行查询