first commit

This commit is contained in:
张乾
2024-10-16 11:38:31 +08:00
parent b2fae18d7e
commit c4bf92ea9d
183 changed files with 39246 additions and 0 deletions

View File

@ -0,0 +1,116 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 高并发系统,技术实力的试金石
你好,我是徐长龙,欢迎加入我的高并发实战课。
我目前在极客时间担任架构师一职在此之前从事架构已有十几年曾就职于穷游网、微博、好未来主要做老系统的高并发迁移与改造对RPC建设、服务化、框架、分布式链路跟踪监控以及Kubernetes管理平台拥有丰富的经验。
我个人对计算机技术有浓厚的兴趣始终在主动学习各种技术早年曾活跃在Swoole社区、PHP开发者大会。
作为一名一线技术老兵,回顾我这么多年职业生涯的发展关键节点,总是和“高并发系统改造”密切相关。
为什么大厂这么重视高并发?
说起高并发系统,你可能既熟悉又陌生。
熟悉是因为我们生活中常用的服务都属于高并发系统比如淘宝、微博、美团、饿了么、12306、滴滴等等。
说它陌生,则是因为现实中只有少部分研发同学才能真正接触到这类系统,更多同学的刚需可能会局限于大厂面试。比如你是否也刷过这些问题:
1.为什么百万并发系统不能直接使用MySQL服务-
2.为什么Redis内存相比磁盘需要用更多的空间-
3.怎么保证条件查询缓存的数据一致性?-
4.为什么高级语言不能直接做业务缓存服务?
那么大厂究竟关注的是什么呢?我们又该怎么看待高并发?
无论问题多么花哨,归根结底其实就一句话:大厂看重的是你解决问题的思路和方法,而支撑你去完美回应这些的是更深层次的系统设计方向和原理。
比如说上面我们提到的为什么百万并发不能直接使用MySQL服务没有足够积累的话你回答的大概是因为太高的并发查询会导致MySQL缓慢然后简单地讲讲如何用缓存抵挡流量。
但是如果你面的是更高级别的岗位面试官想要的其实是让你讲讲MySQL数据库为什么不能提供这么大的并发服务同时你需要深入一起讨论下分布式数据库索引、存储、数据分片、存算分离等相关知识。
我们知道互联网服务的核心价值就是流量流量越大平台的可能性和空间就越大所以这也是为什么大厂倾向于有高并发经验的研发。2014年后互联网迈入高并发时代大厂与创业公司之间的技术壁垒一直在不断加码高并发相关人才从早几年的趋势已然成为如今的大厂标配。
近几年云服务厂商的基础建设越来越成熟,他们直接提供了无感的分布式服务支撑,这进一步减少了我们亲自动手实践的机会,这会导致很多架构师的工作只剩下选厂商、选服务、如何快速接入和如何节省成本。
所以我们需正视,高并发在大厂与小厂之间确实建起了一道墙,想跨越它,系统学习底层知识、实践高并发场景就是必经之路。
进阶高并发,最重要的是项目级实战
那具体怎么跨越?可以参考我的经历。
2007年我刚毕业那会儿国内的技术环境还谈不上什么高并发我的工作局限在小流量场景最多就是想想代码的可复用性和业务逻辑的完整性而市场上最不缺的就是我这个阶段的研发。被套牢在业务逻辑实现里的日子我开始关注各种技术但对开源和系统底层的认识还很浅薄也不知道该怎么去加深这些知识。
直到我加入穷游网实际主持老系统高并发改造工作在RPC建设时因为RPC性能瓶颈我碰了一鼻子灰才真正发现了差距。
之前的一些技巧,不见得适用于更高要求的系统。小流量场景里无伤大雅的问题,系统规模变大后都可能被无限放大,这会给脆弱的系统造成“致命打击”。在高并发场景中,你会发现很多网上开源的自我介绍,跟实践验证的结果大相径庭。
这段经历让我看问题的思路和视角有了一个很大的转变。为了弥补自己的不足我阅读了大量计算机系统著作恶补底层知识。在相关技术社区与同好激烈地讨论在项目中我动手实测过大量的开源也对他们提了很多改进issue建议。
总之,学习、实践、交流多管齐下,还是非常有成效的,很快我加入了微博广告部,从事基础架构方面的相关工作。
微博是我的一个黄金成长期,在这里体验了不少“有趣但变态的需求”,这里常常就给两台服务器。就要你去开发服务微博全网的业务,还要求你不能崩。期间我还参与建设了很多实用有趣的服务,这让我从三百多人的广告部脱颖而出,得到了珍贵的晋升机会。也是这段经历,让我真正转向基础服务研发,在数据服务和高并发服务方面积累了更多经验。
后来,我陆陆续续收到很多公司或朋友的邀请,为各种系统提供服务改造优化方面的指导。有的系统迁移改造好比蚂蚁搬家,断断续续花了两年多的时间;有的系统崩溃,公司损失达到千万元,叫我去救火;有的系统谁都拆不动,没有人说得清到底该怎么优化……
所以你清楚进阶路径了吗?学习、实践、交流会是最实用的方法,最终帮助你建立系统化的思维。
你可以先从手边的项目开始,比如对你所在企业的现有系统进行高并发改造,注意不要只阅读理论,而是要一边分析实践,一边用压测去验证。风险可控的话,推荐你可以先找一些无关紧要的小系统实践。
如何实践高并发?
那么具体如何改造呢?后面这四步最关键:识别系统类型、完善监控系统、梳理改造要点、小步改造验证。
以第一步为例,我们可以按照数据特征给系统归类,分别为读多写少、强一致性、写多读少、读多写多这四种类型。确定了系统的类型,就等同于确定了具体的优化方向。
而这个专栏就会针对这四个优化方向,带你梳理关键改造点。无论你需要构建高并发系统,还是面临业务流量增长或是系统改造升级,都能在这里找到参考。
这里我梳理了课程的知识结构图,下面结合图解说明一下课程的设计思路:
读多写少的系统
我会以占比最高的“读多写少”系统带你入门,梳理和改造用户中心项目。这类系统的优化工作会聚焦于如何通过缓存分担数据库查询压力,所以我们的学习重点就是做好缓存,包括但不限于数据梳理、做数据缓存、加缓存后保证数据一致性等等工作。
另外,为了帮你从单纯的业务实现思想中“跳出来”,我们还会一起拓展下主从同步延迟和多机房同步的相关知识,为后续学习分布式和强一致打好基础。
强一致性的电商系统
这一章我们会以最典型的电商系统为例,学习要求更高的强一致性系统。
这类系统的主要挑战是承接高并发流量的同时,还要做好系统隔离性、事务一致性以及库存高并发争抢不超卖。我会和你详细讨论拆分实践的要点,让你加深对系统隔离、同步降级和库存锁等相关内容的认识,弄明白分布式事务组件的运作规律。了解这些,你会更容易看透一些基础架构组件的设计初衷。
写多读少的系统如何做链路跟踪
接下来是高并发写系统,它涉及大量数据如何落盘、如何传输、存储、压缩,还有冷热数据的切换备份、索引查询等多方面问题,我会一一为你展开分析。我还会给你分享一个全量日志分布式链路跟踪系统的完整案例,帮你熟悉并发写场景落地的方方面面。
另外,行业内写高并发的服务通常需要借助一些开源才能实现,我还会介绍一些相关开源实现原理和应用方向,完善你的“兵器库”。
读多写多的直播系统
读多写多系统是最复杂的系统类型,就像最火热的游戏、直播服务都属于这个类型。其中很多技术都属于行业天花板级别,毕竟线上稍有点问题,都极其影响用户体验。
这类系统数据基本都是在内存中直接对外服务同时服务都要拆成很小的单元数据是周期落到磁盘或数据库而不是实时更新到数据库。因此我们的学习重点是如何用内存数据做业务服务、系统无需重启热更新、脚本引擎集成、脚本与服务互动交换数据、直播场景高并发优化、一些关于网络优化CDN和DNS、知识以及业务流量调度、客户端本地缓存等相关知识。
第五章 内网建设案例讲解
最后一章,我精选了一些案例,也是我特别添加的,这里既有让人眼前一亮的项目方案,也有很多有趣实用的设计,主要目的是帮助你开拓视野,未来能自行实现一些基础服务设计。
对于流量刚成长起来的业务,这一章很有参考价值,能让你的系统在后续业务流量增长时,扛住需求冲击并能快速解决问题。同时,相信你对头部开源解决方案也会有更深的理解。
一起到达目的地之后,我希望你已经有了更加宏观的视野,通过多项目实践系统了解了高并发。在面临各类相关问题时,能针对不同类型的系统,实现更匹配业务需求和技术条件的改造优化。
高并发不会是区别大厂、小厂工程师的标准,却是检验技术实力的一道关。课程搭建的学习场景是个良好起点,为你创造机会提高能力,期待看到你未来的成长突破!
留言区和我聊聊你学习高并发的痛点吧,或许你遇到的困难已经在课程中有了答案,我也可以做针对性的加餐,我们一起交流学习。

View File

@ -0,0 +1,211 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 结构梳理:大并发下,你的数据库表可能成为性能隐患
你好,我是徐长龙,欢迎进入第一章节的学习。
这一章我们主要讲解怎么对读多写少的系统进行高并发优化,我会拿用户中心作为例子,带你来看改造的几个要点。
用户中心是一个典型的读多写少系统,可以说我们大部分的系统都属于这种类型,而这类系统通过缓存就能获得很好的性能提升。并且在流量增大后,用户中心通常是系统改造中第一个要优化的模块,因为它常常和多个系统重度耦合,所以梳理这个模块对整个系统后续的高并发改造非常重要。
今天这节课,我会带你对读多写少的用户中心做数据整理优化,这会让数据更容易缓存。数据梳理是一个很重要的技巧,任何老系统在做高并发改造时都建议先做一次表的梳理。
因为老系统在使用数据库的时候存在很多问题比如实体表字段过多、表查询维度和用途多样、表之间关系混乱且存在m:n情况……这些问题会让缓存改造十分困难严重拖慢改造进度。
如果我们从数据结构出发,先对一些场景进行改造,然后再去做缓存,会让之后的改造变得简单很多。所以先梳理数据库结构,再对系统进行高并发改造是很有帮助的。
这节课我会给你讲几个具体的规律和思路,帮助你快速判断当前的表结构是否适用于高并发场景,方便后续的系统升级和改造。
精简数据会有更好的性能
为了方便讨论,我先对用户中心做一些简单介绍,如图:
用户中心的主要功能是维护用户信息、用户权限和登录状态,它保存的数据大部分都属于读多写少的数据。用户中心常见的优化方式主要是将用户中心和业务彻底拆开,不再与业务耦合,并适当增加缓存来提高系统性能。
我举一个简单的例子当时整表内有接近2000万的账号信息我对表的功能和字段进行了业务解耦和精简让用户中心的账户表里只会保留用户登陆所需的账号、密码
CREATE TABLE `account` (
`id` int(10) NOT NULL AUTO_INCREMENT,
`account` char(32) COLLATE utf8mb4_unicode_ci NOT NULL,
`password` char(32) COLLATE utf8mb4_unicode_ci NOT NULL,
`salt` char(16) COLLATE utf8mb4_unicode_ci NOT NULL,
`status` tinyint(3) NOT NULL DEFAULT '0',
`update_time` int(10) NOT NULL,
`create_time` int(10) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `login_account` (`account`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
我们知道数据库是系统的核心,如果它缓慢,那么我们所有的业务都会受它影响,我们的服务很少能超过核心数据库的性能上限。而我们减少账号表字段的核心在于,长度小的数据在吞吐、查询、传输上都会很快,也会更好管理和缓存。
精简后的表拥有更少的字段,对应的业务用途也会比较单纯。其业务主要功能就是检测用户登陆账号密码是否正确,除此之外平时不会有其他访问,也不会被用于其他范围查询上。可想而知这种表的性能一定极好,虽然存储两千万账号,但是整体表现很不错。
不过你要注意,精简数据量虽然能换来更好的响应速度,但不提倡过度设计。因为表字段如果缺少冗余会导致业务实现更为繁琐,比如账户表如果把昵称和头像删减掉,我们每次登录就需要多读取一次数据库,并且需要一直关注账户表的缓存同步更新;但如果我们在账户表中保留用户昵称和头像,在登陆验证后直接就可以继续其他业务逻辑了,无需再查询一次数据库。
所以你看,有些查询往往会因为精简一两个字段就多查一次数据库,并且还要考虑缓存同步问题,实在是得不偿失,因此我们要在“更多的字段”和“更少的职能”之间找到平衡。
数据的归类及深入整理
除了通过精简表的职能来提高表的性能和维护性外,我们还可以针对不同类型的表做不同方向的缓存优化,如下图用户中心表例子:
数据主要有四种:实体对象主表、辅助查询表、实体关系和历史数据,不同类型的数据所对应的缓存策略是不同的,如果我们将一些职能拆分不清楚的数据硬放在缓存中,使用的时候就会碰到很多烧脑的问题。
我之前就碰到过这样的错误做法——将用户来访记录这种持续增长的操作历史放到缓存里,这个记录的用途是统计有多少好友来访、有多少陌生人来访,但它同时保存着和用户是否是好友的标志。这也就意味着,一旦用户关系发生变化,这些历史数据就需要同步更新,否则里面的好友关系就“过时”了。
将历史记录和需要实时更新的好友状态混在一起,显然不合理。如果我们做归类梳理的话,应该拆分成三个职能表,分别进行管理:
历史记录表,不做缓存,仅展示最近几条,极端情况临时缓存;
好友关系(缓存关系,用于统计有几个好友);
来访统计数字(临时缓存)。
明白了数据归类处理的重要性后,我们接下来分别看看如何对上述四种类型的数据做缓存优化。
数据实体表
先看一下用户账号表,这个表是一个实体表,实体表一般会作为主表 它的一行数据代表一个实体每个实体都拥有一个独立且唯一的ID作为标识。其中“实体”代表一个抽象的事物具体的字段表示的是当前实体实时的状态属性。
这个ID对于高并发环境下的缓存很重要用户登录后就需要用自己账户的ID直接查找到对应的订单、昵称头像和好友列表信息。如果我们的业务都是通过这样的方式查找性能肯定很好并且很适合做长期缓存。
但是业务除了按ID查找外还有一些需要通过组合条件查询的比如
在7月4日下单购买耳机的订单有哪些
天津的用户里有多少新注册的用户?有多少老用户?
昨天是否有用户名前缀是rick账户注册
这种根据条件查询统计的数据是不太容易做缓存的因为高并发服务缓存的数据通常是能够快速通过Hash直接匹配的数据而这种带条件查询统计的数据很容易出现不一致、数据量不确定导致的性能不稳定等问题并且如果涉及的数据出现变化我们很难通过数据确定同步更新哪些缓存。
因此,这类数据只适合存在关系数据库或提前预置计算好结果放在缓存中直接使用,做定期更新。
除了组合条件查询不好缓存外,像 count() 、sum() 等对数据进行实时计算也有更新不及时的问题,同样只能定期缓存汇总结果,不能频繁查询。所以,我们应该在后续的开发过程中尽量避免使用数据库做计算。
回到刚才的话题,我们继续讨论常见的数据实体表的设计。其实这类表是针对业务的主要查询需求而设计的,如果我们没有按照这个用途来查询表的时候,性能往往会很差。
比如前面那个用于账户登录的表当我们拿它查询用户昵称中是否有“极客”两个字的时候需要做很多额外的工作需要对“用户昵称”这个字段增加索引同时这种like查询会扫描全表数据进行计算。
如果这种查询的频率比较高,就会严重影响其他用户的登陆,而且新增的昵称索引还会额外降低当前表插入数据的性能,这也是为什么我们的后台系统往往会单独分出一个从库,做特殊索引。
一般来说高并发用缓存来优化读取的性能时缓存保存的基本都是实体数据。那常见的方法是先通过“key前缀 + 实体ID”获取数据比如user_info_9527然后通过一些缓存中的关联关系再获取指定数据比如我们通过ID就可以直接获取用户好友关系key并且拿到用户的好友ID列表。通过类似的方式我们可以在Redis中实现用户常见的关联查询操作。
总体来说,实体数据是我们业务的主要承载体,当我们找到实体主体的时候,就可以根据这个主体在缓存中查到所有和它有关联的数据,来服务用户。现在我们来稍微总结一下,我们整理实体表的核心思路主要有以下几点:
精简数据总长度;
减少表承担的业务职能;
减少统计计算查询;
实体数据更适合放在缓存当中;
尽量让实体能够通过ID或关系方式查找
减少实时条件筛选方式的对外服务。
下面我们继续来看另外三种表结构,你会发现它们不太适合放在缓存中,因为维护它们的一致性很麻烦。
实体辅助表
为了精简数据且方便管理,我们经常会根据不同用途对主表拆分,常见的方式是做纵向表拆分。
纵向表拆分的目的一般有两个一个是把使用频率不高的数据摘出来。常见主表字段很多经过拆分可以精简它的职能而辅助表的主键通常会保持和主表一致或通过记录ID进行关联它们之间的常见关系为1:1。
而放到辅助表的数据,一般是主要业务查询中不会使用的数据,这些数据只有在极个别的场景下才会取出使用,比如用户账号表为主体用于做用户登陆使用,而辅助信息表保存家庭住址、省份、微信、邮编等平时不会展示的信息。
辅助表的另一个用途是辅助查询,当原有业务数据结构不能满足其他维度的实体查询时,可以通过辅助表来实现。
比如有一个表是以“教师”为主体设计的每次业务都会根据“当前教师ID+条件”来查询学生及班级数据,但从学生的角度使用系统时,需要高频率以“学生和班级”为基础查询教师数据时,就只能先查出 “学生ID”或“班级ID”然后才能查找出老师ID”这样不仅不方便而且还很低效这时候就可以把学生和班级的数据拆分出来额外做一个辅助表包含所有详细信息方便这种查询。
另外我还要提醒一下因为拆分的辅助表会和主体出现1:n甚至是m:n的数据关系所以我们要定期地对数据整理核对通过这个方式保证我们冗余数据的同步和完整。
不过非1:1数据关系的辅助表维护起来并不容易因为它容易出现数据不一致或延迟的情况甚至在有些场景下还需要刷新所有相关关系的缓存既耗时又耗力。如果这些数据的核对通过脚本去定期执行通过核对数据来找出数据差异会更简单一些。
此外,在很多情况下我们为了提高查询效率,会把同一个数据冗余在多个表内,有数据更新时,我们需要同步更新冗余表和缓存的数据。
这里补充一点行业里也会用一些开源搜索引擎辅助我们做类似的关系业务查询比如用ElasticSearch做商品检索、用OpenSearch做文章检索等。这种可横向扩容的服务能大大降低数据库查询压力但唯一缺点就是很难实现数据的强一致性需要人工检测、核对两个系统的数据。
实体关系表
接下来我们再谈谈实体之间的关系。
在关系类型数据中我强烈建议额外用一个关系表来记录实体间m:n的关联关系这样两个实体就不用因为相互依赖关系导致难以维护。
在对1:n或m:n关系的数据做缓存时我们建议提前预估好可能参与的数据量防止过大导致缓存缓慢。同时通常保存这个关系在缓存中会把主体的ID作为key在value内保存多个关联的ID来记录这两个数据的关联关系。而对于读取特别频繁的的业务缓存才会考虑把数据先按关系组织好然后整体缓存起来来方便查询和使用。
需要注意的是,这种关联数据很容易出现多级依赖,会导致我们整理起来十分麻烦。当相关表或条件更新的时候,我们需要及时同步这些数据在缓存中的变化。所以,这种多级依赖关系很难在并发高的系统中维护,很多时候我们会降低一致性要求来满足业务的高并发情况。
总的来说只有通过ID进行关联的数据的缓存是最容易管理的其他的都需要特殊维护我会在下节课给你介绍怎么维护缓存的更新和一致性这里就不展开说了。
现在我们简单总结一下到底什么样的数据适合做缓存。一般来说根据ID能够精准匹配的数据实体很适合做缓存而通过String、List或Set指令形成的有多条value的结构适合做1:1、1:n、m:n辅助或关系查询最后还有一点要注意虽然Hash结构很适合做实体表的属性和状态但是Hgetall指令性能并不好很容易让缓存卡顿建议不要这样做。
动作历史表
介绍到这里,我们已经完成了大部分的整理,同时对于哪些数据可以做缓存,你也有了较深理解。为了加深你的印象,我再介绍一些反例。
一般来说,动作历史数据表记录的是数据实体的动作或状态变化过程,比如用户登陆日志、用户积分消费获取记录等。这类数据会随着时间不断增长,它们一般用于记录、展示最近信息,不建议用在业务的实时统计计算上。
你可能对我的这个建议存有疑虑我再给你举个简单的例子。如果我们要从一个有2000万条记录的积分领取记录表中检测某个用户领取的ID为15的商品个数
CREATE TABLE `user_score_history` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`uid` int(10) NOT NULL DEFAULT '',
`action` varchar(32) NOT NULL,
`action_id` char(16) NOT NULL,
`status` tinyint(3) NOT NULL DEFAULT '0'
`extra` TEXT NOT NULL DEFAULT '',
`update_time` int(10) NOT NULL DEFAULT '0',
`create_time` int(10) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
KEY uid(`uid`,`action`),
) ENGINE=InnoDB AUTO_INCREMENT=1
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci;
select uid, count(*) as action_count, product_id
from user_score_history
where uid = 9527 and action = "fetch_gift"
and action_id = 15 and status = 1
group by uid,action_id
不难看出这个表数据量很大记录了大量的实体动作操作历史并且字段和索引不适合做这种查询。当我们要计算某个用户领取的ID为15的商品个数只能先通过UID索引过滤数据缩小范围。但是这样筛选出的数据仍旧会很大。并且随着时间的推移这个表的数据会不断增长它的查询效率会逐渐降低。
所以,对于这种基于大量的数据统计后才能得到的结论数据,我不建议对外提供实时统计计算服务,因为这种查询会严重拖慢我们的数据库,影响服务稳定。即使使用缓存临时保存统计结果,这也属于临时方案,建议用其他的表去做类似的事情,比如实时查询领取记录表,效果会更好。
总结
在项目初期,数据表的职能设计往往都会比较简单,但随着时间的推移和业务的发展变化,表经过多次修改后,其使用方向和职能都会发生较大的变化,导致我们的系统越来越复杂。
所以,当流量超过数据库的承受能力需要做缓存改造时,我们建议先根据当前的业务逻辑对数据表进行职能归类,它能够帮你快速识别出,表中哪些字段和功能不适合在特定类型的表内使用,这会让数据在缓存中有更好的性价比。
一般来说,数据可分为四类:实体表、实体辅助表、关系表和历史表,而判断是否适合缓存的核心思路主要是以下几点:
能够通过ID快速匹配的实体以及通过关系快速查询的数据适合放在长期缓存当中
通过组合条件筛选统计的数据,也可以放到临时缓存,但是更新有延迟;
数据增长量大或者跟设计初衷不一样的表数据,这种不适合、也不建议去做做缓存。
思考题
请你思考一下,用户邀请其他用户注册的记录,属于历史记录还是关系记录?
欢迎你在留言区与我交流讨论,我们下节课见!

View File

@ -0,0 +1,268 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 缓存一致:读多写少时,如何解决数据更新缓存不同步?
你好,我是徐长龙,我们继续来看用户中心性能改造的缓存技巧。
上节课我们对数据做了归类整理,让系统的数据更容易做缓存。为了降低数据库的压力,接下来我们需要逐步给系统增加缓存。所以这节课,我会结合用户中心的一些业务场景,带你看看如何使用临时缓存或长期缓存应对高并发查询,帮你掌握高并发流量下缓存数据一致性的相关技巧。
我们之前提到过,互联网大多数业务场景的数据都属于读多写少,在请求的读写比例中,写的比例会达到百分之一,甚至千分之一。
而对于用户中心的业务来说,这个比例会更大一些,毕竟用户不会频繁地更新自己的信息和密码,所以这种读多写少的场景特别适合做读取缓存。通过缓存可以大大降低系统数据层的查询压力,拥有更好的并发查询性能。但是,使用缓存后往往会碰到更新不同步的问题,下面我们具体看一看。
缓存性价比
缓存可以滥用吗?在对用户中心优化时,一开始就碰到了这个有趣的问题。
就像刚才所说我们认为用户信息放进缓存可以快速提高性能所以在优化之初我们第一个想到的就是将用户中心账号信息放到缓存。这个表有2000万条数据主要用途是在用户登录时通过用户提交的账号和密码对数据库进行检索确认用户账号和密码是否正确同时查看账户是否被封禁以此来判定用户是否可以登录
# 表结构
CREATE TABLE `accounts` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`account` varchar(15) NOT NULL DEFAULT '',
`password` char(32) NOT NULL,
`salt` char(16) NOT NULL,
`status` tinyint(3) NOT NULL DEFAULT '0'
`update_time` int(10) NOT NULL DEFAULT '0',
`create_time` int(10) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
# 登录查询
select id, account, update_time from accounts
where account = 'user1'
and password = '6b9260b1e02041a665d4e4a5117cfe16'
and status = 1
这是一个很简单的查询你可能会想如果我们将2000万的用户数据放到缓存肯定能提供性能很好的服务。
这个想法是对的,但不全对,因为它的性价比并不高:这个表查询的场景主要用于账号登录,用户即使频繁登录,也不会造成太大的流量冲击。因此,缓存在大部分时间是闲置状态,我们没必要将并发不高的数据放到缓存当中,浪费我们的预算。
这就牵扯到了一个很核心的问题,我们做缓存是要考虑性价比的。如果我们费时费力地把一些数据放到缓存当中,但并不能提高系统的性能,反倒让我们浪费了大量的时间和金钱,那就是不合适的。我们需要评估缓存是否有效,一般来说,只有热点数据放到缓存才更有价值。
临时热缓存
推翻将所有账号信息放到缓存这个想法后,我们把目标放到会被高频查询的信息上,也就是用户信息。
用户信息的使用频率很高,在很多场景下会被频繁查询展示,比如我们在论坛上看到的发帖人头像、昵称、性别等,这些都是需要频繁展示的数据,不过这些数据的总量很大,全部放入缓存很浪费空间。
对于这种数据,我建议使用临时缓存方式,就是在用户信息第一次被使用的时候,同时将数据放到缓存当中,短期内如果再次有类似的查询就可以快速从缓存中获取。这个方式能有效降低数据库的查询压力。常见方式实现的临时缓存的代码如下:
// 尝试从缓存中直接获取用户信息
userinfo, err := Redis.Get("user_info_9527")
if err != nil {
return nil, err
}
//缓存命中找到,直接返回用户信息
if userinfo != nil {
return userinfo, nil
}
//没有命中缓存,从数据库中获取
userinfo, err := userInfoModel.GetUserInfoById(9527)
if err != nil {
return nil, err
}
//查找到用户信息
if userinfo != nil {
//将用户信息缓存并设置TTL超时时间让其60秒后失效
Redis.Set("user_info_9527", userinfo, 60)
return userinfo, nil
}
// 没有找到,放一个空数据进去,短期内不再问数据库
// 可选,这个是用来预防缓存穿透查询攻击的
Redis.Set("user_info_9527", "", 30)
return nil, nil
可以看到我们的数据只是临时放到缓存等待60秒过期后数据就会被淘汰如果有同样的数据查询需要我们的代码会将数据重新填入缓存继续使用。这种临时缓存适合表中数据量大但热数据少的情况可以降低热点数据的压力。
而之所以给缓存设置数据TTL是为了节省我们的内存空间。当数据在一段时间内不被使用后就会被淘汰这样我们就不用购买太大的内存了。这种方式相对来说有极高的性价比并且维护简单很常用。
缓存更新不及时问题
临时缓存是有TTL的如果60秒内修改了用户的昵称缓存是不会马上更新的。最糟糕的情况是在60秒后才会刷新这个用户的昵称缓存显然这会给系统带来一些不必要的麻烦。其实对于这种缓存数据刷新可以分成几种情况不同情况的刷新方式有所不同接下来我给你分别讲讲。
1.单条实体数据缓存刷新
单条实体数据缓存更新是最简单的一个方式比如我们缓存了9527这个用户的info信息当我们对这条数据做了修改我们就可以在数据更新时同步更新对应的数据缓存
Type UserInfo struct {
Id int `gorm:"column:id;type:int(11);primary_key;AUTO_INCREMENT" json:"id"`
Uid int `gorm:"column:uid;type:int(4);NOT NULL" json:"uid"`
NickName string `gorm:"column:nickname;type:varchar(32) unsigned;NOT NULL" json:"nickname"`
Status int16 `gorm:"column:status;type:tinyint(4);default:1;NOT NULL" json:"status"`
CreateTime int64 `gorm:"column:create_time;type:bigint(11);NOT NULL" json:"create_time"`
UpdateTime int64 `gorm:"column:update_time;type:bigint(11);NOT NULL" json:"update_time"`
}
//更新用户昵称
func (m *UserInfo)UpdateUserNickname(ctx context.Context, name string, uid int) (bool, int64, error) {
//先更新数据库
ret, err := m.db.UpdateUserNickNameById(ctx, uid, name)
if ret {
//然后清理缓存,让下次读取时刷新缓存,防止并发修改导致临时数据进入缓存
//这个方式刷新较快,使用很方便,维护成本低
Redis.Del("user_info_" + strconv.Itoa(uid))
}
return ret, count, err
}
整体来讲就是先识别出被修改数据的ID然后根据ID删除被修改的数据缓存等下次请求到来时再把最新的数据更新到缓存中这样就会有效减少并发操作把脏数据带入缓存的可能性。
除此之外,我们也可以给队列发更新消息让子系统更新,还可以开发中间件把数据操作发给子系统,自行决定更新的数据范围。
不过通过队列更新消息这一步我们还会碰到一个问题——条件批量更新的操作无法知道具体有多少个ID可能有修改常见的做法是先用同样的条件把所有涉及的ID都取出来然后update这时用所有相关ID更新具体缓存即可。
2. 关系型和统计型数据缓存刷新
关系型或统计型缓存刷新有很多种方法,这里我给你讲一些最常用的。
首先是人工维护缓存方式。我们知道,关系型数据或统计结果缓存刷新存在一定难度,核心在于这些统计是由多条数据计算而成的。当我们对这类数据更新缓存时,很难识别出需要刷新哪些关联缓存。对此,我们需要人工在一个地方记录或者定义特殊刷新逻辑来实现相关缓存的更新。
不过这种方式比较精细如果刷新缓存很多那么缓存更新会比较慢并且存在延迟。而且人工书写还需要考虑如何查找到新增数据关联的所有ID因为新增数据没有登记在ID内人工编码维护会很麻烦。
除了人工维护缓存外还有一种方式就是通过订阅数据库来找到ID数据变化。如下图我们可以使用Maxwell或Canal对MySQL的更新进行监控。
这样变更信息会推送到Kafka内我们可以根据对应的表和具体的SQL确认更新涉及的数据ID然后根据脚本内设定好的逻辑对相 关key进行更新。例如用户更新了昵称那么缓存更新服务就能知道需要更新user_info_9527这个缓存同时根据配置找到并且删除其他所有相关的缓存。
很显然,这种方式的好处是能及时更新简单的缓存,同时核心系统会给子系统广播同步数据更改,代码也不复杂;缺点是复杂的关联关系刷新,仍旧需要通过人工写逻辑来实现。
如果我们表内的数据更新很少,那么可以采用版本号缓存设计。
这个方式比较狂放一旦有任何更新整个表内所有数据缓存一起过期。比如对user_info表设置一个key假设是user_info_version当我们更新这个表数据时直接对 user_info_version 进行incr +1。而在写入缓存时同时会在缓存数据中记录user_info_version的当前值。
当业务要读取user_info某个用户的信息的时候业务会同时获取当前表的version。如果发现缓存数据内的版本和当前表的版本不一致那么就会更新这条数据。但如果version更新很频繁就会严重降低缓存命中率所以这种方案适合更新很少的表。
当然我们还可以对这个表做一个范围拆分比如按ID范围分块拆分出多个version通过这样的方式来减少缓存刷新的范围和频率。
此外关联型数据更新还可以通过识别主要实体ID来刷新缓存。这要保证其他缓存保存的key也是主要实体ID这样当某一条关联数据发生变化时就可以根据主要实体ID对所有缓存进行刷新。这个方式的缺点是我们的缓存要能够根据修改的数据反向找到它关联的主体ID才行。
最后,我再给你介绍一种方式:异步脚本遍历数据库刷新所有相关缓存。这个方式适用于两个系统之间同步数据,能够减少系统间的接口交互;缺点是删除数据后,还需要人工删除对应的缓存,所以更新会有延迟。但如果能配合订阅更新消息广播的话,可以做到准同步。
长期热数据缓存
到这里我们再回过头看看之前的临时缓存伪代码它虽然能解决大部分问题但是请你想一想当TTL到期时如果大量缓存请求没有命中透传的流量会不会打沉我们的数据库这其实就是行业里常提到的缓存穿透问题如果缓存出现大规模并发穿透那么很有可能导致我们服务宕机。
所以,数据库要是扛不住平时的流量,我们就不能使用临时缓存的方式去设计缓存系统,只能用长期缓存这种方式来实现热点缓存,以此避免缓存穿透打沉数据库的问题。不过,要想实现长期缓存,就需要我们人工做更多的事情来保持缓存和数据表数据的一致性。
要知道长期缓存这个方式自NoSQL兴起后才得以普及使用主要原因在于长期缓存的实现和临时缓存有所不同它要求我们的业务几乎完全不走数据库并且服务运转期间所需的数据都要能在缓存中找到同时还要保证使用期间缓存不会丢失。
由此带来的问题就是,我们需要知道缓存中具体有哪些数据,然后提前对这些数据进行预热。当然,如果数据规模较小,那我们可以考虑把全量数据都缓存起来,这样会相对简单一些。
为了加深理解,同时展示特殊技巧,下面我们来看一种“临时缓存+长期热缓存”的一个有趣的实现,这种方式会有小规模缓存穿透,并且代码相对复杂,不过总体来说成本是比较低的:
// 尝试从缓存中直接获取用户信息
userinfo, err := Redis.Get("user_info_9527")
if err != nil {
return nil, err
}
//缓存命中找到,直接返回用户信息
if userinfo != nil {
return userinfo, nil
}
//set 检测当前是否是热数据
//之所以没有使用Bloom Filter是因为有概率碰撞不准
//如果key数量超过千个建议还是用Bloom Filter
//这个判断也可以放在业务逻辑代码中,用配置同步做
isHotKey, err := Redis.SISMEMBER("hot_key", "user_info_9527")
if err != nil {
return nil, err
}
//如果是热key
if isHotKey {
//没有找到就认为数据不存在
//可能是被删除了
return "", nil
}
//没有命中缓存,并且没被标注是热点,被认为是临时缓存,那么从数据库中获取
//设置更新锁set user_info_9527_lock nx ex 5
//防止多个线程同时并发查询数据库导致数据库压力过大
lock, err := Redis.Set("user_info_9527_lock", "1", "nx", 5)
if !lock {
//没抢到锁的直接等待1秒 然后再拿一次结果类似singleflight实现
//行业常见缓存服务,读并发能力很强,但写并发能力并不好
//过高的并行刷新会刷沉缓存
time.sleep( time.second)
//等1秒后拿数据这个数据是抢到锁的请求填入的
//通过这个方式降低数据库压力
userinfo, err := Redis.Get("user_info_9527")
if err != nil {
return nil, err
}
return userinfo,nil
}
//拿到锁的查数据库,然后填入缓存
userinfo, err := userInfoModel.GetUserInfoById(9527)
if err != nil {
return nil, err
}
//查找到用户信息
if userinfo != nil {
//将用户信息缓存并设置TTL超时时间让其60秒后失效
Redis.Set("user_info_9527", userinfo, 60)
return userinfo, nil
}
// 没有找到,放一个空数据进去,短期内不再问数据库
Redis.Set("user_info_9527", "", 30)
return nil, nil
可以看到这种方式是长期缓存和临时缓存的混用。当我们要查询某个用户信息时如果缓存中没有数据长期缓存会直接返回没有找到临时缓存则直接走更新流程。此外我们的用户信息如果属于热点key并且在缓存中找不到的话就直接返回数据不存在。
在更新期间为了防止高并发查询打沉数据库我们将更新流程做了简单的singleflight请求合并优化只有先抢到缓存更新锁的线程才能进入后端读取数据库并将结果填写到缓存中。而没有抢到更新锁的线程先 sleep 1秒然后直接读取缓存返回结果。这样可以保证后端不会有多个线程读取同一条数据从而冲垮缓存和数据库服务缓存的写并发没有读性能那么好
另外hot_key列表也就是长期缓存的热点key列表会在多个Redis中复制保存如果要读取它随机找一个分片就可以拿到全量配置。
这些热缓存key来自于统计一段时间内数据访问流量计算得出的热点数据。那长期缓存的更新会异步脚本去定期扫描热缓存列表通过这个方式来主动推送缓存同时把TTL设置成更长的时间来保证新的热数据缓存不会过期。当这个key的热度过去后热缓存key就会从当前set中移除腾出空间给其他地方使用。
当然,如果我们拥有一个很大的缓存集群,并且我们的数据都属于热数据,那么我们大可以脱离数据库,将数据都放到缓存当中直接对外服务,这样我们将获得更好的吞吐和并发。
最后还有一种方式来缓解热点高并发查询在每个业务服务器上部署一个小容量的Redis来保存热点缓存数据通过脚本将热点数据同步到每个服务器的小Redis上每次查询数据之前都会在本地小Redis查找一下如果找不到再去大缓存内查询通过这个方式缓解缓存的读取性能。
总结
通过这节课,我希望你能明白:不是所有的数据放在缓存就能有很好的收益,我们要从数据量、使用频率、缓存命中率三个角度去分析。读多写少的数据做缓存虽然能降低数据层的压力,但要根据一致性需求对其缓存的数据做更新。其中,单条实体数据最容易实现缓存更新,但是有条件查询的统计结果并不容易做到实时更新。
除此之外如果数据库承受不了透传流量压力我们需要将一些热点数据做成长期缓存来防止大量请求穿透缓存这样会影响我们的服务稳定。同时通过singleflight方式预防临时缓存被大量请求穿透以防热点数据在从临时缓存切换成热点之前击穿缓存导致数据库崩溃。
读多写少的缓存技巧我还画了一张导图,如下所示:
思考题
1.使用Bloom Filter识别热点key时有时会识别失误进而导致数据没有找到那么如何避免这种情况呢
2.使用Bloom Filter只能添加新key不能删除某一个key如果想更好地更新维护有什么其他方式吗
欢迎你在留言区与我交流讨论,我们下节课见!

View File

@ -0,0 +1,186 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 Token如何降低用户身份鉴权的流量压力
你好我是徐长龙这节课我们来看看如何用token算法降低用户中心的身份鉴权流量压力。
很多网站初期通常会用Session方式实现登录用户的用户鉴权也就是在用户登录成功后将这个用户的具体信息写在服务端的Session缓存中并分配一个session_id保存在用户的Cookie中。该用户的每次请求时候都会带上这个ID通过ID可以获取到登录时写入服务端Session缓存中的记录。
流程图如下所示:
这种方式的好处在于信息都在服务端储存对客户端不暴露任何用户敏感的数据信息并且每个登录用户都有共享的缓存空间Session Cache
但是随着流量的增长这个设计也暴露出很大的问题——用户中心的身份鉴权在大流量下很不稳定。因为用户中心需要维护的Session Cache空间很大并且被各个业务频繁访问那么缓存一旦出现故障就会导致所有的子系统无法确认用户身份进而无法正常对外服务。
这主要是由于Session Cache和各个子系统的耦合极高全站的请求都会对这个缓存至少访问一次这就导致缓存的内容长度和响应速度直接决定了全站的QPS上限让整个系统的隔离性很差各子系统间极易相互影响。
那么,如何降低用户中心与各个子系统间的耦合度,提高系统的性能呢?我们一起来看看。
JWT登陆和token校验
常见方式是采用签名加密的token这是登录的一个行业标准即JWTJSON Web Token
上图就是JWT的登陆流程用户登录后会将用户信息放到一个加密签名的token中每次请求都把这个串放到header或cookie内带到服务端服务端直接将这个token解开即可直接获取到用户的信息无需和用户中心做任何交互请求。
token生成代码如下
import "github.com/dgrijalva/jwt-go"
//签名所需混淆密钥 不要太简单 容易被破解
//也可以使用非对称加密,这样可以在客户端用公钥验签
var secretString = []byte("jwt secret string 137 rick")
type TokenPayLoad struct {
UserId uint64 `json:"userId"` //用户id
NickName string `json:"nickname"` //昵称
jwt.StandardClaims //私有部分
}
// 生成JWT token
func GenToken(userId uint64, nickname string) (string, error) {
c := TokenPayLoad{
UserId: userId, //uid
NickName: nickname, //昵称
//这里可以追加一些其他加密的数据进来
//不要明文放敏感信息,如果需要放,必须再加密
//私有部分
StandardClaims: jwt.StandardClaims{
//两小时后失效
ExpiresAt: time.Now().Add(2 * time.Hour).Unix(),
//颁发者
Issuer: "geekbang",
},
}
//创建签名 使用hs256
token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
// 签名获取token结果
return token.SignedString(secretString)
}
可以看到这个token内部包含过期时间快过期的token会在客户端自动和服务端通讯更换这种方式可以大幅提高截取客户端token并伪造用户身份的难度。
同时服务端也可以和用户中心解耦业务服务端直接解析请求带来的token即可获取用户信息无需每次请求都去用户中心获取。而token的刷新可以完全由App客户端主动请求用户中心来完成而不再需要业务服务端业务请求用户中心去更换。
JWT是如何保证数据不会被篡改并且保证数据的完整性呢我们先看看它的组成。
如上图所示加密签名的token分为三个部分彼此之间用点来分割其中Header用来保存加密算法类型PayLoad是我们自定义的内容Signature是防篡改签名。
JWT token解密后的数据结构如下图所示
//header
//加密头
{
"alg": "HS256", // 加密算法注意检测个别攻击会在这里设置为none绕过签名
"typ": "JWT" //协议类型
}
//PAYLOAD
//负载部分存在JWT标准字段及我们自定义的数据字段
{
"userid": "9527", //我们放的一些明文信息,如果涉及敏感信息,建议再次加密
"nickname": "Rick.Xu", // 我们放的一些明文信息,如果涉及隐私,建议再次加密
"iss": "geekbang",
"iat": 1516239022, //token发放时间
"exp": 1516246222, //token过期时间
}
//签名
//签名用于鉴定上两段内容是否被篡改,如果篡改那么签名会发生变化
//校验时会对不上
JWT如何验证token是否有效还有token是否过期、是否合法具体方法如下
func DecodeToken(token string) (*TokenPayLoad, error) {
token, err := jwt.ParseWithClaims(token, &TokenPayLoad{}, func(tk *jwt.Token) (interface{}, error) {
return secret, nil
})
if err != nil {
return nil, err
}
if decodeToken, ok := token.Claims.(*TokenPayLoad); ok && token.Valid {
return decodeToken, nil
}
return nil, errors.New("token wrong")
}
JWT的token解密很简单第一段和第二段都是通过base64编码的。直接解开这两段数据就可以拿到payload中所有的数据其中包括用户昵称、uid、用户权限和token过期时间。要验证token是否过期只需将其中的过期时间和本地时间对比一下就能确认当前token是不是有效。
而验证token是否合法则是通过签名验证完成的任何信息修改都会无法通过签名验证。要是通过了签名验证就表明token没有被篡改过是一个合法的token可以直接使用。
这个过程如下图所示:-
我们可以看到通过token方式用户中心压力最大的接口可以下线了每个业务的服务端只要解开token验证其合法性就可以拿到用户信息。不过这种方式也有缺点就是用户如果被拉黑客户端最快也要在token过期后才能退出登陆这让我们的管理存在一定的延迟。
如果我们希望对用户进行实时管理可以把新生成的token在服务端暂存一份每次用户请求就和缓存中的token对比一下但这样很影响性能极少数公司会这么做。同时为了提高JWT系统的安全性token一般会设置较短的过期时间通常是十五分钟左右过期后客户端会自动更换token。
token的更换和离线
那么如何对JWT的token进行更换和离线验签呢
具体的服务端换签很简单只要客户端检测到当前的token快过期了就主动请求用户中心更换token接口重新生成一个离当前还有十五分钟超时的token。
但是期间如果超过十五分钟还没换到就会导致客户端登录失败。为了减少这类问题同时保证客户端长时间离线仍能正常工作行业内普遍使用双token方式具体你可以看看后面的流程图
可以看到这个方案里有两种token一种是refresh_token用于更换access_token有效期是30天另一种是access_token用于保存当前用户信息和权限信息每隔15分钟更换一次。如果请求用户中心失败并且App处于离线状态只要检测到本地refresh_token没有过期系统仍可以继续工作直到refresh_token过期为止然后提示用户重新登陆。这样即使用户中心坏掉了业务也能正常运转一段时间。
用户中心检测更换token的实现如下
//如果还有五分钟token要过期那么换token
if decodeToken.StandardClaims.ExpiresAt < TimestampNow() - 300 {
//请求下用户中心问问这个人禁登陆没
//....略具体
//重新发放token
token, err := GenToken(.....)
if err != nil {
return nil, err
}
//更新返回cookie中token
resp.setCookie("xxxx", token)
}
这段代码只是对当前的token做了超时更换JWT对离线App端十分友好因为App可以将它保存在本地在使用用户信息时直接从本地解析出来即可
安全建议
最后我再啰嗦几句除了上述代码中的注释外在使用JWT方案的时候还有一些关键的注意事项这里分享给你
第一通讯过程必须使用HTTPS协议这样才可以降低被拦截的可能
第二要注意限制token的更换次数并定期刷新token比如用户的access_token每天只能更换50次超过了就要求用户重新登陆同时token每隔15分钟更换一次这样可以降低token被盗取后给用户带来的影响
第三Web用户的token保存在cookie中时建议加上httponlySameSite=Strict限制以防止cookie被一些特殊脚本偷走。
总结
传统的Session方式是把用户的登录信息通过SessionID统一缓存到服务端中客户端和子系统每次请求都需要到用户中心去提取”,这就会导致用户中心的流量很大所有业务都很依赖用户中心
为了降低用户中心的流量压力同时让各个子系统与用户中心脱耦我们采用信任签名的token把用户信息加密发放到客户端让客户端本地拥有这些信息而子系统只需通过签名算法对token进行验证就能获取到用户信息
这种方式的核心是把用户信息放在服务端外做传递和维护以此解决用户中心的流量性能瓶颈此外通过定期更换token用户中心还拥有一定的用户控制能力也加大了破解难度可谓一举多得
其实还有很多类似的设计简化系统压力比如文件crc32校验签名可以帮我们确认文件在传输过程中是否损坏通过Bloom Filter可以确认某个key是否存在于某个数据集合文件中等等这些都可以大大提高系统的工作效率减少系统的交互压力这些技巧在硬件能力腾飞的阶段仍旧适用
思考题
用户如果更换了昵称如何快速更换token中保存的用户昵称呢
欢迎你在留言区与我交流讨论我们下节课见

View File

@ -0,0 +1,128 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 同城双活:如何实现机房之间的数据同步?
你好,我是徐长龙。今天我们来看看用户中心改造的另一个阶段:构建多机房。
在业务初期,考虑到投入成本,很多公司通常只用一个机房提供服务。但随着业务发展,流量不断增加,我们对服务的响应速度和可用性有了更高的要求,这时候我们就要开始考虑将服务分布在不同的地区来提供更好的服务,这是互联网公司在流量增长阶段的必经之路。
之前我所在的公司,流量连续三年不断增长。一次,机房对外网络突然断开,线上服务全部离线,网络供应商失联。因为没有备用机房,我们经过三天紧急协调,拉起新的线路才恢复了服务。这次事故影响很大,公司损失达千万元。
经过这次惨痛的教训我们将服务迁移到了大机房并决定在同城建设双机房提高可用性。这样当一个机房出现问题无法访问时用户端可以通过HttpDNS接口快速切换到无故障机房。
为了保证在一个机房损坏的情况下另外一个机房能直接接手流量这两个机房的设备必须是1:1采购。但让其中一个机房长时间冷备不工作过于浪费因此我们期望两个机房能同时对外提供服务也就是实现同城双机房双活。
对此,我们碰到的一个关键问题就是,如何实现同城双活的机房数据库同步?
核心数据中心设计
因为数据库的主从架构,全网必须只能有一个主库,所以我们只能有一个机房存放更新数据的主库,再由这个机房同步给其他备份机房。虽然机房之间有专线连接,但并不能保证网络完全稳定。如果网络出现故障,我们要想办法确保机房之间能在网络修复后快速恢复数据同步。
有人可能会说,直接采用分布式数据库不就得了。要知道改变现有服务体系,投入到分布式数据库的大流中需要相当长的时间,成本也非常高昂,对大部分公司来说是不切实际的。所以我们要看看怎么对现有系统进行改造,实现同城双活的机房数据库同步,这也是我们这节课的目标。
核心数据库中心方案是常见的实现方式这种方案只适合相距不超过50公里的机房。
在这个方案中,数据库主库集中在一个机房,其他机房的数据库都是从库。当有数据修改请求时,核心机房的主库先完成修改,然后通过数据库主从同步把修改后的数据传给备份机房的从库。由于用户平时访问的信息都是从缓存中获取的,为了降低主从延迟,备份机房会把修改后的数据先更新到本地缓存。
与此同时,客户端会在本地记录下数据修改的最后时间戳(如果没有就取当前时间)。当客户端请求服务端时,服务端会自动对比缓存中对应数据的更新时间,是否小于客户端本地记录的修改时间。
如果缓存更新时间小于客户端内的修改时间,服务端会触发同步指令尝试在从库中查找最新数据;如果没有找到,就把从主库获取的最新数据放到被访问机房的缓存中。这种方式可以避免机房之间用户数据更新不及时的问题。
除此之外,客户端还会通过请求调度接口,让一个用户在短期内只访问一个机房,防止用户在多机房间来回切换的过程中,数据在两个机房同时修改引发更新合并冲突。
总体来看这是一个相对简单的设计但缺点也很多。比如如果核心机房离线其他机房就无法更新故障期间需要人工切换各个proxy内的主从库配置才能恢复服务并且在故障过后还需要人工介入恢复主从同步。
此外,因为主从同步延迟较大,业务中刚更新的数据要延迟一段时间,才能在备用机房查到,这会导致我们业务需要人工兼顾这种情况,整体实现十分不便。
这里我给你一个常见的网络延迟参考:
同机房服务器0.1 ms
同城服务器100公里以内 1ms10倍 同机房)
北京到上海: 38ms380倍 同机房)
北京到广州53ms530倍 同机房)
注意上面只是一次RTT请求而机房间的同步是多次顺序地叠加请求。如果要大规模更新数据主从库的同步延迟还会加大所以这种双活机房的数据量不能太大并且业务不能频繁更新数据。
此外还要注意,如果服务有强一致性的要求,所有操作都必须在主库“远程执行”,那么这些操作也会加大主从同步延迟。
除了以上问题外双机房之间的专线还会偶发故障。我碰到过机房之间专线断开两小时的情况期间只能临时用公网保持同步但公网同步十分不稳定网络延迟一直在10ms500ms之间波动主从延迟达到了1分钟以上。好在用户中心服务主要以长期缓存的方式存储数据业务的主要流程没有出现太大问题只是用户修改信息太慢了。
有时候双机房还会偶发主从同步断开对此建议做告警处理。一旦出现这种情况就发送通知到故障警报群由DBA人工修复处理。
另外我还碰到过主从不同步期间有用户注册自增ID出现重复导致主键冲突这种情况。这里我推荐将自增ID更换为“由SnowFlake算法计算出的ID”这样可以减少机房不同步导致的主键冲突问题。
可以看到核心数据库的中心方案虽然实现了同城双机房双活但是人力投入很大。DBA需要手动维护同步主从同步断开后恢复起来也十分麻烦耗时耗力而且研发人员需要时刻关注主从不同步的情况整体维护起来十分不便所以我在这里推荐另外一个解决方案数据库同步工具Otter。
跨机房同步神器Otter
Otter是阿里开发的数据库同步工具它可以快速实现跨机房、跨城市、跨国家的数据同步。如下图所示其核心实现是通过Canal监控主库MySQL的Row binlog将数据更新并行同步给其他机房的MySQL。
因为我们要实现同城双机房双活所以这里我们用Otter来实现同城双主注意双主不通用不推荐一致要求高的业务使用这样双活机房可以双向同步
如上图每个机房内都有自己的主库和从库缓存可以是跨机房主从也可以是本地主从这取决于业务形态。Otter通过Canal将机房内主库的数据变更同步到Otter Node内然后经由Otter的SETL整理后再同步到对面机房的Node节点中从而实现双机房之间的数据同步。
讲到这里不得不说一下Otter是怎么解决两个机房同时修改同一条数据所造成的冲突的。
在Otter中数据冲突有两种一种是行冲突另一种是字段冲突。行冲突可以通过对比数据修改时间来解决或者是在冲突时回源查询覆盖目标库对于字段冲突我们可以根据修改时间覆盖或把多个修改动作合并比如a机房-1b机房-1合并后就是-2以此来实现数据的最终一致性。
但是请注意,这种合并方式并不适合库存一类的数据管理,因为这样会出现超卖现象。如果有类似需求,建议用长期缓存解决。
Otter不仅能支持双主机房还可以支持多机房同步比如星形双向同步、级联同步如下图等。但是这几种方式并不实用因为排查问题比较困难而且当唯一决策库出现问题时恢复起来很麻烦。所以若非必要不推荐用这类复杂的结构。
另外我还要强调一点我们讲的双活双向同步方案只适合同城。一般来说50100公里以内的机房同步都属于同城内。
超过这个距离的话,建议只做数据同步备份,因为同步延迟过高,业务需要在每一步关注延迟的代价过大。如果我们的业务对一致性的要求极高,那么建议在设计时,把这种一致性要求限制在同一个机房内,其他数据库只用于保存结果状态。
那为什么机房间的距离必须是100公里以内呢你看看Otter对于不同距离的同步性能和延迟参考应该就能理解了。
具体表格如下所示:
为了提高跨机房数据同步的效率Otter对用于主从同步的操作日志做了合并把同一条数据的多次修改合并成了一条日志同时对网络传输和同步策略做了滑窗并行优化。
对比MySQL的同步Otter有5倍的性能提升。通过上面的表格可以看到通过Otter实现的数据同步并发性能好、延迟低只要我们将用户一段时间内的请求都控制在一个机房内不频繁切换那么相同数据的修改冲突就会少很多。
用Otter实现双向同步时我们的业务不需要做太多改造就能适应双主双活机房。具体来说业务只需要操作本地主库把“自增主键”换成“snowflake算法生成的主键”、“唯一索引互斥”换成“分布式互斥锁”即可满足大部分需求。
但是要注意,采用同城双活双向同步方案时,数据更新不能过于频繁,否则会出现更大的同步延迟。当业务操作的数据量不大时,才会有更好的效果。
说到这里我们再讲一讲Otter的故障切换。目前Otter提供了简单的主从故障切换功能在Manager中点击“切换”即可实现Canal和数据库的主从同步方式切换。如果是同城双活那关于数据库操作的原有代码我们不需要做更改因为这个同步是双向的。
当一个机房出现故障时先将故障机房的用户流量引到正常运转的机房待故障修复后再恢复数据同步即可不用切换业务代码的MySQL主从库IP。切记如果双活机房有一个出现故障了其他城市的机房只能用于备份或临时独立运行不要跨城市做双活因为同步延迟过高会导致业务数据损坏的后果。
最后我再啰嗦一下使用Otter的注意事项第一为了保证数据的完整性变更表结构时我们一般会先从从库修改表结构因此在设置Otter同步时建议将pipeline同步设置为忽略DDL同步错误第二数据库表新增字段时只能在表结尾新增不能删除老字段并且建议先把新增字段同步到目标库然后再同步到主库因为只有这样才不会丢数据第三双向同步的表在新增字段时不要有默认值同时Otter不支持没有主键的表同步。
总结
机房之间的数据同步一直是行业里的痛因为高昂的实现代价如果不能做到双活总是会有一个1:1机器数量的机房在空跑而且发生故障时没有人能保证冷备机房可以马上对外服务。
但是双活模式的维护成本也不低机房之间的数据同步常常会因为网络延迟或数据冲突而停止最终导致两个机房的数据不一致。好在Otter对数据同步做了很多措施能在大多数情况下保证数据的完整性并且降低了同城双活的实现难度。
即使如此在业务的运转过程中我们仍然需要人工梳理业务避免多个机房同时修改同一条数据。对此我们可以通过HttpDNS调度让一个用户在某一段时间内只在一个机房内活跃这样可以降低数据冲突的情况。
而对于修改频繁、争抢较高的服务,一般都会在机房本地做整体事务执行,杜绝跨机房同时修改导致同步错误的发生。
相信未来随着行业的发展,多活机房的同步会有更好的解决方案,今天的内容就讲到这里,期待你在留言区与我互动交流!
思考题
如果Otter同步的链路是环形的那么如何保证数据不会一直循环同步下去

View File

@ -0,0 +1,128 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 共识Raft如何保证多机房数据的一致性
你好,我是徐长龙。
上节课我们讲了如何通过Otter实现同城双活机房的数据库同步但是这种方式并不能保证双机房数据双主的事务强一致性。
如果机房A对某一条数据做了更改B机房同时修改Otter会用合并逻辑对冲突的数据行或字段做合并。为了避免类似问题我们在上节课对客户端做了要求用户客户端在一段时间内只能访问一个机房。
但如果业务对“事务+强一致”的要求极高,比如库存不允许超卖,那我们通常只有两种选择:一种是将服务做成本地服务,但这个方式并不适合所有业务;另一种是采用多机房,但需要用分布式强一致算法保证多个副本的一致性。
在行业里最知名的分布式强一致算法要属Paxos但它的原理过于抽象在使用过程中经过多次修改会和原设计产生很大偏离这让很多人不确定自己的修改是不是合理的。而且很多人需要一到两年的实践经验才能彻底掌握这个算法。
随着我们对分布式多副本同步的需求增多过于笼统的Paxos已经不能满足市场需要于是Raft算法诞生了。
相比PaxosRaft不仅更容易理解还能保证数据操作的顺序因此在分布式数据服务中被广泛使用像etcd、Kafka这些知名的基础组件都是用Raft算法实现的。
那今天这节课我们就来探寻一下Raft的实现原理可以说了解了Raft就相当于了解了分布式强一致性数据服务的半壁江山。几乎所有关于多个数据服务节点的选举、数据更新和同步都是采用类似的方式实现的只是针对不同的场景和应用做了一些调整。
如何选举Leader
为了帮你快速熟悉Raft的实现原理下面我会基于 Raft官方的例子对Raft进行讲解。
如图所示我们启动五个Raft分布式数据服务S1、S2、S3、S4、S5每个节点都有以下三种状态
Leader负责数据修改主动同步修改变更给Follower
Follower接收Leader推送的变更数据
Candidate集群中如果没有Leader那么进入选举模式。
如果集群中的Follower节点在指定时间内没有收到Leader的心跳那就代表Leader损坏集群无法更新数据。这时候Follower会进入选举模式在多个Follower中选出一个Leader保证一组服务中一直存在一个Leader同时确保数据修改拥有唯一的决策进程。
那Leader服务是如何选举出来的呢进入选举模式后这5个服务会随机等待一段时间。等待时间一到当前服务先投自己一票并对当前的任期“term”加 1 上图中term:4就代表第四任Leader然后对其他服务发送RequestVote RPC即请求投票进行拉票。
收到投票申请的服务,并且申请服务(即“发送投票申请的服务”)的任期和同步进度都比它超前或相同,那么它就会投申请服务一票,并把当前的任期更新成最新的任期。同时,这个收到投票申请的服务不再发起投票,会等待其他服务邀请。
注意每个服务在同一任期内只投票一次。如果所有服务都没有获取到多数票三分之二以上服务节点的投票就会等当前选举超时后对任期加1再次进行选举。最终获取多数票且最先结束选举倒计时的服务会被选为Leader。
被选为Leader的服务会发布广播通知其他服务并向其他服务同步新的任期和其进度情况。同时新任Leader会在任职期间周期性发送心跳保证各个子服务Follwer不会因为超时而切换到选举模式。在选举期间若有服务收到上一任Leader的心跳则会拒绝如下图S1
选举结束后,所有服务都进入数据同步状态。
如何保证多副本写一致?
在数据同步期间Follower会与Leader的日志完全保持一致。不难看出Raft算法采用的也是主从方式同步只不过Leader不是固定的服务而是被选举出来的。
这样当个别节点出现故障时是不会影响整体服务的。不过这种机制也有缺点如果Leader失联那么整体服务会有一段时间忙于选举而无法提供数据服务。
通常来说客户端的数据修改请求都会发送到Leader节点如下图S1进行统一决策如果客户端请求发送到了FollowerFollower就会将请求重定向到Leader。那么Raft是怎么实现同分区数据备份副本的强一致性呢
-
具体来讲Leader成功修改数据后会产生对应的日志然后Leader会给所有Follower发送单条日志同步信息。只要大多数Follower返回同步成功Leader就会对预提交的日志进行commit并向客户端返回修改成功。
接着Leader在下一次心跳时消息中leader commit字段会把当前最新commit的Log index日志进度告知给各Follower节点然后各Follower按照这个index进度对外提供数据未被Leader最终commit的数据则不会落地对外展示。
如果在数据同步期间客户端还有其他的数据修改请求发到Leader那么这些请求会排队因为这时候的Leader在阻塞等待其他节点回应。
不过这种阻塞等待的设计也让Raft算法对网络性能的依赖很大因为每次修改都要并发请求多个节点等待大部分节点成功同步的结果。
最惨的情况是返回的RTT会按照最慢的网络服务响应耗时“两地三中心”的一次同步时间为100ms左右再加上主节点只有一个一组Raft的服务性能是有上限的。对此我们可以减少数据量并对数据做切片提高整体集群的数据修改性能。
请你注意当大多数Follower与Leader同步的日志进度差异过大时数据变更请求会处于等待状态直到一半以上的Follower与Leader的进度一致才会返回变更成功。当然这种情况比较少见。
服务之间如何同步日志进度?
讲到这我们不难看出在Raft的数据同步机制中日志发挥着重要的作用。在同步数据时Raft采用的日志是一个有顺序的指令日志WALWrite Ahead Log类似MySQL的binlog。该日志中记录着每次修改数据的指令和修改任期并通过Log Index标注了当前是第几条日志以此作为同步进度的依据。
其中Leader的日志永远不会删除所有的Follower都会保持和Leader 完全一致如果存在差异也会被强制覆盖。同时每个日志都有“写入”和“commit”两个阶段在选举时每个服务会根据还未commit的Log Index进度优先选择同步进度最大的节点以此保证选举出的Leader拥有最新最全的数据。
Leader在任期内向各节点发送同步请求其实就是按顺序向各节点推送一条条日志。如果Leader同步的进度比Follower超前Follower就会拒绝本次同步。
Leader收到拒绝后会从后往前一条条找出日志中还未同步的部分或者有差异的部分然后开始一个个往后覆盖实现同步。
Leader和Follower的日志同步进度是通过日志index来确认的。Leader对日志内容和顺序有绝对的决策权当它发现自己的日志和Follower的日志有差异时为了确保多个副本的数据是完全一致的它会强制覆盖Follower的日志。
那么Leader是怎么识别出Follower的日志与自己的日志有没有差异呢实际上Leader给Follower同步日志的时候会同时带上Leader上一条日志的任期和索引号与Follower当前的同步进度进行对比。
对比分为两个方面一方面是对比Leader和Follower当前日志中的index、多条操作日志和任期另一方面是对比Leader和Follower上一条日志的index和任期。
如果有任意一个不同那么Leader就认为Follower的日志与自己的日志不一致这时候Leader会一条条倒序往回对比直到找到日志内容和任期完全一致的index然后从这个index开始正序向下覆盖。同时在日志数据同步期间Leader只会commit其所在任期内的数据过往任期的数据完全靠日志同步倒序追回。
你应该已经发现了这样一条条推送同步有些缓慢效率不高这导致Raft对新启动的服务不是很友好。所以Leader会定期打快照通过快照合并之前修改日志的记录来降低修改日志的大小。而同步进度差距过大的Follower会从Leader最新的快照中恢复数据按快照最后的index追赶进度。
如何保证读取数据的强一致性?
通过前面的讲解我们知道了Leader和Follower之间是如何做到数据同步的那从Follower的角度来看它又是怎么保证自己对外提供的数据是最新的呢
这里有个小技巧就是Follower在收到查询请求时会顺便问一下Leader当前最新commit的log index是什么。如果这个log index大于当前Follower同步的进度就说明Follower的本地数据不是最新的这时候Follower就会从Leader获取最新的数据返回给客户端。可见保证数据强一致性的代价很大。
你可能会好奇如何在业务使用时保证读取数据的强一致性呢其实我们之前说的Raft同步等待Leader commit log index的机制已经确保了这一点。我们只需要向Leader正常提交数据修改的操作Follower读取时拿到的就一定是最新的数据。
总结
很多人都说Raft是一个分布式一致性算法但实际上Raft算法是一个共识算法多个节点达成共识它通过任期机制、随机时间和投票选举机制实现了服务动态扩容及服务的高可用。
通过Raft采用强制顺序的日志同步实现多副本的数据强一致同步如果我们用Raft算法实现用户的数据存储层那么数据的存储和增删改查都会具有跨机房的数据强一致性。这样一来业务层就无需关心一致性问题对数据直接操作即可轻松实现多机房的强一致同步。
由于这种方式的同步代价和延迟都比较大建议你尽量在数据量和修改量都比较小的场景内使用行业里也有很多针对不同场景设计的库可以选择parallel-raft、multi-paxos、SOFAJRaft等更多请参考Raft的底部开源列表。
思考题
最后请你思考一下为什么Raft集群成员增减需要特殊去做
欢迎你在留言区与我交流讨论,我们下节课见!

View File

@ -0,0 +1,193 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 领域拆分:如何合理地拆分系统?
你好,我是徐长龙。
从这一章开始,我们一起看看怎么对数据一致性要求极高的系统做高并发改造。在这个章节中,我会以极具代表性的电商系统为例,对改造的技术关键点进行讲解。
一般来说,强一致性的系统都会牵扯到“锁争抢”等技术点,有较大的性能瓶颈,而电商时常做秒杀活动,这对系统的要求更高。业内在对电商系统做改造时,通常会从三个方面入手:系统拆分、库存争抢优化、系统隔离优化。
今天这节课我们先来热个身,学习一些系统拆分的技巧。我们知道,电商系统有很多功能需要保持数据的强一致性,我们一般会用锁确保同一时间只有一个线程在修改。
但这种方式会让业务处理的并行效率很低,还很容易影响系统的性能。再加上这类系统经常有各种个性活动需求,相关功能支撑需要不断更新迭代,而这些变更往往会导致系统脱离原来的设计初衷,所以在开发新需求的同时,我们要对系统定期做拆分整理,避免系统越跑越偏。这时候,如何根据业务合理地拆分系统就非常重要了。
案例背景
为了帮你掌握好系统拆分的技巧,我们来看一个案例。有一次,我受朋友邀请,希望我帮他优化系统。
他们是某行业知名电商的供货商,供应链比较长,而且供应品类和规格复杂。为确保生产计划平滑运转,系统还需要调配多个子工厂和材料商的生产排期。
原本调配订单需要电话沟通,但这样太过随机。为了保证生产链稳定供货,同时提高协调效率,朋友基于订单预订系统增加了排期协商功能,具体就是将 “排期” 作为下订单主流程里的一个步骤,并将协商出的排期按照日历样式来展示,方便上游供应商和各个工厂以此协调生产周期。
整个供货协商流程如下图所示:
如图,上游项目会先发布生产计划(或采购计划),供货商根据计划拆分采购列表(分单),并联系不同的工厂协调做预排期(预约排期)。之后,上游采购方对工厂产品进行质量审核,然后下单支付、确认排期。
工厂根据确认好的排期制定采购材料计划,并通知材料供货商分批供货,开始分批生产制造产品。每当制造好一批产品后,工厂就会通知物流按批次发货到采购方(即供货商),同时更新供货商系统内的分批订单信息。接着,上游对产品进行验收,将不合格的产品走退换流程。
但系统运行了一段时间后朋友发现,由于之前系统是以订单为主体的,增加排期功能后还是以主订单作为聚合根(即主要实体),这就导致上游在发布计划时需要创建主订单。
而主订单一直处于开启状态,随着排期不断调整和新排期的不断加入,订单数据就会持续增加,一年内订单数据量达到了一亿多条。因为数据过多、合作周期长,并且包含了售后环节,所以这些数据无法根据时间做归档,导致整个系统变得越来越慢。
考虑到这是核心业务,如果持续存在问题影响巨大,因此朋友找我取经,请教如何对数据进行分表拆分。但根据我的理解,这不是分表分库维护的问题,而是系统功能设计不合理导致了系统臃肿。于是经过沟通,我们决定对系统订单系统做一次领域拆分。
流程分析整理
我先梳理了主订单的API和流程从上到下简单绘制了流程和订单系统的关系如下图所示
可以看到,有多个角色在使用这个“订单排期系统”。通过这张图与产品、研发团队进行沟通,来确认我理解的主要流程的数据走向和系统数据依赖关系都没有问题。
接着我们将目光放在了订单表上订单表承载的职能过多导致多个流程依赖订单表无法做数据维护而且订单存在多个和订单业务无关的状态比如排期周期很长导致订单一直不能关闭。我们在第1节课讲过一个数据实体不要承担太多职能否则很难管理所以我们需要对订单和排期的主要实体职能进行拆分。
经过分析我们还发现了另一个问题,现在系统的核心并不是订单,而是计划排期。原订单系统在改造前是通过自动匹配功能实现上下游订单分单的,所以系统的主要模块都是围绕订单来流转的。而增加排期功能后,系统的核心就从围绕订单实现匹配分单,转变成了围绕排期产生订单的模式,这更符合业务需要。
排期和订单有关联关系,但职能上有不同的方向用途,排期只是计划,而订单只为工厂后续生产运输和上游核对结果使用。这意味着系统的模块和表的设计核心已经发生了偏移,我们需要拆分模块才能拥有更好的灵活性。
综上所述,我们总体的拆分思路是:要将排期流程和订单交付流程完全拆分开。要知道在创业公司,我们做的项目一开始的设计初衷常常会因为市场需求变化,逐渐偏离原有设计,这就需要我们不断重新审视我们的系统,持续改进,才能保证系统的完善。
因为担心研发团队摆脱不了原有系统的思维定势,拆分做得不彻底,导致改版失败,所以我对角色和流程做了一次梳理,明确了各个角色的职责和流程之间的关系。我按角色及其所需动作画出多个框,将他们需要做的动作和数据流穿插起来,如下图所示:
基于这个图,我再次与研发、产品沟通,找出了订单与排期在功能和数据上的拆分点。具体来讲,就是将上游的职能拆分为:发布进货计划、收货排期、下单、收货/退换;而供货商主要做协调排期分单,同时提供订单相关服务;工厂则主要负责生产排期、生产和售后。这样一来,系统的流程就可以归类成几个阶段:
1.计划排期协调阶段-
2.按排期生产供货+周期物流交付阶段-
3.售后服务调换阶段
可以看到,第一个阶段不牵扯订单,主要是上游和多个工厂的排期协调;第二、三阶段是工厂生产供货和售后,这些服务需要和订单交互,而上游、工厂和物流的视角是完全不同的。
基于这个结论我们完全可以根据数据的主要实体和主要业务流程订单ID做聚合根将流程分为订单和排期两个领域将系统拆分成两个子系统排期调度系统、订单交付系统。
在计划排期协调阶段,上游先在排期调度系统内提交进货计划和收货排期,然后供货商根据上游的排期情况和进货需求,与多家合作工厂协调分单和议价。多方达成一致后,上游对计划排期和工厂生产排期进行预占。
待上游正式签署协议、支付生产批次定金后,排期系统会根据排期和工厂下单在订单系统中产生对应的订单。同时,上游、供货商和工厂一旦达成合作,后续可以持续追加下单排期,而不是将合作周期限制在订单内。
在排期生产供货阶段,排期系统在调用订单系统的同时,会传递具体的主订单号和订单明细。订单明细内包含着计划生产的品类、个数以及每期的交付量,工厂可以根据自己的情况调整生产排期。产品生产完毕后,工厂分批次发送物流进行派送,并在订单系统内记录交付时间、货物量和物流信息。同时,订单系统会生成财务信息,与上游财务和仓库分批次地对账。
这么拆分后,两个系统把采购排期和交付批次作为聚合根,进行了数据关联,这样一来,整体的订单流程就简单了很多。
总体来讲,前面对业务的梳理都以流程、角色和关键动作这三个元素作为分析的切入点,然后将不同流程划分出不同阶段来归类分析,根据不同阶段拆分出两个业务领域:排期和订单,同时找出两个业务领域的聚合根。经过这样大胆的拆分后,再与产品和研发论证可行性。
系统拆分从表开始
经历了上面的过程,相信你对按流程和阶段拆分实体职责的方法,已经有了一定的感觉,这里我们再用代码和数据库表的视角复盘一下该过程。
一般来说,系统功能从表开始拆分,这是最容易实现的路径,因为我们的业务流程往往都会围绕一个主要的实体表运转,并关联多个实体进行交互。在这个案例中,我们将订单表内关于排期的数据和状态做了剥离,拆分之前的代码分层如下图所示:
拆分之后,代码分层变成了这样:
可以看到,最大的变化就是订单实体表的职责被拆分了,我们的系统代码随之变得更加简单,而且同一个订单实体被多个角色交叉调用的情况也完全消失了。在拆分过程中,我们的依据有三个:
数据实体职能只做最核心的一件事,比如订单只管订单的生老病死(包括创建、流程状态更改、退货、订单结束);
业务流程归类按涉及实体进行归类,看能否分为多个阶段,比如“协调排期流程进行中”、“生产流程”、“售后服务阶段”;
由数据依赖交叉的频率决定把订单划分成几个模块如果两个模块业务流程上交互紧密并且有数据关联关系比如Join、调用A必然调用B这种就把这两个模块合并同时保证短期内不会再做更进一步的拆分。
一个核心的系统,如果按实体表职责进行拆分整理,那么它的流程和修改难度都会大大降低。
而模块的拆分也可以通过图6从下往上去看。如果它们之间的数据交互不是特别频繁比如没有出现频繁的Join我们就将系统分成四个模块。如图7所示可以看到这四个模块之间相对独立各自承担一个核心的职责。同时两个实体之间交互没有太大的数据关联每个模块都维护着某个阶段所需的全部数据这么划分比较清晰也易于统一管理。
到这里我们只需要将数据和流程关系都梳理一遍确保它们之间的数据在后续的统计分析中没有频繁数据Join即可完成对表的拆分。
但如果要按业务划分模块我还是建议从上到下去看业务流程来决定数据实体拆分领域模型设计DDD的领域范围以及各个模块的职责范围。
越是底层服务越要抽象
除了系统的拆分外,我们还要注意一下服务的抽象问题。很多服务经常因业务细节变更需要经常修改,而越是底层服务,越要减少变更。如果服务的抽象程度不够,一旦底层服务变更,我们很难确认该变更对上游系统的影响范围。
所以,我们要搞清楚哪些服务可以抽象为底层服务,以及如何对这些服务做更好的抽象。
因为电商类系统经常对服务做拆分和抽象,所以我就以这类系统为例为你进行讲解。你可能感到疑惑:电商系统为什么要经常做系统拆分和服务抽象呢?
这是因为电商系统最核心且最复杂的地方就是订单系统电商商品有多种品类sku+spu不同品类的筛选维度、服务、计量单位都不同这就导致系统要记录大量的冗余品类字段才能保存好用户下单时的交易快照。所以我们需要频繁拆分整理系统避免这些独有特性影响到其他商品。
此外,电商系统不同业务的服务流程是不同的。比如下单购买食品,与下单定制一个柜子完全不同。在用户购买食品时,电商只需要通知仓库打包、打物流单、发货、签收即可;而用户定制柜子则需要厂家上门量尺寸、复尺、定做、运输、后续调整等。所以,我们需要做服务抽象,让业务流程更标准、更通用,避免变更过于频繁。
正是由于业务服务形态存在不同的差异,订单系统需要将自己的职能控制在“一定范围”内。对此,我们应该考虑如何在满足业务需求的情况下,让订单表的数据职能最小。
事实上,这没有绝对的答案,因为不同行业、不同公司的业务形态都是不同的,这里我举几个常见的抽象思路供你参考。
被动抽象法
如果两个或多个服务使用同一个业务逻辑就把这个业务逻辑抽象成公共服务。比如业务A更新了逻辑a业务B也会同步使用新的逻辑a那么就将这个逻辑a放到底层抽象成一个公共服务供两个服务调用。这种属于比较被动的抽象方式很常见适合代码量不大、维护人员很少的系统。
对于创业初期主脉络不清晰的系统,利用被动抽象法很容易做抽象。不过,它的缺点是抽象程度不高,当业务需要大量变更时,需要一定规模的重构。
总的来说,虽然这种方式的代码结构很贴近业务,但是很麻烦,而且代码分层没有规律。所以,被动抽象法适用于新项目的探索阶段。
这里说一个题外话同层级之间的模块是禁止相互调用的。如果调用了就需要将两个服务抽象成公共服务让上层对两个服务进行聚合如上图中的红X拆分后如下图所示
这么做是为了让系统结构从上到下是一个倒置的树形,保证不会出现引用交叉循环的情况,否则会让项目难以排查问题,难以迭代维护,如果前期有大量这样的调用,当我们做系统改造优化时只能投入大量资源才能解决这个问题。
动态辅助表方式
这个方式适用于规模稍微大一点的团队或系统它的具体实现是这样的当订单系统被几个开发小组共同使用而不同业务创建的主订单有不同的type不同的type会将业务特性数据存储在不同的辅助表内比如普通商品保存在表order和表order_product_extra中定制类商品的定制流程状态保存在order_customize_extra中。
这样处理的好处是更贴近业务,方便查询。但由于辅助表有其他业务数据,业务的隔离性比较差,所有依赖订单服务的业务常会受到影响,而且订单需要时刻跟着业务改版。所以,通过这种方式抽象出来的订单服务已经形同虚设,一般只有企业的核心业务才会做类似的定制。
强制标准接口方式
这种方式在大型企业比较常见,其核心点在于:底层服务只做标准的服务,业务的个性部分都由业务自己完成,比如订单系统只有下单、等待支付、支付成功、发货和收货功能,展示的时候用前端对个性数据和标准订单做聚合。
用这种方式抽象出的公共服务订单对业务的耦合性是最小的,业务改版时不需要订单跟随改版,订单服务维护起来更容易。只是上层业务交互起来会很难受,因为需要在本地保存很多附加的信息,并且一些流转要自行实现。不过,从整体来看,对于使用业务多的系统来说,因为业务导致的修改会很少。
通过上面三种方式可以看出,业务的稳定性取决于服务的抽象程度。如果底层经常更改,那么整个业务就需要不断修改,最终会导致业务混乱。所以,我个人还是推荐你使用强制标准接口方式,这也是很多公司的常见做法。虽然很难用,但比起经常重构整个系统总要好一些。
你可能很奇怪,为什么不把第一种方式一口气设计好呢?这是因为大部分的初创业务都不稳定,提前设计虽然能让代码结构保持统一,但是等两年后再回头看,你会发现当初的设计已经面目全非,我们最初信心满满的设计,最后会成为业务的绊脚石。
所以,这种拆分和架构设计需要我们不定期回看、自省、不断调整。毕竟技术是为业务服务的,业务更重要,没有人可以保证项目初期设计的个人中心不会被改成交友的个人门户。
总之,每一种方法并非绝对正确,我们需要根据业务需求来决策用哪一种方式。
总结
业务拆分的方法有很多,最简单便捷的方式是:先从上到下做业务流程梳理,将流程归类聚合;然后从不同的领域聚合中找出交互所需主要实体,根据流程中主要实体之间的数据依赖程度决定是否拆分(从下到上看);把不同的实体和动作拆分成多个模块后,再根据业务流程归类,划分出最终的模块(最终汇总)。
这个拆分过程用一句话总结就是:从上往下看流程,从下往上看模块,最后综合考虑流程和模块的产出结果。用这种方式能快速拆出模块范围,拆分出的业务也会十分清晰。
除了拆分业务外,我们还要关注如何抽象服务。如果底层业务变更频繁,就会导致上层业务频繁修改,甚至出现变更遗漏的情况。所以,我们要确保底层服务足够抽象,具体有很多种办法,比如被动拆分法、动态辅助表方式、标准抽象方式。这几种方式各有千秋,需要我们根据业务来决策。
通常,我们的业务系统在初期都会按照一个特定的目标来设计,但是随着市场需求的变化,业务系统经过不断改版,往往会偏离原有的设计。
虽然我们每次改版都实现了既定需求,但也很容易带来许多不合理的问题。所以,在需求稳定后,一般都会做更合理的改造,保证系统的完整性,提高可维护性。很多时候,第一版本不用做得太过精细,待市场验证后明确了接下来的方向,再利用留出足够的空间改进,这样设计的系统才会有更好的扩展性。
思考题
我们这节课中的有些概念与DDD是重合的但是仍有一些细小的差异请你对比一下MVC三层方式和DDD实现的差异。
欢迎你在留言区与我交流讨论,我们下节课见!

View File

@ -0,0 +1,305 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 强一致锁:如何解决高并发下的库存争抢问题?
你好,我是徐长龙。
这节课我会给你详细讲一讲高并发下的库存争抢案例,我相信很多人都看到过相关资料,但是在实践过程中,仍然会碰到具体的实现无法满足需求的情况,比如说有的实现无法秒杀多个库存,有的实现新增库存操作缓慢,有的实现库存耗尽时会变慢等等。
这是因为对于不同的需求,库存争抢的具体实现是不一样的,我们需要详细深挖,理解各个锁的特性和适用场景,才能针对不同的业务需要做出灵活调整。
由于秒杀场景是库存争抢非常经典的一个应用场景,接下来我会结合秒杀需求,带你看看如何实现高并发下的库存争抢,相信在这一过程中你会对锁有更深入的认识。
锁争抢的错误做法
在开始介绍库存争抢的具体方案之前,我们先来了解一个小知识——并发库存锁。还记得在我学计算机的时候,老师曾演示过一段代码:
public class ThreadCounter {
private static int count = 0;
public static void main(String[] args) throws Exception {
Runnable task = new Runnable() {
public void run() {
for (int i = 0; i < 1000; ++i) {
count += 1;
}
}
};
Thread t1 = new Thread(task);
t1.start();
Thread t2 = new Thread(task);
t2.start();
t1.join();
t2.join();
cout << "count = " << count << endl;
}
}
从代码来看我们运行后结果预期是2000但是实际运行后并不是。为什么会这样呢
当多线程并行对同一个公共变量读写时由于没有互斥多线程的set会相互覆盖或读取时容易读到其他线程刚写一半的数据这就导致变量数据被损坏。反过来说我们要想保证一个变量在多线程并发情况下的准确性就需要这个变量在修改期间不会被其他线程更改或读取。
对于这个情况,我们一般都会用到锁或原子操作来保护库存变量:
如果是简单int类型数据可以使用原子操作保证数据准确
如果是复杂的数据结构或多步操作,可以加锁来保证数据完整性。
这里我附上关于几种锁的参考资料,如果你感兴趣可以深入了解一下。
考虑到我们之前的习惯会有一定惯性,为了让你更好地理解争抢,这里我再举一个我们常会犯错的例子。因为扣库存的操作需要注意原子性,我们实践的时候常常碰到后面这种方式:
redis> get prod_1475_stock_1
15
redis> set prod_1475_stock_1 14
OK
也就是先将变量从缓存中取出,对其做-1操作再放回到缓存当中这是个错误做法。
如上图原因是多个线程一起读取的时候多个线程同时读到的是5set回去时都是6实际每个线程都拿到了库存但是库存的实际数值并没有累计改变这会导致库存超卖。如果你需要用这种方式去做一般建议加一个自旋互斥锁互斥其他线程做类似的操作。
不过锁操作是很影响性能的,在讲锁方式之前,我先给你介绍几个相对轻量的方式。
原子操作
在高并发修改的场景下,用互斥锁保证变量不被错误覆盖性能很差。让一万个用户抢锁,排队修改一台服务器的某个进程保存的变量,这是个很糟糕的设计。
因为锁在获取期间需要自旋循环等待,这需要不断地循环尝试多次才能抢到。而且参与争抢的线程越多,这种情况就越糟糕,这期间的通讯过程和循环等待很容易因为资源消耗造成系统不稳定。
对此我会把库存放在一个独立的且性能很好的内存缓存服务Redis中集中管理这样可以减少用户争抢库存导致其他服务的抖动并且拥有更好的响应速度这也是目前互联网行业保护库存量的普遍做法。
同时,我不建议通过数据库的行锁来保证库存的修改,因为数据库资源很珍贵,使用数据库行锁去管理库存,性能会很差且不稳定。
前面我们提到当有大量用户去并行修改一个变量时,只有用锁才能保证修改的正确性,但锁争抢性能很差,那怎么降低锁的粒度、减少锁的争枪呢?
如上图其实我们可以将一个热门商品的库存做拆分放在多个key中去保存这样可以大幅度减少锁争抢。
举个例子当前商品库存有100个我们可以把它放在10个key中用不同的Redis实例保存每个key里面保存10个商品库存当用户下单的时候可以随机找一个key进行扣库存操作。如果没库存就记录好当前key再随机找剩下的9个key直到成功扣除1个库存。
除了这种方法以外我个人更推荐的做法是使用Redis的原子操作因为原子操作的粒度更小并且是高性能单线程实现可以做到全局唯一决策。而且很多原子操作的底层实现都是通过硬件实现的性能很好比如文稿后面这个例子
redis> decr prod_1475_stock_1
14
incr、decr这类操作就是原子的我们可以根据返回值是否大于0来判断是否扣库存成功。但是这里你要注意如果当前值已经为负数我们需要考虑一下是否将之前扣除的补偿回来。并且为了减少修改操作我们可以在扣减之前做一次值检测整体操作如下
//读取当前库存,确认是否大于零
//如大于零则继续操作,小于等于拒绝后续
redis> get prod_1475_stock_1
1
//开始扣减库存、如返回值大于或等于0那么代表扣减成功小于0代表当前已经没有库存
//可以看到返回-2这可以理解成同时两个线程都在操作扣库存并且都没拿到库存
redis> decr prod_1475_stock_1
-2
//扣减失败、补偿多扣的库存
//这里返回0是因为同时两个线程都在做补偿最终恢复0库存
redis> incr prod_1475_stock
0
这看起来是个不错的保护库存量方案,不过它也有缺点,相信你已经猜到了,这个库存的数值准确性取决于我们的业务是否能够返还恢复之前扣除的值。如果在服务运行过程中,“返还”这个操作被打断,人工修复会很难,因为你不知道当前有多少库存还在路上狂奔,只能等活动结束后所有过程都落地,再来看剩余库存量。
而要想完全保证库存不会丢失我们习惯性通过事务和回滚来保障。但是外置的库存服务Redis不属于数据库的缓存范围这一切需要通过人工代码去保障这就要求我们在处理业务的每一处故障时都能处理好库存问题。
所以,很多常见秒杀系统的库存在出现故障时是不返还的,并不是不想返还,而是很多意外场景做不到。
提到锁也许你会想到使用Setnx指令或数据库CAS的方式实现互斥排他锁以此来解决库存问题。但是这个锁有自旋阻塞等待并发高的时候用户服务需要循环多次做尝试才能够获取成功这样很浪费系统资源对数据服务压力较大不推荐这样去做这里附上锁性能对比参考
令牌库存
除了这种用数值记录库存的方式外,还有一种比较科学的方式就是“发令牌”方式,通过这个方式可以避免出现之前因为抢库存而让库存出现负数的情况。
具体是使用Redis中的list保存多张令牌来代表库存一张令牌就是一个库存用户抢库存时拿到令牌的用户可以继续支付
//放入三个库存
redis> lpush prod_1475_stock_queue_1 stock_1
redis> lpush prod_1475_stock_queue_1 stock_2
redis> lpush prod_1475_stock_queue_1 stock_3
//取出一个超过0.5秒没有返回,那么抢库存失败
redis> brpop prod_1475_stock_queue_1 0.5
在没有库存后用户只会拿到nil。当然这个实现方式只是解决抢库存失败后不用再补偿库存的问题在我们对业务代码异常处理不完善时仍会出现丢库存情况。
同时我们要注意brpop可以从list队列“右侧”中拿出一个令牌如果不需要阻塞等待的话使用rpop压测性能会更好一些。
不过当我们的库存成千上万的时候可能不太适合使用令牌方式去做因为我们需要往list中推送1万个令牌才能正常工作来表示库存。如果有10万个库存就需要连续插入10万个字符串到list当中入库期间会让Redis出现大量卡顿。
到这里,关于库存的设计看起来已经很完美了,不过请你想一想,如果产品侧提出“一个商品可以抢多个库存”这样的要求,也就是一次秒杀多个同种商品(比如一次秒杀两袋大米),我们利用多个锁降低锁争抢的方案还能满足吗?
多库存秒杀
其实这种情况经常出现,这让我们对之前的优化有了更多的想法。对于一次秒杀多个库存,我们的设计需要做一些调整。
之前我们为了减少锁冲突把库存拆成10个key随机获取我们设想一下当库存剩余最后几个商品时极端情况下要想秒杀三件商品如上图我们需要尝试所有的库存key然后在尝试10个key后最终只拿到了两个商品库存那么这时候我们是拒绝用户下单还是返还库存呢
这其实就要看产品的设计了同时我们也需要加一个检测如果商品卖完了就不要再尝试拿10个库存key了毕竟没库存后一次请求刷10次Redis对Redis的服务压力很大Redis O(1)指令性能理论可以达到10w OPS一次请求刷10次那么理想情况下抢库存接口性能为1W QPS压测后建议按实测性能70%漏斗式限流)。
这时候你应该发现了,在“一个商品可以抢多个库存”这个场景下,拆分并没有减少锁争抢次数,同时还加大了维护难度。当库存越来越少的时候,抢购越往后性能表现越差,这个设计已经不符合我们设计的初衷(由业务需求造成我们底层设计不合适的情况经常会碰到,这需要我们在设计之初,多挖一挖产品具体的需求)。
那该怎么办呢我们不妨将10个key合并成1个改用rpop实现多个库存扣减但库存不够三个只有两个的情况仍需要让产品给个建议看看是否继续交易同时在开始的时候用LLENO(1)指令检查一下我们的List里面是否有足够的库存供我们rpop以下是这次讨论的最终设计
//取之前看一眼库存是否空了,空了不继续了(llen O(1))
redis> llen prod_1475_stock_queue
3
//取出库存3个实际抢到俩
redis> rpop prod_1475_stock_queue 3
"stock_1"
"stock_2"
//产品说数量不够不允许继续交易将库存返还
redis> lpush prod_1475_stock_queue stock_1
redis> lpush prod_1475_stock_queue stock_2
通过这个设计我们已经大大降低了下单系统锁争抢压力。要知道Redis是一个性能很好的缓存服务其O(1)类复杂度的指令在使用长链接的情况下多线程压测5.0 版本的Redis就能够跑到10w OPS而6.0版本的网络性能会更好。
这种利用Redis原子操作减少锁冲突的方式对各个语言来说是通用且简单的。不过你要注意不要把Redis服务和复杂业务逻辑混用否则会影响我们的库存接口效率。
自旋互斥超时锁
如果我们在库存争抢时需要操作多个决策key才能够完成争抢那么原子这种方式是不适合的。因为原子操作的粒度过小无法做到事务性地维持多个数据的ACID。
这种多步操作,适合用自旋互斥锁的方式去实现,但流量大的时候不推荐这个方式,因为它的核心在于如果我们要保证用户的体验,我们需要逻辑代码多次循环抢锁,直到拿到锁为止,如下:
//业务逻辑需要循环抢锁如循环10次每次sleep 10ms10次失败后返回失败给用户
//获取锁后设置超时时间,防止进程崩溃后没有释放锁导致问题
//如果获取锁失败会返回nil
redis> set prod_1475_stock_lock EX 60 NX
OK
//抢锁成功,扣减库存
redis> rpop prod_1475_stock_queue 1
"stock_1"
//扣减数字库存,用于展示
redis> decr prod_1475_stock_1
3
// 释放锁
redis> del prod_1475_stock_lock
这种方式的缺点在于在抢锁阶段如果排队抢的线程越多等待时间就越长并且由于多线程一起循环check的缘故在高并发期间Redis的压力会非常大如果有100人下单那么有100个线程每隔10ms就会check一次此时Redis的操作次数就是
\[100线程\\times1000ms\\div10ms次 = 10000 ops\]
CAS乐观锁锁操作后置
除此之外我再推荐一个实现方式CAS乐观锁。相对于自旋互斥锁来说它在并发争抢库存线程少的时候效率会更好。通常我们用锁的实现方式是先抢锁然后再对数据进行操作。这个方式需要先抢到锁才能继续而抢锁是有性能损耗的即使没有其他线程抢锁这个消耗仍旧存在。
CAS乐观锁的核心实现为记录或监控当前库存信息或版本号对数据进行预操作。
如上图,在操作期间如果发现监控的数值有变化,那么就回滚之前操作;如果期间没有变化,就提交事务的完成操作,操作期间的所有动作都是事务的。
//开启事务
redis> multi
OK
// watch 修改值
// 在exec期间如果出现其他线程修改那么会自动失败回滚执行discard
redis> watch prod_1475_stock_queue prod_1475_stock_1
//事务内对数据进行操作
redis> rpop prod_1475_stock_queue 1
QUEUED
//操作步骤2
redis> decr prod_1475_stock_1
QUEUED
//执行之前所有操作步骤
//multi 期间 watch有数值有变化则会回滚
redis> exec
3
可以看到通过这个方式我们可以批量地快速实现库存扣减并且能大幅减少锁争抢时间。它的好处我们刚才说过就是争抢线程少时效率特别好但争抢线程多时会需要大量重试不过即便如此CAS乐观锁也会比用自旋锁实现的性能要好。
当采用这个方式的时候我建议内部的操作步骤尽量少一些。同时要注意如果Redis是Cluster模式使用multi时必须在一个slot内才能保证原子性。
Redis Lua方式实现Redis锁
与“事务+乐观锁”类似的实现方式还有一种就是使用Redis的Lua脚本实现多步骤库存操作。因为Lua脚本内所有操作都是连续的这个操作不会被其他操作打断所以不存在锁争抢问题。
而且、可以根据不同的情况对Lua脚本做不同的操作业务只需要执行指定的Lua脚本传递参数即可实现高性能扣减库存这样可以大幅度减少业务多次请求等待的RTT。
为了方便演示怎么执行Lua脚本我使用了PHP实现
<?php
$script = <<<EOF
// 获取当前库存个数
local stock=tonumber(redis.call('GET',KEYS[1]));
//没找到返回-1
if stock==nil
then
return -1;
end
//找到了扣减库存个数
local result=stock-ARGV[1];
//如扣减后少于指定个数那么返回0
if result<0
then
return 0;
else
//如果扣减后仍旧大于0那么将结果放回Redis内并返回1
redis.call('SET',KEYS[1],result);
return 1;
end
EOF;
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
$result = $redis->eval($script, array("prod_stock", 3), 1);
echo $result;
通过这个方式,我们可以远程注入各种连贯带逻辑的操作,并且可以实现一些补库存的操作。
总结
这节课我们针对库存锁争抢的问题通过Redis的特性实现了六种方案不过它们各有优缺点。
-
以上这些方法可以根据业务需要组合使用。
其实我们用代码去实现锁定扣库存也能够实现库存争抢功能比如本地CAS乐观锁方式但是一般来说我们自行实现的代码会和其他业务逻辑混在一起会受到多方因素影响业务代码会逐渐复杂性能容易失控。而Redis是独立部署的会比我们的业务代码拥有更好的系统资源去快速解决锁争抢问题。
你可能发现我们这节课讲的方案大多数只有一层“锁”,但很多业务场景实际存在多个锁的情况,并不是我不想介绍,而是十分不推荐,因为多层锁及锁重入等问题引入后会导致我们系统很难维护,一个小粒度的锁能解决我们大部分问题,何乐而不为呢?
思考题
1.请你思考一下,通过原子操作+拆开库存方式实现库存方案时如何减少库存为0后接口缓慢的问题
2.我们这节课的内容并不仅仅在讲库存,还包含了大量可实现的锁的使用方式,请你分享一些实践过程中常见但不容易被发现的精妙设计。
欢迎你在评论区与我交流讨论,我们下节课见!

View File

@ -0,0 +1,144 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 系统隔离:如何应对高并发流量冲击?
你好,我是徐长龙,今天我想跟你聊聊如何做好系统隔离。
我曾经在一家教育培训公司做架构师,在一次续报活动中,我们的系统出现了大规模崩溃。在活动开始有五万左右的学员同时操作,大量请求瞬间冲击我们的服务器,导致服务端有大量请求堆积,最终系统资源耗尽停止响应。我们不得不重启服务,并对接口做了限流,服务才恢复正常。
究其原因,我们习惯性地将公用的功能和数据做成了内网服务,这种方式虽然可以提高服务的复用性,但也让我们的服务非常依赖内网服务。当外网受到流量冲击时,内网也会受到放大流量的冲击,过高的流量很容易导致内网服务崩溃,进而最终导致整个网站无法响应。
事故后我们经过详细复盘,最终一致认为这次系统大规模崩溃,核心还是在于系统隔离性做得不好,业务极易相互影响。
如果系统隔离性做得好,在受到大流量冲击时,只会影响被冲击的应用服务,即使某个业务因此崩溃,也不会影响到其他业务的正常运转。这就要求我们的架构要有能力隔离多个应用,并且能够隔离内外网流量,只有如此才能够保证系统的稳定。
拆分部署和物理隔离
为了提高系统的稳定性,我们决定对系统做隔离改造,具体如下图:
-
也就是说每个内、外网服务都会部署在独立的集群内同时每个项目都拥有自己的网关和数据库。而外网服务和内网必须通过网关才能访问外网向内网同步数据是用Kafka来实现的。
网关隔离和随时熔断
在这个改造方案中有两种网关外网网关和内网网关。每个业务都拥有独立的外网网关可根据需要调整来对外网流量做限流。当瞬时流量超过系统承受能力时网关会让超编的请求排队阻塞一会儿等服务器QPS高峰过后才会放行这个方式比起直接拒绝客户端请求来说可以给用户更好的体验。
外网调用内网的接口必须通过内网网关。外网请求内网接口时,内网网关会对请求的来源系统和目标接口进行鉴权,注册授权过的外网服务只能访问对其授权过的内网接口,这样可以严格管理系统之间的接口调用。
同时,我们在开发期间要时刻注意,内网网关在流量增大的时候要做熔断,这样可以避免外网服务强依赖内网接口,保证外网服务的独立性,确保内网不受外网流量冲击。并且外网服务要保证内网网关断开后,仍旧能正常独立运转一小时以上。
但是你应该也发现了,这样的隔离不能实时调用内网接口,会给研发造成很大的困扰。要知道常见外网业务需要频繁调用内网服务获取基础数据才能正常工作,而且内网、外网同时对同一份数据做决策的话,很容易出现混乱。
减少内网API互动
为了防止共享的数据被多个系统同时修改,我们会在活动期间把参与活动的数据和库存做推送,然后自动锁定,这样做可以防止其他业务和后台对数据做修改。若要禁售,则可以通过后台直接调用前台业务接口来操作;活动期间也可以添加新的商品到外网业务中,但只能增不能减。
这样的实现方式既可以保证一段时间内数据决策的唯一性,也可以保证内外网的隔离性。
不过你要注意,这里的锁定操作只是为了保证数据同步不出现问题,活动高峰过后数据不能一直锁定,否则会让我们的业务很不灵活。
因为我们需要把活动交易结果同步回内网,而同步期间外网还是能继续交易的。如果不保持锁定,数据的流向不小心会成为双向同步,这种双向同步很容易出现混乱,系统要是因此出现问题就很难修复,如下图:
我们从图中可以看到,两个系统因为没有实时互动的接口,数据是完全独立的,但是在回传外网数据到内网时,库存如果在两个系统之间来回传递,就很容易出现同步冲突进而导致混乱。那怎么避免类似的问题呢?
其实只有保证数据同步是单向的,才能取消相互锁定操作。我们可以规定所有库存决策由外网业务服务决定,后台对库存操作时必须经过外网业务决策后才能继续操作,这样的方式比锁定数据更加灵活。而外网交易后要向内网同步交易结果,只能通过队列方式推送到内网。
事实上,使用队列同步数据并不容易,其中有很多流程和细节需要我们去打磨,以减少不同步的情况。好在我们使用的队列很成熟,提供了很多方便的特性帮助我们降低同步风险。
现在我们来看下整体的数据流转,如下图:
后台系统推送数据到Redis或数据库中外网服务通过Kafka把结果同步到内网扣减库存需通知外网服务扣减成功后方可同步操作。
分布式队列控流和离线同步
我们刚才提到外网和内网做同步用的是Kafka分布式队列主要因为它有以下几个优点
队列拥有良好吞吐并且能够动态扩容,可应对各种流量冲击场景;
可通过动态控制内网消费线程数,从而实现内网流量可控;
内网消费服务在高峰期可以暂时离线,内网服务可以临时做一些停机升级操作;
内网服务如果出现bug导致消费数据丢失可以对队列消息进行回放实现重新消费
Kafka是分区消息同步消息是顺序的很少会乱序可以帮我们实现顺序同步
消息内容可以保存很久加入TraceID后查找方便并且透明利于排查各种问题。
两个系统之间的数据同步是一件很复杂、很繁琐的事情而使用Kafka可以把这个实时过程变成异步的再加上消息可回放流量也可控整个过程变得轻松很多。
在“数据同步”中最难的一步就是保证顺序,接下来我具体介绍一下我们当时是怎么做的。
当用户在外网业务系统下单购买一个商品时,外网服务会扣减本地缓存中的库存。库存扣减成功后,外网会创建一个订单并发送创建订单消息到消息队列中。当用户在外网业务支付订单后,外网业务订单状态会更新为“已支付”,并给内网发送支付成功的消息到消息队列中,发送消息实现如下:
type ShopOrder struct {
TraceId string `json:trace_id` // trace id 方便跟踪问题
OrderNo string `json:order_no` // 订单号
ProductId string `json:"product_id"` // 课程id
Sku string `json:"sku"` // 课程规格 sku
ClassId int32 `json:"class_id"` // 班级id
Amount int32 `json:amount,string` // 金额,分
Uid int64 `json:uid,string` // 用户uid
Action string `json:"action"` // 当前动作 create创建订单、pay支付订单、refund退费、close关闭订单
Status int16 `json:"status"` // 当前订单状态 0 创建 1 支付 2 退款 3 关闭
Version int32 `json:"version"` // 版本,会用当前时间加毫秒生成一个时间版本,方便后端对比操作版本,如果收到消息的版本比上次操作的时间还小忽略这个事件
UpdateTime int32 `json:"update_time"` // 最后更新时间
CreateTime int32 `json:"create_time"` // 订单创建日期
}
//发送消息到内网订单系统
resp, err := sendQueueEvent("order_event", shopOrder{...略}, 消息所在分区)
if err != nil {
return nil, err
}
return resp, nil
可以看到我们在发送消息的时候已经通过某些依据如订单号、uid算出这条消息应该投放到哪个分区内Kafka同一个分区内的消息是顺序的。
那为什么要保证消费顺序呢?其实核心在于我们的数据操作必须按顺序执行,如果不按顺序,就会出现很多奇怪的场景。
比如“用户执行创建订单、支付订单、退费”这一系列操作,消费进程很有可能会先收到退费消息,但由于还没收到创建订单和支付订单的消息,退费操作在此时就无法进行。
当然,这只是个简单的例子,如果碰到更多步骤乱序的话,数据会更加混乱。所以我们如果想做好数据同步,就要尽量保证数据是顺序的。
不过我们在前面讲Kafka的优点时也提到了队列在大部分时间是能够保证顺序性的但是在极端情况下仍会有乱序发生。为此我们在业务逻辑上需要做兼容即使无法自动解决也要记录好相关日志以方便后续排查问题。
不难发现因为这个“顺序”的要求我们的数据同步存在很大难度好在Kafka是能够长时间保存消息的。如果在同步过程中出现问题除了通过日志对故障进行修复外我们还可以将故障期间的流量进行重放重放要保证同步幂等
这个特性让我们可以做很多灵活的操作,甚至可以在流量高峰期,暂时停掉内网消费服务,待系统稳定后再开启,落地用户的交易。
除了数据同步外,我们还需要对内网的流量做到掌控,我们可以通过动态控制线程数来实现控制内网流量的速度。
好,今天这节课就讲到这里,相信你已经对“如何做好系统隔离”这个问题有了比较深入的理解,期望你在生产过程中能具体实践一下这个方案。
总结
系统的隔离需要我们投入大量的时间和精力去打磨,这节课讲了很多会对系统稳定性产生影响的关键特性,让我们整体回顾一下。
为了实现系统的隔离,我们在外网服务和内网服务之间设立了接口网关,只有通过网关才能调用内网接口服务。并且我们设定了在大流量冲击期间,用熔断内网接口的交互方式来保护内网。而外网所需的所有数据,在活动开始之前都要通过内网脚本推送到商城本地的缓存中,以此来保证业务的运转。
同时,外网成功成交的订单和同步信息通过分布式、可实时扩容和可回放的消息队列投递到了内网,内网会根据内部负载调整消费线程数来实现流量可控的消息消费。由此,我们实现了两个系统之间的同步互动。
我把这节课的关键知识画成了导图,供你参考:-
思考题
用什么方法能够周期检查出两个系统之间不同步的数据?
欢迎你在留言区与我交流讨论,我们下节课见!

View File

@ -0,0 +1,382 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 分布式事务多服务的2PC、TCC都是怎么实现的
你好,我是徐长龙,今天这节课我们聊聊分布式事务。
目前业界流行微服务DDD领域驱动设计也随之流行起来。DDD是一种拆分微服务的方法它从业务流程的视角从上往下拆分领域通过聚合根关联多个领域将多个流程聚合在一起形成独立的服务。相比由数据表结构设计出的微服务DDD这种方式更加合理但也加大了分布式事务的实现难度。
在传统的分布式事务实现方式中,我们普遍会将一个完整的事务放在一个独立的项目中统一维护,并在一个数据库中统一处理所有的操作。这样在出现问题时,直接一起回滚,即可保证数据的互斥和统一性。
不过,这种方式的服务复用性和隔离性较差,很多核心业务为了事务的一致性只能聚合在一起。
为了保证一致性,事务在执行期间会互斥锁定大量的数据,导致服务整体性能存在瓶颈。而非核心业务要想在隔离要求高的系统架构中,实现跨微服务的事务,难度更大,因为核心业务基本不会配合非核心业务做改造,再加上核心业务经常随业务需求改动(聚合的业务过多),结果就是非核心业务没法做事务,核心业务也无法做个性化改造。
也正因为如此,多个系统要想在互动的同时保持事务一致性,是一个令人头疼的问题,业内很多非核心业务无法和核心模块一起开启事务,经常出现操作出错,需要人工补偿修复的情况。
尤其在微服务架构或用DDD方式实现的系统中服务被拆分得更细并且都是独立部署拥有独立的数据库这就导致要想保持事务一致性实现就更难了因此跨越多个服务实现分布式事务已成为刚需。
好在目前业内有很多实现分布式事务的方式比如2PC、3PC、TCC等但究竟用哪种比较合适呢这是我们需要重点关注的。因此这节课我会带你对分布式事务做一些讨论让你对分布式事务有更深的认识帮你做出更好的决策。
XA协议
在讲分布式事务之前我们先认识一下XA协议。
XA协议是一个很流行的分布式事务协议可以很好地支撑我们实现分布式事务比如常见的2PC、3PC等。这个协议适合在多个数据库中协调分布式事务目前Oracle、DB2、MySQL 5.7.7以上版本都支持它虽然有很多bug。而理解了XA协议对我们深入了解分布式事务的本质很有帮助。
支持XA协议的数据库可以在客户端断开的情况下将执行好的业务结果暂存起来直到另外一个进程确认才会最终提交或回滚事务这样就能轻松实现多个数据库的事务一致性。
在XA协议里有三个主要的角色
应用AP应用是具体的业务逻辑代码实现业务逻辑通过请求事务协调器开启全局事务在事务协调器注册多个子事务后业务代码会依次给所有参与事务的子业务下发请求。待所有子业务提交成功后业务代码根据返回情况告诉事务协调器各个子事务的执行情况由事务协调器决策子事务是提交还是回滚有些实现是事务协调器发请求给子服务
事务协调器TM用于创建主事务同时协调各个子事务。事务协调器会根据各个子事务的执行情况决策这些子事务最终是提交执行结果还是回滚执行结果。此外事务协调器很多时候还会自动帮我们提交事务
资源管理器RM是一种支持事务或XA协议的数据资源比如MySQL、Redis等。
另外XA还对分布式事务规定了两个阶段Prepare阶段和Commit阶段。
在Prepare阶段事务协调器会通过xid事务唯一标识由业务或事务协调器生成协调多个资源管理器执行子事务所有子事务执行成功后会向事务协调器汇报。
这时的子事务执行成功是指事务内SQL执行成功并没有执行事务的最终commit提交所有子事务是提交还是回滚需要等事务协调器做最终决策。
接着分布式事务进入Commit阶段当事务协调器收到所有资源管理器成功执行子事务的消息后会记录事务执行成功并对子事务做真正提交。如果Prepare阶段有子事务失败或者事务协调器在一段时间内没有收到所有子事务执行成功的消息就会通知所有资源管理器对子事务执行回滚的操作。
需要说明的是,每个子事务都有多个状态,每个状态的流转情况如下图所示:
如上图,子事务有四个阶段的状态:
ACTIVE子事务SQL正在执行中
IDLE子事务执行完毕等待切换Prepared状态如果本次操作不参与回滚就可以直接提交完成
PREPARED子事务执行完毕等待其他服务实例的子事务全部Ready。
COMMITED/FAILED所有子事务执行成功/失败后,一起提交或回滚。
下面我们来看XA协调两个事务的具体流程这里我拿最常见的2PC方式为例进行讲解。
如上图所示在协调两个服务Application 1和Application 2时业务会先请求事务协调器创建全局事务同时生成全局事务的唯一标识xid然后再在事务协调器里分别注册两个子事务生成每个子事务对应的xid。这里说明一下xid由gtrid+bqual+formatID组成多个子事务的gtrid是相同的但其他部分必须区分开防止这些服务在一个数据库下。
那么有了子事务的xid被请求的服务会通过xid标识开启XA子事务让XA子事务执行业务操作。当事务数据操作都执行完毕后子事务会执行Prepare指令将子事务标注为Prepared状态然后以同样的方式执行xid2事务。
所有子事务执行完毕后Prepared状态的XA事务会暂存在MySQL中即使业务暂时断开事务也会存在。这时业务代码请求事务协调器通知所有申请的子事务全部执行成功。与此同时TM会通知RM1和RM2执行最终的commit或调用每个业务封装的提交接口
至此整个事务流程执行完毕。而在Prepare阶段如果有子事务执行失败程序或事务协调器就会通知所有已经在Prepared状态的事务执行回滚。
以上就是XA协议实现多个子系统的事务一致性的过程可以说大部分的分布式事务都是使用类似的方式实现的。下面我们通过一个案例看看XA协议在MySQL中的指令是如何使用的。
MySQL XA的2PC分布式事务
在进入案例之前你可以先了解一下MySQL中所有关XA协议的指令集以方便接下来的学习
# 开启一个事务Id为xid的XA子事务
# gtrid是事务主IDbqual是子事务标识
# formatid是数据类型标注 类似format type
XA {START|BEGIN} xid[gtrid[,bqual[,format_id]]] [JOIN|RESUME]
# 结束xid的子事务这个事务会标注为IDLE状态
# 如果IDEL状态直接执行XA COMMIT提交那么就是 1PC
XA END xid [SUSPEND [FOR MIGRATE]]
# 让子事务处于Prepared状态等待其他子事务处理后后续统一最终提交或回滚
# 另外 在这个操作之前如果断开链接,之前执行的事务都会回滚
XA PREPARE xid
# 上面不同子事务 用不同的xid(gtrid一致如果在一个实例bqual必须不同)
# 指定xid子事务最终提交
XA COMMIT xid [ONE PHASE]
XA ROLLBACK xid 子事务最终回滚
# 查看处于Prepared状态的事务
# 我们用这个来确认事务进展情况,借此决定是否整体提交
# 即使提交链接断开了我们用这个仍旧能看到所有的PrepareD状态的事务
#
XA RECOVER [CONVERT XID]
言归正传,我们以购物场景为例,在购物的整个事务流程中,需要协调的服务有三个:用户钱包、商品库存和用户购物订单,它们的数据都放在私有的数据库中。
按照业务流程,用户在购买商品时,系统需要执行扣库存、生成购物订单和扣除用户账户余额的操作 。其中“扣库存”和“扣除用户账户余额”是为了保证数据的准确和一致性所以扣减过程中要在事务操作期间锁定互斥的其他线程操作保证一致性然后通过2PC方式对三个服务实现事务协调。
具体实现代码如下:
package main
import (
"database/sql"
"fmt"
_ "github.com/go-sql-driver/mysql"
"strconv"
"time"
)
func main() {
// 库存的连接
stockDb, err := sql.Open("mysql", "root:paswd@tcp(127.0.0.1:3306)/shop_product_stock")
if err != nil {
panic(err.Error())
}
defer stockDb.Close()
//订单的连接
orderDb, err := sql.Open("mysql", "root:paswd@tcp(127.0.0.1:3307)/shop_order")
if err != nil {
panic(err.Error())
}
defer orderDb.Close()
//钱包的连接
moneyDb, err := sql.Open("mysql", "root:paswd@tcp(127.0.0.1:3308)/user_money_bag")
if err != nil {
panic(err.Error())
}
defer moneyDb.Close()
// 生成xid(如果在同一个数据库子事务不能使用相同xid)
xid := strconv.FormatInt(time.Now().UnixMilli(), 10)
//如果后续执行过程有报错,那么回滚所有子事务
defer func() {
if err := recover(); err != nil {
stockDb.Exec("XA ROLLBACK ?", xid)
orderDb.Exec("XA ROLLBACK ?", xid)
moneyDb.Exec("XA ROLLBACK ?", xid)
}
}()
// 第一阶段 Prepare
// 库存 子事务启动
if _, err = stockDb.Exec("XA START ?", xid); err != nil {
panic(err.Error())
}
//扣除库存,这里省略了数据行锁操作
if _, err = stockDb.Exec("update product_stock set stock=stock-1 where id =1"); err != nil {
panic(err.Error())
}
//事务执行结束
if _, err = stockDb.Exec("XA END ?", xid); err != nil {
panic(err.Error())
}
//设置库存任务为Prepared状态
if _, err = stockDb.Exec("XA PREPARE ?", xid); err != nil {
panic(err.Error())
}
// 订单 子事务启动
if _, err = orderDb.Exec("XA START ?", xid); err != nil {
panic(err.Error())
}
//创建订单
if _, err = orderDb.Exec("insert shop_order(id,pid,xx) value (1,2,3)"); err != nil {
panic(err.Error())
}
//事务执行结束
if _, err = orderDb.Exec("XA END ?", xid); err != nil {
panic(err.Error())
}
//设置任务为Prepared状态
if _, err = orderDb.Exec("XA PREPARE ?", xid); err != nil {
panic(err.Error())
}
// 钱包 子事务启动
if _, err = moneyDb.Exec("XA START ?", xid); err != nil {
panic(err.Error())
}
//扣减用户账户现金,这里省略了数据行锁操作
if _, err = moneyDb.Exec("update user_money_bag set money=money-1 where id =9527"); err != nil {
panic(err.Error())
}
//事务执行结束
if _, err = moneyDb.Exec("XA END ?", xid); err != nil {
panic(err.Error())
}
//设置任务为Prepared状态
if _, err = moneyDb.Exec("XA PREPARE ?", xid); err != nil {
panic(err.Error())
}
// 在这时如果链接断开、Prepared状态的XA事务仍旧在MySQL存在
// 任意一个链接调用XA RECOVER都能够看到这三个没有最终提交的事务
// --------
// 第二阶段 运行到这里没有任何问题
// 那么执行 commit
// --------
if _, err = stockDb.Exec("XA COMMIT ?", xid); err != nil {
panic(err.Error())
}
if _, err = orderDb.Exec("XA COMMIT ?", xid); err != nil {
panic(err.Error())
}
if _, err = moneyDb.Exec("XA COMMIT ?", xid); err != nil {
panic(err.Error())
}
//到这里全部流程完毕
}
可以看到MySQL通过XA指令轻松实现了多个库或多个服务的事务一致性提交。
可能你会想为什么在上面的代码中没有看到事务协调器的相关操作这里我们不妨去掉子业务的具体实现用API调用的方式看一下是怎么回事
package main
import (
"database/sql"
"fmt"
_ "github.com/go-sql-driver/mysql"
"strconv"
"time"
)
func main() {
// 库存的连接
stockDb, err := sql.Open("mysql", "root:123456@tcp(127.0.0.1:3306)/shop_product_stock")
if err != nil {
panic(err.Error())
}
defer stockDb.Close()
//订单的连接
orderDb, err := sql.Open("mysql", "root:123456@tcp(127.0.0.1:3307)/shop_order")
if err != nil {
panic(err.Error())
}
defer orderDb.Close()
//钱包的连接
moneyDb, err := sql.Open("mysql", "root:123456@tcp(127.0.0.1:3308)/user_money_bag")
if err != nil {
panic(err.Error())
}
defer moneyDb.Close()
// 生成xid
xid := strconv.FormatInt(time.Now().UnixMilli(), 10)
//如果后续执行过程有报错,那么回滚所有子事务
defer func() {
if err := recover(); err != nil {
stockDb.Exec("XA ROLLBACK ?", xid)
orderDb.Exec("XA ROLLBACK ?", xid)
moneyDb.Exec("XA ROLLBACK ?", xid)
}
}()
//调用API扣款api内执行xa start、sql、xa end、xa prepare
if _, err = API.Call("UserMoneyBagPay", uid, price, xid); err != nil {
panic(err.Error())
}
//调用商品库存扣库存
if _, err = API.Call("ShopStockDecr", productId, 1, xid); err != nil {
panic(err.Error())
}
//调用API生成订单
if _, err = API.Call("ShopOrderCreate",productId, uid, price, xid); err != nil {
panic(err.Error())
}
// --------
// 第二阶段 运行到这里没有任何问题
// 那么执行 commit
// --------
if _, err = stockDb.Exec("XA COMMIT ?", xid); err != nil {
panic(err.Error())
}
if _, err = orderDb.Exec("XA COMMIT ?", xid); err != nil {
panic(err.Error())
}
if _, err = moneyDb.Exec("XA COMMIT ?", xid); err != nil {
panic(err.Error())
}
//到这里全部流程完毕
}
我想你已经知道了,当前程序本身就已经实现了事务协调器的功能。其实一些开源的分布式事务组件,比如 seata或 dtm 等,对事务协调器有一个更好的抽象封装,如果你感兴趣的话可以体验测试一下。
而上面两个演示代码的具体执行过程如下图所示:
通过流程图你会发现2PC事务不仅容易理解实现起来也简单。
不过它最大的缺点是在Prepare阶段很多操作的数据需要先做行锁定才能保证数据的一致性。并且应用和每个子事务的过程需要阻塞等整个事务全部完成才能释放资源这就导致资源锁定时间比较长并发也不高常有大量事务排队。
除此之外在一些特殊情况下2PC会丢数据比如在Commit阶段如果事务协调器的提交操作被打断了XA事务就会遗留在MySQL中。
而且你应该已经发现了2PC的整体设计是没有超时机制的如果长时间不提交遗留在MySQL中的XA子事务就会导致数据库长期被锁表。
在很多开源的实现中2PC的事务协调器会自动回滚或强制提交长时间没有提交的事务但是如果进程重启或宕机这个操作就会丢失了此时就需要人工介入修复了。
3PC简述
另外提一句分布式事务的实现除了2PC外还有3PC。与2PC相比3PC主要多了事务超时、多次重复尝试以及提交check的功能。但因为确认步骤过多很多业务的互斥排队时间会很长所以3PC的事务失败率要比2PC高很多。
为了减少3PC因资源锁定等待超时导致的重复工作3PC做了预操作整体流程分成三个阶段
CanCommit阶段为了减少因等待锁定数据导致的超时情况提高事务成功率事务协调器会发送消息确认资源管理器的资源锁定情况以及所有子事务的数据库锁定数据的情况。
PreCommit阶段执行2PC的Prepare阶段
DoCommit阶段执行2PC的Commit阶段。
总体来说3PC步骤过多过程比较复杂整体执行也更加缓慢所以在分布式生产环境中很少用到它这里我就不再过多展开了。
TCC协议
事实上2PC和3PC都存在执行缓慢、并发低的问题这里我再介绍一个性能更好的分布式事务TCC。
TCC是Try-Confirm-Cancel的缩写从流程上来看它比2PC多了一个阶段也就是将Prepare阶段又拆分成了两个阶段Try阶段和Confirm阶段。TCC可以不使用XA只使用普通事务就能实现分布式事务。
首先在 Try阶段业务代码会预留业务所需的全部资源比如冻结用户账户100元、提前扣除一个商品库存、提前创建一个没有开始交易的订单等这样可以减少各个子事务锁定的数据量。业务拿到这些资源后后续两个阶段操作就可以无锁进行了。
在 Confirm阶段业务确认所需的资源都拿到后子事务会并行执行这些业务。执行时可以不做任何锁互斥也无需检查直接执行Try阶段准备的所有资源就行。
请注意,协议要求所有操作都是幂等的,以支持失败重试,因为在一些特殊情况下,比如资源锁争抢超时、网络不稳定等,操作要尝试执行多次才会成功。
最后在 Cancel阶段如果子事务在Try阶段或Confirm阶段多次执行重试后仍旧失败TM就会执行Cancel阶段的代码并释放Try预留的资源同时回滚Confirm期间的内容。注意Cancel阶段的代码也要做幂等以支持多次执行。
上述流程图如下:
最后我们总结一下TCC事务的优点
并发能力高,且无长期资源锁定;
代码入侵实现分布式事务回滚,开发量较大,需要代码提供每个阶段的具体操作;
数据一致性相对来说较好;
适用于订单类业务,以及对中间状态有约束的业务。
当然,它的缺点也很明显:
只适合短事务,不适合多阶段的事务;
不适合多层嵌套的服务;
相关事务逻辑要求幂等;
存在执行过程被打断时,容易丢失数据的情况。
总结
通常来讲实现分布式事务要耗费我们大量的精力和时间硬件上的投入也不少但当业务真的需要分布式事务时XA协议可以给我们提供强大的数据层支撑。
分布式事务的实现方式有多种常见的有2PC、3PC、TCC等。其中2PC可以实现多个子事务统一提交回滚但因为要保证数据的一致性所以它的并发性能不好。而且2PC没有超时的机制经常会将很多XA子事务遗漏在数据库中。
3PC虽然有超时的机制但是因为交互过多事务经常会出现超时的情况导致事务的性能很差。如果3PC多次尝试失败超时后它会尝试回滚这时如果回滚也超时就会出现丢数据的情况。
TCC则可以提前预定事务中需要锁定的资源来减少业务粒度。它使用普通事务即可完成分布式事务协调因此相对地TCC的性能很好。但是提交最终事务和回滚逻辑都需要支持幂等为此需要人工要投入的精力也更多。
目前市面上有很多优秀的中间件比如DTM、Seata它们对分布式事务协调做了很多的优化比如过程中如果出现打断情况它们能够自动重试、AT模式根据业务修改的SQL自动生成回滚操作的SQL这个相对来说会智能一些。
此外,这些中间件还能支持更复杂的多层级、多步骤的事务协调,提供的流程机制也更加完善。所以在实现分布式事务时,建议使用成熟的开源加以辅助,能够让我们少走弯路。
思考题
现在市面上有诸多分布式实现方式,你觉得哪一种性能更好?
欢迎在留言区与我交流讨论,我们下节课见!

View File

@ -0,0 +1,130 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 稀疏索引:为什么高并发写不推荐关系数据库?
你好,我是徐长龙。
从这一章起我们来学习如何优化写多读少的系统。说到高并发写就不得不提及新分布式数据库HTAP它实现了OLAP和OLTP的融合可以同时提供数据分析挖掘和关系查询。
事实上HTAP的OLAP并不是大数据或者说它并不是我们印象中每天拿几T的日志过来用于离线分析计算的那个大数据。这里更多的是指数据挖掘的最后一环也就是数据挖掘结果对外查询使用的场景。
对于这个范围的服务在行业中比较出名的实时数据统计分析的服务有ElasticSearch、ClickHouse虽然它们的QPS不高但是能够充分利用系统资源对大量数据做统计、过滤、查询。但是相对地为什么MySQL这种关系数据库不适合做类似的事情呢这节课我们一起分析分析。
B+Tree索引与数据量
MySQL我们已经很熟悉了我们常常用它做业务数据存储查询以及信息管理的工作。相信你也听过“一张表不要超过2000万行数据”这句话为什么会有这样的说法呢
核心在于MySQL数据库的索引实现上和我们的需求上有些冲突。具体点说我们对外的服务基本都要求实时处理在保证高并发查询的同时还需要在一秒内找出数据并返回给用户这意味着对数据大小以及数据量的要求都非常高高。
MySQL为了达到这个效果几乎所有查询都是通过索引去缩小扫描数据的范围然后再回到表中对范围内数据进行遍历加工、过滤最终拿到我们的业务需要的数据。
事实上并不是MySQL不能存储更多的数据而限制我们的多数是数据查询效率问题。
那么MySQL限制查询效率的地方有哪些请看下图
众所周知MySQL的InnoDB数据库的索引是B+TreeB+Tree的特点在于只有在最底层才会存储真正的数据ID通过这个ID就可以提取到数据的具体内容同时B+Tree索引最底层的数据是按索引字段顺序进行存储的。
通过这种设计方式我们只需进行13次IO树深度决定了IO次数就能找到所查范围内排序好的数据而树形的索引最影响查询效率的是树的深度以及数据量数据越独特筛选的数据范围就越少
数据量我么很好理解,只要我们的索引字段足够独特,筛选出来的数据量就是可控的。
但是什么会影响到索引树的深度个数呢这是因为MySQL的索引是使用Page作为单位进行存储的而每页只能存储16KBinnodb_page_size数据。如果我们每行数据的索引是1KB那么除去Page页的一些固定结构占用外一页只能放16条数据这导致树的一些分支装不下更多数据时我么就需要对索引的深度再加一层。
我们从这个Page就可以推导出索引第一层放16条树第二层大概能放2万条树第三层大概能放2400万条三层的深度B+Tree按主键查找数据每次查询需要3次IO一层索引在内存IO两次索引最后一次是拿数据
不过这个2000万并不是绝对的如果我们的每行数据是0.5KB那么大概在4000万以后才会出现第四层深度。而对于辅助索引一页Page能存放1170个索引节点主键bigint8字节+数据指针6字节三层深度的辅助索引大概能记录10亿条索引记录。
可以看到我们的数据存储数量超过三层时每次数据操作需要更多的IO操作来进行查询这样做的后果就是查询数据返回的速度变慢。所以很多互联网系统为了保持服务的高效会定期整理数据。
通过上面的讲解相信你已经对整个查询有画面感了当我们查询时通过13次IO查找辅助索引从而找到一批数据主键ID。然后通过MySQL的MMR算法将这些ID做排序再回表去聚簇索引按取值范围提取在子叶上的业务数据将这些数据边取边算或一起取出再进行聚合排序后之后再返回结果。
可以看到,我们常用的数据库之所以快,核心在于索引用得好。由于加工数据光用索引是无法完成的,我们还需要找到具体的数据进行再次加工,才能得到我们业务所需的数据,这也是为什么我们的字段数据长度和数据量会直接影响我们对外服务的响应速度。
同时请你注意我们一个表不能增加过多的索引因为索引太多会影响到表插入的性能。并且我们的查询要遵循左前缀原则来逐步缩小查找的数据范围而不能利用多个CPU并行去查询索引数据。这些大大限制了我们对大数据的处理能力。
另外如果有数据持续高并发插入数据库会导致MySQL集群工作异常、主库响应缓慢、主从同步延迟加大等问题。从部署结构上来说MySQL只有主从模式大批量的数据写操作只能由主库承受当我们数据写入缓慢时客户端只能等待服务端响应严重影响数据写入效率。
看到这里相信你已经理解为什么关系型数据库并不适合太多的数据其实OLAP的数据库也不一定适合大量的数据正如我提到的OLAP提供的服务很多也需要实时响应所以很多时候这类数据库对外提供服务的时候计算用的数据也是做过深加工的。但即使如此OLAP和OLTP底层实现仍旧有很多不同。
我们先来分析索引的不同。OLTP常用的是B+Tree我们知道B+tree索引是一个整体的树当我们的数据量大时会影响索引树的深度如果深度过高就会严重影响其工作效率。对于大量数据OLAP服务会用什么类型的索引呢
稀疏索引LSM Tree与存储
这里重点介绍一下LSM索引。我第一次见到LSM Tree还是从RocksDB以及LevelDB上看到的RocksDB之所以能够得到快速推广并受到欢迎主要是因为它利用了磁盘顺序写性能超绝的特性并以较小的性能查询代价提供了写多读少的KV数据存储查询服务这和关系数据库的存储有很大的不同。
为了更好理解我们详细讲讲Rocksdb稀疏索引是如何实现的如下图所示
我们前面讲过B+Tree是一个大树它是一个聚合的完整整体任何数据的增删改都是在这个整体内进行操作这就导致了大量的随机读写IO。
RocksDB LSM则不同它是由一棵棵小树组成当我们新数据写入时会在内存中暂存这样能够获得非常大的写并发处理能力。而当内存中数据积累到一定程度后会将内存中数据和索引做顺序写落地形成一个数据块。
这个数据块内保存着一棵小树和具体的数据新生成的数据块会保存在Level 0 层最大有几层可配置Level 0 层会有多个类似的数据块文件。结构如下图所示:
每一层的数据块和数据量超过一定程度时RocksDB合并不同Level的数据将多个数据块内的数据和索引合并在一起并推送到Level的下一层。通过这个方式每一层的数据块个数和数据量就能保持一定的数量合并后的数据会更紧密、更容易被找到。
这样的设计可以让一个Key存在于多个Level或者数据块中但是最新的常用的数据肯定是在Level最顶部或内存04层0为顶部中最新的数据块内。
而当我们查询一个key的时候RocksDB会先查内存。如果没找到会从Level 0层到下层每层按生成最新到最老的顺序去查询每层的数据块。同时为了减少IO次数每个数据块都会有一个BloomFIlter辅助索引来辅助确认这个数据块中是否可能有对应的Key如果当前数据块没有那么可以快速去找下一个数据块直到找到为止。当然最惨的情况是遍历所有数据块。
可以看到,这个方式虽然放弃了整体索引的一致性,却换来了更高效的写性能。在读取时通过遍历所有子树来查找,减少了写入时对树的合并代价。
LSM这种方式的数据存储在OLAP数据库中很常用因为OLAP多数属于写多读少而当我们使用OLAP对外提供数据服务的时候多数会通过缓存来帮助数据库承受更大的读取压力。
列存储数据库
说到这里不得不提OLAP数据库和OLTP数据之间的另一个区别。我们常用的关系型数据库属于行式存储数据库Row-based表数据结构是什么样它就会按表结构的字段顺序进行存储而大数据挖掘使用的数据库普遍使用列式存储Column-based原因在于我们用关系数据库保存的多数是实体属性和实体关系很多查询每一列都是不可或缺的。
-
但是实时数据分析则相反很多情况下常用一行表示一个用户或主要实体聚合根而列保存这个用户或主要实体是否买过某物、使用过什么App、去过哪里、开什么车、点过什么食品、哪里人等等。
这样组织出来的数据做数据挖掘、分析对比很方便不过也会导致一个表有成百上千个字段如果用行存储的数据引擎我们对数据的筛选是一行行进行读取的会浪费大量的IO读取。
而列存储引擎可以指定用什么字段读取所需字段的数据,并且这个方式能够充分利用到磁盘顺序读写的性能,大大提高这种列筛选式的查询,并且列方式更好进行数据压缩,在实时计算领域做数据统计分析的时候,表现会更好。
到了这里相信你已经发现,使用场景不同,数据底层的实现也需要不同的方式才能换来更好的性能和性价比。随着行业变得更加成熟,这些需求和特点会不断挖掘、总结、合并到我们的底层服务当中,逐渐降低我们的工作难度和工作量。
HTAP
通过前面的讲解我么可以看到OLAP和OLTP数据库各有特点并且有不同的发展方向事实上它们对外提供的数据查询服务都是期望实时快速的而不同在于如何存储和查找索引。
最近几年流行将两者结合成一套数据库集群服务同时提供OLAP以及OLTP服务并且相互不影响实现行数据库与列数据库的互补。
2022年国产数据库行业内OceanBase、PolarDB等云厂商提供的分布式数据库都在紧锣密鼓地开始支持HTAP。这让我们可以保存同一份数据根据不同查询的范围触发不同的引擎共同对外提供数据服务。
可以看到,未来的某一天,我们的数据库既能快速地实时分析,又能快速提供业务数据服务。逐渐地,数据服务底层会出现多套存储、索引结构来帮助我们更方便地实现数据库。
而目前常见的HTAP实现方式普遍采用一个服务集群内同一套数据支持多种数据存储方式行存储、列存储通过对数据提供不同的索引来实现OLAP及OLTP需求而用户在查询时可以指定或由数据库查询引擎根据SQL和数据情况自动选择使用哪个引擎来优化查询。
总结
这节课我们讨论了OLAP和OLTP数据库的索引、存储、数据量以及应用的不同场景。
OLAP相对于关系数据库的数据存储量会更多并且对于大量数据批量写入支持很好。很多情况下高并发批量写数据很常见其表的字段会更多数据的存储多数是用列式方式存储而数据的索引用的则是列索引通过这些即可实现实时大数据计算结果的查询和分析。
相对于离线计算来说,这种方式更加快速方便,唯一的缺点在于这类服务都需要多台服务器做分布式,成本高昂。
可以看出我们使用的场景不同决定了我们的数据底层如何去做更高效HTAP的出现让我们在不同的场景中有了更多的选择毕竟大数据挖掘是一个很庞大的数据管理体系如果能有一个轻量级的OLAP会让我们的业务拥有更多的可能。
思考题
最后请你思考一下列存储数据库为什么能够提高OLAP查找性能
欢迎你在留言区与我交流讨论,我们下节课见!

View File

@ -0,0 +1,448 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 链路追踪:如何定制一个分布式链路跟踪系统
你好,我是徐长龙,这节课我们讲一讲如何实现分布式链路跟踪。
分布式链路跟踪服务属于写多读少的服务,是我们线上排查问题的重要支撑。我经历过的一个系统,同时支持着多条业务线,实际用上的服务器有两百台左右,这种量级的系统想排查故障,难度可想而知。
因此我结合ELK特性设计了一套十分简单的全量日志分布式链路跟踪把日志串了起来大大降低了系统排查难度。
目前市面上开源提供的分布式链路跟踪都很抽象,当业务复杂到一定程度的时候,为核心系统定制一个符合自己业务需要的链路跟踪,还是很有必要的。
事实上,实现一个分布式链路跟踪并不难,而是难在埋点、数据传输、存储、分析上,如果你的团队拥有这些能力,也可以很快制作出一个链路跟踪系统。所以下面我们一起看看,如何实现一个简单的定制化分布式链路跟踪。
监控行业发展现状
在学习如何制作一个简单的分布式链路跟踪之前,为了更好了解这个链路跟踪的设计特点,我们先简单了解一下监控行业的现状。
最近监控行业有一次大革新现代的链路跟踪标准已经不拘泥于请求的链路跟踪目前已经开始进行融合新的标准和我们定制化的分布式链路跟踪的设计思路很相似即Trace、Metrics、日志合并成一套系统进行建设。
在此之前常见监控系统主要有三种类型Metrics、Tracing和Logging。
常见的开源Metrics有Zabbix、Nagios、Prometheus、InfluxDb、OpenFalcon主要做各种量化指标汇总统计比如监控系统的容量剩余、每秒请求量、平均响应速度、某个时段请求量多少。
常见的开源链路跟踪有Jaeger、Zipkin、Pinpoint、Skywalking主要是通过分析每次请求链路监控分析的系统我么可以通过TraceID查找一次请求的依赖及调用链路分析故障点和传导过程的耗时。
而常见的开源Logging有ELK、Loki、Loggly主要是对文本日志的收集归类整理可以对错误日志进行汇总、警告并分析系统错误异常等情况。
这三种监控系统可以说是大服务集群监控的主要支柱,它们各有优点,但一直是分别建设的。这让我们的系统监控存在一些割裂和功能重复,而且每一个标准都需要独立建设一个系统,然后在不同界面对同一个故障进行分析,排查问题时十分不便。
随着行业发展,三位一体的标准应运而生,这就是 OpenTelemetry 标准集成了OpenCensus、OpenTracing标准。这个标准将Metrics+Tracing+Logging集成一体这样我们监控系统的时候就可以通过三个维度综合观测系统运转情况。
常见OpenTelemetry开源项目中的Prometheus、Jaeger正在遵循这个标准逐步改进实现OpenTelemetry 实现的结构如下图所示:
事实上,分布式链路跟踪系统及监控主要提供了以下支撑服务:
监控日志标准
埋点SDKAOP或侵入式
日志收集
分布式日志传输
分布式日志存储
分布式检索计算
分布式实时分析
个性化定制指标盘
系统警告
我建议使用ELK提供的功能去实现分布式链路跟踪系统因为它已经完整提供了如下功能
日志收集Filebeat
日志传输Kafka+Logstash
日志存储Elasticsearch
检索计算Elasticsearch + Kibana
实时分析Kibana
个性定制表格查询Kibana
这样一来我只需要制定日志格式、埋点SDK即可实现一个具有分布式链路跟踪、Metrics、日志分析系统。
事实上Log、Metrics、trace三种监控体系最大的区别就是日志格式标准底层实现其实是很相似的。既然ELK已提供我们需要的分布式相关服务下面我简单讲讲日志格式和SDK埋点通过这两个点我们就可以窥见分布式链路跟踪的全貌。
TraceID单次请求标识
可以说要想构建一个简单的Trace系统我们首先要做的就是生成并传递TraceID。
分布式链路跟踪的原理其实很简单就是在请求发起方发送请求时或服务被请求时生成一个UUID被请求期间的业务产生的任何日志Warning、Info、Debug、Error、任何依赖资源请求MySQL、Kafka、Redis、任何内部接口调用Restful、Http、RPC都会带上这个UUID。
这样当我们把所有拥有同样UUID的日志收集起来时就可以根据时间有误差、RPCID后续会介绍RPCID或SpanID将它们按依赖请求顺序串起来。
只要日志足够详细我们就能监控到系统大部分的工作状态比如用户请求一个服务会调用多少个接口每个数据查询的SQL以及具体耗时调用的内网请求参数是什么、调用的内网请求返回是什么、内网被请求的接口又做了哪些操作、产生了哪些异常信息等等。
同时,我们可以通过对这些日志做归类分析,分析项目之间的调用关系、项目整体健康程度、对链路深挖自动识别出故障点等,帮助我们主动、快速地查找问题。
“RPCID” VS “SpanID 链路标识”
那么如何将汇总起来的日志串联起来呢有两种方式span链式记录依赖和RPCID层级计数器。我们在记录日志带上UUID的同时也带上RPCID这个信息通过它帮我们把日志关联关系串联起来那么这两种方式有什么区别呢
我们先看看span实现具体如下图
结合上图我们分析一下span的链式依赖记录方式。对于代码来说写的很多功能会被封装成功能模块Service、Model我们通过组合不同的模块实现业务功能并且记录这两个模块、两个服务间或是资源的调用依赖关系。
span这个设计会通过记录自己上游依赖服务的SpanID实现上下游关系关联放在Parent ID中通过整理span之间的依赖关系就能组合成一个调用链路树。
那RPCID方式是什么样的呢RPCID也叫层级计数器我在微博和好未来时都用过为了方便理解我们来看下面这张图
你看RPCID的层级计数器实现很简单第一个接口生成RPCID为 1.1 RPCID的前缀是1计数器是1日志记录为 1.1)。
当所在接口请求其他接口或数据服务MySQL、Redis、API、Kafka计数器+1并在请求当中带上1.2这个数值(因为当前的前缀 + “.” + 计数器值 = 1.2),等到返回结果后,继续请求下一个资源时继续+1期间产生的任何日志都会记录当前 前缀+“.”+计数器值。
每一层收到了前缀后,都在后面加了一个累加的计数器,实际效果如下图所示:
而被请求的接口收到请求时如果请求传递了TraceID那么被请求的服务会继续使用传递过来的TraceID如果请求没有TraceID则自己生成一个。同样地如果传递了RPCID那么被请求的服务会将传递来的RPCID当作前缀计数器从1开始计数。
相对于span通过这个层级计数器做出来的RPCID有两个优点。
第一个优点是我们可以记录请求方日志如果被请求方没有记录日志那么还可以通过请求方日志观测分析被调用方性能MySQL、Redis
另一个优点是哪怕日志收集得不全丢失了一些我们还可以通过前缀有几个分隔符判断出日志所在层级进行渲染。举个例子假设我们不知道上图的1.5.1是谁调用的但是根据它的UUID和层级1.5.1这些信息,渲染的时候,我们仍旧可以渲染它大概的链路位置。
除此之外我们可以利用AOP顺便将各个模块做一个Metrics性能统计分析分析各个模块的耗时、调用次数做周期统计。
同时通过这个维度采样统计数据能够帮助我们分析这个模块的性能和错误率。由于Metrics 这个方式产生的日志量很小有些统计是每10秒才会产生一条Metrics统计日志统计的数值很方便对比很有参考价值。
但是你要注意对于一个模块内有多个分支逻辑时Metrics很多时候取的是平均数偶发的超时在平均数上看不出来所以我们需要另外记录一下最大最小的延迟才可以更好地展现。同时这种统计只是让我们知道这个模块是否有性能问题但是无法帮助我们分析具体的原因。
回到之前的话题我们前面提到请求和被请求方通过传递TraceID和RPCID或SpanID来实现链路的跟踪我列举几个常见的方式供你参考
HTTP协议放在Header
RPC协议放在meta中传递
队列可以放在消息体的Header中或直接在消息体中传递
其他特殊情况下可以通过网址请求参数传递。
那么应用内多线程和多协程之间如何传递TraceID呢一般来说我们会通过复制一份Context传递进入线程或协程并且如果它们之前是并行关系我们复制之后需要对下发之前的RPCID计数器加1并把前缀和计数器合并成新的前缀以此区分并行的链路。
除此之外我们还做了一些特殊设计当我们的请求中带一个特殊的密语并且设置类似X-DEBUG Header等于1时我们可以开启在线debug模式在被调用接口及所有依赖的服务都会输出debug级别的日志这样我们临时排查线上问题会更方便。
日志类型定义
可以说只要让日志输出当前的TraceId和RPCIDSpanID并在请求所有依赖资源时把计数传递给它们就完成了大部分的分布式链路跟踪。下面是我定制的一些日志类型和日志格式供你参考
## 日志类型
* request.info 当前被请求接口的相关信息,如被请求接口,耗时,参数,返回值,客户端信息
* mysql.connect mysql连接时长
* mysql.connect.error mysql链接错误信息
* mysql.request mysql执行查询命令时长及相关信息
* mysql.request.error mysql操作时报错的相关信息
* redis.connect redis 链接时长
* redis.connect.error redis链接错误信息
* redis.request redis执行命令
* redis.request.error redis操作时错误
* memcache.connect
* memcache.connect.error
* memcache.request.error
* http.get 另外可以支持restful操作get put delete
* http.post
* http.*.error
## Metric日志类型
* metric.counter
...略
## 分级日志类型
* log.debug: debug log
* log.trace: trace log
* log.notice: notice log
* log.info: info log
* log.error: application error log
* log.alarm: alarm log
* log.exception: exception log
你会发现所有对依赖资源的请求都有相关日志这样可以帮助我们分析所有依赖资源的耗时及返回内容。此外我们的分级日志也在trace跟踪范围内通过日志信息可以更好地分析问题。而且如果我们监控的是静态语言还可以像之前说的那样对一些模块做Metrics定期产生日志。
日志格式样例
日志建议使用JSON格式所有字段除了标注为string的都建议保存为字符串类型每个字段必须是固定数据类型选填内容如果没有内容就直接不输出。
这样设计其实是为了适配Elasticsearch+KibanaKibana提供了日志的聚合、检索、条件检索和数值聚合但是对字段格式很敏感不是数值类型就无法聚合对比。
下面我给你举一个例子用于链路跟踪和监控,你主要关注它的类型和字段用途。
{
"name": "string:全量字段介绍,必填,用于区分日志类型,上面的日志列表内容写这里",
"trace_id": "string:traceid必填",
"rpc_id": "string:RPCID服务端链路必填客户端非必填",
"department":"部门缩写如client_frontend 必填",
"version": "string:当前服务版本 cpp-client-1.1 php-baseserver-1.4 java-rti-1.9,建议都填",
"timestamp": "int:日志记录时间,单位秒,必填",
"duration": "float:消耗时间,浮点数 单位秒,能填就填",
"module": "string:模块路径建议格式应用名称_模块名称_函数名称_动作必填",
"source": "string:请求来源 如果是网页可以记录ref page选填",
"uid": "string:当前用户uid如果没有则填写为 0长度字符串可选填能够帮助分析用户一段时间行为",
"pid": "string:进程pid如果没有填写为 0长度字符串如果有线程可以为pid-tid格式可选填",
"server_ip": "string 当前服务器ip必填",
"client_ip": "string 客户端ip选填",
"user_agent": "string curl/7.29.0 选填",
"host": "string 链接目标的ip及端口号用于区分环境12.123.23.1:3306选填",
"instance_name": "string 数据库连接配置的标识比如rti的数据库连接选填",
"db": "string 数据库名称如peiyou_stastic选填",
"code": "string:各种驱动或错误或服务的错误码,选填,报错误必填",
"msg": "string 错误信息或其他提示信息,选填,报错误必填",
"backtrace": "string 错误的backtrace信息选填报错误必填",
"action": "string 可以是url、sql、redis命令、所有让远程执行的命令必填",
"param": "string 通用参数模板用于和script配合记录所有请求参数必填",
"file": "string userinfo.php选填",
"line": "string 232选填",
"response": "string:请求返回的结果,可以是本接口或其他资源返回的数据,如果数据太长会影响性能,选填",
"response_length": "int:相应内容结果的长度,选填",
"dns_duration": "float dns解析时间一般http mysql请求域名的时候会出现此选项选填",
"extra": "json 放什么都可以,用户所有附加数据都扔这里"
}
## 样例
被请求日志
{
"x_name": "request.info",
"x_trace_id": "123jiojfdsao",
"x_rpc_id": "0.1",
"x_version": "php-baseserver-4.0",
"x_department":"tal_client_frontend",
"x_timestamp": 1506480162,
"x_duration": 0.021,
"x_uid": "9527",
"x_pid": "123",
"x_module": "js_game1_start",
"x_user_agent": "string curl/7.29.0",
"x_action": "http://testapi.speiyou.com/v3/user/getinfo?id=9527",
"x_server_ip": "192.168.1.1:80",
"x_client_ip": "192.168.1.123",
"x_param": "json string",
"x_source": "www.baidu.com",
"x_code": "200",
"x_response": "json:api result",
"x_response_len": 12324
}
### mysql 链接性能日志
{
"x_name": "mysql.connect",
"x_trace_id": "123jiojfdsao",
"x_rpc_id": "0.2",
"x_version": "php-baseserver-4",
"x_department":"tal_client_frontend",
"x_timestamp": 1506480162,
"x_duration": 0.024,
"x_uid": "9527",
"x_pid": "123",
"x_module": "js_mysql_connect",
"x_instance_name": "default",
"x_host": "12.123.23.1:3306",
"x_db": "tal_game_round",
"x_msg": "ok",
"x_code": "1",
"x_response": "json:****"
}
### Mysql 请求日志
{
"x_name": "mysql.request",
"x_trace_id": "123jiojfdsao",
"x_rpc_id": "0.2",
"x_version": "php-4",
"x_department":"tal_client_frontend",
"x_timestamp": 1506480162,
"x_duration": 0.024,
"x_uid": "9527",
"x_pid": "123",
"x_module": "js_game1_round_sigup",
"x_instance_name": "default",
"x_host": "12.123.23.1:3306",
"x_db": "tal_game_round",
"x_action": "select * from xxx where xxxx",
"x_param": "json string",
"x_code": "1",
"x_msg": "ok",
"x_response": "json:****"
}
### http 请求日志
{
"x_name": "http.post",
"x_trace_id": "123jiojfdsao",
"x_department":"tal_client_frontend",
"x_rpc_id": "0.3",
"x_version": "php-4",
"x_timestamp": 1506480162,
"x_duration": 0.214,
"x_uid": "9527",
"x_pid": "123",
"x_module": "js_game1_round_win_report",
"x_action": "http://testapi.speiyou.com/v3/game/report",
"x_param": "json:",
"x_server_ip": "192.168.1.1",
"x_msg": "ok",
"x_code": "200",
"x_response_len": 12324,
"x_response": "json:responsexxxx",
"x_dns_duration": 0.001
}
### level log info日志
{
"x_name": "log.info",
"x_trace_id": "123jiojfdsao",
"x_department":"tal_client_frontend",
"x_rpc_id": "0.3",
"x_version": "php-4",
"x_timestamp": 1506480162,
"x_duration": 0.214,
"x_uid": "9527",
"x_pid": "123",
"x_module": "game1_round_win_round_end",
"x_file": "userinfo.php",
"x_line": "232",
"x_msg": "ok",
"x_code": "201",
"extra": "json game_id lesson_num xxxxx"
}
### exception 异常日志
{
"x_name": "log.exception",
"x_trace_id": "123jiojfdsao",
"x_department":"tal_client_frontend",
"x_rpc_id": "0.3",
"x_version": "php-4",
"x_timestamp": 1506480162,
"x_duration": 0.214,
"x_uid": "9527",
"x_pid": "123",
"x_module": "game1_round_win",
"x_file": "userinfo.php",
"x_line": "232",
"x_msg": "exception:xxxxx call stack",
"x_code": "hy20001",
"x_backtrace": "xxxxx.php(123) gotError:..."
}
### 业务自发告警日志
{
"x_name": "log.alarm",
"x_trace_id": "123jiojfdsao",
"x_department":"tal_client_frontend",
"x_rpc_id": "0.3",
"x_version": "php-4",
"x_timestamp": 1506480162,
"x_duration": 0.214,
"x_uid": "9527",
"x_pid": "123",
"x_module": "game1_round_win_round_report",
"x_file": "game_win_notify.php",
"x_line": "123",
"x_msg": "game report request fail! retryed three time..",
"x_code": "201",
"x_extra": "json game_id lesson_num xxxxx"
}
### matrics 计数器
{
"x_name": "metrix.count",
"x_trace_id": "123jiojfdsao",
"x_department":"tal_client_frontend",
"x_rpc_id": "0.3",
"x_version": "php-4",
"x_timestamp": 1506480162,
"x_uid": "9527",
"x_pid": "123",
"x_module": "game1_round_win_click",
"x_extra": "json curl invoke count"
}
这个日志不仅可以用在服务端还可以用在客户端。客户端每次被点击或被触发时都可以自行生成一个新的TraceID在请求服务端时就会带上它。通过这个日志我们可以分析不同地域访问服务的性能也可以用作用户行为日志仅仅需添加我们的日志类型即可。
上面的日志例子基本把我们依赖的资源情况描述得很清楚了。另外,我补充一个技巧,性能记录日志可以将被请求的接口也记录成一个日志,记录自己的耗时等信息,方便之后跟请求方的请求日志对照,这样可分析出两者之间是否有网络延迟等问题。
除此之外这个设计还有一个核心要点研发并不一定完全遵守如上字段规则生成日志业务只要保证项目范围内输出的日志输出所有必填项目TraceIDRPCID/SpanIDTimeStamp同时保证数值型字段功能及类型稳定即可实现trace。
我们完全可以汇总日志后再对不同的日志字段做自行解释定制出不同业务所需的统计分析这正是ELK最强大的地方。
为什么大部分设计都是记录依赖资源的日志呢原因在于在没有IO的情况下程序大部分都是可控的侧重计算的服务除外。只有IO类操作容易出现不稳定因素并且日志记录过多也会影响系统性能通过记录对数据源的操作能帮助我们排查业务逻辑的错误。
我们刚才提到日志如果过多会影响接口性能,那如何提高日志的写吞吐能力呢?这里我为你归纳了几个注意事项和技巧:
1.提高写线程的个数一个线程写一个日志也可以每个日志文件单独放一个磁盘但是你要注意控制系统的IOPS不要超过100
2.当写入日志长度超过1kb时不要使用多个线程高并发写同一个文件。原因参考 append is not Atomic简单来说就是文件的append操作对于写入长度超过缓冲区长度的操作不是原子性的多线程并发写长内容到同一个文件会导致日志乱序
3.日志可以通过内存暂存汇总达到一定数据量或缓存超过2秒后再落盘这样可以减少过小日志写磁盘系统的调用次数但是代价是被强杀时会丢日志
4.日志缓存要提前malloc使用固定长度缓存不要频繁分配回收否则会导致系统整体缓慢
5.服务被kill时记得拦截信号快速fsync内存中日志到磁盘以此减少日志丢失的可能。
“侵入式埋点SDK”VS“AOP方式埋点”
最后我们再说说SDK。事实上使用“ELK+自定义的标准”基本上已经能实现大多数的分布式链路跟踪系统使用Kibana可以很快速地对各种日志进行聚合分析统计。
虽然行业中出现过很多链路跟踪系统服务公司做了很多APM等类似产品但是能真正推广开的服务实际占少数究其原因我认为是以下几点
分布式链路跟踪的日志吞吐很大,需要耗费大量的资源,成本高昂;
通用分布式链路跟踪服务很难做贴近业务的个性化,不能定制的第三方服务不如用开源;
分布式链路跟踪的埋点库对代码的侵入性大,需要研发手动植入到业务代码里,操作很麻烦,而且不够灵活。
另外这种做法对语言也有相关的限制因为目前只有Java通过动态启动注入agent才实现了静态语言AOP注入。我之前推广时也是统一了内网项目的开源框架才实现了统一的链路跟踪。
那么如果底层代码不能更新,如何简单暴力地实现链路跟踪呢?
这时候我们可以改造分级日志让它每次在落地的时候都把TraceId和RPCID或SpanID带上就会有很好的效果。如果数据底层做了良好的封装我们可以在发起请求部分中写一些符合标准性能的日志在框架的统一异常处理中也注入我们的标准跟踪即可实现关键点的监控。
当然如果条件允许我们最好提供一个标准的SDK让业务研发伙伴按需调用这能帮助我们统一日志结构。毕竟手写很容易格式错乱需要人工梳理不过即使混乱也仍旧有规律可言这是ELK架构的强大之处它的全文检索功能其实不在乎你的输入格式但是数据统计类却需要我们确保各个字段用途固定。
最后再讲点其他日志的注意事项可能你已经注意到了这个设计日志是全量的。很多链路跟踪其实都是做的采样方式比如Jaeger在应用本地会部署一个Agent对数据暂存汇总统计出每个接口的平均响应时间对具有同样特征的请求进行归类汇总这样可以大大降低服务端压力。
但这么做也有缺点,当我们有一些小概率的业务逻辑错误,在采样中会被遗漏。所以很多核心系统会记录全量日志,周边业务记录采样日志。
由于我们日志结构很简单如有需要可以自行实现一个类似Agent的功能降低我们存储计算压力。甚至我们可以在服务端本地保存原始日志7天当我们查找某个Trace日志的时候直接请求所有服务器在本地查找。事实上在写多读少的情况下为了追一个Trace详细过程而去请求200个服务器这时候即使等十秒钟都是可以接受的。
最后为了方便理解这里给你提供一个我之前写的laravel框架的Aop trace SDK 例子 laravel-aop-trace 供你参考
总结
系统监控一直是服务端重点关注的功能,我们常常会根据链路跟踪和过程日志,去分析排查线上问题。也就是说,监控越是贴近业务、越定制化,我们对线上业务运转情况的了解就越直观。
不过,实现一个更符合业务的监控系统并不容易,因为基础运维监控只会监控线上请求流量、响应速度、系统报错、系统资源等基础监控指标,当我们要监控业务时,还需要人工在业务系统中嵌入大量代码。而且,因为这些服务属于开源,还要求我们必须对监控有较深的了解,投入大量精力才可以。
好在技术逐渐成熟通用的简单日志传输索引统计服务开始流行其中最强的组合就是ELK。通过这类分布式日志技术能让我们轻松实现个性化监控需求。日志格式很杂乱也没关系只要将TraceID和RPCID或SpanID在请求依赖资源时传递下去并将沿途的日志都记录对应的字段即可。也正因如此ELK流行起来很多公司的核心业务都会依托ELK自定义一套自己的监控系统。
不过这么做只能让我们建立起一个粗旷的跟踪系统后续分析的难度和投入成本依然很大因为ELK需要投入大量硬件资源来帮我们处理海量数据相关知识我们后续章节再探讨
思考题
请你思考一下既然我们通过ELK实现Trace那么简单为什么会在当年那么难实现
欢迎你在评论区与我交流讨论,我们下节课见!

View File

@ -0,0 +1,134 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 引擎分片Elasticsearch如何实现大数据检索
你好,我是徐长龙。
上节课我们看到了ELK对日志系统的强大支撑如果没有它的帮助我们自己实现分布式链路跟踪其实是十分困难的。
为什么ELK功能这么强大这需要我们了解ELK中储存、索引等关键技术点的架构实现才能想清楚。相信你学完今天的内容你对大数据分布式的核心实现以及大数据分布式统计服务都会有更深入的理解。
Elasticsearch架构
那么ELK是如何运作的它为什么能够承接如此大的日志量
我们先分析分析ELK的架构长什么样事实上它和OLAP及OLTP的实现区别很大我们一起来看看。Elasticsearch架构如下图
我们对照架构图梳理一下整体的数据流向可以看到我们项目产生的日志会通过Filebeat或Rsyslog收集将日志推送到Kafka内。然后由LogStash消费Kafka内的日志、对日志进行整理并推送到ElasticSearch集群内。
接着,日志会被分词,然后计算出在文档的权重后,放入索引中供查询检索, Elasticsearch会将这些信息推送到不同的分片。每个分片都会有多个副本数据写入时只有大部分副本写入成功了主分片才会对索引进行落地需要你回忆下分布式写一致知识
Elasticsearch集群中服务分多个角色我带你简单了解一下
Master节点负责集群内调度决策集群状态、节点信息、索引映射、分片信息、路由信息Master真正主节点是通过选举诞生的一般一个集群内至少要有三个Master可竞选成员防止主节点损坏回忆下之前Raft知识不过Elasticsearch刚出那会儿还没有Raft标准
Data存储节点用于存储数据及计算分片的主从副本热点节点冷数据节点
Client协调节点协调多个副本数据查询服务聚合各个副本的返回结果返回给客户端
Kibana计算节点作用是实时统计分析、聚合分析统计数据、图形聚合展示。
实际安装生产环境时Elasticsearch最少需要三台服务器三台中有一台会成为Master节点负责调配集群内索引及资源的分配而另外两个节点会用于Data数据存储、数据检索计算当Master出现故障时子节点会选出一个替代故障的Master节点回忆下分布式共识算法中的选举
如果我们的硬件资源充裕我们可以另外增加一台服务器将Kibana计算独立部署这样会获得更好的数据统计分析性能。如果我们的日志写入过慢可以再加一台服务器用于Logstash分词协助加快ELK整体入库的速度。
要知道最近这几年大部分云厂商提供的日志服务都是基于ELK实现的Elasticsearch已经上市可见其市场价值。
Elasticsearch的写存储机制
下图是Elasticsearch的索引存储具体的结构看起来很庞大但是别担心我们只需要关注分片及索引部分即可
我们再持续深挖一下Elasticsearch是如何实现分布式全文检索服务的写存储的。其底层全文检索使用的是Lucene引擎事实上这个引擎是单机嵌入式的并不支持分布式分布式功能是基础分片来实现的。
为了提高写效率常见分布式系统都会先将数据先写在缓存当数据积累到一定程度后再将缓存中的数据顺序刷入磁盘。Lucene也使用了类似的机制将写入的数据保存在Index Buffer中周期性地将这些数据落盘到segment文件。
再来说说存储方面Lucene为了让数据能够更快被查到基本一秒会生成一个segment文件这会导致文件很多、索引很分散。而检索时需要对多个segment进行遍历如果segment数量过多会影响查询效率为此Lucene会定期在后台对多个segment进行合并。
更多索引细节我稍后再给你介绍可以看到Elasticsearch是一个IO频繁的服务将新数据放在SSD上能够提高其工作效率。
但是SSD很昂贵为此Elasticsearch实现了冷热数据分离。我们可以将热数据保存在高性能SSD冷数据放在大容量磁盘中。
同时官方推荐我们按天建立索引当我们的存储数据量达到一定程度时Elasticsearch会把一些不经常读取的索引挪到冷数据区以此提高数据存储的性价比。而且我建议你创建索引时按天创建索引这样查询时。我们可以通过时间范围来降低扫描数据量。
另外Elasticsearch服务为了保证读写性能可扩容Elasticsearch对数据做了分片分片的路由规则默认是通过日志DocId做hash来保证数据分布均衡常见分布式系统都是通过分片来实现读写性能的线性提升。
你可以这样理解单个节点达到性能上限就需要增加Data服务器节点及副本数来降低写压力。但是副本加到一定程度由于写强一致性问题反而会让写性能下降。具体加多少更好呢这需要你用生产日志实测才能确定具体数值。
Elasticsearch的两次查询
前面提到多节点及多分片能够提高系统的写性能但是这会让数据分散在多个Data节点当中Elasticsearch并不知道我们要找的文档到底保存在哪个分片的哪个segment文件中。
所以,为了均衡各个数据节点的性能压力Elasticsearch每次查询都是请求所有索引所在的Data节点查询请求时协调节点会在相同数据分片多个副本中随机选出一个节点发送查询请求从而实现负载均衡。
而收到请求的副本会根据关键词权重对结果先进行一次排序当协调节点拿到所有副本返回的文档ID列表后会再次对结果汇总排序最后才会用 DocId去各个副本Fetch具体的文档数据将结果返回。
可以说Elasticsearch通过这个方式实现了所有分片的大数据集的全文检索但这种方式也同时加大了Elasticsearch对数据查询请求的耗时。下图是协调节点和副本的通讯过程
除了耗时这个方式还有很多缺点比如查询QPS低网络吞吐性能不高协助节点需要每次查询结果做分页分页后如果我们想查询靠后的页面要等每个节点先搜索和排序好该页之前的所有数据才能响应而且翻页跨度越大查询就越慢……
为此ES限制默认返回的结果最多1w条这个限制也提醒了我们不能将Elasticsearch的服务当作数据库去用。
还有一点实践的注意事项这种实现方式也导致了小概率个别日志由于权重太低查不到的问题。为此ES提供了search_type=dfs_query_then_fetch参数来应对特殊情况但是这种方式损耗系统资源严重非必要不建议开启。
除此之外Elasticsearch的查询有query and fetch、dfs query and fetch、dfs query then fetch三种不过它们和这节课主线关联不大有兴趣的话你可以课后自己了解一下。
Elasticsearch的倒排索引
我们再谈谈Elasticsearch的全文检索的倒排索引。
Elasticsearch支持多种查询方式不仅仅是全文检索如数值类使用的是BKD TreeElasticsearch的全文检索查询是通过Lucene实现的索引的实现原理和OLAP的LSM及OLTP的B+Tree完全不同它使用的是倒排索引Inverted Index
一般来说倒排索引常在搜索引擎内做全文检索使用其不同于关系数据库中的B+Tree和B-Tree 。B+Tree和B-Tree 索引是从树根往下按左前缀方式来递减缩小查询范围而倒排索引的过程可以大致分四个步骤分词、取出相关DocId、计算权重并重新排序、展示高相关度的记录。
首先对用户输入的内容做分词找出关键词然后通过多个关键词对应的倒排索引取出所有相关的DocId接下来将多个关键词设计索引ID做交集后再根据关键词在每个文档的出现次数及频率以此计算出每条结果的权重进而给列表排序并实现基于查询匹配度的评分然后就可以根据匹配评分来降序排序列出相关度高的记录。
下面我们简单看一下Lucene具体实现。
如上图Elasticsearch集群的索引保存在Lucene的segment文件中segment文件格式相关信息你可以参考 segment格式其中包括行存、列存、倒排索引。
为了节省空间和提高查询效率Lucene对关键字倒排索引做了大量优化segment主要保存了三种索引
Term Index单词词典索引用于关键词Term快速搜索Term index是基础Trie树改进的FSTFinite State Transducer有限状态传感器占用内存少实现的二级索引。平时这个树会放在内存中用于减少磁盘IO加快Term查找速度检索时会通过FST快速找到Term Dictionary对应的词典文件block。
Term Dictionary单词词典单词词典索引中保存的是单词Term与Posting List的关系而这个单词词典数据会按block在磁盘中排序压缩保存相比B-Tree更节省空间其中保存了单词的前缀后缀可以用于近似词及相似词查询通过这个词典可以找到相关的倒排索引列表位置。
Posting List倒排列表倒排列表记录了关键词Term出现的文档ID以及其所在文档中位置、偏移、词频信息这是我们查找的最终文档列表我们拿到这些就可以拿去排序合并了。
一条日志在入库时,它的具体内容并不会被真实保存在倒排索引中。
在日志入库之前会先进行分词过滤掉无用符号等分隔词找出文档中每个关键词Term在文档中的位置及频率权重然后将这些关键词保存在Term Index以及Term Dictionary内最后将每个关键词对应的文档ID和权重、位置等信息排序合并到Posting List中进行保存。通过上述三个结构就实现了一个优化磁盘IO的倒排索引。
而查询时Elasticsearch会将用户输入的关键字通过分词解析出来在内存中的Term Index单词索引查找到对应Term Dictionary字典的索引所在磁盘的block。接着由Term Dictionary找到对关键词对应的所有相关文档DocId及权重并根据保存的信息和权重算法对查询结果进行排序返回结果。
总结
不得不感叹Elasticsearch通过组合一片片小Lucene的服务就实现了大型分布式数据的全文检索。这无论放到当时还是现在都很不可思议。可以说了Elasticsearch 几乎垄断了所有日志实时分析、监控、存储、查找、统计的市场,其中用到的技术有很多地方可圈可点。
现在市面上新生代开源虽然很多但是论完善性和多样性能够彻底形成平台性支撑的开源仍然很少见。而Elasticsearch本身是一个十分庞大的分布式检索分析系统它对数据的写入和查询做了大量的优化。
我希望你关注的是Elasticsearch用到了大量分布式设计思路和有趣的算法比如分布式共识算法那时还没有Raft、倒排索引、词权重、匹配权重、分词、异步同步、数据一致性检测等。这些行业中的优秀设计值得我们做拓展了解推荐你课后自行探索。
思考题
如果让你实现一个Elasticsearch你觉得需要先解决的核心功能是什么
欢迎你在评论区与我交流讨论,我们下节课见!

View File

@ -0,0 +1,252 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 实时统计:链路跟踪实时计算中的实用算法
你好,我是徐长龙。
前几节课我们了解了ELK架构以及如何通过它快速实现一个定制的分布式链路跟踪系统。不过ELK是一个很庞大的体系使用它的前提是我们至少要有性能很好的三台服务器。
如果我们的数据量很大需要投入的服务器资源就更多之前我们最大一次的规模投入了大概2000台服务器做ELK。但如果我们的服务器资源很匮乏这种情况下要怎样实现性能分析统计和监控呢
当时我只有两台4核8G服务器所以我用了一些巧妙的算法实现了本来需要大量服务器并行计算才能实现的功能。这节课我就给你分享一下这些算法。
我先把实时计算的整体结构图放出来,方便你建立整体印象。
从上图可见我们实时计算的数据是从Kafka拉取的通过进程实时计算统计 Kafka的分组消费。接下来我们具体看看这些算法的思路和功用。
URL去参数聚合
做链路跟踪的小伙伴都会很头疼URL去参数这个问题主要原因是很多小伙伴会使用RESTful方式来设计内网接口。而做链路跟踪或针对API维度进行统计分析时如果不做整理直接将这些带参数的网址录入到统计分析系统中是不行的。
同一个API由于不同的参数无法归类最终会导致网址不唯一而成千上万个“不同”网址的API汇总在一起就会造成统计系统因资源耗尽崩掉。除此之外同一网址不同的method操作在RESTful中实际也是不同的实现所以同一个网址并不代表同一个接口这更是给归类统计增加了难度。
为了方便你理解这里举几个RESTful实现的例子
GET geekbang.com/user/1002312/info 获取用户信息
PUT geekbang.com/user/1002312/info 修改用户信息
DELETE geekbang.com/user/1002312/friend/123455 删除用户好友
可以看到我们的网址中有参数虽然是同样的网址但是GET和PUT方法代表的意义并不一样这个问题在使用Prometheus、Trace等工具时都会出现。
一般来说碰到这种问题我们都会先整理数据再录入到统计分析系统当中。我们有两种常用方式来对URL去参数。
第一种方式是人工配置替换模板也就是人工配置出一个URL规则用来筛选出符合规则的日志并替换掉关键部分的参数。
我一般会用一个类似Trier Tree保存这个URL替换的配置列表这样能够提高查找速度。但是这个方式也有缺点需要人工维护。如果开发团队超过200人列表需要时常更新这样维护起来会很麻烦。
类Radix tree效果
/user
- /*
- - /info
- - - :GET
- - - :PUT
- - /friend
- - - /*
- - - - :DELETE
具体实现是将网址通过/进行分割,逐级在前缀搜索树查找。
我举个例子比如我们请求GET /user/1002312/info使用树进行检索时可以先找到/user根节点。然后在/user子节点中继续查找发现有元素/*(代表这里替换) 而且同级没有其他匹配,那么会被记录为这里可替换。然后需要继续查找/*下子节点/info。到这里网址已经完全匹配。
在网址更深一层是具体请求method我们找到 GET 操作,即可完成这个网址的配置匹配。然后,直接把/*部分的1002312替换成固定字符串即可替换的效果如下所示
GET /user/1002312/info 替换成 /user/replaced/info
另一种方式是数据特征筛选,这种方式虽然会有误差,但是实现简单,无需人工维护。这个方法是我推崇的方式,虽然这种方式有可能有失误,但是确实比第一种方式更方便。
具体请看后面的演示代码:
//根据数据特征过滤网址内参数
function filterUrl($url)
{
$urlArr = explode("/", $url);
foreach ($urlArr as $urlIndex => $urlItem) {
$totalChar = 0; //有多少字母
$totalNum = 0; //有多少数值
$totalLen = strlen($urlItem); //总长度
for ($index = 0; $index < $totalLen; $index++) {
if (is_numeric($urlItem[$index])) {
$totalNum++;
} else {
$totalChar++;
}
}
//过滤md5 长度32或64 内容有数字 有字符混合 直接认为是md5
if (($totalLen == 32 || $totalLen == 64) && $totalChar > 0 && $totalNum > 0) {
$urlArr[$urlIndex] = "*md*";
continue;
}
//字符串 data 参数是数字和英文混合 长度超过3(回避v1/v2一类版本)
if ($totalLen > 3 && $totalChar > 0 && $totalNum > 0) {
$urlArr[$urlIndex] = "*data*";
continue;
}
//全是数字在网址中认为是id一类 直接进行替换
if ($totalChar == 0 && $totalNum > 0) {
$urlArr[$urlIndex] = "*num*";
continue;
}
}
return implode("/", $urlArr);
}
通过这两种方式,可以很方便地将我们的网址替换成后面这样:
GET geekbang.com/user/1002312/info => geekbang.com/user/*num*/info_GET
PUT geekbang.com/user/1002312/info => geekbang.com/user/*num*/info_PUT
DELETE geekbang.com/user/1002312/friend/123455 => geekbang.com/user/*num*/friend/*num*_DEL
经过过滤我们的API列表是不是清爽了很多这时再做API进行聚合统计分析的时候就会更加方便了。
时间分块统计
将URL去参数后我们就可以对不同的接口做性能统计了这里我用的是时间块方式实现。这么设计是因为我的日志消费服务可用内存是有限的只有8G而且如果保存太多数据到数据库的话实时更新效率会很低。
考虑再三,我选择分时间块来保存周期时间块内的统计,将一段时间内的请求数据在内存中汇总统计。
为了更好地展示我将每天24小时按15分钟一个时间块来划分而每个时间块内都会统计各自时间段内的接口数据形成数据统计块。
这样一天就会有96个数据统计块计算公式是86400秒/ (15分钟 * 60秒) = 96。如果API有200个那么我们内存中保存的一天的数据量就是19200条96X200 = 19200
假设我们监控的系统有200个接口就能推算出一年的统计数据量为700w条左右。如果有需要我们可以让这个粒度更小一些。
事实上市面上很多metrics监控的时间块粒度是35秒一个直到最近几年出现OLAP和时序数据库后才出现秒级粒度性能统计。而粒度越小监控越细致粒度过大只能看到时段内的平均性能表现。
我还想说一个题外话近两年出现了influxDB或Prometheus用它们来保存数据也可以但这些方式都需要硬件投入和运维成本你可以结合自身业务情况来权衡。
我们看一下在15分钟为一段的时间块里统计了URL的哪些内容
如上图,每个数据统计块内聚合了以下指标:
累计请求次数
最慢耗时
最快耗时
平均耗时
耗时个数图中使用的是ELK提供的四分位数分析如果拿不到全量数据来计算四分位数也可以设置为小于200ms、小于500ms、小于1000ms、大于1秒的请求个数统计
接口响应http code及对应的响应个数{“200”:1343,“500”:23,“404”: 12, “301”:14}
把这些指标展示出来,主要是为了分析这个接口的性能表现。看到这里,你是不是有疑问,监控方面我们大费周章去统计这些细节,真的有意义么?
的确大多数情况下我们API的表现都很好个别的特殊情况才会导致接口响应很慢。不过监控系统除了对大范围故障问题的监控细微故障的潜在问题也不能忽视。尤其是大吞吐量的服务器更难发现这种细微的故障。
我们只有在监控上支持对细微问题的排查,才能提前发现这些小概率的故障。这些小概率的故障在极端情况下会导致集群的崩溃。因此提前发现、提前处理,才能保证我们线上系统面对大流量并发时不至于突然崩掉。
错误日志聚类
监控统计请求之后,我们还要关注错误的日志。说到故障排查的难题,还得说说错误日志聚类这个方式。
我们都知道,平时常见的线上故障,往往伴随着大量的错误日志。在海量警告面前,我们一方面要获取最新的错误消息,同时还不能遗漏个别重要但低频率出现的故障。
因为资源有限,内存里无法存放太多的错误日志,所以日志聚类的方案是个不错的选择,通过日志聚合,对错误进行分类,给用户排查即可。这样做,在发现错误的同时,还能够提供错误的范本来加快排查速度。
我是这样实现日志错误聚合功能的:直接对日志做近似度对比计算,并加上一些辅助字段作为修正。这个功能可以把个别参数不同、但同属一类错误的日志聚合到一起,方便我们快速发现的低频故障。
通过这种方式实现的错误监控还有额外的好处,有了它,无需全站统一日志格式标准,就能轻松适应各种格式的日志,这大大方便了我们对不同系统的监控。
说到这你是不是挺好奇实现细节的下面是github.com/mfonda/simhash 提供的simhash文本近似度样例
package main
import (
"fmt"
"github.com/mfonda/simhash"
)
func main() {
var docs = [][]byte{
[]byte("this is a test phrass"), //测试字符串1
[]byte("this is a test phrass"), //测试字符串2
[]byte("foo bar"), //测试字符串3
}
hashes := make([]uint64, len(docs))
for i, d := range docs {
hashes[i] = simhash.Simhash(simhash.NewWordFeatureSet(d)) //计算出测试字符串对应的hash值
fmt.Printf("Simhash of %s: %x\n", d, hashes[i])
}
//测试字符串1 对比 测试字符串2
fmt.Printf("Comparison of 0 1 : %d\n", simhash.Compare(hashes[0], hashes[1]))
//测试字符串1 对比 测试字符串3
fmt.Printf("Comparison of 0 2 : %d\n", simhash.Compare(hashes[0], hashes[2]))
}
看完代码,我再给你讲讲这里的思路。
我们可以用一个常驻进程,持续做 group consumer 消费Kafka日志信息消费时每当碰到错误日志就需要通过simhash将其转换成64位hash。然后通过和已有错误类型的列表进行遍历对比日志长度相近且海明距离simhash.compare计算结果差异不超过12个bit差异就可以归为一类。
请注意由于算法的限制simhash对于小于100字的文本误差较大所以需要我们实际测试下具体的运行情况对其进行微调。文本特别短时我们需要一些其他辅助来去重。注意同时100字以下要求匹配度大于80%100字以上则要大于90%匹配度。
最后,除了日志相似度检测以外,也可以通过生成日志的代码文件名、行数以及文本长度来辅助判断。由于是模糊匹配,这样能够减少失误。
接下来,我们要把归好类的错误展示出来。
具体步骤是这样的:如果匹配到当前日志属于已有某个错误类型时,就保存错误第一次出现的日志内容,以及错误最后三次出现的日志内容。
我们需要在归类界面查看错误的最近发生时间、次数、开始时间、开始错误日志同时可以通过Trace ID直接跳转到Trace过程渲染页面。这个做法对排查问题很有帮助你可以看看我在Java单机开源版中的实现体验下效果。
事实上错误去重还有很多的优化空间。比方说我们内存中已经统计出上千种错误类型那么每次新进的错误日志的hash就需要和这1000个类型挨个做对比这无形浪费了我们大量的CPU资源。
对于这种情况网上有一些简单的小技巧比如将64位hash分成两段先对比前半部分如果近似度高的话再对比后半部分。
这类技巧叫日志聚合,但行业里应用得比较少。
云厂商也提供了类似功能但是很少应用于错误去重这个领域相信这里还有潜力可以挖掘算力充足的情况下行业常用K-MEANS或DBSCAN算法做日志聚合有兴趣的小伙伴可以再深挖下。
bitmap 实现频率统计
我们虽然统计出了错误归类,但是这个错误到底发生了多久、线上是否还在持续产生报错?这些问题还是没解决。
若是在平时我们会将这些日志一个个记录在OLAP类的统计分析系统中按时间分区来汇总聚合这些统计。但是这个方式需要大量的算力支撑我们没有那么多资源还有别的方式来表示么
这里我用了一个小技巧就是在错误第一次产生后每一秒用一个bit代表在bitmap中记录。
如果这个分钟内产生了同类错误那么就记录为1以此类推一天会用86400个bit =1350个uint64来记录日志出现的频率周期。这样排查问题时就可以根据bit反推什么时间段内有错误产生这样用少量的内存就能快速实现频率周期的记录。
不过这样做又带来了一个新的问题——内存浪费严重。这是由于错误统计是按错误归类类型放在内存中的。一个新业务平均每天会有上千种错误这导致我需要1350x1000个int64保存在内存中。
为了节省内存的使用我将bitmap实现更换成 Roraing bitmap。它可以压缩bitmap的空间对于连续相似的数据压缩效果更明显。事实上bitmap的应用不止这些我们可以用它做很多有趣的标注相对于传统结构可以节省更多的内存和存储空间。
总结
这节课我给你分享了四种实用的算法,这些都是我实践验证过的。你可以结合后面这张图来复习记忆。
为了解决参数不同给网址聚类造成的难题可以通过配置或数据特征过滤方式对URL进行整理还可以通过时间块减少统计的结果数据量。
为了梳理大量的错误日志simhash算法是一个不错的选择还可以搭配bitmap记录错误日志的出现频率。有了这些算法的帮助用少量系统资源即可实现线上服务的故障监控聚合分析功能将服务的工作状态直观地展示出来。
学完这节课,你有没有觉得,在资源匮乏的情况下,用一些简单的算法,实现之前需要几十台服务器的分布式服务才能实现的服务,是十分有趣的呢?
即使是现代互联网发展这几年仍旧有很多场景需要一些特殊的设计来帮助我们降低资源的损耗比如用Bloom Filter减少扫描次数、通过Redis的hyperLogLog对大量数据做大致计数、利用GEO hash实现地图分块分区统计等。如果你有兴趣课后可以拓展学习一下Redis 模块的内容。
思考题
基于这节课讲到的算法和思路SQL如何做聚合归类去重
欢迎你在留言区和我交流讨论,我们下节课见!

View File

@ -0,0 +1,238 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 跳数索引后起新秀ClickHouse
你好,我是徐长龙。
通过前面的学习我们见识到了Elasticsearch的强大功能。不过在技术选型的时候价格也是重要影响因素。Elasticsearch虽然用起来方便但却有大量的硬件资源损耗再富有的公司看到每月服务器账单的时候也会心疼一下。
而ClickHouse是新生代的OLAP尝试使用了很多有趣的实现虽然仍旧有很多不足比如不支持数据更新、动态索引较差、查询优化难度高、分布式需要手动设计等问题。但由于它架构简单整体相对廉价逐渐得到很多团队的认同很多互联网企业加入社区不断改进ClickHouse。
ClickHouse属于列式存储数据库多用于写多读少的场景它提供了灵活的分布式存储引擎还有分片、集群等多种模式供我们搭建的时候按需选择。
这节课我会从写入、分片、索引、查询的实现这几个方面带你重新认识ClickHouse。在学习过程中建议你对比一下Elasticsearch、MySQL、RocksDB的具体实现想想它们各有什么优缺点适合什么样的场景。相信通过对比你会有更多收获。
并行能力CPU吞吐和性能
我先说说真正使用ClickHouse的时候最让我意料不到的地方。
我们先选个熟悉的参照物——MySQLMySQL在处理一个SQL请求时只能利用一个CPU。但是ClickHouse则会充分利用多核对本地大量数据做快速的计算因此ClickHouse有更高的数据处理能力230G/s未压缩数据但是这也导致它的并发不高因为一个请求就可以用光所有系统资源。
我们刚使用ClickHouse的时候常常碰到查几年的用户行为时一个SQL就会将整个ClickHouse卡住几分钟都没有响应的情况。
官方建议ClickHouse的查询QPS 限制在100左右如果我们的查询索引设置得好几十上百亿的数据可以在1秒内将数据统计返回。作为参考如果换成MySQL这个时间至少需要一分钟以上而如果ClickHouse的查询设计得不好可能等半小时还没有计算完毕甚至会出现卡死的现象。
所以你使用ClickHouse的场景如果是对用户服务的最好对这种查询做缓存。而且界面在加载时要设置30秒以上的等待时间因为我们的请求可能在排队等待别的查询。
如果我们的用户量很大建议多放一些节点用分区、副本、相同数据子集群来分担查询计算的压力。不过考虑到如果想提供1w QPS查询极端的情况下需要100台ClickHouse存储同样的数据所以建议还是尽量用脚本推送数据结果到缓存中对外服务。
但是如果我们的集群都是小数据并且能够保证每次查询都可控ClickHouse能够支持每秒上万QPS的查询这取决于我们投入多少时间去做优化分析。
对此,我推荐的优化思路是:基于排序字段做范围查询过滤后,再做聚合查询。你还要注意,需要高并发查询数据的服务和缓慢查询的服务需要隔离开,这样才能提供更好的性能。
分享了使用体验我们还是按部就班来分析分析ClickHouse在写入、储存、查询等方面的特性这样你才能更加全面深入地认识它。
批量写入优化
ClickHouse的客户端驱动很有意思客户端会有多个写入数据缓存当我们批量插入数据时客户端会将我们要insert的数据先在本地缓存一段时间直到积累足够配置的block_size后才会把数据批量提交到服务端以此提高写入的性能。
如果我们对实时性要求很高的话这个block_size可以设置得小一点当然这个代价就是性能变差一些。
为优化高并发写服务除了客户端做的合并ClickHouse的引擎MergeTree也做了类似的工作。为此单个ClickHouse批量写性能能够达到280M/s受硬件性能及输入数据量影响
MergeTree采用了批量写入磁盘、定期合并方式batch write-merge这个设计让我们想起写性能极强的RocksDB。其实ClickHouse刚出来的时候并没有使用内存进行缓存而是直接写入磁盘。
最近两年ClickHouse做了更新才实现了类似内存缓存及WAL日志。所以如果你使用ClickHouse建议你搭配使用高性能SSD作为写入磁盘存储。
事实上OLAP有两种不同数据来源一个是业务系统一个是大数据。
来自业务系统的数据属性字段比较多但平时更新量并不大。这种情况下使用ClickHouse常常是为了做历史数据的筛选和属性共性的计算。而来自大数据的数据通常会有很多列每个列代表不同用户行为数据量普遍会很大。
两种情况数据量不同那优化方式自然也不同具体ClickHouse是怎么对这这两种方式做优化的呢我们结合后面的图片继续分析
当我们批量输入的数据量小于min_bytes_for_wide_part设置时会按compact part方式落盘。这种方式会将落盘的数据放到一个data.bin文件中merge时会有很好的写效率这种方式适合于小量业务数据筛选使用。
当我们批量输入的数据量超过了配置规定的大小时会按wide part方式落盘落盘数据的时候会按字段生成不同的文件。这个方式适用于字段较多的数据merge相对会慢一些但是对于指定参与计算列的统计计算并行吞吐写入和计算能力会更强适合分析指定小范围的列计算。
可以看到,这两种方式对数据的存储和查询很有针对性,可见字段的多少、每次的更新数据量、统计查询时参与的列个数,这些因素都会影响到我们服务的效率。
当我们大部分数据都是小数据的时候一条数据拆分成多个列有一些浪费磁盘IO因为是小量数据我们也不会给他太多机器这种情况推荐使用compact parts方式。当我们的数据列很大需要对某几个列做数据统计分析时wide part的列存储更有优势。
ClickHouse如何提高查询效率
可以看到数据库的存储和数据如何使用、如何查询息息相关。不过这种定期落盘的操作虽然有很好的写性能却产生了大量的data part文件这会对查询效率很有影响。那么ClickHouse是如何提高查询效率呢
我们再仔细分析下新写入的parts数据保存在了 data parts 文件夹内,数据一旦写入数据内容,就不会再进行更改。
一般来说data part的文件夹名格式为 partition分区_min_block_max_block_level并且为了提高查询效率ClickHouse会对data part定期做merge合并。
如上图所示merge操作会分层进行期间会减少要扫描的文件夹个数对数据进行整理、删除、合并操作。你还需要注意不同分区无法合并所以如果我们想提高一个表的写性能多分几个分区会有帮助。
如果写入数据量太大而且数据写入速度太快产生文件夹的速度会超过后台合并的速度这时ClickHouse就会报Too many part错误毕竟data parts文件夹的个数不能无限增加。
面对这种报错调整min_bytes_for_wide_part或者增加分区都会有改善。如果写入数据量并不大你可以考虑多生成compact parts数据这样可以加快合并速度。
此外因为分布式的ClickHouse表是基于ZooKeeper做分布式调度的所以表数据一旦写并发过高ZooKeeper就会成为瓶颈。遇到类似问题建议你升级ClickHouse新版本支持多组ZooKeeper不过这也意味着我们要投入更多资源。
稀疏索引与跳数索引
ClickHouse的查询功能离不开索引支持。Clickhouse有两种索引方式一种是主键索引这个是在建表时就需要指定的另一种是跳表索引用来跳过一些数据。这里我更推荐我们的查询使用主键索引来查询。
主键索引
ClickHouse的表使用主键索引才能让数据查询有更好的性能这是因为数据和索引会按主键进行排序存储用主键索引查询数据可以很快地处理数据并返回结果。ClickHouse属于“左前缀查询”——通过索引和分区先快速缩小数据范围然后再遍历计算只不过遍历计算是多节点、多CPU并行处理的。
那么ClickHouse如何进行数据检索这需要我们先了解下data parts文件夹内的主要数据组成如下图
结合图示我们按从大到小的顺序看看data part的目录结构。
在data parts文件夹中bin文件里保存了一个或多个字段的数据。继续拆分bin文件它里面是多个block数据块block是磁盘交互读取的最小单元它的大小取决于min_compress_block_size设置。
我们继续看block内的结构它保存了多个granule颗粒这是数据扫描的最小单位。每个granule默认会保存8192行数据其中第一条数据就是主键索引数据。data part文件夹内的主键索引保存了排序后的所有主键索引数据而排序顺序是创建表时就指定好的。
为了加快查询的速度data parts内的主键索引即稀疏索引会被加载在内存中并且为了配合快速查找数据在磁盘的位置ClickHouse在data part文件夹中会保存多个按字段名命名的mark文件这个文件保存的是bin文件中压缩后的block的offset以及granularity在解压后block中的offset整体查询效果如下图
具体查询过程是这样的我们先用二分法查找内存里的主键索引定位到特定的mark文件再根据mark查找到对应的block将其加载到内存之后在block里找到指定的granule开始遍历加工直到查到需要的数据。
同时由于ClickHouse允许同一个主键多次Insert的查询出的数据可能会出现同一个主键数据出现多次的情况需要我们人工对查询后的结果做去重。
跳数索引
你可能已经发现了ClickHouse除了主键外没有其他的索引了。这导致无法用主键索引的查询统计需要扫全表才能计算但数据库通常每天会保存几十到几百亿的数据这么做性能就很差了。
因此在性能抉择中ClickHouse通过反向的思维设计了跳数索引来减少遍历granule的资源浪费常见的方式如下
min_max辅助数字字段范围查询保存当前矩阵内最大最小数
set可以理解为列出字段内所有出现的枚举值可以设置取多少条
Bloom Filter使用Bloom Filter确认数据有没有可能在当前块
func支持很多where条件内的函数具体你可以查看 官网。
跳数索引会按上面提到的类型和对应字段保存在data parts文件夹内跳数索引并不是减少数据搜索范围而是排除掉不符合筛选条件的granule以此加快我们查询速度。
我们回头来整体看看ClickHouse的查询工作流程
1.根据查询条件查询过滤出要查询需要读取的data part 文件夹范围;
2.根据data part 内数据的主键索引、过滤出要查询的granule
3.使用skip index 跳过不符合的granule
4.范围内数据进行计算、汇总、统计、筛选、排序;
5.返回结果。
我补充说明一下,上面这五步里,只有第四步里的几个操作是并行的,其他流程都是串行。
在实际用上ClickHouse之后你会发现很难对它做索引查询优化动不动就扫全表这是为什么呢
主要是我们大部分数据的特征不是很明显、建立的索引区分度不够。这导致我们写入的数据在每个颗粒内区分度不大通过稀疏索引的索引无法排除掉大多数的颗粒所以最终ClickHouse只能扫描全表进行计算。
另一方面因为目录过多有多份数据同时散落在多个data parts文件夹内ClickHouse需要加载所有date part的索引挨个查询这也消耗了很多的资源。这两个原因导致ClickHouse很难做查询优化当然如果我们的输入数据很有特征并且特征数据插入时能够按特征排序顺序插入性能可能会更好一些。
实时统计
前面我们说了ClickHouse往往要扫全表才做统计这导致它的指标分析功能也不是很友好为此官方提供了另一个引擎我们来看看具体情况。
类似我们之前讲过的内存计算ClickHouse能够将自己的表作为数据源再创建一个Materialized View的表View表会将数据源的数据通过聚合函数实时统计计算每次我们查询这个表就能获得表规定的统计结果。
下面我给你举个简单例子,看看它是如何使用的:
-- 创建数据源表
CREATE TABLE products_orders
(
prod_id UInt32 COMMENT '商品',
type UInt16 COMMENT '商品类型',
name String COMMENT '商品名称',
price Decimal32(2) COMMENT '价格'
) ENGINE = MergeTree()
ORDER BY (prod_id, type, name)
PARTITION BY prod_id;
--创建 物化视图表
CREATE MATERIALIZED VIEW product_total
ENGINE = AggregatingMergeTree()
PARTITION BY prod_id
ORDER BY (prod_id, type, name)
AS
SELECT prod_id, type, name, sumState(price) AS price
FROM products_orders
GROUP BY prod_id, type, name;
-- 插入数据
INSERT INTO products_orders VALUES
(1,1,'过山车玩具', 20000),
(2,2,'火箭',10000);
-- 查询结果
SELECT prod_id,type,name,sumMerge(price)
FROM product_total
GROUP BY prod_id, type, name;
当数据源插入ClickHouse数据源表生成data parts数据时就会触发View表。View表会按我们创建时设置的聚合函数对插入的数据做批量的聚合。每批数据都会生成一条具体的聚合统计结果并写入磁盘。
当我们查询统计数据时ClickHouse会对这些数据再次聚合汇总才能拿到最终结果对外做展示。这样就实现了指标统计这个实现方式很符合ClickHouse的引擎思路这很有特色。
分布式表
最后我额外分享一个ClicHouse的新特性。不过这部分实现还不成熟所以我们把重点放在这个特性支持什么功能上。
ClickHouse的分布式表不像Elasticsearch那样全智能地帮我们分片调度而是需要研发手动设置创建虽然官方也提供了分布式自动创建表和分布式表的语法但我不是很推荐因为资源的调配目前还是偏向于人工规划ClickHouse并不会自动规划使用类似的命令会导致100台服务器创建100个分片这有些浪费。
使用分布式表,我们就需要先在不同服务器手动创建相同结构的分片表,同时在每个服务器创建分布式表映射,这样在每个服务上都能访问这个分布式表。
我们通常理解的分片是同一个服务器可以存储多个分片而ClickHouse并不一样它规定一个表在一个服务器里只能存在一个分片。
ClickHouse的分布式表的数据插入一般有两种方式。
一种是对分布式表插入数据,这样数据会先在本地保存,然后异步转发到对应分片,通过这个方式实现数据的分发存储。
第二种是由客户端根据不同规则如随机、hash将分片数据推送到对应的服务器上。这样相对来说性能更好但是这么做客户端需要知道所有分片节点的IP。显然这种方式不利于失败恢复。
为了更好平衡高可用和性能还是推荐你选择前一种方式。但是由于各个分片为了保证高可用会先在本地存储一份然后再同步推送这很浪费资源。面对这种情况我们比较推荐的方式是通过类似proxy服务转发一层用这种方式解决节点变更及直连分发问题。
我们再说说主从分片的事儿。ClickHouse的表是按表设置副本主从同步副本之间支持同步更新或异步同步。
主从分片通过分布式表设置在ZooKeeper内的相同路径来实现同步这种设置方式导致ClickHouse的分片和复制有很多种组合方式比如一个集群内多个子集群、一个集群整体多个分片、客户端自行分片写入数据、分布式表代理转发写入数据等多种方式组合。
简单来说就是ClickHouse支持人为做资源共享的多租户数据服务。当我们扩容服务器时需要手动修改新加入集群分片创建分布式表及本地表这样的配置才可以实现数据扩容但是这种扩容数据不会自动迁移。
总结
ClickHouse作为OLAP的新秀代表拥有很多独特的设计它引起了OLAP数据库的革命也引发很多云厂商做出更多思考参考它的思路来实现HTAP服务。
通过今天的讲解相信你也明白ClickHouse的关键特性了。
我们来回顾一下ClickHouse通过分片及内存周期顺序落盘提高了写并发能力通过后台定期合并data parts文件提高了查询效率在索引方面通过稀疏索引缩小了检索数据的颗粒范围对于不在主键的查询则是通过跳数索引来减少遍历数据的数据量另外ClickHouse还有多线程并行读取筛选的设计。
这些特性共同实现了ClickHouse大吞吐的数据查找功能。
而最近选择 Elasticsearch还是ClickHouse更好的话题讨论得非常火热目前来看还没有彻底分出高下。
个人建议如果硬件资源丰富研发人员少的话就选择Elasticsearch硬件资源少研发人员多的情况可以考虑试用ClickHouse如果硬件和人员都少建议买云服务的云分布式数据库去做需要根据团队具体情况来合理地决策。
我还特意为你整理了一张评估表格,贴在了文稿里。
思考题
ClickHouse是不能轻易修改删除数据的那我们要如何做历史数据的清理呢
期待你在留言区与我互动交流!如果觉得这节课内容还不错,请推荐、分享给更多朋友。

View File

@ -0,0 +1,165 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 本地缓存:用本地缓存做服务会遇到哪些坑?
你好,我是徐长龙。
这一章我们来学习如何应对读多写多的系统。微博Feed、在线游戏、IM、在线课堂、直播都属于读多写多的系统这类系统里的很多技术都属于行业天花板级别毕竟线上稍有点问题都极其影响用户体验。
说到读多写多不得不提缓存,因为目前只有缓存才能够提供大流量的数据服务,而常见的缓存架构,基本都会使用集中式缓存方式来对外提供服务。
但是,集中缓存在读多写多的场景中有上限,当流量达到一定程度,集中式缓存和无状态服务的大量网络损耗会越来越严重,这导致高并发读写场景下,缓存成本高昂且不稳定。
为了降低成本、节省资源,我们会在业务服务层再增加一层缓存,放弃强一致性,保持最终一致性,以此来降低核心缓存层的读写压力。
虚拟内存和缺页中断
想做好业务层缓存,我们需要先了解一下操作系统底层是如何管理内存的。
对照后面这段C++代码,你可以暂停思考一下,这个程序如果在环境不变的条件下启动多次,变量内存地址输出是什么样的?
int testvar = 0;
int main(int argc, char const *argv[])
{
testvar += 1;
sleep(10);
printf("address: %x, value: %d\n", &testvar, testvar );
return 0;
}
答案可能出乎你的意料,试验一下,你就会发现变量内存地址输出一直是固定的,这证明了程序见到的内存是独立的。如果我们的服务访问的是物理内存,就不会发生这种情况。
为什么结果是这样呢这就要说到Linux的内存管理方式它用虚拟内存的方式管理内存因此每个运行的进程都有自己的虚拟内存空间。
回过头来看,我们对外提供缓存数据服务时,如果想提供更高效的并发读写服务,就需要把数据放在本地内存中,一般会实现为一个进程内的多个线程来共享缓存数据。不过在这个过程中,我们还会遇到缺页问题,我们一起来看看。
如上图所示我们的服务在Linux申请的内存不会立刻从物理内存划分出来。系统数据修改时才会发现物理内存没有分配此时CPU会产生缺页中断操作系统才会以page为单位把物理内存分配给程序。系统这么设计主要是为了降低系统的内存碎片并且减少内存的浪费。
不过系统分配的页很小一般是4KB如果我们一次需要把1G的数据插入到内存中写入数据到这块内存时就会频繁触发缺页中断导致程序响应缓慢、服务状态不稳定的问题。
所以当我们确认需要高并发读写内存时都会先申请一大块内存并填0然后再使用这样可以减少数据插入时产生的大量缺页中断。我额外补充一个注意事项这种申请大内存并填0的操作很慢尽量在服务启动时去做。
前面说的操作虽然立竿见影但资源紧张的时候还会有问题。现实中很多服务刚启动就会申请几G的内存但是实际运行过程中活跃使用的内存不到10%Linux会根据统计将我们长时间不访问的数据从内存里挪走留出空间给其他活跃的内存使用这个操作叫Swap Out。
为了降低 Swap Out 的概率,就需要给内存缓存服务提供充足的内存空间和系统资源,让它在一个相对专用的系统空间对外提供服务。
但我们都知道内存空间是有限的所以需要精心规划内存中的数据量确认这些数据会被频繁访问。我们还需要控制缓存在系统中的占用量因为系统资源紧张时OOM会优先杀掉资源占用多的服务同时为了防止内存浪费我们需要通过LRU淘汰掉一些不频繁访问的数据这样才能保证资源不被浪费。
即便这样做还可能存在漏洞,因为业务情况是无法预测的。所以建议对内存做定期扫描续热,以此预防流量突增时触发大量缺页中断导致服务卡顿、最终宕机的情况。
程序容器锁粒度
除了保证内存不放冷数据外,我们放在内存中的公共数据也需要加锁,如果不做互斥锁,就会出现多线程修改不一致的问题。
如果读写频繁我们常常会对相应的struct增加单条数据锁或map锁。但你要注意锁粒度太大会影响到我们的服务性能。
因为实际情况往往会和我们预计有一些差异建议你在具体使用时在本地多压测测试一下。就像我之前用C++ 11写过一些内存服务就遇到过读写锁性能反而比不上自旋互斥锁还有压缩传输效率不如不压缩效率高的情况。
那么我们再看一下业务缓存常见的加锁方式。
为了减少锁冲突我常用的方式是将一个放大量数据的经常修改的map拆分成256份甚至更多的分片每个分片会有一个互斥锁以此方式减少锁冲突提高并发读写能力。
除此之外还有一种方式就是将我们的修改、读取等变动只通过一个线程去执行这样能够减少锁冲突加强执行效率我们常用的Redis就是使用类似的方式去实现的如下图所示
如果我们接受半小时或一小时全量更新一次可以制作map通过替换方式实现数据更新。
具体的做法是用两个指针分别指向两个map一个map用于对外服务当拿到更新数据离线包时另一个指针指向的map会加载离线全量数据。加载完毕后两个map指针指向互换以此实现数据的批量更新。这样实现的缓存我们可以不加互斥锁性能会有很大的提升。
当然行业也存在一些无锁的黑科技这些方法都可以减少我们的锁争抢比如atomic、Go的sync.Map、sync.Pool、Java的volidate。感兴趣的话你可以找自己在用的语言查一下相关知识。除此之外无锁实现可以看看MySQL InnoDB的MVCC。
GC和数据使用类型
当做缓存时我们的数据struct直接放到map一类的容器中就很完美了吗事实上我并不建议这么做。这个回答可能有些颠覆你的认知但看完后面的分析你就明白了。
当我们将十万条数据甚至更多的数据放到缓存中时编程语言的GC会定期扫描这些对象去判断这些对象是否能够回收。这个机制导致map中的对象越多服务GC的速度就会越慢。
因此很多语言为了能够将业务缓存数据放到内存中做了很多特殊的优化这也是为什么高级语言做缓存服务时很少将数据对象放到一个大map中。
这里我以Go语言为例带你看看。为了减少扫描对象个数Go对map做了一个特殊标记如果map中没有指针则GC不会遍历它保存的对象。
为了方便理解举个例子我们不再用map保存具体的对象数据只是使用简单的结构作为查询索引如使用map[int]int其中key是string通过hash算法转成的intvalue保存的内容是数据所在的offset和长度。
对数据做了序列化后我们会把它保存在一个很长的byte数组中通过这个方式缓存数据但是这个实现很难删除修改数据所以删除的一般只是map索引记录。
这也导致了我们做缓存时,要根据缓存的数据特点分情况处理。
如果我们的数据量少且特点是读多写多意味着会频繁更改那么将它的struct放到map中对外服务更合理如果我们的数据量大且特点是读多写少那么把数据放到一个连续内存中通过offset和length访问会更合适。
分析了GC的问题之后相信你已经明白了很多高级语言宁可将数据放到公共的基础服务中也不在本地做缓存的原因。
如果你仍旧想这么做,这里我推荐一个有趣的项目 XMM供你参考它是一个能躲避Golang GC的内存管理组件。事实上其他语言也存在类似的组件你可以自己探索一下。
内存对齐
前面提到数据放到一块虚拟地址连续的大内存中通过offse和length来访问不能修改的问题这个方式其实还有一些提高的空间。
在讲优化方案前,我们需要先了解一下内存对齐,在计算机中很多语言都很关注这一点,究其原因,内存对齐后有很多好处,比如我们的数组内所有数据长度一致的话,就可以快速对其定位。
举个例子如果我想快速找到数组中第6个对象可以用如下方式来实现
sizeof(obj) * index => offset
使用这个方式,要求我们的 struct必须是定长的并且长度要按2的次方倍数做对齐。另外也可以把变长的字段用指针指向另外一个内存空间
通过这个方式我们可以通过索引直接找到对象在内存中的位置并且它的长度是固定的无需记录length只需要根据index即可找到数据。
这么设计也可以让我们在读取内存数据时能快速拿到数据所在的整块内存页然后就能从内存快速查找要读取索引的数据无需读取多个内存页毕竟内存也属于外存访问次数少一些更有效率。这种按页访问内存的方式不但可以快速访问还更容易被CPU L1、L2 缓存命中。
SLAB内存管理
除了以上的方式外,你可能好奇过,基础内存服务是怎么管理内存的。我们来看后面这个设计。
如上图主流语言为了减少系统内存碎片提高内存分配的效率基本都实现了类似Memcache的伙伴算法内存管理甚至高级语言的一些内存管理库也是通过这个方式实现的。
我举个例子Redis里可以选择用jmalloc减少内存碎片我们来看看jmalloc的实现原理。
jmalloc会一次性申请一大块儿内存然后将其拆分成多个组为了适应我们的内存使用需要会把每组切分为相同的chunk size而每组的大小会逐渐递增如第一组都是32byte第二组都是64byte。
需要存放数据的时候jmalloc会查找空闲块列表分配给调用方如果想放入的数据没找到相同大小的空闲数据块就会分配容量更大的块。虽然这么做有些浪费内存但可以大幅度减少内存的碎片提高内存利用率。
很多高级语言也使用了这种实现方式,当本地内存不够用的时候,我们的程序会再次申请一大块儿内存用来继续服务。这意味着,除非我们把服务重启,不然即便我们在业务代码里即使释放了临时申请的内存,编程语言也不会真正释放内存。所以,如果我们使用时遇到临时的大内存申请,务必想好是否值得这样做。
总结
学完这节课,你应该明白,为什么行业中,我们都在尽力避免业务服务缓存应对高并发读写的情况了。
因为我们实现这类服务时不但要保证当前服务能够应对高并发的网络请求还要减少内部修改和读取导致的锁争抢并且要关注高级语言GC原理、内存碎片、缺页等多种因素同时我们还要操心数据的更新、一致性以及内存占用刷新等问题。
即便特殊情况下我们用上了业务层缓存的方式,在业务稳定后,几乎所有人都在尝试把这类服务做降级,改成单纯的读多写少或写多读少的服务。
更常见的情况是如果不得不做我们还可以考虑在业务服务器上启动一个小的Redis分片去应对线上压力。当然这种方式我们同样需要考虑清楚如何做数据同步。
除了今天讲的踩坑点,内存对外服务的过程中,我们还会碰到一些其他问题,我们下节课再展开。
思考题
使用了大数组来保存数据用offset+length实现的数据缓存有什么办法修改数据
欢迎你在评论区与我交流讨论,我们下节课见!

View File

@ -0,0 +1,382 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 业务脚本:为什么说可编程订阅式缓存服务更有用?
你好,我是徐长龙。
我们已经习惯了使用缓存集群对数据做缓存,但是这种常见的内存缓存服务有很多不方便的地方,比如集群会独占大量的内存、不能原子修改缓存的某一个字段、多次通讯有网络损耗。
很多时候我们获取数据并不需要全部字段,但因为缓存不支持筛选,批量获取数据的场景下性能就会下降很多。这些问题在读多写多的场景下,会更加明显。
有什么方式能够解决这些问题呢?这节课,我就带你了解另外一种有趣的数据缓存方式——可编程订阅式缓存服务。学完今天的内容,相信你会对缓存服务如何做产生新的思考。
缓存即服务
可编程订阅式缓存服务的意思是,我们可以自行实现一个数据缓存服务直接提供给业务服务使用,这种实现能够根据业务的需要,主动缓存数据并提供一些数据整理和计算的服务。
自实现的数据缓存服务虽然繁琐,但同时也有很多优势,除去吞吐能力的提升,我们还可以实现更多有趣的定制功能,还有更好的计算能力,甚至可以让我们的缓存直接对外提供基础数据的查询服务。
上图是一个自实现的缓存功能结构,可以说这种缓存的性能和效果更好,这是因为它对数据的处理方式跟传统模式不同。
传统模式下,缓存服务不会对数据做任何加工,保存的是系列化的字符串,大部分的数据无法直接修改。当我们使用这种缓存对外进行服务时,业务服务需要将所有数据取出到本地内存,然后进行遍历加工方可使用。
而可编程缓存可以把数据结构化地存在map中相比传统模式序列化的字符串更节省内存。
更方便的是我们的服务无需再从其他服务取数据来做计算这样会节省大量网络交互耗时适合用在实时要求极高的场景里。如果我们的热数据量很大可以结合RocksDB等嵌入式引擎用有限的内存提供大量数据的服务。
除了常规的数据缓存服务外,可编程缓存还支持缓存数据的筛选过滤、统计计算、查询、分片、数据拼合。关于查询服务,我补充说明一下对外的服务建议通过类似Redis的简单文本协议提供服务这样会比HTTP协议性能会更好。
Lua脚本引擎
虽然缓存提供业务服务能提高业务灵活度,但是这种方式也有很多缺点,最大的缺点就是业务修改后,我们需要重启服务才能够更新我们的逻辑。由于内存中保存了大量的数据,重启一次数据就需要繁琐的预热,同步代价很大。
为此我们需要给设计再次做个升级。这种情况下lua脚本引擎是个不错的选择。lua是一个小巧的嵌入式脚本语言通过它可以实现一个高性能、可热更新的脚本服务从而和嵌入的服务高效灵活地互动。
我画了一张示意图描述了如何通过lua脚本来具体实现可编程缓存服务
如上图所示可以看到我们提供了Kafka消费、周期任务管理、内存缓存、多种数据格式支持、多种数据驱动适配这些服务。不仅仅如此为了减少由于逻辑变更导致的服务经常重启的情况我们还以性能损耗为代价在缓存服务里嵌入了lua脚本引擎借此实现动态更新业务的逻辑。
lua引擎使用起来很方便我们结合后面这个实现例子看一看这是一个Go语言写的嵌入lua实现代码如下所示
package main
import "github.com/yuin/gopher-lua"
// VarChange 用于被lua调用的函数
func VarChange(L *lua.LState) int {
lv := L.ToInt(1) //获取调用函数的第一个参数并且转成int
L.Push(lua.LNumber(lv * 2)) //将参数内容直接x2并返回结果给lua
return 1 //返回结果参数个数
}
func main() {
L := lua.NewState() //新lua线程
defer L.Close() //程序执行完毕自动回收
// 注册lua脚本可调用函数
// 在lua内调用varChange函数会调用这里注册的Go函数 VarChange
L.SetGlobal("varChange", L.NewFunction(VarChange))
//直接加载lua脚本
//脚本内容为:
// print "hello world"
// print(varChange(20)) # lua中调用go声明的函数
if err := L.DoFile("hello.lua"); err != nil {
panic(err)
}
// 或者直接执行string内容
if err := L.DoString(`print("hello")`); err != nil {
panic(err)
}
}
// 执行后输出结果:
//hello world
//40
//hello
从这个例子里我们可以看出lua引擎是可以直接执行lua脚本的而lua脚本可以和Golang所有注册的函数相互调用并且可以相互传递交换变量。
回想一下我们做的是数据缓存服务所以需要让lua能够获取修改服务内的缓存数据那么lua是如何和嵌入的语言交换数据的呢我们来看看两者相互调用交换的例子
package main
import (
"fmt"
"github.com/yuin/gopher-lua"
)
func main() {
L := lua.NewState()
defer L.Close()
//加载脚本
err := L.DoFile("vardouble.lua")
if err != nil {
panic(err)
}
// 调用lua脚本内函数
err = L.CallByParam(lua.P{
Fn: L.GetGlobal("varDouble"), //指定要调用的函数名
NRet: 1, // 指定返回值数量
Protect: true, // 错误返回error
}, lua.LNumber(15)) //支持多个参数
if err != nil {
panic(err)
}
//获取返回结果
ret := L.Get(-1)
//清理下,等待下次用
L.Pop(1)
//结果转下类型,方便输出
res, ok := ret.(lua.LNumber)
if !ok {
panic("unexpected result")
}
fmt.Println(res.String())
}
// 输出结果:
// 30
其中vardouble.lua内容为
function varDouble(n)
return n * 2
end
通过这个方式lua和Golang就可以相互交换数据和相互调用。对于这种缓存服务普遍要求性能很好这时我们可以统一管理加载过lua的脚本及LState脚本对象的实例对象池这样会更加方便不用每调用一次lua就加载一次脚本方便获取和使用多线程、多协程。
Lua脚本统一管理
通过前面的讲解我们可以发现在实际使用时lua会在内存中运行很多实例。为了更好管理并提高效率我们最好用一个脚本管理系统来管理所有lua的实运行例子以此实现脚本的统一更新、编译缓存、资源调度和控制单例。
lua脚本本身是单线程的但是它十分轻量一个实例大概是144kb的内存损耗有些服务平时能跑成百上千个lua实例。
为了提高服务的并行处理能力我们可以启动多协程让每个协程独立运行一个lua线程。为此gopher-lua库提供了一个类似线程池的实现通过这个方式我们不需要频繁地创建、关闭lua官方例子具体如下
//保存lua的LState的池子
type lStatePool struct {
m sync.Mutex
saved []*lua.LState
}
// 获取一个LState
func (pl *lStatePool) Get() *lua.LState {
pl.m.Lock()
defer pl.m.Unlock()
n := len(pl.saved)
if n == 0 {
return pl.New()
}
x := pl.saved[n-1]
pl.saved = pl.saved[0 : n-1]
return x
}
//新建一个LState
func (pl *lStatePool) New() *lua.LState {
L := lua.NewState()
// setting the L up here.
// load scripts, set global variables, share channels, etc...
//在这里我们可以做一些初始化
return L
}
//把Lstate对象放回到池中方便下次使用
func (pl *lStatePool) Put(L *lua.LState) {
pl.m.Lock()
defer pl.m.Unlock()
pl.saved = append(pl.saved, L)
}
//释放所有句柄
func (pl *lStatePool) Shutdown() {
for _, L := range pl.saved {
L.Close()
}
}
// Global LState pool
var luaPool = &lStatePool{
saved: make([]*lua.LState, 0, 4),
}
//协程内运行的任务
func MyWorker() {
//通过pool获取一个LState
L := luaPool.Get()
//任务执行完毕后将LState放回pool
defer luaPool.Put(L)
// 这里可以用LState变量运行各种lua脚本任务
//例如 调用之前例子中的的varDouble函数
err = L.CallByParam(lua.P{
Fn: L.GetGlobal("varDouble"), //指定要调用的函数名
NRet: 1, // 指定返回值数量
Protect: true, // 错误返回error
}, lua.LNumber(15)) //这里支持多个参数
if err != nil {
panic(err) //仅供演示用实际生产不推荐用panic
}
}
func main() {
defer luaPool.Shutdown()
go MyWorker() // 启动一个协程
go MyWorker() // 启动另外一个协程
/* etc... */
}
通过这个方式我们可以预先创建一批LState让它们加载好所有需要的lua脚本当我们执行lua脚本时直接调用它们即可对外服务提高我们的资源复用率。
变量的交互
事实上我们的数据既可以保存在lua内也可以保存在Go中通过相互调用来获取对方的数据。个人习惯将数据放在Go中封装供lua调用主要是因为这样相对规范、比较好管理毕竟脚本会有损耗。
前面提到过我们会将一些数据用struct和map组合起来对外提供数据服务。那么lua和Golang如何交换struct一类数据呢
这里我选择了官方提供的例子,但额外加上了大量注释,帮助你理解这个交互过程。
// go用于交换的 struct
type Person struct {
Name string
}
//为这个类型定义个类型名称
const luaPersonTypeName = "person"
// 在LState对象中声明这种类型这个只会在初始化LState时执行一次
// Registers my person type to given L.
func registerPersonType(L *lua.LState) {
//在LState中声明这个类型
mt := L.NewTypeMetatable(luaPersonTypeName)
//指定 person 对应 类型type 标识
//这样 person在lua内就像一个 类声明
L.SetGlobal("person", mt)
// static attributes
// 在lua中定义person的静态方法
// 这句声明后 lua中调用person.new即可调用go的newPerson方法
L.SetField(mt, "new", L.NewFunction(newPerson))
// person new后创建的实例在lua中是table类型你可以把table理解为lua内的对象
// 下面这句主要是给 table定义一组methods方法可以在lua中调用
// personMethods是个map[string]LGFunction
// 用来告诉luamethod和go函数的对应关系
L.SetField(mt, "__index", L.SetFuncs(L.NewTable(), personMethods))
}
// person 实例对象的所有method
var personMethods = map[string]lua.LGFunction{
"name": personGetSetName,
}
// Constructor
// lua内调用person.new时会触发这个go函数
func newPerson(L *lua.LState) int {
//初始化go struct 对象 并设置name为 1
person := &Person{L.CheckString(1)}
// 创建一个lua userdata对象用于传递数据
// 一般 userdata包装的都是go的structtable是lua自己的对象
ud := L.NewUserData()
ud.Value = person //将 go struct 放入对象中
// 设置这个lua对象类型为 person type
L.SetMetatable(ud, L.GetTypeMetatable(luaPersonTypeName))
// 将创建对象返回给lua
L.Push(ud)
//告诉lua脚本返回了数据个数
return 1
}
// Checks whether the first lua argument is a *LUserData
// with *Person and returns this *Person.
func checkPerson(L *lua.LState) *Person {
//检测第一个参数是否为其他语言传递的userdata
ud := L.CheckUserData(1)
// 检测是否转换成功
if v, ok := ud.Value.(*Person); ok {
return v
}
L.ArgError(1, "person expected")
return nil
}
// Getter and setter for the Person#Name
func personGetSetName(L *lua.LState) int {
// 检测第一个栈,如果就只有一个那么就只有修改值参数
p := checkPerson(L)
if L.GetTop() == 2 {
//如果栈里面是两个,那么第二个是修改值参数
p.Name = L.CheckString(2)
//代表什么数据不返回,只是修改数据
return 0
}
//如果只有一个在栈那么是获取name值操作返回结果
L.Push(lua.LString(p.Name))
//告诉会返回一个参数
return 1
}
func main() {
// 创建一个lua LState
L := lua.NewState()
defer L.Close()
//初始化 注册
registerPersonType(L)
// 执行lua脚本
if err := L.DoString(`
//创建person并设置他的名字
p = person.new("Steven")
print(p:name()) -- "Steven"
//修改他的名字
p:name("Nico")
print(p:name()) -- "Nico"
`); err != nil {
panic(err)
}
}
可以看到我们通过lua脚本引擎就能很方便地完成相互调用和交换数据从而实现很多实用的功能甚至可以用少量数据直接写成lua脚本的方式来加载服务。-
另外gopher-lua还提供了模块功能帮助我们更好地管理脚本和代码有兴趣的话可以自行深入参考资料在这里。
缓存预热与数据来源
了解了lua后我们再看看服务如何加载数据。服务启动时我们需要将数据缓存加载到缓存中做缓存预热待数据全部加载完毕后再开放对外的API端口对外提供服务。
加载过程中如果用上了lua脚本就可以在服务启动时对不同格式的数据做适配加工这样做也能让数据来源更加丰富。
常见的数据来源是大数据挖掘周期生成的全量数据离线文件通过NFS或HDFS挂载定期刷新、加载最新的文件。这个方式适合数据量大且更新缓慢的数据缺点则是加载时需要整理数据如果情况足够复杂800M大小的数据要花110分钟方能加载完毕。
除了使用文件方式外,我们也可以在程序启动后扫数据表恢复数据,但这么做数据库要承受压力,建议使用专用的从库。但相对磁盘离线文件的方式,这种方式加载速度更慢。
上面两种方式加载都有些慢,我们还可以将 RocksDB 嵌入到进程中,这样做可以大幅度提高我们的数据存储容量,实现内存磁盘高性能读取和写入。不过代价就是相对会降低一些查询性能。
RocksDB的数据可以通过大数据生成RocksDB格式的数据库文件拷贝给我们的服务直接加载。这种方式可以大大减少系统启动中整理、加载数据的时间实现更多的数据查询。
另外,如果我们对于本地有关系数据查询需求,也可以嵌入 SQLite 引擎通过这个引擎可以做各种关系数据查询SQLite的数据的生成也可以通过工具提前生成给我们服务直接使用。但你要注意这个数据库不要超过10w条数据否则很可能导致服务卡顿。
最后对于离线文件加载最好做一个CheckSum一类的文件用来在加载文件之前检查文件的完整性。由于我们使用的是网络磁盘不太确定这个文件是否正在拷贝中需要一些小技巧保证我们的数据完整性最粗暴的方式就是每次拷贝完毕后生成一个同名的文件内部记录一下它的CheckSum方便我们加载前校验。
离线文件能够帮助我们快速实现多个节点的数据共享和统一,如果我们需要多个节点数据保持最终一致性,就需要通过离线+同步订阅方式来实现数据的同步。
订阅式数据同步及启动同步
那么,我们的数据是如何同步更新的呢?
正常情况下我们的数据来源于多个基础数据服务。如果想实时同步数据的更改我们一般会通过订阅binlog将变更信息同步到Kafka再通过Kafka的分组消费来通知分布在不同集群中的缓存。
收到消息变更的服务会触发lua脚本对数据进行同步更新。通过lua我们可以触发式同步更新其他相关缓存比如用户购买一个商品我们要同步刷新他的积分、订单和消息列表个数。
周期任务
提到任务管理不得不提一下周期任务。周期任务一般用于刷新数据的统计我们通过周期任务结合lua自定义逻辑脚本就能实现定期统计这给我们提供了更多的便利。
定期执行任务或延迟刷新的过程中常见的方式是用时间轮来管理任务用这个方式可以把定时任务做成事件触发这样能轻松地管理内存中的待触发任务列表从而并行多个周期任务无需使用sleep循环方式不断查询。对时间轮感兴趣的话你可以点击这里查看具体实现。
另外,前面提到我们的很多数据都是通过离线文件做批量更新的,如果是一小时更新一次,那么一小时内新更新的数据就需要同步。
一般要这样处理:在我们服务启动加载的离线文件时,保存离线文件生成的时间,通过这个时间来过滤数据更新队列中的消息,等到我们的队列任务进度追到当前时间附近时,再开启对外数据的服务。
总结
读多写多的服务中,实时交互类服务非常多,对数据的实时性要求也很高,用集中型缓存很难满足服务所需。为此,行业里多数会通过服务内存数据来提供实时交互服务,但这么做维护起来十分麻烦,重启后需要恢复数据。为了实现业务逻辑无重启的更新,行业里通常会使用内嵌脚本的热更新方案。
常见的通用脚本引擎是lua这是一个十分流行且方便的脚本引擎在行业中很多知名游戏及服务都使用lua来实现高性能服务的定制化业务功能比如Nginx、Redis等。
把lua和我们的定制化缓存服务结合起来即可制作出很多强大的功能来应对不同的场景。由于lua十分节省内存我们在进程中开启成千上万的lua小线程甚至一个用户一个LState线程对客户端提供状态机一样的服务。
用上面的方法再结合lua和静态语言交换数据相互调用并配合上我们的任务管理以及各种数据驱动就能完成一个几乎万能的缓存服务。推荐你在一些小项目中亲自实践一下相信会让你从不同视角看待已经习惯的服务这样会有更多收获。
思考题
如何让Go的协程访问一个LState保存的数据
欢迎你在留言区与我交流讨论,我们下节课见!

View File

@ -0,0 +1,158 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 流量拆分:如何通过架构设计缓解流量压力?
你好,我是徐长龙。
今天,我会以直播互动为例,带你看看读多写多的情况下如何应对流量压力。-
一般来说,这种服务多数属于实时互动服务,因为时效性要求很高,导致很多场景下,我们无法用读缓存的方式来降低核心数据的压力。所以,为了降低这类互动服务器的压力,我们可以从架构入手,做一些灵活拆分的设计改造。
事实上这些设计是混合实现对外提供服务的,为了让你更好地理解,我会针对直播互动里的特定的场景进行讲解。一般来说,直播场景可以分为可预估用户量和不可预估用户量的场景,两者的设计有很大的不同,我们分别来看看。
可预估用户量的服务:游戏创建房间
相信很多玩对战游戏的伙伴都有类似经历,就是联网玩游戏要先创建房间。这种设计主要是通过设置一台服务器可以开启的房间数量上限,来限制一台服务器能同时服务多少用户。
我们从服务器端的资源分配角度分析一下,创建房间这个设计是如何做资源调配的。创建房间后,用户通过房间号就可以邀请其他伙伴加入游戏进行对战,房主和加入的伙伴,都会通过房间的标识由调度服务统一分配到同一服务集群上进行互动。
这里我提示一下,开房间这个动作不一定需要游戏用户主动完成,可以设置成用户开启游戏就自动分配房间,这样做不但能提前预估用户量,还能很好地规划和掌控我们的服务资源。
如何评估一个服务器支持多少人同时在线呢?
我们可以通过压测测出单台服务器的服务在线人数,以此精确地预估带宽和服务器资源,算出一个集群(集群里包括若干服务器)需要多少资源、可以承担多少人在线进行互动,再通过调度服务分配资源,将新来的房主分配到空闲的服务集群。
最后的实现效果如下所示:
如上图所示在创建房间阶段我们的客户端在进入区域服务器集群之前都是通过请求调度服务来进行调度的。调度服务器会定期接收各组服务器的服务用户在线情况以此来评估需要调配多少用户进入到不同区域集群同时客户端收到调度后会拿着调度服务给的token去不同区域申请创建房间。
房间创建后,调度服务会在本地集群内维护这个房间的列表和信息,提供给其他要加入游戏的玩家展示。而加入的玩家同样会接入对应房间的区域服务器,与房主及同房间玩家进行实时互动。
这种通过配额房间个数来做服务器资源调度的设计,不光是对战游戏里,很多场景都用了类似设计,比如在线小课堂这类教学互动的。我们可以预见,通过这个设计能够对资源做到精准把控,用户不会超过我们服务器的设计容量。
不可预估用户量的服务
但是,有很多场景是随机的,我们无法把控有多少用户会进入这个服务器进行互动。
全国直播就无法确认会有多少用户访问,为此,很多直播服务首先按主播过往预测用户量。通过预估量,提前将他们的直播安排到相对空闲的服务器群组里,同时提前准备一些调度工具,比如通过控制曝光度来延缓用户进入直播,通过这些为服务器调度争取更多时间来动态扩容。
由于这一类的服务无法预估会有多少用户,所以之前的服务器小组模式并不适用于这种方式,需要更高一个级别的调度。
我们分析一下场景,对于直播来说,用户常见的交互形式包括聊天、答题、点赞、打赏和购物,考虑到这些形式的特点不同,我们针对不同的关键点依次做分析。
聊天:信息合并
聊天的内容普遍比较短,为了提高吞吐能力,通常会把用户的聊天内容放入分布式队列做传输,这样能延缓写入压力。
另外,在点赞或大量用户输入同样内容的刷屏情境下,我们可以通过大数据实时计算分析用户的输入,并压缩整理大量重复的内容,过滤掉一些无用信息。
压缩整理后的聊天内容会被分发到多个聊天内容分发服务器上,直播间内用户的聊天长连接会收到消息更新的推送通知,接着客户端会到指定的内容分发服务器群组里批量拉取数据,拿到数据后会根据时间顺序来回放。请注意,这个方式只适合用在疯狂刷屏的情况,如果用户量很少可以通过长链接进行实时互动。
答题:瞬时信息拉取高峰
除了交互流量极大的聊天互动信息之外,还有一些特殊的互动,如做题互动。直播间老师发送一个题目,题目消息会广播给所有用户,客户端收到消息后会从服务端拉取题目的数据。
如果有10w用户在线很有可能导致瞬间有10w人在线同时请求服务端拉取题目。这样的数据请求量需要我们投入大量的服务器和带宽才能承受不过这么做这个性价比并不高。
理论上我们可以将数据静态化并通过CDN阻挡这个流量但是为了避免出现瞬时的高峰推荐客户端拉取时加入随机延迟几秒再发送请求这样可以大大延缓服务器压力获得更好的用户体验。
切记对于客户端来说,这种服务如果失败了,就不要频繁地请求重试,不然会将服务端打沉。如果必须这样做,那么建议你对重试的时间做退火算法,以此保证服务端不会因为一时故障收到大量的请求,导致服务器崩溃。
如果是教学场景的直播,有两个缓解服务器压力的技巧。第一个技巧是在上课当天,把抢答题目提前交给客户端做预加载下载,这样可以减少实时拉取的压力。
第二个方式是题目抢答的情况老师发布题目的时候提前设定发送动作生效后5秒再弹出题目这样能让所有直播用户的接收端“准时”地收到题目信息而不至于出现用户题目接收时间不一致的情况。
至于非抢答类型的题目,用户回答完题目后,我们可以先在客户端本地先做预判卷,把正确答案和解析展示给用户,然后在直播期间异步缓慢地提交用户答题结果到服务端,以此保证服务器不会因用户瞬时的流量被冲垮。
点赞:客户端互动合并
对于点赞的场景,我会分成客户端和服务端两个角度带你了解。
先看客户端,很多时候,客户端无需实时提交用户的所有交互,因为有很多机械的重复动作对实时性要求没那么高。
举个例子用户在本地狂点了100下赞客户端就可以合并这些操作为一条消息例如用户3秒内点赞10次。相信聪明如你可以把互动动作合并这一招用在更多情景比如用户连续打赏100个礼物。
通过这个方式可以大幅度降低服务器压力,既可以保证直播间的火爆依旧,还节省了大量的流量资源,何乐而不为。
点赞:服务端树形多层汇总架构
我们回头再看看点赞的场景下,如何设计服务端才能缓解请求压力。
如果我们的集群QPS超过十万服务端数据层已经无法承受这样的压力时如何应对高并发写、高并发读呢微博做过一个类似的案例用途是缓解用户的点赞请求流量这种方式适合一致性要求不高的计数器如下图所示
这个方式可以将用户点赞流量随机压到不同的写缓存服务上,通过第一层写缓存本地的实时汇总来缓解大量用户的请求,将更新数据周期性地汇总后,提交到二级写缓存。
之后,二级汇总所在分片的所有上层服务数值后,最终汇总同步给核心缓存服务。接着,通过核心缓存把最终结果汇总累加起来。最后通过主从复制到多个子查询节点服务,供用户查询汇总结果。
另外说个题外话微博是Redis重度用户后来因为点赞数据量太大在Redis中缓存点赞数内存浪费严重可以回顾上一节课 jmalloc兄弟算法的内容改为自行实现点赞服务来节省内存。
打赏&购物:服务端分片及分片实时扩容
前面的互动只要保证最终一致性就可以,但打赏和购物的场景下,库存和金额需要提供事务一致性的服务。
因为事务一致性的要求,这种服务我们不能做成多层缓冲方式提供服务,而且这种服务的数据特征是读多写多,所以我们可以通过数据分片方式实现这一类服务,如下图:
看了图是不是很好理解我们可以按用户id做了 hash拆分通过网关将不同用户uid取模后根据范围分配到不同分片服务上然后分片内的服务对类似的请求进行内存实时计算更新。
通过这个方式可以快速方便地实现负载切分但缺点是hash分配容易出现个别热点当我们流量扛不住的时候需要扩容。
但是hash这个方式如果出现个别服务器故障的话会导致hash映射错误从而请求到错误的分片。类似的解决方案有很多如一致性hash算法这种算法可以对局部的区域扩容不会影响整个集群的分片但是这个方法很多时候因为算法不通用无法人为控制使用起来很麻烦需要开发配套工具。
除此之外,我给你推荐另外一个方式——树形热迁移切片法,这是一种类似虚拟桶的方式。
比如我们将全量数据拆分成256份一份代表一个桶16个服务器每个分16个桶当我们个别服务器压力过大的时候可以给这个服务器增加两个订阅服务器去做主从同步迁移这个服务器的16个桶的数据。
待同步迁移成功后将这个服务器的请求流量拆分转发到两个8桶服务器分别请求这两个订阅服务器继续对外服务原服务器摘除回收即可。
服务切换成功后由于是全量迁移这两个服务同时同步了不属于自己的8个桶数据这时新服务器遍历自己存储的数据删除掉不属于自己的数据即可。当然也可以在同步16桶服务的数据时过滤掉这些数据这个方法适用于Redis、MySQL等所有有状态分片数据服务。
这个服务的难点在于请求的客户端不直接请求分片,而是通过代理服务去请求数据服务,只有通过代理服务才能够动态更新调度流量,实现平滑无损地转发流量。
最后,如何让客户端知道请求哪个分片才能找到数据呢?我给你分享两个常见的方式:
第一种方式是,客户端通过算法找到分片,比如:用户 hash(uid) % 100 = 桶id在配置中通过桶id找到对应分片。
第二种方式是数据服务端收到请求后将请求转发到有数据的分片。比如客户端请求A分片再根据数据算法对应的分片配置找到数据在B分片这时A分片会转发这个请求到B待B处理后返回给客户端数据A返回或B返回取决于客户端跳转还是服务端转发
服务降级:分布式队列汇总缓冲
即使通过这么多技术来优化架构,我们的服务仍旧无法完全承受过高的瞬发流量。
对于这种情况,我们可以做一些服务降级的操作,通过队列将修改合并或做网关限流。虽然这会牺牲一些实时性,但是实际上,很多数字可能没有我们想象中那么重要。像微博的点赞统计数据,如果客户端点赞无法请求到服务器,那么这些数据会在客户端暂存一段时间,在用户看数据时看到的只是短期历史数字,不是实时数字。
十万零五的点赞数跟十万零三千的点赞数,差异并不大,等之后服务器有空闲了,结果追上来最终是一致的。但作为降级方案,这么做能节省大量的服务器资源,也算是个好方法。
总结
这节课我们学习了如何通过架构以及设计去缓解流量冲击。场景不同,拆分的技巧各有不同。
我们依次了解了如何用房间方式管理用户资源调配、如何对广播大量刷屏互动进行分流缓冲、如何规避答题的瞬时拉题高峰、如何通过客户端合并多次点赞动作、如何通过多个服务树形结构合并点赞流量压力,以及如何对强一致实现分片、调度等。
因为不同场景对一致性要求不同,所以延伸出来的设计也是各有不同的。
为了实现可动态调配的高并发的直播系统,我们还需要良好的基础建设,具体包括以下方面的支撑:
分布式服务:分布式队列、分布式实时计算、分布式存储。
动态容器服务器统一调度系统、自动化运维、周期压力测试、Kubernetes动态扩容服务。
调度服务通过HttpDNS临时调度用户流量等服务来实现动态的资源调配。
思考题
既然CDN能够缓存我们的静态数据那么它是如何识别到我们本地的静态数据有更新的呢
欢迎你在评论区与我交流讨论,我们下节课见!

View File

@ -0,0 +1,194 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 流量调度DNS、全站加速及机房负载均衡
你好,我是徐长龙。
上节课我们学习了如何从架构设计上应对流量压力,像直播这类的服务不容易预估用户流量,当用户流量增大到一个机房无法承受的时候,就需要动态调度一部分用户到多个机房中。
同时,流量大了网络不稳定的可能性也随之增加,只有让用户能访问就近的机房,才能让他们的体验更好。
综合上述考量,这节课我们就重点聊聊流量调度和数据分发的关键技术,帮你弄明白怎么做好多个机房的流量切换。
直播服务主要分为两种流量一个是静态文件访问一个是直播流这些都可以通过CDN分发降低我们的服务端压力。
对于直播这类读多写多的服务来说动态流量调度和数据缓存分发是解决大量用户在线互动的基础但是它们都和DNS在功能上有重合需要一起配合实现所以在讲解中也会穿插CDN的介绍。
DNS域名解析及缓存
服务流量切换并没有想象中那么简单因为我们会碰到一个很大的问题那就是DNS缓存。DNS是我们发起请求的第一步如果DNS缓慢或错误解析的话会严重影响读多写多系统的交互效果。
那DNS为什么会有刷新缓慢的情况呢这需要我们先了解DNS的解析过程你可以对照下图听我分析
客户端或浏览器发起请求时第一个要请求的服务就是DNS域名解析过程可以分成下面三个步骤
1.客户端会请求ISP商提供的DNS解析服务而ISP商的DNS服务会先请求根DNS服务器-
2.通过根DNS服务器找到.org顶级域名DNS服务器-
3.再通过顶级域名服务器找到域名主域名服务器权威DNS
找到主域名服务器后DNS就会开始解析域名。
一般来说主域名服务器是我们托管域名的服务商提供的而域名具体解析规则和TTL时间都是我们在域名托管服务商管理系统里设置的。
当请求主域名解析服务时主域名服务器会返回服务器所在机房的入口IP以及建议缓存的 TTL时间这时DNS解析查询流程才算完成。
在主域名服务返回结果给ISP DNS服务时ISP的DNS服务会先将这个解析结果按TTL规定的时间缓存到服务本地然后才会将解析结果返回给客户端。在ISP DNS缓存TTL有效期内同样的域名解析请求都会从ISP缓存直接返回结果。
可以预见客户端会把DNS解析结果缓存下来而且实际操作时很多客户端并不会按DNS建议缓存的TTL时间执行而是优先使用配置的时间。
同时途经的ISP服务商也会记录相应的缓存如果我们域名的解析做了改变最快也需要服务商刷新自己服务器的时间通常需要3分钟+TTL时间才能获得更新。
事实上比较糟糕的情况是下面这样:
// 全网刷新域名解析缓存时间
客户端本地解析缓存时间30分钟
+ 市级 ISP DNS缓存时间 30分钟
+ 省级 ISP DNS缓存时间 30分钟
+ 主域名服务商 刷新解析服务器配置耗时 3分钟
+ ... 后续ISP子网情况 略
= 域名解析实际更新时间 93分钟以上
为此很多域名解析服务建议我们的TTL设置在30分钟以内而且很多大型互联网公司会在客户端的缓存上人为地减少缓存时间。如果你设置的时间过短虽然刷新很快但是会导致服务请求很不稳定。
当然93分钟是理想情况根据经验正常域名修改后全国DNS缓存需要48小时才能大部分更新完毕而刷全世界缓存需要72小时所以不到万不得已不要变更主域名解析。
如果需要紧急刷新我建议你购买强制推送解析的服务去刷新主干ISP的DNS缓冲但是这个服务不光很贵而且只能覆盖主要城市主干线个别地区还是会存在刷新缓慢的情况取决于宽带服务商。不过整体来说确实会加快DNS缓存的刷新速度。
DNS刷新缓慢这个问题给我们带来了很多困扰如果我们做故障切换需要三天时间才能够彻底切换显然这会给系统的可用性带来毁灭性打击。好在近代有很多技术可以弥补这个问题比如CDN、GTM、HttpDNS等服务我们依次来看看。
CDN全网站加速
可能你会奇怪“为什么加快刷新DNS缓存和CDN有关系
在讲如何实现CDN加速之前我们先了解下CDN和它的网站加速技术是怎么回事。网站加速对于读多写多的系统很重要一般来说常见的CDN提供了静态文件加速功能如下图
当用户请求CDN服务时CDN服务会优先返回本地缓存的静态资源。
如果CDN本地没有缓存这个资源或者这个资源是动态内容如API接口的话CDN就会回源到我们的服务器从我们的服务器获取资源同时CDN会按我们服务端返回的资源超时时间来刷新本地缓存这样可以大幅度降低我们机房静态数据服务压力节省大量带宽和硬件资源的投入。
除了加速静态资源外CDN还做了区域化的本地CDN网络加速服务具体如下图
CDN会在各大主要省市中部署加速服务机房而且机房之间会通过高速专线实现互通。
当客户端请求DNS做域名解析时所在省市的DNS服务会通过GSLB返回当前用户所在省市最近的CDN机房IP这个方式能大大减少用户和机房之间的网络链路节点数加快网络响应速度还能减少网络请求被拦截的可能。
客户端请求服务的路径效果如下图所示:
如果用户请求的是全站加速网站的动态接口CDN节点会通过 CDN内网用最短最快的网络链路将用户请求转发到我们的机房服务器。
相比客户端从外省经由多个ISP服务商网络转发然后才能请求到服务器的方式这样做能更好地应对网络缓慢的问题给客户端提供更好的用户体验。
而网站做了全站加速后所有的用户请求都会由CDN转发而客户端请求的所有域名也都会指向CDN再由CDN把请求转到我们的服务端。
在此期间如果机房变更了CDN提供服务的IP为了加快DNS缓存刷新可以使用CDN内网DNS的服务该服务由CDN供应商提供去刷新CDN中的DNS缓存。这样做客户端的DNS解析是不变的不用等待48小时域名刷新会更加方便。
由于48小时刷新缓存的问题大多数互联网公司切换机房时都不会采用改DNS解析配置的方式去做故障切换而是依托CDN去做类似的功能。但CDN入口出现故障的话对网站服务影响也是很大的。
国外为了减少入口故障问题配合使用了anycast技术。通过anycast技术就能让多个机房服务入口拥有同样的IP如果一个入口发生故障运营商就会将流量转发到另外的机房。但是国内因为安全原因并不支持anycast技术。
除了CDN入口出现故障的风险外请求流量进入CDN后CDN本地没有缓存回源而且本地网站服务也发生故障时也会出现不能自动切换源到多个机房的问题。所以为了加强可用性我们可以考虑在CDN后面增加GTM。
GTM全局流量管理
在了解GTM和CDN的组合实现之前我先给你讲讲GTM的工作原理和主要功能。
GTM是全局流量管理系统的简称。我画了一张工作原理图帮你加深理解
当客户端请求服务域名时客户端先会请求DNS服务解析请求的域名。而客户端请求主域名DNS服务来解析域名时会请求到 GTM服务的智能解析DNS。
相比传统技术GTM还多了三个功能服务健康监控、多线路优化和流量负载均衡。
首先是服务健康监控功能。GTM会监控服务器的工作状态如果发现机房没有响应就自动将流量切换到健康的机房。在此基础上GTM还提供了故障转移功能也就是根据机房能力和权重将一些用户流量转移到其他机房。
其次是多线路优化功能国内宽带有不同的服务提供商移动、联通、电信、教育宽带不同的宽带的用户访问同提供商的网站入口IP性能最好如果跨服务商访问会因为跨网转发会加大请求延迟。因此使用GTM可以根据不同机房的CDN来源找到更快的访问路径。
GTM还提供了流量负载均衡功能即根据监控服务的流量及请求延迟情况来分配流量从而实现智能地调度客户端的流量。
当GTM和CDN网站加速结合后会有更好的效果具体组合方式如下图所示
由于GTM和CDN加速都是用了CNAME做转发我们可以先将域名指向CDN通过CDN的GSLB和内网为客户端提供网络加速服务。而在CDN回源时请求会转发到GTM解析经过GTM解析DNS后将CDN的流量转发到各个机房做负载均衡。
当我们机房故障时GTM会从负载均衡列表快速摘除故障机房这样既满足了我们的网络加速又实现了多机房负载均衡及更快的故障转移。
不过即使使用了CDN+GTM还是会有一批用户出现网络访问缓慢现象这是因为很多ISP服务商提供的DNS服务并不完美我们的用户会碰到DNS污染、中间人攻击、DNS解析调度错区域等问题。
为了缓解这些问题我们需要在原有的服务基础上强制使用HTTPS协议对外服务同时建议再配合GPS定位在客户端App启用HttpDNS服务。
HttpDNS服务
HttpDNS服务能够帮助我们绕过本地ISP提供的DNS服务防止DNS劫持并且没有DNS域名解析刷新的问题。同样地HttpDNS也提供了GSLB功能。HttpDNS还能够自定义解析服务从而实现灰度或A/B测试。
一般来说HttpDNS只能解决App端的服务调度问题。因此客户端程序如果用了HttpDNS服务为了应对HttpDNS服务故障引起的域名解析失败问题还需要做备选方案。
这里我提供一个解析服务的备选参考顺序一般会优先使用HttpDNS然后使用指定IP的DNS服务再然后才是本地ISP商提供的DNS服务这样可以大幅度提高客户端DNS的安全性。
当然我们也可以开启DNS Sec进一步提高DNS服务的安全性但是上述所有服务都要结合我们实际的预算和时间精力综合决策。
不过HttpDNS这个服务不是免费的尤其对大企业来说成本更高因为很多HttpDNS服务商提供的查询服务会按请求次数计费。
所以为了节约成本我们会设法减少请求量建议在使用App时根据客户端链接网络的IP以及热点名称Wifi、5G、4G作为标识做一些DNS缓存。
业务自实现流量调度
HttpDNS服务只能解决DNS污染的问题但是它无法参与到我们的业务调度中所以当我们需要根据业务做管控调度时它能够提供的支持有限。
为了让用户体验更好互联网公司结合HttpDNS的原理实现了流量调度比如很多无法控制用户流量的直播服务就实现了类似HttpDNS的流量调度服务。调度服务常见的实现方式是通过客户端请求调度服务调度服务调配客户端到附近的机房。
这个调度服务还能实现机房故障转移如果服务器集群出现故障客户端请求机房就会出现失败、卡顿、延迟的情况这时客户端会主动请求调度服务。如果调度服务收到了切换机房的命令调度服务给客户端返回健康机房的IP以此提高服务的可用性。
调度服务本身也需要提高可用性具体做法就是把调度服务部署在多个机房而多个调度机房会通过Raft强一致来同步用户调度结果策略。
我举个例子当一个用户请求A机房的调度时被调度到了北京机房那么这个用户再次请求B机房调度服务时短期内仍旧会被调度到北京机房。除非客户端切换网络或我们的服务机房出现故障才会做统一的流量变更。
为了提高客户端的用户体验我们需要给客户端调配到就近的、响应性能最好的机房为此我们需要一些辅助数据来支撑调度服务分配客户端这些辅助数据包括IP、GPS定位、网络服务商、ping网速、实际播放效果。
客户端会定期收集这些数据,反馈给大数据中心做分析计算,提供参考建议,帮助调度服务更好地决策当前应该链接哪个机房和对应的线路。
其实这么做就相当于自实现了GSLB功能。但是自实现GSLB功能的数据不是绝对正确的因为不同省市的DNS服务解析的结果不尽相同同时如果客户端无法联通需要根据推荐IP挨个尝试来保证服务高可用。
此外为了验证调度是否稳定我们可以在客户端暂存调度结果每次客户端请求时在header中带上当前调度的结果通过这个方式就能在服务端监控有没有客户端错误请求到其他机房的情况。
如果发现错误的请求可以通过机房网关做类似CDN全站加速一样的反向代理转发来保证客户端稳定。
对于直播和视频也需要做类似调度的功能,当我们播放视频或直播时出现监控视频的卡顿等情况。如果发现卡顿过多,客户端应能够自动切换视频源,同时将情况上报到大数据做记录分析,如果发现大规模视频卡顿,大数据会发送警报给我们的运维和研发伙伴。
总结
域名是我们的服务的主要入口请求一个域名时首先需要通过DNS将域名解析成IP。但是太频繁请求DNS的话会影响服务响应速度所以很多客户端、ISP服务商都会对DNS做缓存不过这种多层级缓存直接导致了刷新域名解析变得很难。
即使花钱刷新多个带宽服务商的缓存我们个别区域仍旧需要等待至少48小时才能完成大部分用户的缓存刷新。
如果我们因为网站故障等特殊原因必须切换IP时带来的影响将是灾难性的好在近几年我们可以通过CDN、GTM、HttpDNS来强化我们多机房的流量调度。
但CDN、GTM都是针对机房的调度对业务方是透明的。所以在更重视用户体验的高并发场景中我们会自己实现一套调度系统。
在这种自实现方案中你会发现自实现里的思路和HttpDNS和GSLB的很类似区别在于之前的服务只是基础服务我们自实现的服务还可以快速地帮助我们调度用户流量。
而通过HttpDNS来实现用户切机房切视频流的实现无疑是十分方便简单的只需要在我们App发送请求的封装上更改链接的IP即可实现业务无感的机房切换。
思考题
视频、WebSocket这一类长链接如何动态切换机房
欢迎你在留言区与我交流讨论,我们下节课见!

View File

@ -0,0 +1,166 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 数据引擎:统一缓存数据平台
你好,我是徐长龙。
通过前四章,我们已经了解了不同类型的系统如何优化,其中有哪些关键技术点。不过除了这些基础知识之外,我们还要了解大型互联网是如何设计支撑一个高并发系统的。所以,在这个章节里我精选了几个案例,帮助你打开视野,看看都有哪些实用的内网服务设计。
任何一个互联网公司都会有几个核心盈利的业务,我们经常会给基础核心业务做一些增值服务,以此来扩大我们的服务范围以及构建产业链及产业生态,但是这些增值服务需要核心项目的数据及交互才能更好地提供服务。
但核心系统如果对增值业务系统做太多的耦合适配,就会导致业务系统变得十分复杂,如何能既让增值服务拿到核心系统的资源,又能减少系统之间的耦合?
这节课我会重点带你了解一款内网主动缓存支撑的中间件,通过这个中间件,可以很方便地实现高性能实体数据访问及缓存更新。
回顾临时缓存的实现
我们先回顾下之前展示的临时缓存实现,这个代码摘自之前的第二节课。
// 尝试从缓存中直接获取用户信息
userinfo, err := Redis.Get("user_info_9527")
if err != nil {
return nil, err
}
//缓存命中找到,直接返回用户信息
if userinfo != nil {
return userinfo, nil
}
//没有命中缓存,从数据库中获取
userinfo, err := userInfoModel.GetUserInfoById(9527)
if err != nil {
return nil, err
}
//查找到用户信息
if userinfo != nil {
//将用户信息缓存并设置TTL超时时间让其60秒后失效
Redis.Set("user_info_9527", userinfo, 60)
return userinfo, nil
}
// 没有找到,放一个空数据进去,短期内不再访问数据库
// 可选,这个是用来预防缓存穿透查询攻击的
Redis.Set("user_info_9527", "", 30)
return nil, nil
上述代码演示了临时缓存提高读性能的常用方式即查找用户信息时直接用ID从缓存中进行查找如果在缓存中没有找到那么会从数据库中回源查找数据找到数据后再将数据写入缓存方便下次查询。
相对来说这个实现很简单,但是如果我们所有业务代码都需要去这么写,工作量还是很大的。
即便我们会对这类实现做一些封装,但封装的功能在静态语言中并不是很通用,性能也不好。那有没有什么方式能统一解决这类问题,减少我们的重复工作量呢?
实体数据主动缓存
之前我们在第二节课讲过实体数据最容易做缓存实体数据的缓存key可以设计为前缀+主键ID这种形式 。通过这个设计我们只要拥有实体的ID就可以直接在缓存中获取到实体的数据了。
为了降低重复的工作量,我们对这个方式做个提炼,单独将这个流程做成中间件,具体实现如下图:
结合上图我们分析一下这个中间件的工作原理。我们通过canal来监控MySQL数据库的binlog日志当有数据变更时消息监听端会收到变更通知。
因为变更消息包含变更的表名和所有变更数据的所有主键ID所以这时我们可以通过主键ID回到数据库主库查询出最新的实体数据再根据需要来加工这个数据并将其推送数据到缓存当中。
而从过往经验来看很多刚变动的数据有很大概率会被马上读取。所以这个实现会有较好的缓存命中率。同时当我们的数据被缓存后会根据配置设置一个TTL缓存在一段时间没有被读取的话就会被LRU策略淘汰掉这样还能节省缓存空间。
如果你仔细思考一下,就会发现这个设计还是有缺陷:如果业务系统无法从缓存中拿到所需数据,还是要回数据库查找数据,并且再次将数据放到缓存当中。这和我们设计初衷不一致。为此,我们还需要配套一个缓存查询服务,请看下图:
如上图所示当我们查找缓存时如果没找到数据中间件就会通过Key识别出待查数据属于数据库的哪个表和处理脚本再按配置执行脚本查询数据库做数据加工然后中间件将获取的数据回填到缓存当中最后再返回结果。
为了提高查询效率建议查询服务使用类似Redis的纯文本长链接协议同时还需要支持批量获取功能比如Redis的mget实现。如果我们的数据支撑架构很复杂并且一次查询的数据量很大还可以做成批量并发处理来提高系统吞吐性能。
落地缓存服务还有一些实操的技巧,我们一起看看。
如果查询缓存时数据不存在,会导致请求缓存穿透的问题,请求量很大核心数据库就会崩溃。为了预防这类问题我们需要在缓存中加一个特殊标志,这样查询服务查不到数据时,就会直接返回数据不存在。
我们还要考虑到万一真的出现缓存穿透问题时要如何限制数据库的并发数建议使用SingleFlight合并并行请求无需使用全局锁只要在每个服务范围内实现即可。
有时要查询的数据分布在数据库的多个表内,我们需要把多个表的数据组合起来或需要刷新多个缓存,所以这要求我们的缓存服务能提供定制脚本,这样才能实现业务数据的刷新。
另外,由于是数据库和缓存这两个系统之间的同步,为了更好的排查缓存同步问题,建议在数据库中和缓存中都记录数据最后更新的时间,方便之后对比。
到这里我们的服务就基本完整了。当业务需要按id查找数据时直接调用数据中间件即可获取到最新的数据而无需重复实现开发过程变得简单很多。
L1缓存及热点缓存延期
上面我们设计的缓存中间件已经能够应付大部分临时缓存所需的场景。但如果碰到大并发查询的场景,缓存出现缺失或过期的情况,就会给数据库造成很大压力,为此还需要继续改进这个服务。
改进方式就是统计查询次数判断被查询的key是否是热点缓存。举个例子比如通过时间块异步统计5分钟内缓存key被访问的次数单位时间内超过设定次数根据业务实现设定就是热点缓存。
具体的热点缓存统计和续约流程如下图所示:
对照流程图可以看到热点统计服务获取了被认定是热点的key之后会按统计次数大小做区分。如果是很高频率访问的key会被定期从脚本推送到L1缓存中L1缓存可以部署在每台业务服务器上或每几台业务服务器共用一个L1缓存
当业务查询数据时业务的查询SDK驱动会通过热点key配置检测当前key是否为热点key如果是会去L1缓存获取如果不是热点缓存会去集群缓存获取数据。
而相对频率较高的key热点缓存服务只会定期通知查询服务刷新对应的key或做TTL刷新续期的操作。
当我们被查询的数据退热后我们的数据时间块的访问统计数值会下降这时L1热点缓存推送或TTL续期会停止继续操作不久后数据会TTL过期。
增加这个功能后,这个缓存中间件就可以改名叫做数据缓存平台了,不过它和真正的平台还有一些差距,因为这个平台只能提供实体数据的缓存,无法灵活加工推送的数据,一些业务结构代码还要人工实现。
关系数据缓存
可以看到,目前我们的缓存还仅限于实体数据的缓存,并不支持关系数据库的缓存。
为此我们首先需要改进消息监听服务将它做成Kafka Group Consumer服务同时实现可动态扩容这能提升系统的并行数据处理能力支持更大量的并发修改。
其次,对于量级更高的数据缓存系统,还可以引入多种数据引擎共同提供不同的数据支撑服务,比如:
lua脚本引擎具体可以回顾第十七节课是数据推送的“发动机”能帮我们把数据动态同步到多个数据源
Elasticsearch负责提供全文检索功能
Pika负责提供大容量KV查询功能
ClickHouse负责提供实时查询数据的汇总统计功能
MySQL引擎负责支撑新维度的数据查询。
你有没有发现这几个引擎我们在之前的课里都有涉及唯一你可能感到有点陌生的就是Pika不过它也没那么复杂可以理解成RocksDB的加强版。
这里我没有把每个引擎一一展开,但概括了它们各自擅长的方面。如果你有兴趣深入研究的话,可以自行探索,看看不同引擎适合用在什么业务场景中。
多数据引擎平台
一个理想状态的多数据引擎平台是十分庞大的,需要投入很多人力建设,它能够给我们提供强大的数据查询及分析能力,并且接入简单方便,能够大大促进我们的业务开发效率。
为了让你有个整体认知,这里我特意画了一张多数据引擎平台的架构图,帮助你理解数据引擎和缓存以及数据更新之间的关系,如下图所示:
可以看到这时基础数据服务已经做成了一个平台。MySQL数据更新时会通过我们订阅的变更消息根据数据加工过滤进程将数据推送到不同的引擎当中对外提供数据统计、大数据KV、内存缓存、全文检索以及MySQL异构数据查询的服务。
具体业务需要用到核心业务基础数据时需要在该平台申请数据访问授权。如果还有特殊需要可以向平台提交数据加工lua脚本。高流量的业务甚至可以申请独立部署一套数据支撑平台。
总结
这节课我们一起学习了统一缓存数据平台的实现方案,有了这个中间件,研发效率会大大提高。在使用数据支撑组件之前,是业务自己实现的缓存以及多数据源的同步,需要我们业务重复写大量关于缓存刷新的逻辑,如下图:
而使用数据缓存平台后,我们省去了很多人工实现的工作量,研发同学只需要在平台里做好配置,就能坐享中间件提供的强大多级缓存功能、多种数据引擎提供的数据查询服务,如下图所示:-
我们回顾下中间件的工作原理。首先我们通过Canal订阅MySQL数据库的binlog获取数据的变更消息。然后缓存平台根据订阅变更信息实现触发式的缓存更新。另外结合客户端SDK及缓存查询服务实现热点数据的识别即可实现多级缓存服务。
可以说,数据是我们系统的心脏,如数据引擎能力足够强大,能做的事情会变得更多。数据支撑平台最大的特点在于,将我们的数据和各种数据引擎结合起来,从而实现更强大的数据服务能力。
大公司的核心系统通常会用多引擎组合的方式,共同提供数据支撑数据服务,甚至有些服务的服务端只需做配置就可以得到这些功能,这样业务实现更轻量,能给业务创造更广阔的增值空间。
思考题
L1缓存使用BloomFilter来减少L1缓存查询那么BloomFilter的hash列表如何更新到客户端呢
欢迎你在留言区与我交流讨论,我们下节课见!

View File

@ -0,0 +1,177 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 业务缓存:元数据服务如何实现?
你好,我是徐长龙。
当你随手打开微博或者一个综合的新闻网站,可以看到丰富的媒体文件,图片、文本、音频、视频应有尽有,一个页面甚至可能是由成百上千个文件组合而成。
那这些文件都存在哪里呢通常来说低于1KB的少量文本数据我们会保存在数据库中而比较大的文本或者多媒体文件比如MP4、TS、MP3、JPG、JS、CSS等等我们通常会保存在硬盘当中这些文件的管理并不复杂。
不过如果文件数量达到百万以上,用硬盘管理文件的方式就比较麻烦了,因为用户请求到服务器时,有几十台服务器需要从上百块硬盘中找到文件保存在哪里,还得做好定期备份、统计访问记录等工作,这些给我们的研发工作带来了很大的困扰。
直到出现了对象存储这种技术,帮我们屏蔽掉了很多细节,这大大提升了研发效率。这节课,我们就聊聊存储的演变过程,让你对服务器存储和对象存储的原理和实践有更深的认识。
分布式文件存储服务
在讲解对象存储之前,我们先了解一下支撑它的基础——分布式文件存储服务,这也是互联网媒体资源的数据支撑基础。
我们先来具体分析一下,分布式文件存储提供了什么功能,以及数据库管理文件都需要做哪些事儿。因为数据库里保存的是文件路径,在迁移、归档以及副本备份时,就需要同步更新这些记录。
当文件数量达到百万以上,为了高性能地响应文件的查找需求,就需要为文件索引信息分库分表,而且还需要提供额外的文件检索、管理、访问统计以及热度数据迁移等服务。
那么这些索引和存储具体是如何工作的呢?请看下图:
我们从上图也能看出光是管理好文件的索引这件事研发已经疲于奔命了更不要说文件存储、传输和副本备份工作这些工作更加复杂。在没有使用分布式存储服务之前实现静态文件服务时我们普遍采用Nginx + NFS 挂载NAS这个方式实现但是该方式缺点很明显文件只有一份而且还需要人工定期做备份。
为了在存储方面保证数据完整性,提高文件服务的可用性,并且减少研发的重复劳动,业内大多数选择了用分布式存储系统来统一管理文件分发和存储。通过分布式存储,就能自动实现动态扩容、负载均衡、文件分片或合并、文件归档,冷热点文件管理加速等服务,这样管理大量的文件的时候会更方便。
为了帮助你理解常见的分布式存储服务是如何工作的我们以FastDFS分布式存储为例做个分析请看下图
其实,分布式文件存储的方案也并不是十全十美的。
就拿FastDFS来说它有很多强制规范比如新保存文件的访问路径是由分布式存储服务生成的研发需要维护文件和实际存储的映射。这样每次获取要展示的图片时都需要查找数据库或者为前端提供一个没有规律的hash路径这样一来如果文件丢失的话前端都不知道这个文件到底是什么。
FastDFS生成的文件名很难懂演示路径如下所示
# 在网上找的FastDFS生成的演示路径
/group1/M00/03/AF/wKi0d1QGA0qANZk5AABawNvHeF0411.png
相信你一定也发现了,这个地址很长很难懂,这让我们管理文件的时候很不方便,因为我们习惯通过路径层级归类管理各种图片素材信息。如果这个路径是/active/img/banner1.jpg相对就会更好管理。
虽然我只是举了一种分布式存储系统,但其他分布式存储系统也会有这样那样的小问题。这里我想提醒你注意的是,即便用了分布式存储服务,我们的运维和研发工作也不轻松。
为什么这么说呢?根据我的实践经验,我们还需要关注以下五个方面的问题:
1.磁盘监控监控磁盘的寿命、容量、inode剩余同时我们还要故障监控警告及日常维护
2.文件管理:使用分布式存储控制器对文件做定期、冷热转换、定期清理以及文件归档等工作。
3.确保服务稳定:我们还要关注分布式存储副本同步状态及服务带宽。如果服务流量过大,运维和研发还需要处理好热点访问文件缓存的问题。
4.业务定制化一些稍微个性点的需求比如在文件中附加业务类型的标签、增加自动TTL清理现有的分布式存储服务可能无法直接支持还需要我们阅读相关源码进一步改进代码才能实现功能。
5.硬件更新:服务器用的硬盘寿命普遍不长,特别是用于存储服务的硬盘,需要定期更换(比如三年一换)。
对象存储
自从使用分布式存储后,再回想过往的经历做总结时,突然觉得磁盘树形的存储结构,给研发带来很多额外的工作。比如,挂载磁盘的服务,需要在上百台服务器和磁盘上提供相对路径和绝对路径,还要有能力提供文件检索、遍历功能以及设置文件的访问权限等。
这些其实属于管理功能,并不是我们对外业务所需的高频使用的功能,这样的设计导致研发投入很重,已经超出了研发本来需要关注的范围。
这些烦恼在使用对象存储服务后,就会有很大改善。对象存储完美解决了很多问题,这个设计优雅地屏蔽了底层的实现细节,隔离开业务和运维的工作,让我们的路径更优雅简单、通俗易懂,让研发省下更多时间去关注业务。
对象存储的优势具体还有哪些?我主要想强调后面这三个方面。
首先,从文件索引来看。在对象存储里,树形目录结构被弱化,甚至可以说是被省略了。
之所以说弱化意思是对象存储里树形目录结构仍然可以保留。当我们需要按树形目录结构做运维操作的时候可以利用前缀检索对这些Key进行前缀检索从而实现目录的查找和管理。整体使用起来很方便不用担心数据量太大导致索引查找缓慢的问题。
我想强调一下对象存储并不是真正按照我们指定的路径做存储的实际上文件的路径只是一个key。当我们查询文件对象时实际上是做了一次hash查询这比在数据库用字符串做前缀匹配查询快得多。而且由于不用维护整体树索引KV方式在查询和存储上都快了很多还更容易做维护。
其次读写管理也从原先的通过磁盘文件管理改成了通过API方式管理文件对象经过这种思路简化后的接口方式会让数据读写变得简单使用起来更灵活方便不用我们考虑太多磁盘相关的知识。
另外,对象存储还提供了文件的索引管理与映射,管理数据和媒体文件有了更多可能。在之前我们的文件普遍是图片、音频、视频文件,这些文件普遍对于业务系统来说属于独立的存在,结合对象存储后,我们就可以将一些数据当作小文件管理起来。
但是,如果把数据放到存储中,会导致有大量的小文件需要管理,而且这些小文件很碎,需要更多的管理策略和工具。我们这就来看看对象存储的思路下,如何管理小文件。
对象存储如何管理小文件
前面我提过对象存储里实际的存储路径已经变成了hash方式存储。为此我们可以用一些类似RESTful的思路去设计我们的对象存储路径
user\info\9527.json 保存的是用户的公共信息
user\info\head\9527.jpg是我们的对应用户的头像
product\detail\4527.json 直接获取商品信息
可以看到,通过这个设计,我们无需每次请求都访问数据库,就可以获取特定对象的信息,前端只需要简单拼接路径就能拿到所有所需文件,这样的方式能帮我们减少很多缓存的维护成本。
看到这里,你可能有疑问:既然这个技巧十分好用,那么为什么这个技巧之前没有普及?
这是因为以前的实现中请求访问的路径就是文件实际物理存储的路径而对于Linux来说一个目录下文件无法放太多文件如果放太多文件会导致很难管理。就拿上面的例子来说如果我们有300W个用户。把300W个头像文件放在同一个目录这样哪怕是一个ls命令都能让服务器卡住十分钟。
为了避免类似的问题很多服务器存储这些小文件时会用文件名做hash后取hash结果最后四位作为双层子目录名以此来保证一个目录下不会存在太多文件。但是这么做需要通过hash计算前端用起来十分不便而且我们平时查找、管理磁盘数据也十分痛苦所以这个方式被很多人排斥。
不过,即使切换到了分布式存储服务,小文件存储这个问题还是让我们困扰,因为做副本同步和存储时都会以文件为单位来进行。如果文件很小,一秒上传上千个,做副本同步时会因为大量的分配调度请求出现延迟,这样也很难管理副本同步的流量。
为了解决上述问题,对象存储和分布式存储服务对这里做了优化,小文件不再独立地保存,而是用文件块方式压缩存储多个文件。
文件块管理示意图如下所示:
比如把100个文件压缩存储到一个10M大小的文件块里统一管理比直接管理文件简单很多。不过可以预见这样数据更新会麻烦为此我们通常会在小文件更新数据时直接新建一个文件来更新内容。定期整理数据的时候才会把新老数据合并写到新的块里清理掉老数据。
这里顺便提示一句,大文件你也可以使用同样的方式,切成多个小文件块来管理,这样更方便。
对象存储如何管理大文本
前面我们讲了对象存储在管理小文件管理时有什么优势,接下来我们就看看对象存储如何管理大文本,这个方式更抽象地概括,就是用对象存储取代缓存。
什么情况下会有大文本的管理需求呢?比较典型的场景就是新闻资讯网站,尤其是资讯量特别丰富的那种,常常会用对象存储直接对外提供文本服务。
这样设计主要是因为1KB大小以上的大文本其实并不适合放在数据库或者缓存里这是为什么呢我们结合后面的示意图分析一下。
如上图,左边是我们通过缓存提供数据查询服务的常见方式,右图则是通过对象存储的方式,从结构上看,对象存储使用及维护更方便简单。
我们来估算一下成本。先算算带宽需求假定我们的请求访问量是1W QPS那么1KB的数据缓存服务就需要 1KB X 10000 QPS 约等于 10MB X 8网卡单位转换bit= 80MB/s 网络带宽单位的外网带宽。为了稍微留点余地这样我们大概需要100MB/s大小的带宽。另外我们还需要多台高性能服务器和一个大容量的缓存集群才能实现我们的服务。
这么一算是不是感觉成本挺高的?像资讯类网站这种读多写少的系统,不能降低维护成本,就意味着更多的资源投入。我们常见的解决方法就是把资讯内容直接生成静态文件,不过这样做流量成本是控制住了,但运维和开发成本又增高了,还有更好的方法么?
相比之下,用对象存储来维护资源的具体页面这个方式更胜一筹。
我们具体分析一下主要过程所有的流量会请求到云厂商的对象存储服务并且由CDN实现缓存及加速。一旦CDN找不到待查文件时就会回源到对象存储查找如果对象存储也找不到则回源到服务端生成。这样可以大大降低网络流量压力如果配合版本控制功能还能回退文件的历史版本提高服务可用性。
这里我再稍微补充一下实践细节。如果资讯有阅读权限限制比如只有会员才能阅读。我们可以对特定对象设置权限只有用短期会失效的token才可以读取文件的内容。
文件的云中转
除了服务端提供数据供用户下载的方式以外,还有一种实现比较普遍,就是用户之间交换数据。
比如A用户传递给B用户一个文件正常流程是通过TCP将两个客户端链接或通过服务端中转但是这样的方式传输效率都很低。
而使用对象存储的话就能快速实现文件的传输交换。主要过程是这样的文件传输服务给文件发送方生成一个临时授权token再将这个文件上传到对象存储上传成功后接收方通过地址即可获取到授权token进行多线程下载而临时文件过期后就会自动清除。
事实上,这个方式不仅仅可以给用户交换数据,我们的业务也可以通过对象存储,实现跨机房数据交换和数据备份存储。
很多提供对象服务的厂商已经在客户端SDK内置了多线程分片上传下载、GSLB就近CDN线路优化上传加速的功能使用这类服务能大大加快数据传输的速度。
最后,再提一句容灾,可以说大部分对象存储服务的服务商都提供了容灾服务,我们所有的数据都可以开启同城做双活备份、全球加速、灾难调度、主备切换等功能。
总结
这节课我们一起学习了对象存储。通过和传统存储方式的对比,不难发现对象存储的优势所在。首先它的精简设计彻底屏蔽了底层实现细节,降低了磁盘维护的运维投入和维护成本。
我们可以把一些经常读取的数据从数据库挪到对象存储中通过CDN和本地缓存实现来降低成本综合应用这些经典设计会帮我们节约大量的时间和资源。
希望这节课激发你对对象存储的探索兴趣。行业里常用的对象存储项目包括阿里云的OSS腾讯的COS华为云的OBS开源方面有Ceph、MinIO等项目。
通过了解这些项目,你会对存储行业的未来发展趋势有更深入的认识。事实上,这个行业开始专注于为大型云服务厂商提供大型高速存储的服务,这样的集中管理会更加节省成本。
最后,我还为你整理了一个表格,帮你从多个维度审视不同存储技术的特点:
可以看到它们的设计方向和理念不同NFS偏向服务器的服务分布式存储偏向存储文件的管理而对象存储偏向业务的应用。
思考题
分布式存储通过文件块作为单位来保存管理小文件,当我们对文件内容进行更新时,如何刷新这个文件的内容呢?
今天的这节课就到这里,期待和你在留言区里交流。

View File

@ -0,0 +1,232 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 存储成本:如何推算日志中心的实现成本?
你好,我是徐长龙。
前面我们比较过很多技术,细心的你应该发现了,比较时我们常常会考虑实现成本这一项。这是因为技术选型上的“斤斤计较”,能够帮我们省下真金白银。那么你是否系统思考过,到底怎么计算成本呢?
这节课,我会结合日志中心的例子带你计算成本。
之所以选日志中心,主要有这两方面的考虑:一方面是因为它重要且通用,作为系统监控的核心组件,几乎所有系统监控和故障排查都依赖日志中心,大部分的系统都用得上;另一方面日志中心是成本很高的项目,计算也比较复杂,如果你跟着我把课程里的例子拿下了,以后用类似思路去计算其他组件也会容易很多。
根据流量推算存储容量及投入成本
在互联网服务中,最大的变数就在用户流量上。相比普通的服务,高并发的系统需要同时服务的在线人数会更多,所以对这类系统做容量设计时,我们就需要根据用户请求量和同时在线人数,来推算系统硬件需要投入多少成本。
很多系统在初期会用云服务实现日志中心但核心接口流量超过10W QPS后很多公司就会考虑自建机房去实现甚至后期还会持续改进日志中心自己制作一些个性化的服务。
其实,这些优化和实现本质上都和成本息息相关。这么说你可能不太理解,所以我们结合例子,实际算算一个网站的日志中心存储容量和成本要怎么计算。
通常来说一个高并发网站高峰期核心API的QPS在30W左右我们按每天8个小时来计算并且假定每次核心接口请求都会产生1KB日志这样的话每天的请求量和每天的日志数据量就可以这样计算
每天请求量=3600秒 X 8 小时 X 300000 QPS = 8 640 000 000次请求/天 = 86亿次请求/天
每天日志数据量8 640 000 000 X 1KB => 8.6TB/天
你可能奇怪,这里为什么要按每天 8 小时 计算?这是因为大多数网站的用户访问量都很有规律,有的网站集中在上下班时间和夜晚,有的网站访问量集中在工作时间。结合到一人一天只有 8 小时左右的专注时间,就能推导出一天按 8 小时计算比较合理。
当然这个数值仅供参考,不同业务表现会不一样,你可以根据这个思路,结合自己的网站用户习惯来调整这个数值。
我们回到刚才的话题根据上面的算式可以直观看到如果我们的单次请求产生1KB日志的话那么每天就有8T的日志需要做抓取、传输、整理、计算、存储等操作。为了方便追溯问题我们还需要设定日志保存的周期这里按保存30天计算那么一个月日志量就是258TB大小的日志需要存储计算公式如下
8.6TB X 30天 = 258 TB /30天
从容量算硬盘的投入
算完日志量,我们就可以进一步计算购买硬件需要多少钱了。
我要提前说明的是,硬件价格一直是动态变化的,而且不同商家的价格也不一样,所以具体价格会有差异。这里我们把重点放在理解整个计算思路上,学会以后,你就可以结合自己的实际情况做估算了。
目前常见的服务器硬盘8 TB、7200转、3.5寸)的单价是 2300元 8 TB硬盘的实际可用内存为7.3 TB结合前面每月的日志量就能算出需要的硬盘个数。计算公式如下
258 TB/7.3 TB = 35.34 块
因为硬盘只能是整数所以需要36块硬盘。数量和单价相乘就得到了购入硬件的金额
2300元 X 36 = 82800元
为了保证数据的安全以及加强查询性能我们常常会通过分布式存储服务将数据存三份那么分布式存储方案下用单盘最少需要108 块硬盘,那么可以算出我们需要的投入成本是:
82800 X 3 个数据副本 = 24.8W 元
如果要保证数据的可用性,硬盘需要做 Raid5。该方式会把几个硬盘组成一组对外服务其中一部分用来提供完整容量剩余部分用于校验。不过具体的比例有很多种为了方便计算我们选择的比例是这样的按四个盘一组且四个硬盘里有三个提供完整容量另外一个做校验。
Raid5方式中计算容量的公式如下
单组raid5容量=((n-1)/n) * 总磁盘容量其中n为硬盘数
我们把硬盘数代入到公式里,就是:
((4-1)/4) X (7.3T X 4) = 21.9 T = 三块8T 硬盘容量
这个结果表示一组Raid5四个硬盘有三个能提供完整容量由此不难算出我们需要的容量还要再增加1/4
108 / 3 = 36块校验盘
最终需要的硬盘数量就是 108块 + 36块Raid5校验硬盘 = 144块硬盘每块硬盘2300元总成本是
144 X 2300元 = 331200元
为了计算方便之后我们取整按33W元来计算。
除了可用性,还得考虑硬盘的寿命。因为硬盘属于经常坏的设备,一般连续工作两年到三年以后,会陆续出现坏块,由于有时出货缓慢断货等原因以及物流问题,平时需要常备 40 块左右的硬盘大部分公司会常备硬盘总数的三分之一用于故障替换大致需要的维护成本是2300元 X 40 = 92000 元。
到目前为止。我们至少需要投入的硬件成本就T是一次性硬盘购买费用加上维护费用即33 + 9.2 = 42W元。
根据硬盘推算服务器投入
接下来,我们还需要计算服务器的相关成本。由于服务器有多个规格,不同规格服务器能插的硬盘个数是不同的,情况如下面列表所示:
普通 1u 服务器 能插 4个 3.5 硬盘 、SSD硬盘 2 个
普通 2u 服务器 能插 12个 3.5 硬盘 、SSD硬盘 6 个
上一环节我们计算过了硬盘需求,做 Raid5的情况下需要144块硬盘。这里如果使用2u服务器那么需要的服务器数量就是12台144块硬盘/12 = 12台
我们按一台服务器3W元的费用来计算服务器的硬件投入成本就是36W元计算过程如下
12台服务器 X 3W = 36W元
这里说个题外话,同样数据的副本要分开在多个机柜和交换机分开部署,这么做的目的是提高可用性。
根据服务器托管推算维护费用
好,咱们回到计算成本的主题上。除了购买服务器,我们还得算算维护费用。
把2u服务器托管在较好的机房里 每台服务器托管的费用每年大概是 1W元。前面我们算过服务器需要12台那么一年的托管费用就是 12W元。
现在我们来算算第一年的投入是多少,这个投入包括硬盘的投入及维护费用、服务器的硬件费用和托管费用,以及宽带费用。计算公式如下:
第一年投入费用 = 42W硬盘新购与备用盘+ 36W服务器一次性投入+ 12W服务器托管费+ 10W宽带费用= 100W元
而后续每年维护费用,包括硬盘替换费用(假设都用完)、服务器的维护费用和宽带费用。计算过程如下:
9.2W(备用硬盘)+12W一年托管+10W一年宽带=31.2W元
根据第一年投入费用和后续每年的维护费用我们就可以算出核心服务30W QPS的网站服务运转三年所需要的成本计算过程如下
31.2W X 2年 = 62.4W + 第一年投入 100W = 162.4W 元
当然这里的价格并没有考虑大客户购买硬件的折扣、服务容量的冗余以及一些网络设备、适配卡等费用以及人力成本。但即便忽略这些算完前面这笔账再想想用2000台服务器跑ELK的场景相信你已经体会到多写一行日志有多么贵了。
服务器采购冗余
接下来,我们再聊聊采购服务器要保留冗余的事儿,这件事儿如果没亲身经历过,你可能很容易忽略。
如果托管的是核心机房,我们就需要关注服务器采购和安装周期。因为很多核心机房常常缺少空余机柜位,所以为了给业务后几年的增长做准备,很多公司都是提前多买几台备用。之前有的公司是按评估出结果的四倍来准备服务器,不过不同企业增速不一样,冗余比例无法统一。
我个人习惯是根据当前流量增长趋势评估出的3年的服务器预购数量。所以回想之前我们计算的服务器费用只是算了系统计算刚好够用的流量这么做其实是已经很节俭了。实际你做估算的时候一定要考虑好冗余。
如何节省存储成本?
一般来说,业务都有成长期,当我们业务处于飞速发展、快速迭代的阶段,推荐前期多投入硬件来支撑业务。当我们的业务形态和市场稳定后,就要开始琢磨如何在保障服务的前提下降低成本的问题。
临时应对流量方案
如果在服务器购买没有留冗余的情况下,服务流量增长了,我们有什么暂时应对的方式呢?
我们可以从节省服务器存储量或者降低日志量这两个思路入手,比如后面这些方式:
减少我们保存日志的周期,从保存 30 天改为保存 7 天,可以节省四分之三的空间;
非核心业务和核心业务的日志区分开,非核心业务只存 7 天,核心业务则存 30 天;
减少日志量,这需要投入人力做分析。可以适当缩减稳定业务的排查日志的输出量;
如果服务器多或磁盘少,服务器 CPU压力不大数据可以做压缩处理可以节省一半磁盘
上面这些临时方案,确实可以解决我们一时的燃眉之急。不过在节约成本的时候,建议不要牺牲业务服务,尤其是核心业务。接下来,我们就来讨论一种特殊情况。
如果业务高峰期的流量激增远超过30W QPS就有更多流量瞬间请求尖峰或者出现大量故障的情况。这时甚至没有报错服务的日志中心也会被影响开始出现异常。
高峰期日志会延迟半小时,甚至是一天,最终后果就是系统报警不及时,即便排查问题,也查不到实时故障情况,这会严重影响日志中心的运转。
出现上述情况,是因为日志中心普遍采用共享的多租户方式,隔离性很差。这时候个别系统的日志会疯狂报错,占用所有日志中心的资源。为了规避这种风险,一些核心服务通常会独立使用一套日志服务,和周边业务分离开,保证对核心服务的及时监控。
高并发写的存储冷热分离
为了节省成本,我们还可以从硬件角度下功夫。如果我们的服务周期存在高峰,平时流量并不大,采购太多服务器有些浪费,这时用一些高性能的硬件去扛住高峰期压力,这样更节约成本。
举例来说单个磁盘的写性能差不多是200MB/S做了Raid5后单盘性能会折半这样的话写性能就是100MB/S x 一台服务器可用9块硬盘=900MB/S的写性能。如果是实时写多读少的日志中心系统这个磁盘吞吐量勉强够用。
不过。要想让我们的日志中心能够扛住极端的高峰流量压力,常常还需要多做几步。所以这里我们继续推演,如果实时写流量激增,超过我们的预估,如何快速应对这种情况呢?
一般来说,应对这种情况我们可以做冷热分离,当写需求激增时,大量的写用 SSD扛冷数据存储用普通硬盘。如果一天有 8 TB 新日志,一个副本 4 台服务器,那么每台服务器至少要承担 2 TB/天 存储。
一个1TB 实际容量为960G、M.2口的SSD硬盘单价是1800元顺序写性能大概能达到35GB/s大致数据
每台服务器需要买两块SSD硬盘总计 24个 1 TB SSD 另外需要配适配卡这里先不算这个成本了。算下来初期购买SSD的投入是43200元计算过程如下
1800 元 X 12 台服务器 X 2 块SSD = 43200 元
同样地SSD也需要定期更换寿命三年左右每年维护费是 1800 X 8 = 14400 元
这里我额外补充一个知识SSD除了可以提升写性能还可以提升读性能一些分布式检索系统可以提供自动冷热迁移功能。
需要多少网卡更合算
通过加SSD和冷热数据分离就能延缓业务高峰日志的写压力。不过当我们的服务器磁盘扛住了流量的时候还有一个瓶颈会慢慢浮现那就是网络。
一般来说,我们的内网速度并不会太差,但是有的小的自建机房内网带宽是万兆的交换机,服务器只能使用千兆的网卡。
理论上,千兆网卡传输文件速度是 1000mbps/8bit= 125MB/s换算单位为 8 mbps = 1MB/s。不过实际上无法达到理论速度千兆的网卡实际测试传输速度大概是100MB/s左右所以当我们做一些比较大的数据文件内网拷贝时网络带宽往往会被跑满。
更早的时候为了提高网络吞吐会采用诸如多网卡接入交换机后服务器做bond的方式提高网络吞吐。
后来光纤网卡普及后现在普遍会使用万兆光接口网卡这样传输性能更高能达到1250MB/s10000mbps/8bit = 1250MB/s同样实际速度无法达到理论值实际能跑到 900MB/s 左右,即 7200 mbps。
再回头来看,之前提到的高峰期日志的数据吞吐量是多少呢?是这样计算的:
30W QPS * 1KB = 292.96MB/s
刚才说了千兆网卡速度是100MB/s这样四台服务器分摊勉强够用。但如果出现多倍的流量高峰还是不够用所以还是要升级下网络设备也就是换万兆网卡。
不过万兆网卡要搭配更好的三层交换机使用,才能发挥性能,最近几年已经普及这种交换机了,也就是基础建设里就包含了交换机的成本,所以这里不再专门计算它的投入成本。
先前计算硬件成本时,我们说过每组服务器要存三个副本,这样算起来有三块万兆光口网卡就足够了。但是为了稳定,我们不会让网卡跑满来对外服务,最佳的传输速度大概保持在 300500 MB/s就可以了其他剩余带宽留给其他服务或应急使用。这里推荐一个限制网络流量的配置——QoS你有兴趣可以课后了解下。
12台服务器分3组副本每个副本存一份全量数据每组4台服务器每台服务器配置1块万兆网卡那么每台服务器平时的网络吞吐流量就是
292.96MB/s (高峰期日志的数据吞吐量) / 4台服务器 = 73MB/S
可以说用万兆卡只需十分之一即可满足日常的日志传输需求如果是千兆网卡则不够。看到这你可能有疑问千兆网卡速度不是100MB/s刚才计算吞吐流量是73MB/s为什么说不够呢
这是因为我们估算容量必须留有弹性,如果用千兆网卡,其实是接近跑满的状态,一旦稍微有点波动就会卡顿,严重影响到系统的稳定性。
另一方面,实际使用的时候,日志中心不光是满足基础的业务使用,承担排查问题的功能,还要用来做数据挖掘分析,否则投入这么大的成本建设日志中心,就有些得不偿失了。
我们通常会利用日志中心的闲置资源,用做限速的大数据挖掘。联系这一点,相信你也就明白了,我们为什么要把日志保存三份。其实目的就是通过多个副本来提高并发计算能力。不过,这节课我们的重点是演示如何计算成本,所以这里就点到为止了,有兴趣的话,你可以课后自行探索。
总结
这节课我们主要讨论了如何通过请求用户量评估出日志量,从而推导计算出需要多少服务器和费用。
你可以先自己思考一下,正文里的计算过程还有什么不足。
其实这个计算只是满足了业务现有的流量。现实中做估算会更加严谨综合更多因素比如我们在拿到当前流量的计算结果后还要考虑后续的增长。这是因为机房的空间有限如果我们不能提前半年规划出服务器资源情况之后一旦用户流量增长了却没有硬件资源就只能“望洋兴叹”转而用软件优化方式去硬扛突发de 情况。
当然了,根据流量计算硬盘和服务器的投入,只是成本推算的一种思路。如果是大数据挖掘我们还需要考虑CPU、内存、网络的投入以及系统隔离的成本。
不同类型的系统,我们的投入侧重点也是不一样的。比如读多写少的服务要重点“堆“内存和网络;强一致服务更关注系统隔离和拆分;写多读少的系统更加注重存储性能优化;读多写多的系统更加关注系统的调度和系统类型的转变。
尽管技术决策要考虑的因素非常多,我们面临的业务和团队情况也各有不同。但通过这节课,我希望能让你掌握成本推算的思维,尝试结合计算来指导我们的计算决策。当你建议团队自建机房,或者建议选择云服务的时候,如果有一套这样的计算做辅助,相信方案通过的概率也会有所提升。
思考题
1.建设日志中心,使用云厂商的服务贵还是自己建设的贵?
2.大数据挖掘服务如何计算成本?
期待你在留言区和我交流互动,也推荐你把这节课分享给更多同事、朋友。我们下节课见!

View File

@ -0,0 +1,150 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 网关编程:如何通过用户网关和缓存降低研发成本?
你好,我是徐长龙。
如果说用户的流量就像波涛汹涌的海浪那网关就是防御冲击的堤坝。在大型的互联网项目里网关必不可少是我们目前最好用的防御手段。通过网关我们能把大量的流量分流到各个服务上如果配合使用Lua脚本引擎提供的一些能力还能大大降低系统的耦合度和性能损耗节约我们的成本。
一般来说网关分为外网网关和内网网关。外网网关主要负责做限流、入侵预防、请求转发等工作常见方式是使用Nginx + Lua做类似的工作而最近几年内网网关发展出现了各种定制功能的网关比如ServiceMesh、SideCar等方式以及类似Kong、Nginx Unit等它们的用途虽然有差异但是主要功能还是做负载均衡、流量管理调度和入侵预防这些工作。
那么网关到底提供了哪些至关重要的功能支持呢?这节课我们就来分析分析。
外网网关功能
我们先从外网网关的用法说起,我会给你分享两类外网网关的实用设计,两个设计可以帮助我们预防入侵和接触业务的依赖。
蜘蛛嗅探识别
流量大一些的网站都有过网站被攻击、被蜘蛛抓取,甚至被黑客入侵的经历。有了网关,我们就能实现限速和入侵检测功能,预防一些常见的入侵。
这里我主要想和你分享一下,非法引用和机器人抓取这两类最常见、也最严重的问题要如何应对。
一般来说常见的非法使用会大量引用我们的网络资源。对此可以用检测请求refer方式来预防如果refer不是本站域名就拒绝用户请求这种方式可以降低我们的资源被非法使用的风险。
另一类问题就是机器人抓取。识别机器人抓取我们需要一些小技巧。
首先是划定范围一般这类用户有两种一种是匿名的用户请求我们需要根据IP记录统计请求排行时间块分析请求热点IP请求频率过高的IP会被筛选关注另外一种是登录用户这种我们用时间块统计记录单个用户的请求次数及频率超过一定程度就拒绝请求同时将用户列入怀疑名单方便后续进一步确认。
想要确认怀疑名单中用户的行为。具体怎么实现呢?这里我给你分享一个误判概率比较低的技巧。
我们可以在被怀疑用户请求时通过网关对特定用户或IP动态注入JS嗅探代码这个代码会在Cookie及LocalStorage内写入特殊密文。
我们的前端JS代码检测到密文后就会进入反机器人模式。反机器人模式可以识别客户端是否有鼠标移动及点击动作以此判断用户是否为机器人。确认用户没问题以后才会对服务端发送再次签名的密文请求解锁。如果客户端一直没有回馈就自动将怀疑用户列为准备封禁的用户并封禁该请求当一个IP被封禁的请求达到一定程度就会进行封禁。
不过这种设计有一个缺点——对SEO很不友好各大搜索引擎的机器人都会被拒绝。我们之前的做法是用白名单方式避免机器人被阻拦具体会根据机器人的UserAgent放行各大引擎的机器人并定期人工审核确认搜索引擎机器人的IP。
除此之外,对于一些核心重要的接口,我们可以增加“必须增加带时间的签名,方可请求,否则拒绝服务”这样的规则,来避免一些机器人抓取。
网关鉴权与用户中心解耦
刚才我分享了如何利用网关来阻挡一些非法用户骚扰的技巧,其实网关除了防御攻击、避免资源被恶意消耗的作用外,还能帮我们解除一些业务依赖。
还记得我们第三节课提到的用户登陆设计么每个业务可以不依赖用户中心来验证用户合法性用户鉴权普遍会通过每个子业务集成用户中心的SDK来实现校验逻辑统一。
不过这也牵扯到一个问题那就是SDK同步依赖升级问题。基础公共组件通常会提供SDK这样做业务开发更加方便而仅仅通过API提供服务的话有一些特殊的操作就需要重复实现但是这个SDK一旦放出我们后续就要做好同时维护多个版本SDK在线工作的心理准备。
下图是第三节课用SDK鉴权token方式以及通过用户中心接口鉴权的效果-
如上图集成SDK可以让业务自行校验用户身份而无需请求用户中心但是SDK会有多个版本后续用户中心升级会碰到很大阻力因为要兼顾我们所有的“用户”业务。
SDK属于植入对方项目内的组件为了确保稳定性很多项目不会频繁升级修改组件的版本这导致了用户中心很难升级。每一次基础服务的大升级都需要大量的人力配合同步更新服务的SDK加大了项目的维护难度。
那么除了使用SDK以外还有什么方式能够避免这种组件的耦合呢这里我分享一种有趣的设计那就是把用户登陆鉴权的功能放在网关。
我用画图的方式描述了请求过程,你可以对照示意图听我继续分析。
结合上图,我们来看看这个实现的工作流程。用户业务请求发到业务接口时,首先会由网关来鉴定请求用户的身份。
如果鉴定通过用户的信息就会通过header传递给后面的服务而业务的API无需关注用户中心的实现细节只需接收header中附带的用户信息即可直接工作。如果业务上还要求用户必须登录才能使用我们可以在业务中增加一个对请求header是否有uid的判断。如果没有uid则给前端返回统一的错误码提醒用户需要先登陆。
不难看出,这种鉴权服务的设计,解耦了业务和用户中心这两个模块。用户中心如果有逻辑变更,也不需要业务配合升级。
除了常见的登陆鉴权外我们可以对一些域名开启RBAC服务根据不同业务的需要定制不同的RBAC、ABAC服务并且通过网关对不同的用户开启不同的权限以及灰度测试等功能。
内网网关服务
了解了外网的两种妙用,我们再看看内网的功能。它可以提供失败重试服务和平滑重启机制,我们分别来看看。
失败重试
当我们的项目发布升级期间需要重启或者发生崩溃的故障服务会短暂不可用。这时如果有用户发出服务请求会因为后端没有响应返回504错误这样用户体验很不好。
面对这种情况我们可以利用内网网关的自动重试功能这样在请求发到后端并且服务返回500、403或504错误时网关不会马上返回错误而是让请求等待一会儿后再次重试或者直接返回上次的缓存内容。这样就能实现业务热更新的平滑升级让服务看起来更稳定用户也不会对线上升级产生明显感知。
平滑重启
接下来,我再说说平滑重启的机制。
在我们的服务升级时可以不让服务进程收到kill信号后直接退出而是制作平滑重启功能即先让服务停止接收新的请求等待之前的请求处理完成如果等待超过10秒则直接退出。
通过这个机制,用户请求处理就不会被中断,这样就能保证正在处理中的业务事务是完整的,否则很有可能会导致业务事务不一致,或只做了一半的情况。
有了这个重试和平滑重启的机制后,我们可以随时在线升级发布我们的代码,发布新的功能。不过开启这个功能后,可能会屏蔽一些线上的故障,这时候可以配合网关服务的监控,来帮我们检测系统的状态。
内外网关综合应用
前面我们说了外网网关和内网网关独立提供的功能,接下来我们再看看二者的综合应用。
服务接口缓存
首先来看网关接口缓存功能,也就是利用网关实现一些接口返回内容的缓存,适合用在服务降级场景,用它短暂地缓解用户流量的冲击,或者用于降低内网流量的冲击。
具体实现如下图所示:
结合上图,我们可以看到,网关实现的缓存基本都是用临时缓存 + TTL 方式实现的。当用户请求服务端时被缓存的API如果之前已经被请求过并且缓存还没有过期的话就会直接返回缓存内容给客户端。这个方式能大大降低后端的数据服务压力。
不过每一种技术选择都是反复权衡的结果这个方式是牺牲了数据的强一致性才实现的。另外这个方式对缓存能力的性能要求比较高必须保证网关缓存可以扛得住外网流量的QPS。
如果想预防穿透流量过多,也可以通过脚本定期刷新缓存数据,网关查到相关缓存就直接返回,如果没有命中,才会将真正请求到服务器后端服务上并缓存结果。这样实现的方式更加灵活,数据的一致性会更好,只是实现起来需要人力去写好维护代码。-
当然这种缓存的数据长度建议不超过5KB10w QPS X 5KB = 488MB/s因为数据太长会拖慢我们的缓存服务响应速度。
服务监控
最后我们再说说利用网关做服务监控的问题。我们先思考这样一个问题,在没有链路跟踪之前,通常会怎么做监控呢?
事实上大部分系统都是通过网关的日志做监控的。我们可以通过网关访问日志中的Http Code来判断业务是否正常。配合不同请求的耗时信息就能完成简单的系统监控功能。
为了帮助你进一步理解,下面这张图画的是如何通过网关监控服务,你可以对照图片继续听我分析。
为了方便判断线上情况我们需要先统计信息。具体方法就是周期性地聚合访问日志中的错误将其汇总起来通过聚合汇总不同接口的请求的错误个数格式类似“30秒内出现500错误20个504报错15个某域名接口响应速度大于1秒的情况有40次”来分析服务状态。
和其他监控不同网关监控的方式可以监控到所有业务只是粒度会大一些不失为一个好方法。如果我们结合Trace还可以将访问日志中落地Traceid这样就能根据Traceid进一步排查问题原因操作更方便在好未来、极客时间都有类似的实现。
总结
这节课我给你分享了网关的很多巧妙用法,包括利用网关预防入侵、解除业务依赖、辅助系统平滑升级、提升用户体验、缓解流量冲击以及实现粒度稍大一些的服务监控。
我画了一张导图帮你总结要点,如下所示:-
相信学到这里,你已经体会到了网关的重要性。没错,在我们的系统里,网关有着举足轻重的地位,现在的技术趋势也证明了这一点。随着发展,网关开始区分内网网关和外网网关,它们的功能和发展方向也开始出现差异化。
这里我想重点再聊聊内网网关的发展。最近几年微服务、Sidecar技术逐渐流行和内网网关一样它们解决的都是内网流量调度和高可用问题。
当然了传统的内网网关也在更新换代出现了很多优秀的开源项目比如Kong、Apisix、OpenResty这些网关可以支持Http2.0长链接双工通讯和RPC协议。
业界对于到底选择Sidecar Agent还是用内网网关一直处于激烈讨论的阶段。而在我看来随着容器化的流行内网网关会迎来新的变革。服务发现、服务鉴权、流量调度、数据缓存、服务高可用、服务监控这些服务最终会统一成一套标准。如果现有的内网网关能降低复杂度未来会更胜一筹。
思考题
为什么内网都在用网关或实现服务发现功能而不用内网DNS服务来实现这一功能呢
期待你在留言区和我交流互动,也推荐你把这节课分享给更多同事、朋友。我们下节课见!

View File

@ -0,0 +1,166 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 性能压测:压测不完善,效果减一半
你好,我是徐长龙。
之前我们讨论了很多高并发改造思路和设计。
高并发的系统很复杂,所以对这样的系统做并发优化也相当有挑战。很多服务的局部优化,不见得能真正优化整体系统的服务效果,甚至有的尝试还会适得其反,让服务变得不稳定。
在这种情况下,压测就显得更加重要了。通常来说,通过压测可以帮我们做很多事儿,比如确认单个接口、单台服务器、单组服务集群甚至是整个机房整体的性能,方便我们判断服务系统的瓶颈在哪里。而且根据压测得出的结果,也能让我们更清晰地了解系统能够承受多少用户同时访问,为限流设置提供决策依据。
这节课,我们就专门聊聊性能压测里,需要考虑哪些关键因素。
压测与架构息息相关
在压测方面我们很容易踩的一个坑就是盲目相信QPS结果误以为“接口并发高就等同于系统稳定”但却忽视了系统业务架构的形态。
所以在讲压测之前,我们需要先了解一些关于性能与业务架构的相关知识,这能让我们在压测中更清醒。
并行优化
前面我说过不能盲目相信QPS结果优化的时候要综合分析。为了让你理解这一点我们结合一个例子来看看。
我们常见的业务会请求多个依赖的服务处理数据这些都是串行阻塞等待的。当一个服务请求过多其他服务时接口的响应速度和QPS就会变得很差。
这个过程,你可以结合后面的示意图看一下:
-
为了提高性能,有些业务对依赖资源做了优化,通过并行请求依赖资源的方式提高接口响应速度。具体的实现请看下图:
如上图,业务请求依赖接口的时候不再是串行阻塞等待处理,而是并行发起请求获取所有结果以后,并行处理业务逻辑,最终合并结果返回给客户端。这个设计会大大提高接口的响应速度,特别是依赖多个资源的服务。
但是这样优化的话有一个副作用这会加大内网依赖服务的压力导致内网的服务收到更多的瞬时并发请求。如果我们大规模使用这个技巧流量大的时候会导致内网请求放大比如外网是1WQPS而内网流量放大后可能会有10W QPS而内网压力过大就会导致网站整体服务不稳定。
所以,并行请求依赖技巧并不是万能的,我们需要注意依赖服务的承受能力,这个技巧更适合用在读多写少的系统里。对于很多复杂的内网服务,特别是事务一致性的服务,如果并发很高,这类服务反而会因为锁争抢超时,无法正常响应。
那问题来了像刚才例子里这种依赖较多的业务系统什么样的压测思路才更合理呢我的建议是先做内网服务的压测确认了内网可以稳定服务的QPS上限之后我们再借此反推外网的QPS应该限制在多少。
临时缓存服务
临时缓存优化也是压测里需要特殊应对的一种情况,其实我们早在第二节课就提到过。
临时缓存通常会这样实现,示意图如下所示:-
结合上图,我们可以看到,接口请求依赖数据时会优先请求缓存,如果拿到缓存,那么就直接从缓存中获取数据,如果没有缓存直接从数据源获取,这样可以加快我们服务的响应速度。
在通过临时缓存优化的服务做压测的时候你会看到同参数的请求响应很快甚至QPS也很高但这不等同于服务的真实性能情况系统不稳定的隐患仍然存在。为什么这么说呢
这是因为临时缓存的优化,针对的是会被频繁重复访问的接口,优化之后,接口的第一次请求还是很缓慢。如果某类服务原有接口依赖响应很慢,而且同参数的请求并不频繁,那这类服务的缓存就是形同虚设的。
所以这种结构不适合用在低频率访问的业务场景,压测时我们也要注意这种接口平时在线上的表现。
分片架构
接下来我们再看看数据分片架构。下图是通过分片缓解压力的架构我们在第18节课的时候提到过
数据分片架构的服务会根据一些标识id作为分片依据期望将请求均衡地转发到对应分片但是实际应用时情况不一定和预期一致。
我结合一个曾经踩过的坑和你分享经验。在线培训的业务里当时选择了班级ID作为分片标识10W人在线互动时实际却只有一个分片对外服务所有用户都请求到了一个分片上其他分片没有太多流量。
出现这种情况主要是两个原因第一我们的班级id很少这是一个很小的数据范围所以hash的时候如果算法不够分散就会把数据放到同一个分片上第二因为hash算法有很多种不同算法计算出的结果分散程度也不同因此有些特征的数据计算结果不会太分散需要我们验证选择。
为了预防类似的问题建议你压测时多拿实际的线上数据做验证如果总有单个热点分片就需要考虑更换hash算法。做好这个测试后别忘了配合随机数据再压测一次直到找到最适合业务情况的算法hash算法变更牵连甚广所以选择和更换时一定要慎重
数据量
除了架构情况以外,数据量也是影响压测效果的重要因素。
如果接口通过多条数据来进行计算服务就需要考虑到数据量是否会影响到接口的QPS和稳定性。如果数据量对接口性能有直接影响压测时就要针对不同数据量分别做验证。
因为不完善的测试样例,会给大流量服务留下雪崩的隐患,为了尽可能保证测试真实,这类接口在压测时,要尽量采用一些脱敏后的线上真实数据来操作。
这里特别提醒一下,对于需要实时汇总大量数据的统计服务,要慎重对外提供服务。如果服务涉及的数据量过多,建议转换实现的思路,用预计算方式去实现。
如果我们的核心业务接口不得不提供数据统计的服务,建议更改方式或增加缓存,预防核心服务崩溃。
压测环境注意事项
了解到性能和架构的关系知识后,相信你已经有了很多清晰的想法,是不是觉得已经可以顺利上机做压测了?
但现实并非这么简单,我们还得考虑压测环境和真实环境的差异。在压测之前,要想让自己的压测结果更准确,最好减少影响的因素。
在压测前的数据准备环节,我们通常要考虑的因素包括这些方面:
压测环境前后要一致:尽量用同一套服务器及配置环境验证优化效果。
避免缓存干扰:建议在每次压测时,缓一段时间让服务和缓存过期后再进行压测,这样才能验证测试的准确性。
数据状态一致:要尽量保证服务用的数据量、压测用户量以及缓存的状态是一致的。
接下来,我们再看看搭建压测环境时还有哪些注意事项。
我发现很多朋友会在本地开发电脑上做压测验证,但这样很多情况是测试不出来的,建议多准备几个发起压测请求的服务器,再弄几个业务服务器接收压测请求,这样压测才更接近真实业务的运转效果。
另外Linux环境配置我们也不能忽视。Linux内核优化配置选项里比较常用的包括本地可用端口个数限制、文件句柄限制、长链接超时时间、网卡软中断负载均衡、各种IO缓存大小等。这些选项都会影响我们的服务器性能建议在正式压测之前优化一遍在这里提及这个是因为我之前碰到过类似问题。
某次压测的时候我们发现业务不管怎么压测都无法超过1W QPS为此我们写了一个不执行任何逻辑的代码直接返回文本的接口然后对这个接口进行基准测试压测发现性能还是达不到1W QPS最后把Linux配置全部升级改进后才解决了这个问题。
线上压测及影子库
虽然线上压测更真实但这样会在短时间内会产生大量垃圾数据比如大量的日志、无用测试数据、伪造的业务数据可能有大量堆积的队列占用服务器的资源甚至直接引起各种线上故障。压测QPS在10W以上时压测一次制造的“数据垃圾”相当于日常业务一个月产生的数据量人工清理起来也非常困难。
因此为了确保测试不会影响线上正常服务我更推荐用影子库的方式做压测。该方式会在压测的请求里带上一个特殊的header这样所有的数据读写请求都会转为请求压测数据库而不是线上库。有了影子数据库可以帮我们有效地降低业务数据被污染的风险。
全链路压测以及流量回放测试
之前讨论的压测都是单接口、单个服务的压测。但实践过程中,最常遇到的问题就是单接口压测时表现很好,但是实际生产还没到预估流量,系统就崩掉了。
出现这种问题,原因在于我们的服务并不是完全独立的,往往上百个接口共享一套数据库、缓存、队列。所以,我们检测系统服务能力要综合检测。
比如你优化了单接口A但这条流程需要调用A、B、C三个接口而B、C接口性能较慢或对系统资源消耗很大。那么即便单接口A压测状况很好但整体的服务流程性能仍然上不去。再比如如果一个业务占用过多的公共资源就会影响到其他共用资源的服务性能所以压测做完单接口性能测试后建议做全链路压测。
上面这两种情况,都可以通过全链路压测来解决,这种方式可以帮助我们将各种交叉复杂的使用情况模拟出来,帮助我们更综合地评估系统运转情况,从而找到性能瓶颈。
如何模拟“交叉复杂的使用情况”呢建议你最好可以把多个业务主要场景设计成并行运行的流程一起跑比如一组vUser在浏览搜索商品一组vUser在下单支付一组vUser在后台点常见功能。
这种方式压测出来的性能数据可以作为我们最忙时线上服务压力的上限如果某个流程核心的接口压力大、响应慢的话则会拖慢整个流程的效率这样我们可以通过整体流程的QPS发现瓶颈点和隐患。
如果压测一段时间服务指标都很稳定,我们可以加大单个流程压测线程数,尝试压垮系统,以此观察系统可能出现的缺陷以及预警系统是否及时预警。不过这样做,需要做好修复数据库的准备。
如果业务比较复杂,人工写压测脚本比较困难,还有一个方式,就是回放线上真实用户请求进行压测。这种方式还可以用于一些特殊故障的请求场景还原。
具体可以使用tcpcopy这个工具录制线上的流量请求生成请求记录文件后模拟搭建录制时线上数据时的全量数据镜像然后回放即可。
不过这个工具使用起来有一定难度,最好配合成型的压测平台工具使用。此外,我们还需要一个独立旁路服务器来压测或录制,要注意支付一类的服务不要请求到线上,否则可能会造成用户财产损失。
总结
性能压测是我们的验证我们服务改造效果、容量评估、架构合理性以及灾难演练的必备工具。
通过压测,我们会更清楚服务的运转情况和承压能力,综合分析出性能瓶颈点。每次业务出现变更,或者做了优化时,都可以通过性能压测来评估优化效果。
我想强调的是压测的QPS并不一定能够反映我们的优化是否合理这一点需要结合业务架构来综合评估。
我们来回顾一下课程里讲过的几个典型例子:
并行请求依赖服务优化成串行请求的服务,虽然能够提高接口的响应速度,但是会让内网压力更大;
临时缓存服务虽然能降低内网重复查询的压力,但如果是低频率数据访问,那么优化效果就很一般;
分片架构的服务压测时需要注意单片热点的问题,不然压测虽然表现良好,线上运转却可能会出问题。
受参与计算的数据量影响大的接口,要尤其注意真实系统环境和极端数据量的测试。
除了对并行请求、临时缓存、分片架构、数据量这几个点做验证以外,还建议做一些极端测试,对服务的稳定性进行评估。数据量较多的接口,压测时要时刻关注相关数据库压力及索引、缓存的命中率情况,预防数据库出现压力过大、响应缓慢的问题。
另外,我们要在人少的时候停机做线上环境压测,但是要预防压测期间产生的垃圾数据,这里可以用影子库方式解决,不过这需要所有业务配合,需要提前做好协调确认。
最后,相比单接口的压测,为了尽量模拟线上真实情况,我带你了解了两种更综合的压测方式,分别是全链路压测和流量回放测试。
思考题
如何保证上线前的单元测试里,测试生产的数据不会污染线上环境?
期待你在留言区和我交流互动,也推荐你把这节课分享给更多同事、朋友。

View File

@ -0,0 +1,151 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
答疑课堂 思考题答案(一)
你好,我是编辑小新。
今天是一节答疑课。我们的专栏已经步入尾声。除了紧跟更新节奏的第一批同学,也很开心看到有更多新朋友加入到这个专栏的学习中。
很多同学的留言也是这门课的亮丽风景,给专栏增色了不少。大部分的疑问,老师都在留言区里做了回复,期待更多同学在留言区里分享经验,提出问题或尝试解答他人的疑问,我们来共建一个共同学习、积极交流的良好氛围。
为了给你留下足够的思考和研究的时间,我们选择用加餐的方式,公布每节课的参考答案,也会精选一些优秀同学的答案展示出来。这里要提醒一下,建议你先做了自己的思考后,再核对答案。另外每节课都有超链接,方便你跳转回顾。
第一节课
Q请你思考一下用户邀请其他用户注册的记录属于历史记录还是关系记录
A用户邀请其他用户注册的记录我认为属于关系记录。
虽然这种记录有历史记录特征但是被邀请注册的用户只能被邀请一次所以总量是可控的。同时这种表的用途很明确表内记录的是关系记录查询时会按邀请人或被邀请人uid进行查询。
留言区里也有不少精彩的答案,推荐你去看看。比如@移横为固的答案,这里我也复制过来:
一开始觉得注册邀请表应该作为历史表。思考了下作为关系表也是可以的。
在满足下面的注册邀请前提下:
1.邀请人用类似二维码分享方式,注册人主动扫码注册(不使用点对点邀请,被邀请人可能不接受)。
2.只能注册成功一次。-
这样每一条邀请记录都是一个用户的注册记录:可以定义如下字段:(邀请者,注册人,注册时间,邀请方式)。-
表的字段结构都非常简单,记录的总量最多就是账号量,并不会随时间不断膨胀。因此可以胜任关系表的查询需求。
在实际项目中,我们会遇到很多类似情况,需要我们预防超出预期的操作,核心在于我们怎么约束使用表的人,以及我们要怎么用表里的数据。
第二节课
Q1使用BloomFilter识别热点key时有时会识别失误进而导致数据没有找到那么如何避免这种情况呢
A1有一个特殊方法能降低概率原始key通过BloomFilter 检测一次md5后再通过另外一个BloomFilter再测一次。
Q2使用BloomFilter只能添加新key不能删除某一个key如果想更好地更新维护有什么其他方式吗
A2请参考Redis的Cuckoo Filter的实现。
第三节课
Q用户如果更换了昵称如何快速更换token中保存的用户昵称呢
A在更换用户昵称同时更换修改端的token。如果我们的用户有多个客户端那么可以利用缓存更新提及的Version版本号让客户端定期检测判断token是否需要更换。
对于这个问题,置顶留言里@徐曙辉同学的回答也很有趣
如果我来做快速更换昵称的功能,有两种方式:
a.在用户修改昵称后内存中加入一个用户标识解析token后读取该标识有则返回特定code让客户端重新拿token。甚至可以不用客户端参与返回301重定向到获取新token的路由。
b. token里面不存用户信息只存用户ID需要用户信息的时候从缓存读。
徐同学的第一个解法很暴力,但是很有趣。
第二个方式也很有意思,这里我也补一个应用技巧:我们可以通过设定固定网址 user/用户uid/heaer.jpg方式直接获取用户头像这样也不用考虑更新问题了。
围绕着我的补充,这个话题还有后续讨论,我也一并展示出来,仍然是徐同学的回答:
按这样做头像可以http://xx.com/user/用户ID/header.jpg静态文件可以因为反正都是远程http渲染。但是昵称和其他信息都这样处理每一项都放到远程地址性能不是很好是不是可以http://xxx.com/user/用户ID/info.json再反序列化呢
这样确定是占了额外的存储空间优点是不用查DB和缓存减少它们的压力在Web应用中用户信息读取挺频繁。
我认为这个思路很优秀,建议尽量使用对象存储做。关于对象存储的话题,你还可以参考第二十一节课的内容,我在里面详细分享了对象存储如何管理小文件和大文本。
第四节课
Q如果 Otter 同步的链路是环形的,那么如何保证数据不会一直循环同步下去?
AOtter在事务头尾插入同步标识解析时会通过这个方式防止发起方再执行同样事务。
第五节课
Q请你思考一下为什么Raft集群成员增减需要特殊去做
A这是一个复杂的话题核心在于增减成员在加入后需要同步数据并且会参与竞选。我觉得后面这篇文章分析得相对完整你可以点击这里查看原文。
第六节课
Q这节课中的有些概念与 DDD 是重合的,但是仍有一些细小的差异,请你对比一下 MVC 三层方式和 DDD 实现的差异。
A这个问题没有标准答案。我们结合同学的回答一起看看。
@Geek_994f3b同学的回答是这样的
我个人觉得两者只是作用域范围不同从程序的角度看MVC模式用在线程间(单体应用)而DDD用在进程间(微服务)那么MVC + RPC协议 + 业务拆分 ≈ DDD(个人愚见:),像是在单体上多套了一层。
@徐曙辉同学的回答是这样的
MVC是项目目录功能分层设计偏框架而DDD更多是业务实体领域和彼此之间的关系偏业务。
再补充一下我的想法建议结合贫血模型和充血模型区别以及领域模型和Service的区别来考虑这个问题。
第七节课
Q1请你思考一下通过原子操作+拆开库存方式实现库存方案时如何减少库存为0 后接口缓慢的问题?
A1我们可以再设置一个key标注还有哪些key有库存。
Q2我们这节课的内容并不仅仅在讲库存还包含了大量可实现的锁的使用方式请你分享一些实践过程中常见但不容易被发现的精妙设计。
A2这道题没有标准答案希望你做一个有心人在实践中多多关注各种锁的有趣设计。
第八节课
Q用什么方法能够周期检查出两个系统之间不同步的数据
A在数据上增加修改时间或版本号每次更新的时候同步更新版本号通过版本号能够很好地帮助我们识别哪个数据是最新的。
我们再看看@LecKeyt同学的回答
每条数据都有唯一的数据标识一般是自增id或者有规律一串数字唯一id而且一般都是小到大根据这个最大值应该就能判断出来。如果数据不同步应该找到对应数据节点做补偿操作。
看到他的回答后,我又追加了一个提问“如何避免更新操作同一条数据”?你也可以自行思考一下,再继续往下看。
后面的回答同样来自@LecKeyt同学
更新带来的数据不一致的情况,我个人认为要看具体业务,如果是实时性要求不高的可以用事件队列处理,如果要求强一致性那最好的方式应该是分布式事务保证了。
第九节课
Q现在市面上有诸多分布式实现方式你觉得哪一种性能更好
A建议考虑使用AT或Seata方式。
以上就是用户中心和电商系统这两个章节的思考题答案,希望能带给你一些启发。接下来,老师还会针对剩余的课后思考题,以及你的提问来作出解答。有任何问题,还是跟以前一样,欢迎你在留言区多多互动。

View File

@ -0,0 +1,84 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 为者常成,行者常至
你好,我是徐长龙。
今天是2022年的最后一个月我们也来到了这门课的最后一讲很荣幸能陪你度过这几个月的学习时间。感谢你的一路相伴有很多伙伴的留言的内容很有趣也给我带来了一些启发。
高并发系统里每一种优化技术,后面都蕴含着许多优秀的思想,这些内容提炼出来是一个很耗精力的过程。
这半年以来每天晚上我不是在写稿、改稿就是在查资料忙到12点也是常态。说实话写这个专栏远比我想象中更难需要不断整理自己的思路、优化表达还要反复查证各类资料料尽量保证讲到的知识准确无误。
在实践中,我们做系统优化常常要综合考虑、多次试错,才有可能找到适合的解决方法,并没有四海皆准的通用法门。解决一个问题时,会有很多个选项,不同的选项背后又会关联更多的分支,就像是小径分岔的花园。
因此,我觉得比起穷尽各种细节问题,帮你搭建完善的知识体系,形成理解系统的正确思路更为关键。
开发一个系统,我们首先考虑的就是用户量,然后分析用户的主要动作,并根据这些分析数据量以及核心频繁调用的功能是什么(功能是读的多,还是写的多),以此判断出我们的系统类型和优化方向。
确定了优化方向,剩下的工作,就是结合不同类型的系统特点来做设计和优化。这也是这个专栏的设计思路:高并发的优化主流方案与案例为主,实践技巧为辅。
现在,让我再次回顾一下,这几个月来,我们都学到了哪些内容,希望能对你沉淀知识有所帮助。
-
首先,我们从互联网服务里最常见的读多写少系统开始入手。这种系统结构简单、维护方便、成本可控,优化时主要的挑战就是保证数据一致性。
为了做好缓存的数据一致性我们可以尝试优化数据表结构并处理好临时缓存和长期缓存的刷新机制。在读多写少系统下能按ID查询的数据性能最好关系查询及统计计算放在缓存的话维护起来会比较难。
按照读多写少系统的发展规律用户登陆的脱耦合改造也是一个“必经关卡”这里我们的优化思路是通过签名实现去中心化。到了读多写少的系统演进的后期多机房数据同步建设也是重要话题通过Raft共识算法等分布式核心知识的学习我们为后续进阶学习打下基础。
相比读多写少的系统,强一致性系统需要在保证数据一致性同时,追求更好的性能。我们选择了最典型的电商秒杀系统来分析,秒杀系统常常和多系统耦合,难以管理,所以合理拆分系统非常必要。另外,我们还需要深入了解锁,灵活地使用锁来实现库存争抢功能。
这类系统的优化改造中需要斩断秒杀服务与内网的耦合并处理好多服务协调的事务。最近微服务的流行让分布式事务变成了必备组件我们通过深入研究了2PC以及TCC这两个例子之后就能了解大部分分布式事务的实现思路。
至于写多读少的系统,则是监控系统及日志中心的核心支撑。
优化时主要有两个挑战:一个是协调多服务器,提供线性分片来提升读写性能;另一个是理解分布式服务如何应对写压力,并在此基础上做好分布式数据服务的存储和查询。我们以日志中心为例,明白了几大数据引擎的实现和原理,就能更深入地理解分布式存储、查询、扩容的核心思路。
此外,我还分享了如何在资源有限的情况下,实现分布式链路跟踪系统,只有做好了监控,才能更好地观测、理解我们的系统。这样的探索有助于理解一些大厂的实践思路,建议你课后多多尝试这些技巧。
读多写多的系统是难度最高的一类服务,可以说是我们行业的天花板,对服务响应要求更高,还得处理好系统的高可用问题。
由于集中式缓存很难满足这类服务的需要,我们往往为了做好数据缓存做出各种尝试。然而无论是本地缓存,还是分流架构(比如引入脚本引擎集成),实现起来都有重重挑战,稍不留意就会踩坑。为了尽力避免业务服务缓存应对高并发读写的情况,我们还要设法合理拆分架构,并优化流量调度。
除了上面这四大类系统,我们还在内网建设这个部分讨论了对象存储、日志中心成本估算、如何巧妙使用网关、如何落地压测等问题。相信这些能帮你打开视野、开阔思路,更重要的是让你从容应对业务流量增长带来的冲击。这里再次感谢同学们的留言,很多内容也激发了我的灵感,尤其是在写内网建设这部分的时候,我结合留言里的疑问做了不少优化。
也许你接触的系统,现在并没有那么大的流量,但我仍然认为,高并发是未来方向。这和大多数互联网公司的业务发展规律有关:很多业务在前期,会通过算法和硬件来应对性能问题,但流量变大之后,硬件维护成本会居高不下;即便很多大型互联网公司,系统也需要持续优化改进,才能匹配日益增长的业务需要。
大部分业务系统会优化成读多写少的类型,这类架构设计都会以缓存、分布式队列为主,硬扛常见的用户流量压力,相对而言,这种服务最省钱。
强一致性的交易系统,则是通过缩小争抢强一致的服务及数据粒度,并不断拆分隔离系统,以此分散流量,最终控制成本。
如果是基础服务优化,那么我们只能通过隔离、算法、队列、动态调配基础服务器资源,降低存储周期等方式来硬扛流量压力,降低我们的成本。待业务成熟后,会逐渐优化掉一些无用业务流程及功能,节省基础服务的投入。
至于读多写多的核心业务我们前期只能投入大量硬件资源并配合CDN做流量调度。不过等到流量降低时或市场成熟时还是会尝试把一些服务降成读多写少。
专栏里,为了给你呈现一条足够清晰的知识脉络,我把系统按数据特征分了四大类。然而实际的系统里可能会更加复杂,所以你在区分系统类型的时候很可能会产生困惑:这个服务、这个系统到底属于上面哪种类型?
事实上,当你碰到这种情况的时候,就需要思考一下:这些不同类型的服务是否需要单独拆开、分别部署?一个系统如果混合使用多个运行模式,其组合复杂度会随着时间呈指数增长,逐渐让系统往失控的方向靠近。
理想状态是业务系统设计得更简单一些,让模块更加垂直。如果确实存在混合情况,要么就选一种类型作为主要优化方向,要么就把系统彻底拆开。不同类型的系统,优化方向并不一样,相应的配套设施、服务器对各层的投入比例,还有基础支撑要求都不一样,混在一起不但浪费硬件资源,而且不好维护。
说到这里,我们可以再想想优化的核心价值到底是什么?是让服务跑得更快?还是让功能更好用?还是让我们的设计更简洁统一?我觉得这些答案都对,但除此之外还有一个更核心的价值——节省成本。
你会发现,各种优化和新技术都在试图降低成本,无论解法是拆分、解耦还是集成。而监控和规范流程也是同样的道理,看似这些没能直接节约成本,但却能尽早发现系统隐患,间接降低我们解决问题的成本,减少潜在损失。
所以,相信在未来高并发优化会发挥出更大的价值,节省成本,正是高并发优化的核心竞争力。
课程的学习告一段落,到了说再见的时候。这门课可以说是高并发系统学习的一个索引,如果你能认真阅读、深挖,不断思考、实践,你的未来会拥有更大的空间。
如果遇到了什么问题,也欢迎继续在留言区里给我留言。
行业一直在飞速发展,各种技术不断更新换代,等待我们的是浩如烟海的知识海洋,一起努力前行吧。
高并发系统的优化无法毕其功于一役,个人的成长精进也一样。为者常成,行者常至,愿你我都能在未来的事业中披荆斩棘,所向披靡,再会!
最后,我还给你准备了一份毕业问卷,希望你能花两三分钟填写一下,非常期待能听到你对这门课的反馈。