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

简介: 本文结合这三年作者在钉钉见到的应用架构,以及一些业界的实践分享,整理出一篇关于应用读写扩散设计的维基。

应用程序员常常自嘲 "CRUD Boy"。也确实, 在应用开发的过程中,主要是在和各式各样的存储打交道,比如 MySQL, Tair, Odps 或者搜索引擎等等,应用主要时间都是在执行各种各样的 "SQL", 而不是在运行业务逻辑。


2020年刚来阿里(钉钉)时,第一次听到同事们用 "读扩散" 或者 "写扩散" 来描述自己的架构方案, 瞬间眼前一亮, 觉得这两个词非常简洁地概括了业务应用性能设计的实质。


所有的业务应用性能优化,无非就是根据业务场景,通过读写扩散,将数据额外写扩散到一份性能更好的存储,或者读扩散减少数据冗余。比如我们 Tair 缓存,本质上就是将数据库的数据额外写扩散一份到 Tair,这样后续读取性能更好。


本文结合这三年我在钉钉见到的应用架构,以及一些业界的实践分享,希望能整理出一篇关于应用读写扩散设计的维基。


概念的三层理解

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

读扩散,指牺牲了读的性能,去提升写的性能。而写扩散,一般指牺牲了写的性能,去提升读的性能。比如群发消息的场景,就有两种方案:

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


image.png


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

上文中用户消息订阅场景是读写扩散的经典案例,比如 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 数据库建模的本质。


image.png

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

image.png

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


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


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


image.png

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


image.png

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


image.png

最初的方案是,每次用户打卡只存储一条打卡记录,然后在老板查看统计页时实时计算周期内的统计数据。这种方案虽然简单,但是每到月初业务高峰期,就产生大量的慢 SQL,有些企业打卡记录众多导致 Full GC。这个方案本质上就是一个 "读扩散",写路径很短,只需要写入一条打卡记录,代价就是读路径很长,需要做各种复杂的聚合统计。


既然 "读路径" 太长,我们就可以通过延长 "写路径",来缩短 "读路径"。比如在每次用户打卡后,异步更新当前周期的统计数据,或者定期地离线计算不同周期的数据,这样就能大大降低读取的压力。


读写路径的观点参考自 《数据密集型应用系统设计》钉钉考勤的案例参考自 @乐徐 的文档


写扩散的使用场景

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


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

该案例参考自 《HBase实战》


Twitter 有一个正在关注列表的功能, 能够按时间显示用户关注的人所发表的内容:

image.png

最初 Twitter 采用的也是简单的读扩散的方案,每个作者只会将推文写到自己的发表记录中,粉丝实时去发表记录表中读取所有关注者的推文,并且按时间排序。


一开始这个方案不会有太大问题,但是后来 A 平台出现了很多大 V,大 V 的粉丝量非常多,而 Twitter 底层的分布式存储是按照用户 ID 来分区,这就造成大 V 所在分区的读热点。


image.png

后来 Twitter 还是改造成了写扩散的方案,每个粉丝都有自己的收件箱,大 V 发表推文后,除了写到自己的发表记录中,还会异步地写入所有粉丝的收件箱。Twitter 的场景非常适合写扩散,原因在于:

  • Twitter 在很长的时间里严格限制了推文的长度, 最多只允许 140 个字符,所以每条推文都不大;
  • Twitter 的用户对延迟不太敏感,系统可以根据自己当前的水位,逐步将推文推送下去;


image.png

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

image.png

钉钉审批首页的 "全部审批单" 部分,会展示用户在该企业所有可见的审批单。然而审批单可见性取决的因素非常多:

  • 员工本人是否有可见权限;
  • 员工所处的部门是否可见;
  • 员工在企业中的角色是否可见;
  • 审批单是否是特殊的 "业务审批单",一些特殊的 "业务审批单" 在首页对所有员工不可见,只有在特定钉钉应用才会展示;

这就导致审批首页完全没法做分页查询,只能一次性把所有审批单查询出来,逐个判断用户是否可见,最后只把用户可见的审批单返回给用户,伪代码如下:


List result = new ArrayList();
List 所有审批单 = 查询企业所有审批单();
for (审批单 : 所有审批单) {
    if (判断审批单对用户是否可见(用户ID, 审批单)) {
        result.add(审批单);
    }
}
return result;

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


所以我们就计划采用写扩散进行优化,给每个用户推送自己的可见模板列表,在模板可见性变更时,再修改推送的内容。这样当用户读取时,分页查询就非常好做了。


不过代价就是会造成大量的数据冗余,增加成本,一般方案都不会像上文写的那样简单直接,还会使用多种手段优化写扩散带来的数据冗余。后文中会再详细阐述,针对写扩散的缺陷,常见的优化思路。


审批首页的案例参考自 @发靥 的文档


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

image.png


工作台是钉钉的一个一级入口,因为钉钉的业务特点,有明显的流量毛刺,业务高峰期一瞬间的请求量能是平时的 3-4 倍,影响系统的稳定性。


我们可以通过进一步缩短读路径来优化该场景。比如利用钉钉客户端的能力,缓存用户上次打开的工作台界面,等到用户再次打开,直接显示缓存的页面。然后请求服务端更新工作台,这个请求立即返回。如果服务端发现该用户的工作台需要更新,再根据自己的当前负载,以稳定的速率将更新内容推送给客户端,实现削峰填谷的效果。


image.png

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

工作台的案例参考自 @北集 的文档


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

image.png

审批在提供基本的功能之外,为了方便用户使用,还支持按照关键字检索审批单。MySQL 中的数据结构无法满足灵活搜索的需求,所以业务上会冗余一份数据到专门的搜索引擎,搜索引擎采用搜索友好的倒排索引来存储数据,可以实现灵活搜索的需求。


这里也体现了前文所述的查询驱动(query-driven)的设计原则,即每一份数据冗余存储,其实都是对应的一种数据查询视图。案例中就是专门冗余了一份数据到搜索引擎,来应对关键词检索的查询视图。


不过这也给我们带来了多数据源一致性的问题,下文中会再讨论。


写扩散的缺陷治理


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


实时性差

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

  • 首先用户写的数据大多不是给自己看,比如推文是给粉丝看的,用户很难发觉其中的延迟;
  • 其次,即使写入的数据是给自己看的,也可以在用户提交完数据后,给用户一个完成页面,一定要用户手动点击退出,才能看到自己写入的数据。比如每次在淘宝提交订单后,都会弹出一个 "订单已完成" 页面,要先点击退出,才能看到订单列表;


数据一致性

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

  • 数据对账
  • 定期全量刷新,纠正增量链路中可能存在的无法核对的错误
  • 冗余数据无法写入时(比如数据源故障),记录错误日志,并实时同步到 odps,等到数据源恢复后根据日志再重新同步。


无效数据过多

上面的案例中,写扩散总是从用户维度切入,给每个用户保存一份数据。不禁让人困惑,这数据量会不会太大了?而且大多数用户根本就不会进入这个页面,所以写入的大多数数据都是无效的。优化这两个问题的方法常被称做 "读写结合",本质就是在部分场景采用读扩散减少数据冗余,举几个例子:

  • 上文中的钉钉审批首页案例,我们可以做好用户分层。对于审批单模板数量较小的企业,还是采用读扩散,只有在审批单数据达到一定规模后,才触发写扩散的方案。"写扩散" 就变成了一个针对大客户的 "高端服务方案",甚至可以引导用户付费购买超额的模板数量。
  • 钉钉视频号中的大 V 拥有几十万的粉丝,大 V 一旦发一条视频,系统就需要在所有粉丝收件箱中推送这条记录,造成了巨大的延迟和系统压力。一个优化方案是,只给高活用户收件箱进行推送(写扩散),普通用户等在下一次访问时,才即时构建收件箱(读扩散),这样就能大大减少写入的无效数据。
钉钉视频号的案例参考自 @定理 的文档


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

从上面的案例进一步总结,可以发现,我们甚至可以通过应用的读写扩散设计,看出业务当前的发展阶段:

  • 第一阶段:业务刚刚启动,还处于探索与试错期。应用倾向于使用读扩散方案快速迭代试错。
  • 第二阶段:业务已经确定可行,很快就进入了快速规模增长期。之前快速迭代留下的坑,都随着规模增长一一暴露。之前读扩散的方案已经很难满足业务要求,架构治理迫在眉睫,开发者们就会使用各种写扩散的技术优化性能,以支持快速增长的规模。
  • 第三阶段:业务的规模已经达到天花板,进入 业务瓶颈期。之前因为规模和营收还在快速增长,所以写扩散带来的存储成本,看起来没什么。但是到了业务瓶颈期,写扩散带来的成本,已经没法带来相应的规模增长了。此时很多开发者们的 OKR 都会变成 “降成本”, 读扩散的方案因为存储成本低,在很多场景又会被重新提出,最终变成 “读写混合” 的方案。

image.png


image.png

上图只是对于一个成功业务的一般情况,现实情况会更加复杂,可能有很多业务在探索期就死了,或者有的业务瓶颈不高,即使在瓶颈期,也可以通过读扩散和加机器硬撑下去。


所以有句话说的没错,“应用架构也是业务现状的一个反映”。

参考资料:

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


来源|阿里云开发者公众号

作者|悬衡

相关文章
|
存储 缓存 监控
美团面试:说说OOM三大场景和解决方案? (绝对史上最全)
小伙伴们,有没有遇到过程序突然崩溃,然后抛出一个OutOfMemoryError的异常?这就是我们俗称的OOM,也就是内存溢出 本文来带大家学习Java OOM的三大经典场景以及解决方案,保证让你有所收获!
6099 0
美团面试:说说OOM三大场景和解决方案? (绝对史上最全)
|
算法 Unix API
指数退避(Exponential backoff)在网络请求中的应用
## 一、背景 最近做云服务 API 测试项目的过程中,发现某些时候会大批量调用 API,从而导致限流的报错。在遇到这种报错时,传统的重试策略是每隔一段时间重试一次。但由于是固定的时间重试一次,重试时又会有大量的请求在同一时刻涌入,会不断地造成限流。 这让我回想起两年前在查阅[Celery Task 文档](http://docs.celeryproject.org/en/latest
13518 1
|
7月前
|
人工智能 Cloud Native Java
2025 开源之夏开启报名|AI + 云原生,10个开源项目、24个课题任您挑选
“开源之夏”是由中国科学院软件研究所发起的暑期活动,旨在鼓励高校学生参与开源软件开发与维护。活动联合各大开源社区提供项目任务,面向全球年满18周岁的高校学生开放报名。每位学生最多可申请一个项目。阿里云云原生提报了包括Apache Dubbo、RocketMQ、Seata等在内的10个开源项目共24个课题,涵盖技术优化、功能实现及AI应用等领域。活动流程包括选题、申请、开发与结项考核,具体信息可访问官网了解。
2257 32
|
Java Spring
从@Async案例找到Spring框架的bug:exposeProxy=true不生效原因大剖析+最佳解决方案【享学Spring】(上)
从@Async案例找到Spring框架的bug:exposeProxy=true不生效原因大剖析+最佳解决方案【享学Spring】(上)
|
XML Java 开发者
深入解析 Spring 和 Spring Boot 的区别
深入解析 Spring 和 Spring Boot 的区别
|
11月前
|
存储 关系型数据库 MySQL
美团面试:MySQL为什么 不用 Docker部署?
45岁老架构师尼恩在读者交流群中分享了关于“MySQL为什么不推荐使用Docker部署”的深入分析。通过系统化的梳理,尼恩帮助读者理解为何大型MySQL数据库通常不使用Docker部署,主要涉及性能、管理复杂度和稳定性等方面的考量。文章详细解释了有状态容器的特点、Docker的资源隔离问题以及磁盘IO性能损耗,并提供了小型MySQL使用Docker的最佳实践。此外,尼恩还介绍了Share Nothing架构的优势及其应用场景,强调了配置管理和数据持久化的挑战。最后,尼恩建议读者参考《尼恩Java面试宝典PDF》以提升技术能力,更好地应对面试中的难题。
|
消息中间件 网络协议 NoSQL
1000W长连接,如何建立和维护?千万用户IM 架构设计
最近有小伙伴在面试 美团,又遇到了 IM 架构问题。小伙伴支支吾吾的说了几句,面试挂了。 所以,尼恩给大家做一下系统化、体系化的梳理,使得大家内力猛增,可以充分展示一下大家雄厚的 “技术肌肉”,**让面试官爱到 “不能自已、口水直流”**,然后实现”offer直提”
|
安全 Go 数据处理
Go语言中的并发编程:掌握goroutine和channel的艺术####
本文深入探讨了Go语言在并发编程领域的核心概念——goroutine与channel。不同于传统的单线程执行模式,Go通过轻量级的goroutine实现了高效的并发处理,而channel作为goroutines之间通信的桥梁,确保了数据传递的安全性与高效性。文章首先简述了goroutine的基本特性及其创建方法,随后详细解析了channel的类型、操作以及它们如何协同工作以构建健壮的并发应用。此外,还介绍了select语句在多路复用中的应用,以及如何利用WaitGroup等待一组goroutine完成。最后,通过一个实际案例展示了如何在Go中设计并实现一个简单的并发程序,旨在帮助读者理解并掌
|
NoSQL 关系型数据库 MySQL
排行榜系统设计:高并发场景下的最佳实践
本文由技术分享者小米带来,详细介绍了如何设计一个高效、稳定且易扩展的排行榜系统。内容涵盖项目背景、技术选型、数据结构设计、基本操作实现、分页显示、持久化与数据恢复,以及高并发下的性能优化策略。通过Redis与MySQL的结合,确保了排行榜的实时性和可靠性。适合对排行榜设计感兴趣的技术人员参考学习。
1748 7
排行榜系统设计:高并发场景下的最佳实践