今天需要查一点东西翻到一篇老文档,发现其中内容对于数据库系统的设计还是有一点有参考价值的,所以整理一下发一篇博客,记录一下半年前设计的数据库和一些思考。其中包括基本的设计原则以及具体设计的细节部分。
设计原则
对于嵌入对象和引用的选择,应考虑:
- Embed the N side if the cardinality is one-to-few and there is no need to access the embedded object outside the context of the parent object
如果是一对少数,并且不需要单独访问的对象,则嵌入对象
- Use an array of references to the N-side objects if the cardinality is one-to-many or if the N-side objects should stand alone for any reasons
如果是一对多数,或者如果对象因为任何原因应该单独存在,则使用引用数组
- Use a reference to the One-side in the N-side objects if the cardinality is one-to-squillions
如果是单方面对于多方面,则使用引用
非规范化允许您避免某些应用程序级别的连接,但代价是要进行更复杂和昂贵的更新,对于是否进行denormalize
,应考虑:
You cannot perform an atomic update on denormalized data
不能对非规范化数据执行原子更新
Denormalization only makes sense when you have a high read to write ratio
只有当读写比率较高时,非规范化才有意义
总结一下,就是:
One: favor embedding unless there is a compelling reason not to
除非有令人信服的理由,否则使用嵌入
Two: needing to access an object on its own is a compelling reason not to embed it
如果需要单独访问对象,则不使用嵌入
Three: Arrays should not grow without bound. If there are more than a couple of hundred documents on the “many” side, don’t embed them; if there are more than a few thousand documents on the “many” side, don’t use an array of ObjectID references. High-cardinality arrays are a compelling reason not to embed.
数组不应该无限制地增长
如果在”多”方面有几百个以上的文档,不要嵌入它们
如果在”多”方面有几千个以上的文档,不要使用一个 ObjectID 引用数组
如果数组长度过多,则不使用嵌入
Four: Don’t be afraid of application-level joins: if you index correctly and use the projection specifier then application-level joins are barely more expensive than server-side joins in a relational database.
不要害怕应用程序级别的连接
如果正确地索引并使用投影说明符,那么应用程序级别的连接几乎不会比服务器端连接关系数据库更昂贵
Five: Consider the write/read ratio when denormalizing. A field that will mostly be read and only seldom updated is a good candidate for denormalization: if you denormalize a field that is updated frequently then the extra work of finding and updating all the instances is likely to overwhelm the savings that you get from denormalizing.
考虑非规范化时的写 / 读比率
一个大多数时候会被读取但很少更新的字段是非规范化的好候选者: 如果你对一个频繁更新的字段进行非规范化,那么查找和更新所有实例的额外工作很可能会超过你从非规范化中节省的
Six: As always with MongoDB, how you model your data depends – entirely – on your particular application’s data access patterns. You want to structure your data to match the ways that your application queries and updates it.
在 MongoDB 中,如何对数据建模完全取决于特定应用程序的数据访问模式
需要使数据的结构与应用程序查询和更新数据的方式相匹配
基本数据库划分
结合以上的经验,根据TimeForCoin
的需求,目前数据库的数据分为11 个部分:
用户数据
- 用户基本信息
- 用户个性信息(昵称、头像、联系方式等)
- 用户数据(钱包、积分等)
- 其他相关数据 (完成任务数、发布任务数、未读通知数等)
对于一个 Web 应用,用户系统是不可或缺的部分。
对于用户昵称,头像等经常需要批量获取(比如在评论列表中)的数据,这里会使用 Redis 对其进行缓存。
这里其他相关的数据都属于反范式的冗余数据,但是考虑到这些数据读取次数远大于写入次数,写读比率很低,因此使用冗余数据可以很好提高读取效率,而不需要每次都查看相关数据的数组长度。
关注系统
- 用户关注的人
- 用户的粉丝
本系统除了有问卷系统,跑腿任务也是本系统一大特色,这种线下任务自然很容易形成一个圈子,因此本应用通过维护用户关系,使得用户更容易得到自身可以接受的任务的信息,由此来增强用户粘性。同时借助关注系统,可以为用户生成个性的信息流。
由于我们同时存在获取粉丝列表和关注人列表两种需求,因此这里使用双向的冗余数据存储关注关系。
任务数据
- 任务的基本信息(名称、类型、酬劳、时间等)
- 任务扩展信息(浏览数、收藏数、点赞数、图片、附件等)
- 参与的用户数
任务系统是本项目中的核心。
这个部分专门存储任务的各种信息,还有任务的评论。
而图片附件之类,是任务一对多的对象,和任务有强烈的关联性,数量也不会很多,因此也一同作为嵌入对象存储。
而参与的用户数为反范式的冗余数据,因为这个数据的写读比比较低,使用反范式的设计,可以极大提高了读取的效率。否则每次读取都需要对下面的整个集合做一次查询
接受任务
- 用户与任务的状态
- 任务 ID、用户 ID
- 状态:已完成、进行中、已放弃等
- 数据:评价,完成度,反馈等信息
根据需求,不仅需要获取任务信息流,还需要获取不同用户发布和接受的任务。
每个用户对于不同的任务也存在一些状态数据,比如完成、放弃、失败等状态,
由于我们需要根据某个用户获取其指定的任务列表,因此这里没有将这些数据一同放在任务数据中,而是存放在每个用户的数据对象当中,可以极大提高查询效率。
由于我们需要对这些任务状态进行管理和分类,并且也有读取某一任务所有参与用户的需求,再者考虑到这些数据存在无限增长的可能,因此这里把这些数据单独提取出来作为一个集合,而不是与用户数据存放在一起。
评论数据
所属任务(索引)
评论基本内容
评论数据(点赞数、点赞的用户 ID)
评论数据为任务提供评论数据
评论信息使用了单独的对象而不是嵌入到任务对象中,这是因为考虑到评论数据是一个增长并且频繁变动的数组,并且具有分页的需求,很明显引用数组是不适合的,如果作为内嵌对象数组的话排序和分页也是一个很大的问题。因此这里把评论单独抽离出来,可以更加灵活地查询评论数据。
这里把点赞数和点赞用户 ID 放在一起是为了点赞操作的原子性,而点赞数的冗余是为了减少数据的传输而提高查询的效率,因为点赞的用户 ID 是一个通常比较长的数组,对于整个数据对象来说都是十分大的,查询的时候可以通过选取指定的数据,不返回 ID 列表。换句话说,这个 ID 列表是不出现与服务端和前端的,服务端和前端得到的数据只有点赞数以及当前用户是否点赞,点赞的查询和防止重复完全可以交给数据库操作。
问卷系统
- 问卷题目
- 类型
- 具体数据
- 问卷数据
- 每个用户的所选选项
因为问卷题目和统计数据都是依附于问卷的,因此以一个问卷为一个对象进行存储,分别存储问卷的题目和数据。
虽然我们也有获取所有数据选项有多少人选的需求,但是这个需求不大,仅仅发布者具有这个需求,而填写数据的需求往往比这个多很多,如果添加反范式的冗余数据,那么写读比就会很高,完全违背了反范式的目的。
文件系统
- 文件信息
由于任务、问卷、用户认证等多个地方都需要用到上传和管理文件,这里将其统一起来,将文件集中管理,并统一做好权限管理,将文件部分模块化。
公告系统
- 公告文章
用于存储一些显示在首页的公告文章,比如:使用帮助、系统升级提示、重要通知等文章,主要用作应用的运营。
点赞系统
- 点赞某内容的用户ID
由于任务和评论都有点赞的功能,我们需要判断该用户是否点赞过某个内容,可以通过存储点赞某内容的所有用户ID或者存储某用户点赞过所有内容来判断。使用哪一种方式存储其实不太好决定,当单个内容点赞数太多或者单个用户点赞数太多都会影响数据库的性能。考虑到内容数(任务+评论)一般会大于用户数,因此使用前者大概可以很好地分散数据的存储。
再者考虑到这个数据有可能会大幅度地增加,可能会使得某个对象过大导致读取任务和评论的性能下降,因此把这个数据单独抽出来而不是嵌入到任务或者评论里面。
消息系统
- 用户之间私密沟通
- 任务通知
- 系统通知
任务发布者和接收者的沟通是非常必要的,尤其对于一些跑腿任务,需要任务执行者实时于发布者沟通。
基于统一性,任务通知和系统通知以消息的形式和用户消息一同存储,因为他们都有相同的功能和结构。
因为消息数据是一组可以快速增长数据,因为每个会话都会被划分成一个对象,并且以用户 ID 作为辅助索引,避免单一对象过大导致 MongoDB 的性能问题。
单个会话也可能因为消息过多形成过大的对象,但这只是极个别现象,一般都不会造成太大的性能问题。
考虑到一般只需要获取最近的前几条消息,而久远的信息一般都不会读取,因此可以考虑信息数量超过一定的阈值之后删除某个时间之前的所有消息。
日志系统
- 重要操作
- 数据埋点
- 数据更改
记录导致用户数据变化的事件,用于分析以及维护系统安全
数据埋点主要用于应用运营分析以及生成用户画像等
Redis数据
统计信息
- 用户搜索关键词
- 任务标签关键词
这一部分使用redis实现,因为这些数据重要性不高,但是读写很频繁,通常需要重复访问数据库,因此将其使用redis缓存起来,并且对于非缓存内容定时进行持久化。
通过这些信息,可以为用户提供搜索关键词联想、热门搜索、推荐标签、推荐内容等服务,可以极大地提高用户体验。
缓存信息
- 热门任务列表
- 任务浏览量(热度)
- 用户基本信息(头像、昵称)
- 点赞用户记录
这些信息的特点是大量的重复请求,因此可以缓存在Redis中缓解数据库的压力
排行榜以浏览量、评论数、收藏数、参与人数、时间[负权]加权计算进行排序,每10分钟更新一次
个性信息流
- 用户感兴趣的信息队列
根据用户的关注以及搜索为用户生成个性的信息流的话,如果在读的时候实时生成信息流,需要多次访问数据库根据关注关系和关键词获取相关数据,必然会给服务器带来很大的压力。因此这里将读写压力分散。当有新内容产生的时候,添加到对此感兴趣的用户信息流队列中(避免关注内容被用户刷新忽略掉,增加曝光率,可以给每条信息一个复活机会,重新加入队列尾。),在用户获取热门内容的时候,从队列中随机抽取穿插在内容中,形成用户个性信息流。
写入时加入:
- 任务创建时:从用户搜索记录判断是否感兴趣、为用户粉丝推荐、为同(城/校/地点)用户推荐
- 任务被点赞时:一定概率(以粉丝数量、是否互相关注加权计算)为点赞人的的被关注人推荐
读取时加入:
- 热门信息(随机加入热门信息到队列尾)
个性信息流以任务的时间排序为基准,穿插定制的个性信息
附录:数据库具体设计
🚀 代码: Github
用户数据库
1 | // UserGender 用户性别 |
关注数据库
1 | // RelationModel 文件数据库 |
任务数据库
1 | // TaskType 任务类型 |
接受任务数据库
1 | // PlayerStatus 参与用户状态 |
评论数据库
1 | // CommentSchema 评论数据结构 |
问卷数据库
1 | // ProblemType 问题类型 |
文件数据库
1 | // FileType 文件类型 |
公告数据库
1 | // ArticleModel 文章数据库 |
点赞数据库
1 | // LikeModel 点赞数据库 |
消息数据库
1 | // MessageType 消息类型 |
日志数据库
1 | // LogType 日志类型 |