Redian新闻
>
应用性能设计的圣杯:读写扩散的概念与实践

应用性能设计的圣杯:读写扩散的概念与实践

科技

阿里妹导读


本文结合这三年作者在钉钉见到的应用架构,以及一些业界的实践分享,整理出一篇关于应用读写扩散设计的维基。
应用程序员常常自嘲 "CRUD Boy"。也确实, 在应用开发的过程中,主要是在和各式各样的存储打交道,比如 MySQL, Tair, Odps 或者搜索引擎等等,应用主要时间都是在执行各种各样的 "SQL", 而不是在运行业务逻辑。
2020年刚来阿里(钉钉)时,第一次听到同事们用 "读扩散" 或者 "写扩散" 来描述自己的架构方案, 瞬间眼前一亮, 觉得这两个词非常简洁地概括了业务应用性能设计的实质。
所有的业务应用性能优化,无非就是根据业务场景,通过读写扩散,将数据额外写扩散到一份性能更好的存储,或者读扩散减少数据冗余。比如我们 Tair 缓存,本质上就是将数据库的数据额外写扩散一份到 Tair,这样后续读取性能更好。
本文结合这三年我在钉钉见到的应用架构,以及一些业界的实践分享,希望能整理出一篇关于应用读写扩散设计的维基。

概念的三层理解


基本理解: 经典读写扩散案例

读扩散,指牺牲了读的性能,去提升写的性能。
而写扩散,一般指牺牲了写的性能,去提升读的性能。
比如群发消息的场景,就有两种方案:
  • 方案一:往每个群员的收件箱中发一个消息,这样虽然要写很多冗余的数据,但是当每个群员阅读消息时,性能会好很多(因为只需要查询自己的收件箱)。所谓 "写扩散";
  • 方案二: 只往群聊中写一条数据,每个群员都去群聊中拉取最新消息,此时没有数据冗余,写性能非常好,但是读却很容易产生热点. 所谓 "读扩散";


数据库的规范化和反规范化: 日常开发中的读写扩散

上文中用户消息订阅场景是读写扩散的经典案例,比如 IM 或者 微博/Twitter 的 feeds 流订阅。
但是大多数业务应用场景都跟消息订阅不太一样,从数据库的规范化和反规范化角度来看,能帮我们进一步理解日常业务开发中的读写扩散。
试想一个电商购物车系统的设计,根据购物车中商品信息的存储方式不同,我们又能得出两个方案。
第一种方案, 在购物车表中只存储一个商品 ID,每次用户读取时, 用购物车表去 join 商品表 (或者等价的,在应用中通过 rpc 去调用商品系统查询),获得商品名称,价格等额外的商品信息。
这种方案非常符合我们上学时学习的所谓数据库 “三大范式”,也称为数据库的 “规范化”。“三大范式” 主张每张表都是原子的,不能含有不属于该维度的冗余数据,读取时通过外键 join 来获取其他维度数据。所以“三大范式”对于写性能是非常友好的,但是读取却需要查询大量其他维度的数据,非常符合上面对于 “读扩散” 的定义。
第二种方案, 在购物车表中冗余需要的商品信息,比如商品名称,商品价格等等,这样在读取时只需要读取这一张表即可。但是对于写入却是灾难,商品更新时,除了要更新商品表外,还要去更新冗余了商品信息的购物车表。
这种方案和“三大范式” 理念相反, 所以被称为 “反规范化”,它通过写冗余数据,提升了读性能,非常符合上面对于 “写扩散” 的定义。
"反规范化" 在互联网中越来越受到欢迎, 主要有两点原因:
第一点是很多场景读多写少,比如商品信息可能每年才会更新一次,但是购物车却每时每刻都在读取,所以着力优化读取性能,牺牲一点写入也未尝不可。
第二点是,很多新兴的 no sql 数据库为了提升水平扩展能力,都不再支持 join。它们主张一种叫做查询驱动(query-driven)的库表设计,即针对每一种查询视图,设计一张单独的表。比如针对购物车视图,我在 MongoDB 中单独新建一个集合,集合中的每个文档就是完整的购物车记录:
在 MongoDB 中, "集合" 相当于一张 "表", "文档" 相当于表中的 "一行"
{    // 购物车记录主键    "id": "123456",    // 购物车中的商品冗余信息    "product": {        "name": "康师傅方便面",        // 商品图片        "pic": "https://xx.jpg",        // 商品价格        "price": 2    },    // 商品数量    "num": 10}
查询驱动(query-driven)这个词我最早是在 no sql 数据库 Cassandra 的 官方文档 [参考1] 中读到的, 这个词简洁地阐释了所有 no sql 数据库建模的本质。

"规范化" 和 "反规范化" 也不是一个一刀切的概念, 随着冗余的数据越来越多 (比如我还额外冗余了用户的头像,这样就不用在购物车页面额外查询用户系统了),读性能会越来越好,写性能会越来越差。另外,对于糟糕的软件设计,读写性能会同时很差。

上图参考自 《HBase实战》的第四章


应用的读写路径: 不存在绝对的读写扩散

"读写扩散" 这个词其实有一定误导性,让人觉得设计一个架构,肯定不是 "写扩散",就是 "读扩散"。
下文中我们发现不是这样,不存在绝对的 "读扩散" 和 "写扩散",任何架构设计都是 "读写路径" 的结合。
我们先来试想一下 "绝对的写扩散" 是像什么样子。在 "绝对的写扩散" 中,读性能已经好了极致,而最好的读性能就是 "不读", 躺平等待推送:

但是现实中没有人会这么做,首先读者客户端不一定总是在线给你写,所以开发者总是会找个地方先暂存下写入的内容,等读者上线后,再去读取。另外这么无节制地写入, 也容易超过读者自身处理能力上限,把读者写挂。所以现实架构中总是需要读者稍微做点 "努力" 去读取内容的,典型的就是消息队列的架构:

所以不管是有意还是无意,应用最后总是被设计成了读路径与写路径的结合,它们一般在某个持久化存储中交汇(比如数据库, 消息队列等等)。
所谓 "写扩散",其实就是把写路径延长一些,读路径缩短一些。
而 "读扩散" 相反,把读路径延长一点,写路径缩短一些。
以钉钉考勤的统计页为例,用户可以在此查看不同周期内公司的打卡签到情况:

最初的方案是,每次用户打卡只存储一条打卡记录,然后在老板查看统计页时实时计算周期内的统计数据。这种方案虽然简单,但是每到月初业务高峰期,就产生大量的慢 SQL,有些企业打卡记录众多导致 Full GC。这个方案本质上就是一个 "读扩散",写路径很短,只需要写入一条打卡记录,代价就是读路径很长,需要做各种复杂的聚合统计。
既然 "读路径" 太长,我们就可以通过延长 "写路径",来缩短 "读路径"。比如在每次用户打卡后,异步更新当前周期的统计数据,或者定期地离线计算不同周期的数据,这样就能大大降低读取的压力。
读写路径的观点参考自 《数据密集型应用系统设计》
钉钉考勤的案例参考自 @乐徐 的文档

写扩散的使用场景

从上面考勤例子可以看出,一般项目初期会先使用相对简单的 "读扩散" 方案,后期随着项目规模变大,遇到问题,才会采用适当的 "写扩散" 去解决问题。
本节列举了几个我在业界和钉钉看到架构优化案例,希望在遇到类似场景时,能够快速有个参考。

Twitter: 通过写扩散分散读热点

该案例参考自 《HBase实战》
Twitter 有一个正在关注列表的功能, 能够按时间显示用户关注的人所发表的内容:

最初 Twitter 采用的也是简单的读扩散的方案,每个作者只会将推文写到自己的发表记录中,粉丝实时去发表记录表中读取所有关注者的推文,并且按时间排序。
一开始这个方案不会有太大问题,但是后来 A 平台出现了很多大 V,大 V 的粉丝量非常多,而 Twitter 底层的分布式存储是按照用户 ID 来分区,这就造成大 V 所在分区的读热点。

后来 Twitter 还是改造成了写扩散的方案,每个粉丝都有自己的收件箱,大 V 发表推文后,除了写到自己的发表记录中,还会异步地写入所有粉丝的收件箱。Twitter 的场景非常适合写扩散,原因在于:
  • Twitter 在很长的时间里严格限制了推文的长度, 最多只允许 140 个字符,所以每条推文都不大;

  • Twitter 的用户对延迟不太敏感,系统可以根据自己当前的水位,逐步将推文推送下去;

钉钉审批首页: 查询维度过于复杂, 分页不好设计

钉钉审批首页的 "全部审批单" 部分,会展示用户在该企业所有可见的审批单。然而审批单可见性取决的因素非常多:
  • 员工本人是否有可见权限;

  • 员工所处的部门是否可见;

  • 员工在企业中的角色是否可见;

  • 审批单是否是特殊的 "业务审批单",一些特殊的 "业务审批单" 在首页对所有员工不可见,只有在特定钉钉应用才会展示;

这就导致审批首页完全没法做分页查询,只能一次性把所有审批单查询出来,逐个判断用户是否可见,最后只把用户可见的审批单返回给用户,伪代码如下:
List result = new ArrayList();List 所有审批单 = 查询企业所有审批单();for (审批单 : 所有审批单) {    if (判断审批单对用户是否可见(用户ID, 审批单)) {        result.add(审批单);    }}return result;

可以看出读路径非常复杂,需要逐个审批单按照各种维度进行过滤,是典型的 "读扩散" 方案。当企业模板数量非常多时,首页的加载时间就会特别长,所以我们限制了企业的审批单模板数量不能超过 1000 个,对大企业非常不友好。

所以我们就计划采用写扩散进行优化,给每个用户推送自己的可见模板列表,在模板可见性变更时,再修改推送的内容。这样当用户读取时,分页查询就非常好做了。
不过代价就是会造成大量的数据冗余,增加成本,一般方案都不会像上文写的那样简单直接,还会使用多种手段优化写扩散带来的数据冗余。后文中会再详细阐述,针对写扩散的缺陷,常见的优化思路。
审批首页的案例参考自 @发靥 的文档

钉钉工作台: 通过推送削峰填谷,降低服务器压力

工作台是钉钉的一个一级入口,因为钉钉的业务特点,有明显的流量毛刺,业务高峰期一瞬间的请求量能是平时的 3-4 倍,影响系统的稳定性。
我们可以通过进一步缩短读路径来优化该场景。比如利用钉钉客户端的能力,缓存用户上次打开的工作台界面,等到用户再次打开,直接显示缓存的页面。然后请求服务端更新工作台,这个请求立即返回。如果服务端发现该用户的工作台需要更新,再根据自己的当前负载,以稳定的速率将更新内容推送给客户端,实现削峰填谷的效果。

这个方案的缺陷就是,只适合那些能够支持服务端推送的客户端,比如钉钉。对于需要在浏览器打开的网页端应用来说,是无法实现的。
工作台的案例参考自 @北集 的文档 

审批单搜索: 业务功能需要特殊的存储引擎实现

审批在提供基本的功能之外,为了方便用户使用,还支持按照关键字检索审批单。MySQL 中的数据结构无法满足灵活搜索的需求,所以业务上会冗余一份数据到专门的搜索引擎,搜索引擎采用搜索友好的倒排索引来存储数据,可以实现灵活搜索的需求。
这里也体现了前文所述的查询驱动(query-driven)的设计原则,即每一份数据冗余存储,其实都是对应的一种数据查询视图。案例中就是专门冗余了一份数据到搜索引擎,来应对关键词检索的查询视图。
不过这也给我们带来了多数据源一致性的问题,下文中会再讨论。

写扩散的缺陷治理

上文为了方便理解,将写扩散的方案都描述得很简单,似乎就是多写几份数据,或者给每个用户推送一份数据。虽然大体思路就是这样,但是现实中的方案都要复杂很多,这些复杂性都是为了治理写扩散带来的数据不一致,延迟高,数据冗余等缺陷。

实时性差

写扩散虽然对于读取更为有利,但是写的性能也不能太差,所以冗余数据的写入常常是异步的。这就导致写者写完后,读者无法立马读到。这在分布式系统中叫做 读自己写(read-own-write) 问题。
不过这对于大多数应用来说,这都不是什么问题,可以通过下面手段解决:
  • 首先用户写的数据大多不是给自己看,比如推文是给粉丝看的,用户很难发觉其中的延迟;

  • 其次,即使写入的数据是给自己看的,也可以在用户提交完数据后,给用户一个完成页面,一定要用户手动点击退出,才能看到自己写入的数据。比如每次在淘宝提交订单后,都会弹出一个 "订单已完成" 页面,要先点击退出,才能看到订单列表;

数据一致性

基本上所有大规模应用都会碰到的问题,但是大家又不得不都做一遍这些老生常谈的保障:
  • 数据对账

  • 定期全量刷新,纠正增量链路中可能存在的无法核对的错误

  • 冗余数据无法写入时(比如数据源故障),记录错误日志,并实时同步到 odps,等到数据源恢复后根据日志再重新同步。

无效数据过多

上面的案例中,写扩散总是从用户维度切入,给每个用户保存一份数据。不禁让人困惑,这数据量会不会太大了?而且大多数用户根本就不会进入这个页面,所以写入的大多数数据都是无效的。
优化这两个问题的方法常被称做 "读写结合",本质就是在部分场景采用读扩散减少数据冗余,举几个例子:
  • 上文中的钉钉审批首页案例,我们可以做好用户分层。对于审批单模板数量较小的企业,还是采用读扩散,只有在审批单数据达到一定规模后,才触发写扩散的方案。"写扩散" 就变成了一个针对大客户的 "高端服务方案",甚至可以引导用户付费购买超额的模板数量。

  • 钉钉视频号中的大 V 拥有几十万的粉丝,大 V 一旦发一条视频,系统就需要在所有粉丝收件箱中推送这条记录,造成了巨大的延迟和系统压力。一个优化方案是,只给高活用户收件箱进行推送(写扩散),普通用户等在下一次访问时,才即时构建收件箱(读扩散),这样就能大大减少写入的无效数据。
钉钉视频号的案例参考自 @定理 的文档

从读写扩散看业务发展的三阶段

从上面的案例进一步总结,可以发现,我们甚至可以通过应用的读写扩散设计,看出业务当前的发展阶段:
  • 第一阶段:业务刚刚启动,还处于探索与试错期。应用倾向于使用读扩散方案快速迭代试错。

  • 第二阶段:业务已经确定可行,很快就进入了快速规模增长期。之前快速迭代留下的坑,都随着规模增长一一暴露。之前读扩散的方案已经很难满足业务要求,架构治理迫在眉睫,开发者们就会使用各种写扩散的技术优化性能,以支持快速增长的规模。

  • 第三阶段:业务的规模已经达到天花板,进入 业务瓶颈期。之前因为规模和营收还在快速增长,所以写扩散带来的存储成本,看起来没什么。但是到了业务瓶颈期,写扩散带来的成本,已经没法带来相应的规模增长了。此时很多开发者们的 OKR 都会变成 “降成本”, 读扩散的方案因为存储成本低,在很多场景又会被重新提出,最终变成 “读写混合” 的方案。

上图只是对于一个成功业务的一般情况,现实情况会更加复杂,可能有很多业务在探索期就死了,或者有的业务瓶颈不高,即使在瓶颈期,也可以通过读扩散和加机器硬撑下去。
所以有句话说的没错,“应用架构也是业务现状的一个反映”。

参考资料:

[参考1] : Cassandra 数据库官方文档:https://cassandra.apache.org/doc/latest/cassandra/data_modeling/intro.html?spm=ata.21736010.0.0.20207d51bUhe0F

欢迎加入【阿里云开发者公众号】读者群


这是一个专门面向“阿里云开发者”公众号的读者交流空间
💡 在这里你可以探讨技术和实践,我们也会定期发布群福利和活动~
欢迎扫码或者添加微信:argentinaliu 加入我们👇


微信扫码关注该文公众号作者

戳这里提交新闻线索和高质量文章给我们。
相关阅读
一百一十三 夏承焘简单有效!Direct Inversion:三行代码提升基于扩散的图像编辑效果他最早提出“中华民族”的概念开发团队解析:《捉鬼敢死队 鬼王崛起》的设计理念与PS VR2的机能运用2023地中海邮轮行 (十二)卡蒂兹自私自利和爱护子女的人性(第一章摘要)NeurIPS 2023 | MSRA、清华、复旦等联合提出AR-Diffusion:基于自回归扩散的文本生成三星 T9 移动固态硬盘上市:读写速度 2000MB/s,799 元起售如何用生物/化学的概念做艺术设计?《胡润光谷中国设计榜》|打破壁垒,探索中国设计方向,为商业和设计的生态聚力免费《大模型应用实践》实训营第二周课程来啦!这次百度算法工程师团队手把手教你构建大模型应用,另有第一周干货回顾!浙江大学阿里巴巴:2023大模型时代智能设计的机遇与挑战报告扎克伯格发布首款MR头显!性能设计大升级,体验后我发现它和苹果Vision Pro完全不同得物 Redis 设计与实践打破流量瓶颈:增长操盘手的策略与实践新学期 新教材:《生物学概念与途径》2023年秋结果准确、易用性高及应用支持强大——原子力显微镜Jupiter飞入寻常企业家空天地一体化技术在应急通信领域的应用——运营商快速恢复宏站的创新与实践陆怀良:数智重塑未来--菜鸟产业园的思考与实践终于找全了!40+集《新概念英语》动画精讲资源,听说读写都有!追本溯源,全方位解读INOCA的概念、评估和治疗黄河、张荣钢、廖晓格三位专家解读:数智化金融的前景与实践 | FCon 金融科技峰会贺荣在华东政法大学调研时强调 深入践行习近平法治思想 加强新时代法学教育和法学理论研究与实践珠海芯动力李原:什么是AI芯片最优解?自研RPP创新架构,将通用性与高性能融合丨GACS 20232023地中海邮轮行 (十二) 卡蒂兹【金融行业】欧盟数据跨境监管:缘起、要求与实践生成的分子几乎 100% 有效,用于逆向分子设计的引导扩散模型咀外文嚼汉字(281)“电凸”、“炎上”一文搞懂 GPU 的概念、工作原理,以及与 CPU 的区别关于智慧校园全光网建设的探讨与实践——以北京化工大学昌平校区全光网覆盖为例大幅提升用户行为表征通用性,蚂蚁新模型获CIKM 2023最佳应用论文奖招募|思想湃WDCC2023特别专场:设计的价值——从商业设计到社会创新8堂课走进纪录片理论与实践从技术创新到应用实践:百度智能云发起大模型平台应用开发挑战赛!新型工业化领域学术期刊《新型工业化理论与实践》正式获批,启动征稿
logo
联系我们隐私协议©2024 redian.news
Redian新闻
Redian.news刊载任何文章,不代表同意其说法或描述,仅为提供更多信息,也不构成任何建议。文章信息的合法性及真实性由其作者负责,与Redian.news及其运营公司无关。欢迎投稿,如发现稿件侵权,或作者不愿在本网发表文章,请版权拥有者通知本网处理。