引子: 昨天DynamoDB支持TTL, 这个期待已久新特性的引入, 极大减少了我们的开发和维护工作.
业务场景: 在广告业务中, 对于每次点击跳转, 或者展示, 需要记录其会话信息, 从而在发生后续行为的时候, 能够查找到对应的来源, 进行归因统计.
系统需求:
- 数据写入非常大, 流量变化也会非常剧烈, 需要我们能够即时扩容降容
- 读取, 以及更新操作, 相对写入来说, 频次不高
- 写入记录需要一定的过期时间(retention period), 因为超过这段时间的记录意义不大, 需要清理(prune)掉节省存储.
具体到我们一个广告联盟的业务场景: 在点击跳转时, 需要生成点击ID, 保存会话记录, 保存点击时的广告相关信息, 渠道请求参数等; 当收到点击转化的通知时, 查找到对应的点击会话信息, 进行结算逻辑, 并标记该次点击为已结算.
Redis / SSDB 的方案
Redis 是全内存的存储, 量级较小的场景比较方便. 但是考虑到需要保存的记录条数, (内存)存储成本太高.
在量级大时, 必须做sharding, 用集群方案, 这对于小团队来说, 还是比较麻烦的. 另外需要合理预留容量, 考虑到流量的突发性 (burst), 会存在过预留 (over-provisioning) 的浪费.
SSDB 使用上和 Redis 类似, 但是落地到磁盘读写, 从而降低了存储成本. 不过读写频繁时对于磁盘IO压力较大. 实际使用中踩坑无数, 内部系统已经全部弃用SSDB.
所以我们需要托管的, 可以灵活扩容降容的 KV 存储服务.
DynamoDB 是 AWS 的全托管的 KV 存储服务, 按照预留的读写容量及实际存储空间收费. 除了支持主键查找外, 对于二级索引也有支持.
一些实际使用的注意点
最终一致性的问题
DynamoDB只能保证最终一致性, 会存在写入之后立刻读, 仍然读到旧数据的情况. 在我们的转化结算的场景, 会出现重复结算的问题. 解决办法: 在DynamoDB中标记结算后, 在本地Redis也标记一下已接算, 当然过期时间可以取的短一点; 在DynamoDB查询对应会话信息前, 先在Redis中查询一次.
写入性能优化
DynamoDB只能通过HTTPS读写, 不支持二进制的协议, 因此在量级较大时, 写DynamoDB的服务CPU开销会很高, 全花在请求构造和解析上了. 不过这是没有办法的事情, 谁叫 AWS 的服务全都是走 HTTPS REST 接口呢.
我们在优化DynamoDB写入服务的尝试中, 曾经希望通过批量接口, 通过减少总请求数, 提高吞吐. 然而, 实践中, DynamoDB的批量写功能还是有局限性的. 首先, 批量更新支持的量级很小 (只支持不超过25个), 批量写提升空间有限; 其次, 也是更麻烦的是, 不能够实现批量更新(Update)操作, 如果我们会话写入段全部用写入(Put)方式去做, 会导致脏写. 例如, 一个点击已标记为已结算, 但是我们有重新写入了一次该点击信息, 那么已结算的状态就丢失了. 这会带来潜在的重复结算的问题, 因此我们放弃了批量写的尝试. 不过想想也很合理, 考虑到DynamoDB的最终一致性模型, 批量操作中遇到更新冲突如何决议, 确实也没有什么好办法.
在DynamoDB不支持TTL之前, 如何淘汰旧记录的
我们的实现是按天建表, 定时创建新表, 删除老表. 记录键值里面保存了日期信息, 查询时直接查找对应的会话表. 并且由于写入主要发生在当日表, 我们会对于旧表即时降容处理. 这种按时间的冷热分区的模式比较常见.
队列写入
采用加队列的方式来写, 从而错峰消谷. 监控该队列的堆积情况, 确保系统可用. 另外, 由于DynamoDB会限制请求容量, 当遇到请求被限(throttle)时, 需要退避重试. 此外, 队列的长度/延迟也可以作为一个告警指标.
结算恢复策略
有时由于写入延迟, 导致点击信息尚未写入, 对应的回调查询就过来了. 为了处理这种问题, 当结算时查询部到对应的会话信息, 需要做重试的逻辑. 实现中采用了Redis的sorted set数据结构, 按照结算时间排序. 定期重试最近的结算, 并清理过久失败结算请求.
并发处理结算与事务问题
为了加速结算过程, 实现中使用多个结算服务消费结算请求队列. 但注意, 结算过程是个先数据库读, 程序中做些逻辑判断操作, 然后更新的过程, 如果重复的结算同时被不同的结算服务处理, 则会出现非预期情况. 因此, 在结算分发的时候, 需要根据UUID来分发, 保证相同重复结算由一个服务处理.
分区过热问题
在线上使用遇到的故障是: 明明容量足够, 但是很多请求被Throttled. 研究发现, 如果单个键值写入过于频繁的话, 会触发分区限制, 导致整体写入受限. 只能通过额外机制保证相同键值不会重复读写来保障了. 不过这就涉及到了一个悖论: 如果我就是要用DynamoDB来统计键值频次, 如何避免?
如何自动扩容降容
AWS家没有自带该功能, 需要通过 定期查询 预留重量消耗情况及请求被限统计, 来做扩容降容. 我们实现时自己写了一个简单的程序来维护按天分表的创建, 旧表的删除, 昨日表的降容, 以及当日表的容量预估和动态扩容, 不过动态扩容降容逻辑做的比较简单. 对于单表维护, 推荐使用 dynamic-dynamodb 来管理, 其策略也做得更加细致; 另外一种更AWS的方式, 是使用CloudWatch + Lambda来做.
Update: 现在有了, 可以在控制面板上设置自动扩容策略了.
其他的一些NoSQL的方案, 如 Aerospike, HBase, Riak 也适合这种 数据量大的 KV 存储业务场景.
总结
DynamoDB 理论很美好, 学术上研究和推崇很多, 最终一致性的始作俑者吧? 工程实践上需要注意踩坑. 对于我们使用的好处在于没有维护成本, 按需收费, DBaaS, 这就够了.
以上结束正文的讨论, 说一个题外话, 很多偏渠道端的广告系统, 实际上并不需要自己保存会话信息, 可以利用请求参数的传递, 将会话信息全部编码在请求参数中, 在发生后续访问行为时再解析对应参数得到原始的信息. 从而整个系统可以做到无状态, 减少了一个存储服务, 降低了复杂性, 并节约了成本. 但这里请求参数的编码需要注意, 避免被伪造.