沙井网站优化,网络营销策略包括哪几大策略,标志空间 网站,oppo开发者选项在哪里打开高并发系统实战课 
场景 读多写少 
我会以占比最高的“读多写少”系统带你入门#xff0c;梳理和改造用户中心项目。这类系统的优化工作会聚焦于如何通过缓存分担数据库查询压力#xff0c;所以我们的学习重点就是做好缓存#xff0c;包括但不限于数据梳理、做数据缓存、加缓…高并发系统实战课 
场景 读多写少 
我会以占比最高的“读多写少”系统带你入门梳理和改造用户中心项目。这类系统的优化工作会聚焦于如何通过缓存分担数据库查询压力所以我们的学习重点就是做好缓存包括但不限于数据梳理、做数据缓存、加缓存后保证数据一致性等等工作。 
另外为了帮你从单纯的业务实现思想中“跳出来”我们还会一起拓展下主从同步延迟和多机房同步的相关知识为后续学习分布式和强一致打好基础。 
强一致性 
这类系统的主要挑战是承接高并发流量的同时还要做好系统隔离性、事务一致性以及库存高并发争抢不超卖。我会和你详细讨论拆分实践的要点让你加深对系统隔离、同步降级和库存锁等相关内容的认识弄明白分布式事务组件的运作规律。了解这些你会更容易看透一些基础架构组件的设计初衷。 
写多读少 
接下来是高并发写系统它涉及大量数据如何落盘、如何传输、存储、压缩还有冷热数据的切换备份、索引查询等多方面问题我会一一为你展开分析。我还会给你分享一个全量日志分布式链路跟踪系统的完整案例帮你熟悉并发写场景落地的方方面面。 
读多写多 
读多写多系统是最复杂的系统类型就像最火热的游戏、直播服务都属于这个类型。其中很多技术都属于行业天花板级别毕竟线上稍有点问题都极其影响用户体验。 
这类系统数据基本都是在内存中直接对外服务同时服务都要拆成很小的单元数据是周期落到磁盘或数据库而不是实时更新到数据库。因此我们的学习重点是如何用内存数据做业务服务、系统无需重启热更新、脚本引擎集成、脚本与服务互动交换数据、直播场景高并发优化、一些关于网络优化 CDN 和 DNS、知识以及业务流量调度、客户端本地缓存等相关知识。 
用户中心读多写少的系统高并发优化实践 
结构梳理大并发下你的数据库表可能成为性能隐患 
因为老系统在使用数据库的时候存在很多问题比如实体表字段过多、表查询维度和用途多样、表之间关系混乱且存在 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)
) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_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 做文章检索等。这种可横向扩容的服务能大大降低数据库查询压力但唯一缺点就是很难实现数据的强一致性需要人工检测、核对两个系统的数据。 
实体关系表 在对 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 0extra 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),
) ENGINEInnoDB AUTO_INCREMENT1 
DEFAULT CHARSETutf8mb4 
COLLATEutf8mb4_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 快速匹配的实体以及通过关系快速查询的数据适合放在长期缓存当中通过组合条件筛选统计的数据也可以放到临时缓存但是更新有延迟数据增长量大或者跟设计初衷不一样的表数据这种不适合、也不建议去做做缓存。 缓存一致读多写少时如何解决数据更新缓存不同步 
而对于用户中心的业务来说这个比例会更大一些毕竟用户不会频繁地更新自己的信息和密码所以这种读多写少的场景特别适合做读取缓存。通过缓存可以大大降低系统数据层的查询压力拥有更好的并发查询性能。但是使用缓存后往往会碰到更新不同步的问题下面我们具体看一看。 
缓存性价比 
就像刚才所说我们认为用户信息放进缓存可以快速提高性能所以在优化之初我们第一个想到的就是将用户中心账号信息放到缓存。这个表有 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 0update_time int(10) NOT NULL DEFAULT 0,create_time int(10) NOT NULL DEFAULT 0,PRIMARY KEY (id),
) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_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:idUid        int    gorm:column:uid;type:int(4);NOT NULL json:uidNickName   string gorm:column:nickname;type:varchar(32) unsigned;NOT NULL json:nicknameStatus     int16  gorm:column:status;type:tinyint(4);default:1;NOT NULL json:statusCreateTime int64  gorm:column:create_time;type:bigint(11);NOT NULL json:create_timeUpdateTime 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 才行。 
通过主要实体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 方式预防临时缓存被大量请求穿透以防热点数据在从临时缓存切换成热点之前击穿缓存导致数据库崩溃。 
读多写少的缓存技巧我还画了一张导图如下所示 Token如何降低用户身份鉴权的流量压力 
很多网站初期通常会用 Session 方式实现登录用户的用户鉴权也就是在用户登录成功后将这个用户的具体信息写在服务端的 Session 缓存中并分配一个 session_id 保存在用户的 Cookie 中。该用户的每次请求时候都会带上这个 ID通过 ID 可以获取到登录时写入服务端 Session 缓存中的记录。 
流程图如下所示 Session Cache实现的用户鉴权 
种方式的好处在于信息都在服务端储存对客户端不暴露任何用户敏感的数据信息并且每个登录用户都有共享的缓存空间Session Cache。 
但是随着流量的增长这个设计也暴露出很大的问题——用户中心的身份鉴权在大流量下很不稳定。因为用户中心需要维护的 Session Cache 空间很大并且被各个业务频繁访问那么缓存一旦出现故障就会导致所有的子系统无法确认用户身份进而无法正常对外服务。 
这主要是由于 Session Cache 和各个子系统的耦合极高全站的请求都会对这个缓存至少访问一次这就导致缓存的内容长度和响应速度直接决定了全站的 QPS 上限让整个系统的隔离性很差各子系统间极易相互影响。 
那么如何降低用户中心与各个子系统间的耦合度提高系统的性能呢我们一起来看看。 
JWT 登陆和 token 校验 
常见方式是采用签名加密的 token这是登录的一个行业标准即 JWTJSON Web Token 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 //用户idNickName string json:nickname //昵称jwt.StandardClaims //私有部分
}// 生成JWT token
func GenToken(userId uint64, nickname string) (string, error) {c : TokenPayLoad{UserId: userId, //uidNickName: nickname, //昵称//这里可以追加一些其他加密的数据进来//不要明文放敏感信息如果需要放必须再加密//私有部分StandardClaims: jwt.StandardClaims{//两小时后失效ExpiresAt: time.Now().Add(2 * time.Hour).Unix(),//颁发者Issuer:    geekbang,},}//创建签名 使用hs256token : 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 {//请求下用户中心问问这个人禁登陆没//....略具体//重新发放tokentoken, err : GenToken(.....)if err ! nil {return nil, err}//更新返回cookie中tokenresp.setCookie(xxxx, token)
}这段代码只是对当前的 token 做了超时更换。JWT 对离线 App 端十分友好因为 App 可以将它保存在本地在使用用户信息时直接从本地解析出来即可。 
安全建议 
最后我再啰嗦几句除了上述代码中的注释外在使用 JWT 方案的时候还有一些关键的注意事项这里分享给你。 
第一通讯过程必须使用 HTTPS 协议这样才可以降低被拦截的可能。 
第二要注意限制 token 的更换次数并定期刷新 token比如用户的 access_token 每天只能更换 50 次超过了就要求用户重新登陆同时 token 每隔 15 分钟更换一次。这样可以降低 token 被盗取后给用户带来的影响。第三Web 用户的 token 保存在 cookie 中时建议加上 httponly、SameSiteStrict 限制以防止 cookie 被一些特殊脚本偷走。 
总结 
传统的 Session 方式是把用户的登录信息通过 SessionID 统一缓存到服务端中客户端和子系统每次请求都需要到用户中心去“提取”这就会导致用户中心的流量很大所有业务都很依赖用户中心。 
为了降低用户中心的流量压力同时让各个子系统与用户中心脱耦我们采用信任“签名”的 token把用户信息加密发放到客户端让客户端本地拥有这些信息。而子系统只需通过签名算法对 token 进行验证就能获取到用户信息。 
这种方式的核心是**把用户信息放在服务端外做传递和维护以此解决用户中心的流量性能瓶颈。**此外通过定期更换 token用户中心还拥有一定的用户控制能力也加大了破解难度可谓一举多得。 
其实还有很多类似的设计简化系统压力比如文件 crc32 校验签名可以帮我们确认文件在传输过程中是否损坏通过 Bloom Filter 可以确认某个 key 是否存在于某个数据集合文件中等等这些都可以大大提高系统的工作效率减少系统的交互压力。这些技巧在硬件能力腾飞的阶段仍旧适用。 
同城双活如何实现机房之间的数据同步 
在业务初期考虑到投入成本很多公司通常只用一个机房提供服务。但随着业务发展流量不断增加我们对服务的响应速度和可用性有了更高的要求这时候我们就要开始考虑将服务分布在不同的地区来提供更好的服务这是互联网公司在流量增长阶段的必经之路。 
之前我所在的公司流量连续三年不断增长。一次机房对外网络突然断开线上服务全部离线网络供应商失联。因为没有备用机房我们经过三天紧急协调拉起新的线路才恢复了服务。这次事故影响很大公司损失达千万元。 
经过这次惨痛的教训我们将服务迁移到了大机房并决定在同城建设双机房提高可用性。这样当一个机房出现问题无法访问时用户端可以通过 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 来实现同城双主注意双主不通用不推荐一致要求高的业务使用这样双活机房可以双向同步 同城双活双向同步方案 
如上图每个机房内都有自己的主库和从库缓存可以是跨机房主从也可以是本地主从这取决于业务形态。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 调度让一个用户在某一段时间内只在一个机房内活跃这样可以降低数据冲突的情况。 
而对于修改频繁、争抢较高的服务一般都会在机房本地做整体事务执行杜绝跨机房同时修改导致同步错误的发生。 
共识Raft如何保证多机房数据的一致性 
如果机房 A 对某一条数据做了更改B 机房同时修改Otter 会用合并逻辑对冲突的数据行或字段做合并。为了避免类似问题我们在上节课对客户端做了要求用户客户端在一段时间内只能访问一个机房。 
但如果业务对“事务  强一致”的要求极高比如库存不允许超卖那我们通常只有两种选择一种是将服务做成本地服务但这个方式并不适合所有业务另一种是采用多机房但需要用分布式强一致算法保证多个副本的一致性。 
在行业里最知名的分布式强一致算法要属 Paxos但它的原理过于抽象在使用过程中经过多次修改会和原设计产生很大偏离这让很多人不确定自己的修改是不是合理的。而且很多人需要一到两年的实践经验才能彻底掌握这个算法。 
随着我们对分布式多副本同步的需求增多过于笼统的 Paxos 已经不能满足市场需要于是Raft 算法诞生了。相比 PaxosRaft 不仅更容易理解还能保证数据操作的顺序因此在分布式数据服务中被广泛使用像 etcd、Kafka 这些知名的基础组件都是用 Raft 算法实现的。 
那今天这节课我们就来探寻一下 Raft 的实现原理可以说了解了 Raft就相当于了解了分布式强一致性数据服务的半壁江山。几乎所有关于多个数据服务节点的选举、数据更新和同步都是采用类似的方式实现的只是针对不同的场景和应用做了一些调整。 
如何选举 Leader 
为了帮你快速熟悉 Raft 的实现原理下面我会基于 Raft 官方的例子对 Raft 进行讲解。 Raft服务状态角色、调用关系、日志 
如图所示我们启动五个 Raft 分布式数据服务S1、S2、S3、S4、S5每个节点都有以下三种状态 
Leader负责数据修改主动同步修改变更给 FollowerFollower接收 Leader 推送的变更数据Candidate集群中如果没有 Leader那么进入选举模式。 
如果集群中的 Follower 节点在指定时间内没有收到 Leader 的心跳那就代表 Leader 损坏集群无法更新数据。这时候 Follower 会进入选举模式在多个 Follower 中选出一个 Leader保证一组服务中一直存在一个 Leader同时确保数据修改拥有唯一的决策进程。 
那 Leader 服务是如何选举出来的呢进入选举模式后这 5 个服务会随机等待一段时间。等待时间一到当前服务先投自己一票并对当前的任期“term”加 1 上图中 term:4 就代表第四任 Leader然后对其他服务发送 RequestVote RPC即请求投票进行拉票。 S1失去联系S5最先超时发起选举 
收到投票申请的服务并且申请服务即“发送投票申请的服务”的任期和同步进度都比它超前或相同那么它就会投申请服务一票并把当前的任期更新成最新的任期。同时这个收到投票申请的服务不再发起投票会等待其他服务邀请。 
注意每个服务在同一任期内只投票一次。如果所有服务都没有获取到多数票三分之二以上服务节点的投票就会等当前选举超时后对任期加 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 在阻塞等待其他节点回应。 通过日志同步同时同步Follower目前Leader 的commit index 
不过这种阻塞等待的设计也让 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 获取最新的数据返回给客户端。可见保证数据强一致性的代价很大。 Follower保持与Leader进度一致的方式保证读到的数据和Leader强一致 
你可能会好奇如何在业务使用时保证读取数据的强一致性呢其实我们之前说的 Raft 同步等待 Leader commit log index 的机制已经确保了这一点。我们只需要向 Leader 正常提交数据修改的操作Follower 读取时拿到的就一定是最新的数据。 
总结 
很多人都说 Raft 是一个分布式一致性算法但实际上 Raft 算法是一个共识算法多个节点达成共识它通过任期机制、随机时间和投票选举机制实现了服务动态扩容及服务的高可用。 
通过 Raft 采用强制顺序的日志同步实现多副本的数据强一致同步如果我们用 Raft 算法实现用户的数据存储层那么数据的存储和增删改查都会具有跨机房的数据强一致性。这样一来业务层就无需关心一致性问题对数据直接操作即可轻松实现多机房的强一致同步。 
由于这种方式的同步代价和延迟都比较大建议你尽量在数据量和修改量都比较小的场景内使用行业里也有很多针对不同场景设计的库可以选择如parallel-raft、multi-paxos、SOFAJRaft 等更多请参考 Raft 的底部开源列表。 
电商系统强一致性系统如何改造 
领域拆分如何合理地拆分系统 
一般来说强一致性的系统都会牵扯到“锁争抢”等技术点有较大的性能瓶颈而电商时常做秒杀活动这对系统的要求更高。业内在对电商系统做改造时通常会从三个方面入手系统拆分、库存争抢优化、系统隔离优化。 
今天这节课我们先来热个身学习一些系统拆分的技巧。我们知道电商系统有很多功能需要保持数据的强一致性我们一般会用锁确保同一时间只有一个线程在修改。 
但这种方式会让业务处理的并行效率很低还很容易影响系统的性能。再加上这类系统经常有各种个性活动需求相关功能支撑需要不断更新迭代而这些变更往往会导致系统脱离原来的设计初衷所以在开发新需求的同时我们要对系统定期做拆分整理避免系统越跑越偏。这时候如何根据业务合理地拆分系统就非常重要了。 
案例背景 
他们是某行业知名电商的供货商供应链比较长而且供应品类和规格复杂。为确保生产计划平滑运转系统还需要调配多个子工厂和材料商的生产排期。 
原本调配订单需要电话沟通但这样太过随机。为了保证生产链稳定供货同时提高协调效率朋友基于订单预订系统增加了排期协商功能具体就是将 “排期” 作为下订单主流程里的一个步骤并将协商出的排期按照日历样式来展示方便上游供应商和各个工厂以此协调生产周期。 
整个供货协商流程如下图所示 图1供货商供货协商流程 
如图上游项目会先发布生产计划或采购计划供货商根据计划拆分采购列表分单并联系不同的工厂协调做预排期预约排期。之后上游采购方对工厂产品进行质量审核然后下单支付、确认排期。 
工厂根据确认好的排期制定采购材料计划并通知材料供货商分批供货开始分批生产制造产品。每当制造好一批产品后工厂就会通知物流按批次发货到采购方即供货商同时更新供货商系统内的分批订单信息。接着上游对产品进行验收将不合格的产品走退换流程。 
但系统运行了一段时间后朋友发现由于之前系统是以订单为主体的增加排期功能后还是以主订单作为聚合根即主要实体这就导致上游在发布计划时需要创建主订单。 
而主订单一直处于开启状态随着排期不断调整和新排期的不断加入订单数据就会持续增加一年内订单数据量达到了一亿多条。因为数据过多、合作周期长并且包含了售后环节所以这些数据无法根据时间做归档导致整个系统变得越来越慢。 
考虑到这是核心业务如果持续存在问题影响巨大因此朋友找我取经请教如何对数据进行分表拆分。但根据我的理解这不是分表分库维护的问题而是系统功能设计不合理导致了系统臃肿。于是经过沟通我们决定对系统订单系统做一次领域拆分。流程分析整理。 
流程分析整理 
我先梳理了主订单的 API 和流程从上到下简单绘制了流程和订单系统的关系如下图所示 图2角色动作与系统 
可以看到有多个角色在使用这个“订单排期系统”。通过这张图与产品、研发团队进行沟通来确认我理解的主要流程的数据走向和系统数据依赖关系都没有问题。 
接着我们将目光放在了订单表上订单表承载的职能过多导致多个流程依赖订单表无法做数据维护而且订单存在多个和订单业务无关的状态比如排期周期很长导致订单一直不能关闭。我们在第 1 节课讲过一个数据实体不要承担太多职能否则很难管理所以我们需要对订单和排期的主要实体职能进行拆分。 
经过分析我们还发现了另一个问题现在系统的核心并不是订单而是计划排期。原订单系统在改造前是通过自动匹配功能实现上下游订单分单的所以系统的主要模块都是围绕订单来流转的。而增加排期功能后系统的核心就从围绕订单实现匹配分单转变成了围绕排期产生订单的模式这更符合业务需要。 
排期和订单有关联关系但职能上有不同的方向用途排期只是计划而订单只为工厂后续生产运输和上游核对结果使用。这意味着系统的模块和表的设计核心已经发生了偏移我们需要拆分模块才能拥有更好的灵活性。 
综上所述我们总体的拆分思路是要将排期流程和订单交付流程完全拆分开。要知道在创业公司我们做的项目一开始的设计初衷常常会因为市场需求变化逐渐偏离原有设计这就需要我们不断重新审视我们的系统持续改进才能保证系统的完善。 
因为担心研发团队摆脱不了原有系统的思维定势拆分做得不彻底导致改版失败所以我对角色和流程做了一次梳理明确了各个角色的职责和流程之间的关系。我按角色及其所需动作画出多个框将他们需要做的动作和数据流穿插起来如下图所示 图3按角色及其动作整理 
基于这个图我再次与研发、产品沟通找出了订单与排期在功能和数据上的拆分点。具体来讲就是将上游的职能拆分为发布进货计划、收货排期、下单、收货 / 退换而供货商主要做协调排期分单同时提供订单相关服务工厂则主要负责生产排期、生产和售后。这样一来系统的流程就可以归类成几个阶段 
计划排期协调阶段按排期生产供货  周期物流交付阶段售后服务调换阶段 
可以看到第一个阶段不牵扯订单主要是上游和多个工厂的排期协调第二、三阶段是工厂生产供货和售后这些服务需要和订单交互而上游、工厂和物流的视角是完全不同的。 
基于这个结论我们完全可以根据数据的主要实体和主要业务流程订单 ID 做聚合根将流程分为订单和排期两个领域将系统拆分成两个子系统排期调度系统、订单交付系统。 
在计划排期协调阶段上游先在排期调度系统内提交进货计划和收货排期然后供货商根据上游的排期情况和进货需求与多家合作工厂协调分单和议价。多方达成一致后上游对计划排期和工厂生产排期进行预占。 
待上游正式签署协议、支付生产批次定金后排期系统会根据排期和工厂下单在订单系统中产生对应的订单。同时上游、供货商和工厂一旦达成合作后续可以持续追加下单排期而不是将合作周期限制在订单内。 
在排期生产供货阶段排期系统在调用订单系统的同时会传递具体的主订单号和订单明细。订单明细内包含着计划生产的品类、个数以及每期的交付量工厂可以根据自己的情况调整生产排期。产品生产完毕后工厂分批次发送物流进行派送并在订单系统内记录交付时间、货物量和物流信息。同时订单系统会生成财务信息与上游财务和仓库分批次地对账。 图4拆分成两大流程后的系统 
这么拆分后两个系统把采购排期和交付批次作为聚合根进行了数据关联这样一来整体的订单流程就简单了很多。 
总体来讲前面对业务的梳理都以流程、角色和关键动作这三个元素作为分析的切入点然后将不同流程划分出不同阶段来归类分析根据不同阶段拆分出两个业务领域排期和订单同时找出两个业务领域的聚合根。经过这样大胆的拆分后再与产品和研发论证可行性。 
系统拆分从表开始 
经历了上面的过程相信你对按流程和阶段拆分实体职责的方法已经有了一定的感觉这里我们再用代码和数据库表的视角复盘一下该过程。 
一般来说系统功能从表开始拆分这是最容易实现的路径因为我们的业务流程往往都会围绕一个主要的实体表运转并关联多个实体进行交互。在这个案例中我们将订单表内关于排期的数据和状态做了剥离拆分之前的代码分层如下图所示 图5拆分之前订单表承担计划、排期协调、订单售后的职责 
拆分之后代码分层变成了这样 图6拆分之后分为计划表、排期协调表和订单售后表 
可以看到最大的变化就是订单实体表的职责被拆分了我们的系统代码随之变得更加简单而且同一个订单实体被多个角色交叉调用的情况也完全消失了。在拆分过程中我们的依据有三个 
数据实体职能只做最核心的一件事比如订单只管订单的生老病死包括创建、流程状态更改、退货、订单结束业务流程归类按涉及实体进行归类看能否分为多个阶段比如“协调排期流程进行中”、“生产流程”、“售后服务阶段”由数据依赖交叉的频率决定把订单划分成几个模块如果两个模块业务流程上交互紧密并且有数据关联关系比如 Join、调用 A 必然调用 B 这种就把这两个模块合并同时保证短期内不会再做更进一步的拆分。 图7从下往上按数据实体设计和从流程往下按领域流程设计DDD 
一个核心的系统如果按实体表职责进行拆分整理那么它的流程和修改难度都会大大降低。 
而模块的拆分也可以通过图 6从下往上去看。如果它们之间的数据交互不是特别频繁比如没有出现频繁的 Join我们就将系统分成四个模块。如图 7 所示可以看到这四个模块之间相对独立各自承担一个核心的职责。同时两个实体之间交互没有太大的数据关联每个模块都维护着某个阶段所需的全部数据这么划分比较清晰也易于统一管理。 
到这里我们只需要将数据和流程关系都梳理一遍确保它们之间的数据在后续的统计分析中没有频繁数据 Join即可完成对表的拆分。 
但如果要按业务划分模块我还是建议从上到下去看业务流程来决定数据实体拆分领域模型设计 DDD的领域范围以及各个模块的职责范围。 
越是底层服务越要抽象 
除了系统的拆分外我们还要注意一下服务的抽象问题。很多服务经常因业务细节变更需要经常修改而越是底层服务越要减少变更。如果服务的抽象程度不够一旦底层服务变更我们很难确认该变更对上游系统的影响范围。 
所以我们要搞清楚哪些服务可以抽象为底层服务以及如何对这些服务做更好的抽象。 
因为电商类系统经常对服务做拆分和抽象所以我就以这类系统为例为你进行讲解。你可能感到疑惑电商系统为什么要经常做系统拆分和服务抽象呢 
这是因为电商系统最核心且最复杂的地方就是订单系统电商商品有多种品类skuspu不同品类的筛选维度、服务、计量单位都不同这就导致系统要记录大量的冗余品类字段才能保存好用户下单时的交易快照。所以我们需要频繁拆分整理系统避免这些独有特性影响到其他商品。 
此外电商系统不同业务的服务流程是不同的。比如下单购买食品与下单定制一个柜子完全不同。在用户购买食品时电商只需要通知仓库打包、打物流单、发货、签收即可而用户定制柜子则需要厂家上门量尺寸、复尺、定做、运输、后续调整等。所以我们需要做服务抽象让业务流程更标准、更通用避免变更过于频繁。 
正是由于业务服务形态存在不同的差异订单系统需要将自己的职能控制在“一定范围”内。对此我们应该考虑如何在满足业务需求的情况下让订单表的数据职能最小。 
被动抽象法 
如果两个或多个服务使用同一个业务逻辑就把这个业务逻辑抽象成公共服务。比如业务 A 更新了逻辑 a业务 B 也会同步使用新的逻辑 a那么就将这个逻辑 a 放到底层抽象成一个公共服务供两个服务调用。这种属于比较被动的抽象方式很常见适合代码量不大、维护人员很少的系统。 
对于创业初期主脉络不清晰的系统利用被动抽象法很容易做抽象。不过它的缺点是抽象程度不高当业务需要大量变更时需要一定规模的重构。 
总的来说虽然这种方式的代码结构很贴近业务但是很麻烦而且代码分层没有规律。所以被动抽象法适用于新项目的探索阶段。 图8都用的服务才抽象成服务个性部分放自身决策权在业务。 
这里说一个题外话同层级之间的模块是禁止相互调用的。如果调用了就需要将两个服务抽象成公共服务让上层对两个服务进行聚合如上图中的红 X拆分后如下图所示 图9同层有交叉调用的服务 
这么做是为了让系统结构从上到下是一个倒置的树形保证不会出现引用交叉循环的情况否则会让项目难以排查问题难以迭代维护如果前期有大量这样的调用当我们做系统改造优化时只能投入大量资源才能解决这个问题。 
动态辅助表方式 
这个方式适用于规模稍微大一点的团队或系统它的具体实现是这样的当订单系统被几个开发小组共同使用而不同业务创建的主订单有不同的 type不同的 type 会将业务特性数据存储在不同的辅助表内比如普通商品保存在表 order 和表 order_product_extra 中定制类商品的定制流程状态保存在 order_customize_extra 中。 
这样处理的好处是更贴近业务方便查询。但由于辅助表有其他业务数据业务的隔离性比较差所有依赖订单服务的业务常会受到影响而且订单需要时刻跟着业务改版。所以通过这种方式抽象出来的订单服务已经形同虚设一般只有企业的核心业务才会做类似的定制。 图10动态辅助表方式 
强制标准接口方式 
这种方式在大型企业比较常见其核心点在于底层服务只做标准的服务业务的个性部分都由业务自己完成比如订单系统只有下单、等待支付、支付成功、发货和收货功能展示的时候用前端对个性数据和标准订单做聚合。 
用这种方式抽象出的公共服务订单对业务的耦合性是最小的业务改版时不需要订单跟随改版订单服务维护起来更容易。只是上层业务交互起来会很难受因为需要在本地保存很多附加的信息并且一些流转要自行实现。不过从整体来看对于使用业务多的系统来说因为业务导致的修改会很少。 图11只提供标准的公共服务业务隔离性最好 
通过上面三种方式可以看出业务的稳定性取决于服务的抽象程度。如果底层经常更改那么整个业务就需要不断修改最终会导致业务混乱。所以我个人还是推荐你使用强制标准接口方式这也是很多公司的常见做法。虽然很难用但比起经常重构整个系统总要好一些。 
你可能很奇怪为什么不把第一种方式一口气设计好呢这是因为大部分的初创业务都不稳定提前设计虽然能让代码结构保持统一但是等两年后再回头看你会发现当初的设计已经面目全非我们最初信心满满的设计最后会成为业务的绊脚石。 
所以这种拆分和架构设计需要我们不定期回看、自省、不断调整。毕竟技术是为业务服务的业务更重要没有人可以保证项目初期设计的个人中心不会被改成交友的个人门户。 
总之每一种方法并非绝对正确我们需要根据业务需求来决策用哪一种方式。 
总结 
结业务拆分的方法有很多最简单便捷的方式是先从上到下做业务流程梳理将流程归类聚合然后从不同的领域聚合中找出交互所需主要实体根据流程中主要实体之间的数据依赖程度决定是否拆分从下到上看把不同的实体和动作拆分成多个模块后再根据业务流程归类划分出最终的模块最终汇总。 
这个拆分过程用一句话总结就是从上往下看流程从下往上看模块最后综合考虑流程和模块的产出结果。用这种方式能快速拆出模块范围拆分出的业务也会十分清晰。 图12拆分理论总结 
除了拆分业务外我们还要关注如何抽象服务。如果底层业务变更频繁就会导致上层业务频繁修改甚至出现变更遗漏的情况。所以我们要确保底层服务足够抽象具体有很多种办法比如被动拆分法、动态辅助表方式、标准抽象方式。这几种方式各有千秋需要我们根据业务来决策。 图13系统在不断改进的同时需要不断做核心修正 
通常我们的业务系统在初期都会按照一个特定的目标来设计但是随着市场需求的变化业务系统经过不断改版往往会偏离原有的设计。 
虽然我们每次改版都实现了既定需求但也很容易带来许多不合理的问题。所以在需求稳定后一般都会做更合理的改造保证系统的完整性提高可维护性。很多时候第一版本不用做得太过精细待市场验证后明确了接下来的方向再利用留出足够的空间改进这样设计的系统才会有更好的扩展性。 
强一致锁如何解决高并发下的库存争抢问题 
这节课我会给你详细讲一讲高并发下的库存争抢案例我相信很多人都看到过相关资料但是在实践过程中仍然会碰到具体的实现无法满足需求的情况比如说有的实现无法秒杀多个库存有的实现新增库存操作缓慢有的实现库存耗尽时会变慢等等。 
这是因为对于不同的需求库存争抢的具体实现是不一样的我们需要详细深挖理解各个锁的特性和适用场景才能针对不同的业务需要做出灵活调整。 
由于秒杀场景是库存争抢非常经典的一个应用场景接下来我会结合秒杀需求带你看看如何实现高并发下的库存争抢相信在这一过程中你会对锁有更深入的认识。 
锁争抢的错误做法 
在开始介绍库存争抢的具体方案之前我们先来了解一个小知识——并发库存锁。还记得在我学计算机的时候老师曾演示过一段代码 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 中集中管理这样可以减少用户争抢库存导致其他服务的抖动并且拥有更好的响应速度这也是目前互联网行业保护库存量的普遍做法。 
同时我不建议通过数据库的行锁来保证库存的修改因为数据库资源很珍贵使用数据库行锁去管理库存性能会很差且不稳定。 
前面我们提到当有大量用户去并行修改一个变量时只有用锁才能保证修改的正确性但锁争抢性能很差那怎么降低锁的粒度、减少锁的争枪呢 举个例子当前商品库存有 100 个我们可以把它放在 10 个 key 中用不同的 Redis 实例保存每个 key 里面保存 10 个商品库存当用户下单的时候可以随机找一个 key 进行扣库存操作。如果没库存就记录好当前 key 再随机找剩下的 9 个 key直到成功扣除 1 个库存。 
除了这种方法以外我个人更推荐的做法是使用 Redis 的原子操作因为原子操作的粒度更小并且是高性能单线程实现可以做到全局唯一决策。而且很多原子操作的底层实现都是通过硬件实现的性能很好比如文稿后面这个例子 redis decr prod_1475_stock_1
14incr、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线程×1000ms÷10ms次10000ops 
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 stocktonumber(redis.call(GET,KEYS[1])); 
//没找到返回-1
if stocknil 
then return -1; 
end 
//找到了扣减库存个数
local resultstock-ARGV[1]; 
//如扣减后少于指定个数那么返回0
if result0 
then return 0; 
else //如果扣减后仍旧大于0那么将结果放回Redis内并返回1redis.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 是独立部署的会比我们的业务代码拥有更好的系统资源去快速解决锁争抢问题。 
你可能发现我们这节课讲的方案大多数只有一层“锁”但很多业务场景实际存在多个锁的情况并不是我不想介绍而是十分不推荐因为多层锁及锁重入等问题引入后会导致我们系统很难维护一个小粒度的锁能解决我们大部分问题何乐而不为呢 
系统隔离如何应对高并发流量冲击 
我曾经在一家教育培训公司做架构师在一次续报活动中我们的系统出现了大规模崩溃。在活动开始有五万左右的学员同时操作大量请求瞬间冲击我们的服务器导致服务端有大量请求堆积最终系统资源耗尽停止响应。我们不得不重启服务并对接口做了限流服务才恢复正常。 
究其原因我们习惯性地将公用的功能和数据做成了内网服务这种方式虽然可以提高服务的复用性但也让我们的服务非常依赖内网服务。当外网受到流量冲击时内网也会受到放大流量的冲击过高的流量很容易导致内网服务崩溃进而最终导致整个网站无法响应。 
事故后我们经过详细复盘最终一致认为这次系统大规模崩溃核心还是在于系统隔离性做得不好业务极易相互影响。 改造前的系统部署结构 
如果系统隔离性做得好在受到大流量冲击时只会影响被冲击的应用服务即使某个业务因此崩溃也不会影响到其他业务的正常运转。这就要求我们的架构要有能力隔离多个应用并且能够隔离内外网流量只有如此才能够保证系统的稳定。 
拆分部署和物理隔离 
为了提高系统的稳定性我们决定对系统做隔离改造具体如下图 也就是说每个内、外网服务都会部署在独立的集群内同时每个项目都拥有自己的网关和数据库。而外网服务和内网必须通过网关才能访问外网向内网同步数据是用 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  // 课程idSku        string json:sku         // 课程规格 skuClassId    int32  json:class_id    // 班级idAmount     int32  json:amount,string // 金额分Uid        int64  json:uid,string    // 用户uidAction     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 是能够长时间保存消息的。如果在同步过程中出现问题除了通过日志对故障进行修复外我们还可以将故障期间的流量进行重放重放要保证同步幂等。 
这个特性让我们可以做很多灵活的操作甚至可以在流量高峰期暂时停掉内网消费服务待系统稳定后再开启落地用户的交易。 
除了数据同步外我们还需要对内网的流量做到掌控我们可以通过动态控制线程数来实现控制内网流量的速度。好今天这节课就讲到这里相信你已经对“如何做好系统隔离”这个问题有了比较深入的理解期望你在生产过程中能具体实践一下这个方案。 
总结 
系统的隔离需要我们投入大量的时间和精力去打磨这节课讲了很多会对系统稳定性产生影响的关键特性让我们整体回顾一下。 
为了实现系统的隔离我们在外网服务和内网服务之间设立了接口网关只有通过网关才能调用内网接口服务。并且我们设定了在大流量冲击期间用熔断内网接口的交互方式来保护内网。而外网所需的所有数据在活动开始之前都要通过内网脚本推送到商城本地的缓存中以此来保证业务的运转。 
同时外网成功成交的订单和同步信息通过分布式、可实时扩容和可回放的消息队列投递到了内网内网会根据内部负载调整消费线程数来实现流量可控的消息消费。由此我们实现了两个系统之间的同步互动。 
我把这节课的关键知识画成了导图供你参考 分布式事务多服务的2PC、TCC都是怎么实现的 
目前业界流行微服务DDD 领域驱动设计也随之流行起来。DDD 是一种拆分微服务的方法它从业务流程的视角从上往下拆分领域通过聚合根关联多个领域将多个流程聚合在一起形成独立的服务。相比由数据表结构设计出的微服务DDD 这种方式更加合理但也加大了分布式事务的实现难度。 
在传统的分布式事务实现方式中我们普遍会将一个完整的事务放在一个独立的项目中统一维护并在一个数据库中统一处理所有的操作。这样在出现问题时直接一起回滚即可保证数据的互斥和统一性。 
不过这种方式的服务复用性和隔离性较差很多核心业务为了事务的一致性只能聚合在一起。 
为了保证一致性事务在执行期间会互斥锁定大量的数据导致服务整体性能存在瓶颈。而非核心业务要想在隔离要求高的系统架构中实现跨微服务的事务难度更大因为核心业务基本不会配合非核心业务做改造再加上核心业务经常随业务需求改动聚合的业务过多结果就是非核心业务没法做事务核心业务也无法做个性化改造。 
也正因为如此多个系统要想在互动的同时保持事务一致性是一个令人头疼的问题业内很多非核心业务无法和核心模块一起开启事务经常出现操作出错需要人工补偿修复的情况。 
尤其在微服务架构或用 DDD 方式实现的系统中服务被拆分得更细并且都是独立部署拥有独立的数据库这就导致要想保持事务一致性实现就更难了因此跨越多个服务实现分布式事务已成为刚需。 
好在目前业内有很多实现分布式事务的方式比如 2PC、3PC、TCC 等但究竟用哪种比较合适呢这是我们需要重点关注的。因此这节课我会带你对分布式事务做一些讨论让你对分布式事务有更深的认识帮你做出更好的决策。 
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 方式为例进行讲解。 XA 协调两个服务的分布式事务过程 
如上图所示在协调两个服务 Application 1 和 Application 2 时业务会先请求事务协调器创建全局事务同时生成全局事务的唯一标识 xid然后再在事务协调器里分别注册两个子事务生成每个子事务对应的 xid。这里说明一下xid 由 gtridbqualformatID 组成多个子事务的 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/sqlfmt_ github.com/go-sql-driver/mysqlstrconvtime
)
func main() {// 库存的连接stockDb, err : sql.Open(mysql, root:paswdtcp(127.0.0.1:3306)/shop_product_stock)if err ! nil {panic(err.Error())}defer stockDb.Close()//订单的连接orderDb, err : sql.Open(mysql, root:paswdtcp(127.0.0.1:3307)/shop_order)if err ! nil {panic(err.Error())}defer orderDb.Close()//钱包的连接moneyDb, err : sql.Open(mysql, root:paswdtcp(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 stockstock-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 moneymoney-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/sqlfmt_ github.com/go-sql-driver/mysqlstrconvtime
)
func main() {// 库存的连接stockDb, err : sql.Open(mysql, root:123456tcp(127.0.0.1:3306)/shop_product_stock)if err ! nil {panic(err.Error())}defer stockDb.Close()//订单的连接orderDb, err : sql.Open(mysql, root:123456tcp(127.0.0.1:3307)/shop_order)if err ! nil {panic(err.Error())}defer orderDb.Close()//钱包的连接moneyDb, err : sql.Open(mysql, root:123456tcp(127.0.0.1:3308)/user_money_bag)if err ! nil {panic(err.Error())}defer moneyDb.Close()// 生成xidxid : 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 prepareif _, 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的实现 
最后我们总结一下 TCC 事务的优点 
并发能力高且无长期资源锁定代码入侵实现分布式事务回滚开发量较大需要代码提供每个阶段的具体操作数据一致性相对来说较好适用于订单类业务以及对中间状态有约束的业务。 
当然它的缺点也很明显 
只适合短事务不适合多阶段的事务不适合多层嵌套的服务相关事务逻辑要求幂等存在执行过程被打断时容易丢失数据的情况。 
总结 
通常来讲实现分布式事务要耗费我们大量的精力和时间硬件上的投入也不少但当业务真的需要分布式事务时XA 协议可以给我们提供强大的数据层支撑。 
分布式事务的实现方式有多种常见的有 2PC、3PC、TCC 等。其中2PC 可以实现多个子事务统一提交回滚但因为要保证数据的一致性所以它的并发性能不好。而且 2PC 没有超时的机制经常会将很多 XA 子事务遗漏在数据库中。 
3PC 虽然有超时的机制但是因为交互过多事务经常会出现超时的情况导致事务的性能很差。如果 3PC 多次尝试失败超时后它会尝试回滚这时如果回滚也超时就会出现丢数据的情况。 
TCC 则可以提前预定事务中需要锁定的资源来减少业务粒度。它使用普通事务即可完成分布式事务协调因此相对地 TCC 的性能很好。但是提交最终事务和回滚逻辑都需要支持幂等为此需要人工要投入的精力也更多。 
目前市面上有很多优秀的中间件比如 DTM、Seata它们对分布式事务协调做了很多的优化比如过程中如果出现打断情况它们能够自动重试、AT 模式根据业务修改的 SQL 自动生成回滚操作的 SQL这个相对来说会智能一些。 
此外这些中间件还能支持更复杂的多层级、多步骤的事务协调提供的流程机制也更加完善。所以在实现分布式事务时建议使用成熟的开源加以辅助能够让我们少走弯路。 
基础服务写多读少的链路跟踪系统 
稀疏索引为什么高并发写不推荐关系数据库 
从这一章起我们来学习如何优化写多读少的系统。说到高并发写就不得不提及新分布式数据库 HTAP它实现了 OLAP 和 OLTP 的融合可以同时提供数据分析挖掘和关系查询。 
事实上HTAP 的 OLAP 并不是大数据或者说它并不是我们印象中每天拿几 T 的日志过来用于离线分析计算的那个大数据。这里更多的是指数据挖掘的最后一环也就是数据挖掘结果对外查询使用的场景。 
对于这个范围的服务在行业中比较出名的实时数据统计分析的服务有 ElasticSearch、ClickHouse虽然它们的 QPS 不高但是能够充分利用系统资源对大量数据做统计、过滤、查询。但是相对地为什么 MySQL 这种关系数据库不适合做类似的事情呢这节课我们一起分析分析。 
BTree 索引与数据量 
MySQL 我们已经很熟悉了我们常常用它做业务数据存储查询以及信息管理的工作。相信你也听过“一张表不要超过 2000 万行数据”这句话为什么会有这样的说法呢 
核心在于 MySQL 数据库的索引实现上和我们的需求上有些冲突。具体点说我们对外的服务基本都要求实时处理在保证高并发查询的同时还需要在一秒内找出数据并返回给用户这意味着对数据大小以及数据量的要求都非常高高。 
MySQL 为了达到这个效果几乎所有查询都是通过索引去缩小扫描数据的范围然后再回到表中对范围内数据进行遍历加工、过滤最终拿到我们的业务需要的数据。 
事实上并不是 MySQL 不能存储更多的数据而限制我们的多数是数据查询效率问题。 
那么 MySQL 限制查询效率的地方有哪些请看下图 众所周知MySQL 的 InnoDB 数据库的索引是 BTreeBTree 的特点在于只有在最底层才会存储真正的数据 ID通过这个 ID 就可以提取到数据的具体内容同时 BTree 索引最底层的数据是按索引字段顺序进行存储的。 
通过这种设计方式我们只需进行 13 次 IO树深度决定了 IO 次数就能找到所查范围内排序好的数据而树形的索引最影响查询效率的是树的深度以及数据量数据越独特筛选的数据范围就越少。 
数据量我么很好理解只要我们的索引字段足够独特筛选出来的数据量就是可控的。 
但是什么会影响到索引树的深度个数呢这是因为 MySQL 的索引是使用 Page 作为单位进行存储的而每页只能存储 16KBinnodb_page_size数据。如果我们每行数据的索引是 1KB那么除去 Page 页的一些固定结构占用外一页只能放 16 条数据这导致树的一些分支装不下更多数据时我么就需要对索引的深度再加一层。 
我们从这个 Page 就可以推导出索引第一层放 16 条树第二层大概能放 2 万条树第三层大概能放 2400 万条三层的深度 BTree 按主键查找数据每次查询需要 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 常用的是 BTree我们知道Btree 索引是一个整体的树当我们的数据量大时会影响索引树的深度如果深度过高就会严重影响其工作效率。对于大量数据OLAP 服务会用什么类型的索引呢 
稀疏索引 LSM Tree 与存储 
这里重点介绍一下 LSM 索引。我第一次见到 LSM Tree 还是从 RocksDB以及 LevelDB上看到的RocksDB 之所以能够得到快速推广并受到欢迎主要是因为它利用了磁盘顺序写性能超绝的特性并以较小的性能查询代价提供了写多读少的 KV 数据存储查询服务这和关系数据库的存储有很大的不同。 
为了更好理解我们详细讲讲 Rocksdb 稀疏索引是如何实现的如下图所示 我们前面讲过BTree 是一个大树它是一个聚合的完整整体任何数据的增删改都是在这个整体内进行操作这就导致了大量的随机读写 IO。 
RocksDB LSM 则不同它是由一棵棵小树组成当我们新数据写入时会在内存中暂存这样能够获得非常大的写并发处理能力。而当内存中数据积累到一定程度后会将内存中数据和索引做顺序写落地形成一个数据块。 
这个数据块内保存着一棵小树和具体的数据新生成的数据块会保存在 Level 0 层最大有几层可配置Level 0 层会有多个类似的数据块文件。结构如下图所示 每一层的数据块和数据量超过一定程度时RocksDB 合并不同 Level 的数据将多个数据块内的数据和索引合并在一起并推送到 Level 的下一层。通过这个方式每一层的数据块个数和数据量就能保持一定的数量合并后的数据会更紧密、更容易被找到。 
这样的设计可以让一个 Key 存在于多个 Level 或者数据块中但是最新的常用的数据肯定是在 Level 最顶部或内存04 层0 为顶部中最新的数据块内。 bloomfilter 能辅助确认数据的绝对没有 
而当我们查询一个 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会让我们的业务拥有更多的可能。 
链路追踪如何定制一个分布式链路跟踪系统  
分布式链路跟踪服务属于写多读少的服务是我们线上排查问题的重要支撑。我经历过的一个系统同时支持着多条业务线实际用上的服务器有两百台左右这种量级的系统想排查故障难度可想而知。 
因此我结合 ELK 特性设计了一套十分简单的全量日志分布式链路跟踪把日志串了起来大大降低了系统排查难度。 
目前市面上开源提供的分布式链路跟踪都很抽象当业务复杂到一定程度的时候为核心系统定制一个符合自己业务需要的链路跟踪还是很有必要的。 
事实上实现一个分布式链路跟踪并不难而是难在埋点、数据传输、存储、分析上如果你的团队拥有这些能力也可以很快制作出一个链路跟踪系统。所以下面我们一起看看如何实现一个简单的定制化分布式链路跟踪。 
监控行业发展现状 
在学习如何制作一个简单的分布式链路跟踪之前为了更好了解这个链路跟踪的设计特点我们先简单了解一下监控行业的现状。 
最近监控行业有一次大革新现代的链路跟踪标准已经不拘泥于请求的链路跟踪目前已经开始进行融合新的标准和我们定制化的分布式链路跟踪的设计思路很相似即 Trace、Metrics、日志合并成一套系统进行建设。 在此之前常见监控系统主要有三种类型Metrics、Tracing 和 Logging。 常见的开源 Metrics 有 Zabbix、Nagios、Prometheus、InfluxDb、OpenFalcon主要做各种量化指标汇总统计比如监控系统的容量剩余、每秒请求量、平均响应速度、某个时段请求量多少。 
常见的开源链路跟踪有 **Jaeger、Zipkin、Pinpoint、Skywalking**主要是通过分析每次请求链路监控分析的系统我么可以通过 TraceID 查找一次请求的依赖及调用链路分析故障点和传导过程的耗时。 Skywalking官方trace界面 kibanaELK官网日志查找 
而常见的开源 Logging 有 ELK、Loki、Loggly主要是对文本日志的收集归类整理可以对错误日志进行汇总、警告并分析系统错误异常等情况。 
这三种监控系统可以说是大服务集群监控的主要支柱它们各有优点但一直是分别建设的。这让我们的系统监控存在一些割裂和功能重复而且每一个标准都需要独立建设一个系统然后在不同界面对同一个故障进行分析排查问题时十分不便。 
随着行业发展三位一体的标准应运而生这就是 OpenTelemetry 标准集成了 OpenCensus、OpenTracing 标准。这个标准将 MetricsTracingLogging 集成一体这样我们监控系统的时候就可以通过三个维度综合观测系统运转情况。 
常见 OpenTelemetry 开源项目中的 Prometheus、Jaeger 正在遵循这个标准逐步改进实现 OpenTelemetry 实现的结构如下图所示 OpenTelemetry标准架构 
事实上分布式链路跟踪系统及监控主要提供了以下支撑服务 
监控日志标准埋点 SDKAOP 或侵入式日志收集分布式日志传输分布式日志存储分布式检索计算分布式实时分析个性化定制指标盘系统警告 
我建议使用 ELK 提供的功能去实现分布式链路跟踪系统因为它已经完整提供了如下功能 
日志收集Filebeat日志传输KafkaLogstash日志存储Elasticsearch检索计算Elasticsearch  Kibana实时分析Kibana个性定制表格查询Kibana 
这样一来我只需要制定日志格式、埋点 SDK即可实现一个具有分布式链路跟踪、Metrics、日志分析系统。 
事实上Log、Metrics、trace 三种监控体系最大的区别就是日志格式标准底层实现其实是很相似的。既然 ELK 已提供我们需要的分布式相关服务下面我简单讲讲日志格式和 SDK 埋点通过这两个点我们就可以窥见分布式链路跟踪的全貌。 
TraceID 单次请求标识 
可以说要想构建一个简单的 Trace 系统我们首先要做的就是生成并传递 TraceID。 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 的层级计数器实现很简单第一个接口生成 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 协议放在 HeaderRPC 协议放在 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 的都建议保存为字符串类型每个字段必须是固定数据类型选填内容如果没有内容就直接不输出。 
这样设计其实是为了适配 ElasticsearchKibanaKibana 提供了日志的聚合、检索、条件检索和数值聚合但是对字段格式很敏感不是数值类型就无法聚合对比。 
下面我给你举一个例子用于链路跟踪和监控你主要关注它的类型和字段用途。 {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?id9527,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 类操作容易出现不稳定因素并且日志记录过多也会影响系统性能通过记录对数据源的操作能帮助我们排查业务逻辑的错误。 
我们刚才提到日志如果过多会影响接口性能那如何提高日志的写吞吐能力呢这里我为你归纳了几个注意事项和技巧 
提高写线程的个数一个线程写一个日志也可以每个日志文件单独放一个磁盘但是你要注意控制系统的 IOPS 不要超过 100当写入日志长度超过 1kb 时不要使用多个线程高并发写同一个文件。原因参考 append is not Atomic简单来说就是文件的 append 操作对于写入长度超过缓冲区长度的操作不是原子性的多线程并发写长内容到同一个文件会导致日志乱序日志可以通过内存暂存汇总达到一定数据量或缓存超过 2 秒后再落盘这样可以减少过小日志写磁盘系统的调用次数但是代价是被强杀时会丢日志日志缓存要提前 malloc 使用固定长度缓存不要频繁分配回收否则会导致系统整体缓慢服务被 kill 时记得拦截信号快速 fsync 内存中日志到磁盘以此减少日志丢失的可能。 
“侵入式埋点 SDK”VS“AOP 方式埋点” 
最后我们再说说 SDK。事实上使用“ELK 自定义的标准”基本上已经能实现大多数的分布式链路跟踪系统使用 Kibana 可以很快速地对各种日志进行聚合分析统计。 
虽然行业中出现过很多链路跟踪系统服务公司做了很多 APM 等类似产品但是能真正推广开的服务实际占少数究其原因我认为是以下几点 
分布式链路跟踪的日志吞吐很大需要耗费大量的资源成本高昂通用分布式链路跟踪服务很难做贴近业务的个性化不能定制的第三方服务不如用开源分布式链路跟踪的埋点库对代码的侵入性大需要研发手动植入到业务代码里操作很麻烦而且不够灵活。另外这种做法对语言也有相关的限制因为目前只有 Java 通过动态启动注入 agent才实现了静态语言 AOP 注入。我之前推广时也是统一了内网项目的开源框架才实现了统一的链路跟踪。 
那么如果底层代码不能更新如何简单暴力地实现链路跟踪呢 
这时候我们可以改造分级日志让它每次在落地的时候都把 TraceId 和 RPCID或 SpanID带上就会有很好的效果。如果数据底层做了良好的封装我们可以在发起请求部分中写一些符合标准性能的日志在框架的统一异常处理中也注入我们的标准跟踪即可实现关键点的监控。 
当然如果条件允许我们最好提供一个标准的 SDK让业务研发伙伴按需调用这能帮助我们统一日志结构。毕竟手写很容易格式错乱需要人工梳理不过即使混乱也仍旧有规律可言这是 ELK 架构的强大之处它的全文检索功能其实不在乎你的输入格式但是数据统计类却需要我们确保各个字段用途固定。 
最后再讲点其他日志的注意事项可能你已经注意到了这个设计日志是全量的。很多链路跟踪其实都是做的采样方式比如 Jaeger 在应用本地会部署一个 Agent对数据暂存汇总统计出每个接口的平均响应时间对具有同样特征的请求进行归类汇总这样可以大大降低服务端压力。 
但这么做也有缺点当我们有一些小概率的业务逻辑错误在采样中会被遗漏。所以很多核心系统会记录全量日志周边业务记录采样日志。 
由于我们日志结构很简单如有需要可以自行实现一个类似 Agent 的功能降低我们存储计算压力。甚至我们可以在服务端本地保存原始日志 7 天当我们查找某个 Trace 日志的时候直接请求所有服务器在本地查找。事实上在写多读少的情况下为了追一个 Trace 详细过程而去请求 200 个服务器这时候即使等十秒钟都是可以接受的。 
总结 
系统监控一直是服务端重点关注的功能我们常常会根据链路跟踪和过程日志去分析排查线上问题。也就是说监控越是贴近业务、越定制化我们对线上业务运转情况的了解就越直观。 
不过实现一个更符合业务的监控系统并不容易因为基础运维监控只会监控线上请求流量、响应速度、系统报错、系统资源等基础监控指标当我们要监控业务时还需要人工在业务系统中嵌入大量代码。而且因为这些服务属于开源还要求我们必须对监控有较深的了解投入大量精力才可以。 
好在技术逐渐成熟通用的简单日志传输索引统计服务开始流行其中最强的组合就是 ELK。通过这类分布式日志技术能让我们轻松实现个性化监控需求。日志格式很杂乱也没关系只要将 TraceID 和 RPCID或 SpanID在请求依赖资源时传递下去并将沿途的日志都记录对应的字段即可。也正因如此ELK 流行起来很多公司的核心业务都会依托 ELK 自定义一套自己的监控系统。 
不过这么做只能让我们建立起一个粗旷的跟踪系统后续分析的难度和投入成本依然很大因为 ELK 需要投入大量硬件资源来帮我们处理海量数据. 
引擎分片Elasticsearch如何实现大数据检索 
为什么 ELK 功能这么强大这需要我们了解 ELK 中储存、索引等关键技术点的架构实现才能想清楚。相信你学完今天的内容你对大数据分布式的核心实现以及大数据分布式统计服务都会有更深入的理解。 
Elasticsearch 架构 
我们先分析分析 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 会把一些不经常读取的索引挪到冷数据区以此提高数据存储的性价比。而且我建议你创建索引时按天创建索引这样查询时。我们可以通过时间范围来降低扫描数据量。 ES索引组成 
另外Elasticsearch 服务为了保证读写性能可扩容Elasticsearch 对数据做了分片分片的路由规则默认是通过日志 DocId 做 hash 来保证数据分布均衡常见分布式系统都是通过分片来实现读写性能的线性提升。 
你可以这样理解单个节点达到性能上限就需要增加 Data 服务器节点及副本数来降低写压力。但是副本加到一定程度由于写强一致性问题反而会让写性能下降。具体加多少更好呢这需要你用生产日志实测才能确定具体数值。 
Elasticsearch 的两次查询 
前面提到多节点及多分片能够提高系统的写性能但是这会让数据分散在多个 Data 节点当中Elasticsearch 并不知道我们要找的文档到底保存在哪个分片的哪个 segment 文件中。 
所以, 为了均衡各个数据节点的性能压力Elasticsearch 每次查询都是请求所有索引所在的 Data 节点查询请求时协调节点会在相同数据分片多个副本中随机选出一个节点发送查询请求从而实现负载均衡。 
而收到请求的副本会根据关键词权重对结果先进行一次排序当协调节点拿到所有副本返回的文档 ID 列表后会再次对结果汇总排序最后才会用 DocId 去各个副本 Fetch 具体的文档数据将结果返回。 
可以说Elasticsearch 通过这个方式实现了所有分片的大数据集的全文检索但这种方式也同时加大了 Elasticsearch 对数据查询请求的耗时。下图是协调节点和副本的通讯过程 除了耗时这个方式还有很多缺点比如查询 QPS 低网络吞吐性能不高协助节点需要每次查询结果做分页分页后如果我们想查询靠后的页面要等每个节点先搜索和排序好该页之前的所有数据才能响应而且翻页跨度越大查询就越慢…… 
为此ES 限制默认返回的结果最多 1w 条这个限制也提醒了我们不能将 Elasticsearch 的服务当作数据库去用。还有一点实践的注意事项这种实现方式也导致了小概率个别日志由于权重太低查不到的问题。为此ES 提供了 search_typedfs_query_then_fetch 参数来应对特殊情况但是这种方式损耗系统资源严重非必要不建议开启。 
除此之外Elasticsearch 的查询有 query and fetch、dfs query and fetch、dfs query then fetch 三种不过它们和这节课主线关联不大有兴趣的话你可以课后自己了解一下。 
Elasticsearch 的倒排索引 
Elasticsearch 支持多种查询方式不仅仅是全文检索如数值类使用的是 BKD TreeElasticsearch 的全文检索查询是通过 Lucene 实现的索引的实现原理和 OLAP 的 LSM 及 OLTP 的 BTree 完全不同它使用的是倒排索引Inverted Index。 正排索引及倒排索引 查询时多个分词的合并交集 
一般来说倒排索引常在搜索引擎内做全文检索使用其不同于关系数据库中的 BTree 和 B-Tree 。BTree 和 B-Tree 索引是从树根往下按左前缀方式来递减缩小查询范围而倒排索引的过程可以大致分四个步骤分词、取出相关 DocId、计算权重并重新排序、展示高相关度的记录。 
首先对用户输入的内容做分词找出关键词然后通过多个关键词对应的倒排索引取出所有相关的 DocId接下来将多个关键词设计索引 ID 做交集后再根据关键词在每个文档的出现次数及频率以此计算出每条结果的权重进而给列表排序并实现基于查询匹配度的评分然后就可以根据匹配评分来降序排序列出相关度高的记录。 
下面我们简单看一下 Lucene 具体实现。 ES的倒排索引原理 
如上图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、倒排索引、词权重、匹配权重、分词、异步同步、数据一致性检测等。这些行业中的优秀设计值得我们做拓展了解推荐你课后自行探索。 
实时统计链路跟踪实时计算中的实用算法 
如果我们的数据量很大需要投入的服务器资源就更多之前我们最大一次的规模投入了大概 2000 台服务器做 ELK。但如果我们的服务器资源很匮乏这种情况下要怎样实现性能分析统计和监控呢 
当时我只有两台 4 核 8G 服务器所以我用了一些巧妙的算法实现了本来需要大量服务器并行计算才能实现的功能。这节课我就给你分享一下这些算法。 
我先把实时计算的整体结构图放出来方便你建立整体印象。 实时计算的整体结构图 
从上图可见我们实时计算的数据是从 Kafka 拉取的通过进程实时计算统计 Kafka 的分组消费。接下来我们具体看看这些算法的思路和功用。URL 去参数聚合。 
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 内容有数字 有字符混合 直接认为是md5if (($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_GETPUT geekbang.com/user/1002312/info  geekbang.com/user/num/info_PUTDELETE 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 (fmtgithub.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 对比 测试字符串2fmt.Printf(Comparison of  0 1 : %d\n, simhash.Compare(hashes[0], hashes[1]))//测试字符串1 对比 测试字符串3fmt.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 模块的内容。 
跳数索引后起新秀ClickHouse 
通过前面的学习我们见识到了 Elasticsearch 的强大功能。不过在技术选型的时候价格也是重要影响因素。Elasticsearch 虽然用起来方便但却有大量的硬件资源损耗再富有的公司看到每月服务器账单的时候也会心疼一下。 
而 ClickHouse 是新生代的 OLAP尝试使用了很多有趣的实现虽然仍旧有很多不足比如不支持数据更新、动态索引较差、查询优化难度高、分布式需要手动设计等问题。但由于它架构简单整体相对廉价逐渐得到很多团队的认同很多互联网企业加入社区不断改进 ClickHouse。 
ClickHouse 属于列式存储数据库多用于写多读少的场景它提供了灵活的分布式存储引擎还有分片、集群等多种模式供我们搭建的时候按需选择。 
这节课我会从写入、分片、索引、查询的实现这几个方面带你重新认识 ClickHouse。在学习过程中建议你对比一下 Elasticsearch、MySQL、RocksDB 的具体实现想想它们各有什么优缺点适合什么样的场景。相信通过对比你会有更多收获。 
并行能力 CPU 吞吐和性能 
我们先选个熟悉的参照物——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 合并。 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 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 的查询工作流程 
根据查询条件查询过滤出要查询需要读取的 data part 文件夹范围根据 data part 内数据的主键索引、过滤出要查询的 granule使用 skip index 跳过不符合的 granule范围内数据进行计算、汇总、统计、筛选、排序返回结果。 
在实际用上 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如果硬件和人员都少建议买云服务的云分布式数据库去做需要根据团队具体情况来合理地决策。我还特意为你整理了一张评估表格贴在了文稿里。 实践方案如何用C自实现链路跟踪 
案例背景 
2016 年我在微博任职那时微博有很多重要但复杂的内部系统由于相互依赖较为严重并且不能登陆公用集群每次排查问题的时候都很痛苦。很多问题需要不断加日志试探三天左右才能摸出眉目。为了更高效地排查线上故障我们需要一些工具辅助提高排查问题效率于是我和几个伙伴合作实现了一个分布式链路跟踪的系统。由于那时候我只有两台 4 核 8G 内存服务器可用硬件资源不多所以分布式链路跟踪的存储和计算的功能是通过 C 11 实现的。这个项目最大的挑战就是如何在有限的资源下记录下所有请求过程并能够实时统计监控线上故障辅助排查问题。要想做一个这样的系统主要分为几个关键功能日志采集、日志传输、日志存储、日志查询、实时性能统计展示以及故障线索收集。经过讨论我们确定了具体项目实现思路如下图所示 链路跟踪的第一步就是收集日志。当时我看了链路跟踪的相关资料后决定按分布式链路跟踪思路去设计实现。因为这样做可以通过每次请求入口产生的的 TraceID汇集一次请求的所有相关日志。但是具体收集什么日志才对排查问题更有帮助呢如果链路跟踪只记录接口的性能实际就只能辅助我们分析性能问题对排查逻辑问题意义并不大。经过进一步讨论我们决定给分级日志和异常日志都带上 TraceID方便我们获取更多业务过程状态。另外我们在请求其他服务的请求 Header 内也加上 TraceID 和 RPCID并且记录了 API、SQL 请求的参数、返回内容和性能数据。综合这些就能实现完整的全量日志监控跟踪系统性能问题和逻辑缺陷都能排查。接下来我们就看看这里的主要功能是怎样实现的。 
抓取、采集与传输 
日志采集在我们的系统里怎么实现呢相信你多少能猜到大致做法一般来说我们需要在接口被请求时接收传递过来的 TraceID 以及 RPCID如果没有传递过来的 TraceID那么自己可以用 UUID 生成一个用于标识后续在请求期间所有的日志。 埋点监控示意图 
服务被请求时建议记录一条被调用的访问日志具体可以记录当前被请求的参数以及接口最后返回的结果、httpcode、耗时。通过这个日志后续可以方便我们分析服务的性能和故障。 所有非本地的依赖资源都要记录日志 
而对于被请求期间的业务所产生的业务日志、错误日志以及请求其他资源的日志都需要做详细记录比如 SQL 查询记录、API 请求记录以及这些请求的参数、返回、耗时。 直接请求、中间件以及 AOP 的切面效果 
无论我们想做链路跟踪还是统计系统服务状态都需要做类似 AOP 切面拦截通过切面编程抓取所有操作数据库或 API 请求前后的数据。为了更好理解这里给你提供一个 AOP 的实现样例这是我之前在生产环境中使用的。记录了项目的请求依赖资源部分之后我用了两个传输方式来传输生成的日志一个是通过 memcache 的长链接协议将日志推送到我们日志收集服务上这种推送日志请求超时超过 200ms 就会丢弃这样能避免拖慢接口的性能。另外一个方式是落地到本地磁盘通过 Filebeat 实时抓取推送将日志收集汇总起来。当然第二种方式最稳定但是由于我们公共服务器集群不让登录的限制有一部分系统只能使用第一种方式来传递日志。前面提到由于跟踪的都是核心系统并且业务都很复杂所以我们对所有的请求过程和参数返回都做了记录。可以预见这样的方式产生的日志量很大而且日志的写并发吞吐很高甚至支付系统在某次服务高峰时会出现日志写 100MB/s 的情况。因此我们的日志写入及传输都需要有很好的性能服务支持同时还要保证日志不会丢失。为此我们选择了用 Kafka 来传输日志Kafka 通过对同一个 topic 数据做多个分区动态调配来实现负载均衡及动态扩容当我们流量超过其承受能力时可以随时通过给服务器群组增加服务器来扩容从而提供更好的吞吐量。可以说多系统之间的实时高吞吐传输同步几乎都是使用 Kafka 实现的。 
可动态扩容的分组消费 
那么 Kafka 是如何帮助业务动态扩容消费性能的呢 Kafka 分组消费不同的进程个数分配的消费分区不同 
在 Kafka 消费这里使用的是 Consumer Group 分组消费分组消费是一个很棒的实现我们可以让多个服务同时消费一组数据比如启动两个进程消费 20 个分区的数据也就是一个服务负责消费 10 个区的数据。如果服务运转期间消费能力不够了消息出现堆积我们可以找两台服务器新启动 2 个消费进程此时 Kafka 会对 consumer 进程自动重新调度rebalance让四个消费进程平分 20 个分区即自动调度成每个消费进程消费 5 个分区的数据。通过这个功能我们可以动态扩容消费服务器的能力比如随时增加消费进程数来提高消费能力甚至一些消费服务可以随时重启。这个功能可以让我们动态扩容变得更容易对于写并发大的数据流传输或同步的服务帮助很大几乎大部分最终一致性的数据服务最终都是靠分布式队列来实现的。微博内部很多系统间的数据同步最后都改成了使用 kafka 去做同步。基于 Kafka 的分组特性我们将服务做成了两组消费服务一组用于数据的统计一组用于存储通过这个方式隔离存储和实时统计服务。 
写多读少的 RocksDB 
接下来我们重点说说分布式存储怎么处理因为这是自实现最有特色的地方。另外计算部分的实现和第十三节课的情况大同小异你可以点这里回看。考虑到只有两台存储服务器我需要提供一个写性能很好并支持“检索”的日志存储检索服务经过查找和对比最终我选择了 RocksDB。RocksDB 是 Facebook 做实验出来的产品后经不断完善最终被大量用户使用。它提供了超越 LevelDB 写性能的服务能够在 Flash、磁盘、HDFS 媒介上存储并且能够充分利用多核以及 SSD 提供更高性能的高负载数据存储服务。由于 Rocksdb 是嵌入式的所以我们实现的服务和存储引擎之间没有网络消耗性能会更好再配合上 Kafka 分组消费就可以实现一个无副本的分布式存储。我首先看中的是 RocksDB 这个引擎的写性能。回想一下我们第十节课讲过的内容RocksDB 利用了内存做缓存同时利用磁盘顺序写性能最强的特性能够提供接近单机 300M/s 的写数据能力理想情况下两台存储服务器就可以提供 600M/s 的写入能力。再加上 Kafka 缓解写高峰压力这个设计已经能满足大部分业务需求了。其次RocksDB 的接入非常简单想要在项目中引入它的库只要保证它的写操作只有一个线程写其他线程可以实例化 Secondary 只读即可。此外RocksDB 还支持内存和磁盘冷热数据的自动管理、存储数据压缩等功能而且单个库就能存储上 TB 的数据、单个 Value 长度能够达到 3G这非常适合在分布式链路跟踪的系统里存储和查找大量的文本日志。接下来要解决的问题就是如何在 RocksdDB 分配管理我们的 Trace 日志。为了提高查询效率并且只保留 7 天日志我们选择了按天保存日志一天一个 RocksDB 库过期的数据库可以删除或归档到 HDFS 内。汇总保存日志的时候我们利用了 RocksDB 的这两个方面的特性。一方面通过 Trace 日志的 TraceID 作为 key 来存储这样我们直接通过 TraceID 就可以查到所有相关日志。另一方面是利用 Merge 操作对 KV 中的 value 实现 string append。Merge 是 RocksDB 里很少有人提到的一个功能但用起来还不错可以帮我们把所有日志高性能地追加到一个 Key内。Merge 操作的官方 demo 代码你可以从这里获取如果对于实现原理感兴趣还可以参考下 rocksdb-merge-operators。 
分布式查询与计算 
数据存储好后如何查询呢事实上很简单我们的 Trace SDK 会让每个接口返回响应内容的同时在 header 中包含了 TraceIDdebug 的时候使用返回 traceId 进行查询时界面会对所有存储节点发送查询请求通过 TraceID 从 RocksDB 拿出所有按回车分割的日志后汇总排序即可。另外日志存储服务集成了 Libevent通过它实现了 HTTP 和 Memcache 协议的查询接口服务由于这里比较复杂有多个模式这里不对这个做详细介绍了如果你想了解如何用 epoll 和 Socket 实现一个简单的 HTTP 服务具体可以看看我闲暇时写的小 demo 。我再补充说一下怎么对多节点数据进行查询。由于读操作很少我们可以通过异步请求多个存储实例直接问询查询内容再到协调节点进行汇总排序即可。说完了数据查询我们再聊聊分布式计算。想要实现服务器状态统计计算核心还是利用 Kafka 的分组消费另外启动一组服务消费日志内容在内存中对日志进行汇总计算。如果想采样服务器的请求情况可以定期按时间块索引随机采 1000 个 TraceID 到 RocksDB 的时间块索引内需要展示的时候将它们取出聚合展示即可。关于实时计算的算法和思路我在第十三节课中已经讲过了你可以去回顾一下。关于自实现的整体思路我们聊完了。看完以后你可能会好奇现在硬件资源已经很充裕了我还用学习这些吗事实上在硬件资源充裕的时代我们还是要考虑成本。我们推算一下比如 2000 台服务器性能提升一倍就能节省 1000 台服务器。如果一台每年 1w 维护费用那么就是每年能节省 1000w。架构师除了解决业务问题外大部分时间都是在思考如何在保证服务稳定的情况下降低成本。另外我再说说选择开源的一些建议。由于市面很多开源是共建的并且有一些开源属于个人的习作没有在生产环境验证过。我们要尽量选择在生产环境验证过的、活跃的社区功能。虽然之前我使用 C 实现链路跟踪但现在技术发展得很快如果放在今天我是不推荐你也用同样方法做这个服务的。实践的时候你可以考虑使用 Java、GO、Rust 等语言去尝试相信这样会让你节省大量的时间。 
总结 
这节课我和你分享了我用 C 实现链路跟踪的实践方案其中的技术要点你可以参考下图。 监控跟踪的对应思路 
写多读少的系统普遍会用分布式的队列服务类似 Kafka汇总数据配合多台服务器或分片来消费加工数据通过这样的架构来应对数据洪流。这一章我们详细分析了写多读少系统的几种方案你会发现它们各有千秋。为了方便你对比学习我引入了 MySQL 作为参考。你也可以参考后面这张表格的思路把技术实现的关键点比如数据传输、写入、分片、扩容、查询等等列出来通过这种方式可以帮你快速分析出哪种技术实现更匹配自己项目的业务需要。 直播互动读多写多系统如何实现 
本地缓存用本地缓存做服务会遇到哪些坑 
这一章我们来学习如何应对读多写多的系统。微博 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 的内存管理方式它用虚拟内存的方式管理内存因此每个运行的进程都有自己的虚拟内存空间。回过头来看我们对外提供缓存数据服务时如果想提供更高效的并发读写服务就需要把数据放在本地内存中一般会实现为一个进程内的多个线程来共享缓存数据。不过在这个过程中我们还会遇到缺页问题我们一起来看看。 虚拟内存及 page fault 
如上图所示我们的服务在 Linux 申请的内存不会立刻从物理内存划分出来。系统数据修改时才会发现物理内存没有分配此时 CPU 会产生缺页中断操作系统才会以 page 为单位把物理内存分配给程序。系统这么设计主要是为了降低系统的内存碎片并且减少内存的浪费。不过系统分配的页很小一般是 4KB如果我们一次需要把 1G 的数据插入到内存中写入数据到这块内存时就会频繁触发缺页中断导致程序响应缓慢、服务状态不稳定的问题。所以当我们确认需要高并发读写内存时都会先申请一大块内存并填 0然后再使用这样可以减少数据插入时产生的大量缺页中断。我额外补充一个注意事项这种申请大内存并填 0 的操作很慢尽量在服务启动时去做。前面说的操作虽然立竿见影但资源紧张的时候还会有问题。现实中很多服务刚启动就会申请几 G 的内存但是实际运行过程中活跃使用的内存不到 10%Linux 会根据统计将我们长时间不访问的数据从内存里挪走留出空间给其他活跃的内存使用这个操作叫 Swap Out。为了降低 Swap Out 的概率就需要给内存缓存服务提供充足的内存空间和系统资源让它在一个相对专用的系统空间对外提供服务。但我们都知道内存空间是有限的所以需要精心规划内存中的数据量确认这些数据会被频繁访问。我们还需要控制缓存在系统中的占用量因为系统资源紧张时 OOM 会优先杀掉资源占用多的服务同时为了防止内存浪费我们需要通过 LRU 淘汰掉一些不频繁访问的数据这样才能保证资源不被浪费。即便这样做还可能存在漏洞因为业务情况是无法预测的。所以建议对内存做定期扫描续热以此预防流量突增时触发大量缺页中断导致服务卡顿、最终宕机的情况。 
程序容器锁粒度 
除了保证内存不放冷数据外我们放在内存中的公共数据也需要加锁如果不做互斥锁就会出现多线程修改不一致的问题。如果读写频繁我们常常会对相应的 struct 增加单条数据锁或 map 锁。但你要注意锁粒度太大会影响到我们的服务性能。因为实际情况往往会和我们预计有一些差异建议你在具体使用时在本地多压测测试一下。就像我之前用 C 11 写过一些内存服务就遇到过读写锁性能反而比不上自旋互斥锁还有压缩传输效率不如不压缩效率高的情况。那么我们再看一下业务缓存常见的加锁方式。 多线程修改一个数据配一个锁 
为了减少锁冲突我常用的方式是将一个放大量数据的经常修改的 map 拆分成 256 份甚至更多的分片每个分片会有一个互斥锁以此方式减少锁冲突提高并发读写能力。 多线程 多个分块锁 
除此之外还有一种方式就是将我们的修改、读取等变动只通过一个线程去执行这样能够减少锁冲突加强执行效率我们常用的 Redis 就是使用类似的方式去实现的如下图所示 单线程更新配合 sync.map 
如果我们接受半小时或一小时全量更新一次可以制作 map通过替换方式实现数据更新。具体的做法是用两个指针分别指向两个 map一个 map 用于对外服务当拿到更新数据离线包时另一个指针指向的 map 会加载离线全量数据。加载完毕后两个 map 指针指向互换以此实现数据的批量更新。这样实现的缓存我们可以不加互斥锁性能会有很大的提升。 第一步更新内存 1并切换阅读指针 切换后效果对外提供 memory 1 的数据开始更新内存 2 
当然行业也存在一些无锁的黑科技这些方法都可以减少我们的锁争抢比如 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 分片去应对线上压力。当然这种方式我们同样需要考虑清楚如何做数据同步。 
业务脚本为什么说可编程订阅式缓存服务更有用 
我们已经习惯了使用缓存集群对数据做缓存但是这种常见的内存缓存服务有很多不方便的地方比如集群会独占大量的内存、不能原子修改缓存的某一个字段、多次通讯有网络损耗。很多时候我们获取数据并不需要全部字段但因为缓存不支持筛选批量获取数据的场景下性能就会下降很多。这些问题在读多写多的场景下会更加明显。有什么方式能够解决这些问题呢这节课我就带你了解另外一种有趣的数据缓存方式——可编程订阅式缓存服务。学完今天的内容相信你会对缓存服务如何做产生新的思考。 
缓存即服务 
可编程订阅式缓存服务的意思是我们可以自行实现一个数据缓存服务直接提供给业务服务使用这种实现能够根据业务的需要主动缓存数据并提供一些数据整理和计算的服务。自实现的数据缓存服务虽然繁琐但同时也有很多优势除去吞吐能力的提升我们还可以实现更多有趣的定制功能还有更好的计算能力甚至可以让我们的缓存直接对外提供基础数据的查询服务。 自实现缓存功能结构图 
上图是一个自实现的缓存功能结构可以说这种缓存的性能和效果更好这是因为它对数据的处理方式跟传统模式不同。传统模式下缓存服务不会对数据做任何加工保存的是系列化的字符串大部分的数据无法直接修改。当我们使用这种缓存对外进行服务时业务服务需要将所有数据取出到本地内存然后进行遍历加工方可使用。而可编程缓存可以把数据结构化地存在 map 中相比传统模式序列化的字符串更节省内存。更方便的是我们的服务无需再从其他服务取数据来做计算这样会节省大量网络交互耗时适合用在实时要求极高的场景里。如果我们的热数据量很大可以结合 RocksDB 等嵌入式引擎用有限的内存提供大量数据的服务。除了常规的数据缓存服务外可编程缓存还支持缓存数据的筛选过滤、统计计算、查询、分片、数据拼合。关于查询服务, 我补充说明一下对外的服务建议通过类似 Redis 的简单文本协议提供服务这样会比 HTTP 协议性能会更好。 
Lua 脚本引擎 
虽然缓存提供业务服务能提高业务灵活度但是这种方式也有很多缺点最大的缺点就是业务修改后我们需要重启服务才能够更新我们的逻辑。由于内存中保存了大量的数据重启一次数据就需要繁琐的预热同步代价很大。为此我们需要给设计再次做个升级。这种情况下lua 脚本引擎是个不错的选择。lua 是一个小巧的嵌入式脚本语言通过它可以实现一个高性能、可热更新的脚本服务从而和嵌入的服务高效灵活地互动。我画了一张示意图描述了如何通过 lua 脚本来具体实现可编程缓存服务 可编程缓存服务结构图 
如上图所示可以看到我们提供了 Kafka 消费、周期任务管理、内存缓存、多种数据格式支持、多种数据驱动适配这些服务。不仅仅如此为了减少由于逻辑变更导致的服务经常重启的情况我们还以性能损耗为代价在缓存服务里嵌入了 lua 脚本引擎借此实现动态更新业务的逻辑。lua 引擎使用起来很方便我们结合后面这个实现例子看一看这是一个 Go 语言写的嵌入 lua 实现代码如下所示 package mainimport github.com/yuin/gopher-lua// VarChange 用于被lua调用的函数
func VarChange(L *lua.LState) int {lv : L.ToInt(1)            //获取调用函数的第一个参数并且转成intL.Push(lua.LNumber(lv * 2)) //将参数内容直接x2并返回结果给luareturn 1                    //返回结果参数个数
}func main() {L : lua.NewState() //新lua线程defer L.Close() //程序执行完毕自动回收// 注册lua脚本可调用函数// 在lua内调用varChange函数会调用这里注册的Go函数 VarChangeL.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 mainimport (fmtgithub.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.Mutexsaved []*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获取一个LStateL : luaPool.Get()//任务执行完毕后将LState放回pooldefer 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为 1person : Person{L.CheckString(1)}// 创建一个lua userdata对象用于传递数据// 一般 userdata包装的都是go的structtable是lua自己的对象ud : L.NewUserData() ud.Value  person //将 go struct 放入对象中// 设置这个lua对象类型为 person typeL.SetMetatable(ud, L.GetTypeMetatable(luaPersonTypeName))// 将创建对象返回给luaL.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 {//检测第一个参数是否为其他语言传递的userdataud : 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 LStateL : 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 脚本的方式来加载服务。 
缓存预热与数据来源 
了解了 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 和静态语言交换数据相互调用并配合上我们的任务管理以及各种数据驱动就能完成一个几乎万能的缓存服务。推荐你在一些小项目中亲自实践一下相信会让你从不同视角看待已经习惯的服务这样会有更多收获。 
流量拆分如何通过架构设计缓解流量压力 
一般来说这种服务多数属于实时互动服务因为时效性要求很高导致很多场景下我们无法用读缓存的方式来降低核心数据的压力。所以为了降低这类互动服务器的压力我们可以从架构入手做一些灵活拆分的设计改造。事实上这些设计是混合实现对外提供服务的为了让你更好地理解我会针对直播互动里的特定的场景进行讲解。一般来说直播场景可以分为可预估用户量和不可预估用户量的场景两者的设计有很大的不同我们分别来看看。 
可预估用户量的服务游戏创建房间 
相信很多玩对战游戏的伙伴都有类似经历就是联网玩游戏要先创建房间。这种设计主要是通过设置一台服务器可以开启的房间数量上限来限制一台服务器能同时服务多少用户。我们从服务器端的资源分配角度分析一下创建房间这个设计是如何做资源调配的。创建房间后用户通过房间号就可以邀请其他伙伴加入游戏进行对战房主和加入的伙伴都会通过房间的标识由调度服务统一分配到同一服务集群上进行互动。这里我提示一下开房间这个动作不一定需要游戏用户主动完成可以设置成用户开启游戏就自动分配房间这样做不但能提前预估用户量还能很好地规划和掌控我们的服务资源。如何评估一个服务器支持多少人同时在线呢我们可以通过压测测出单台服务器的服务在线人数以此精确地预估带宽和服务器资源算出一个集群集群里包括若干服务器需要多少资源、可以承担多少人在线进行互动再通过调度服务分配资源将新来的房主分配到空闲的服务集群。 
最后的实现效果如下所示 如上图所示在创建房间阶段我们的客户端在进入区域服务器集群之前都是通过请求调度服务来进行调度的。调度服务器会定期接收各组服务器的服务用户在线情况以此来评估需要调配多少用户进入到不同区域集群同时客户端收到调度后会拿着调度服务给的 token 去不同区域申请创建房间。房间创建后调度服务会在本地集群内维护这个房间的列表和信息提供给其他要加入游戏的玩家展示。而加入的玩家同样会接入对应房间的区域服务器与房主及同房间玩家进行实时互动。这种通过配额房间个数来做服务器资源调度的设计不光是对战游戏里很多场景都用了类似设计比如在线小课堂这类教学互动的。我们可以预见通过这个设计能够对资源做到精准把控用户不会超过我们服务器的设计容量。 
不可预估用户量的服务 
但是有很多场景是随机的我们无法把控有多少用户会进入这个服务器进行互动。全国直播就无法确认会有多少用户访问为此很多直播服务首先按主播过往预测用户量。通过预估量提前将他们的直播安排到相对空闲的服务器群组里同时提前准备一些调度工具比如通过控制曝光度来延缓用户进入直播通过这些为服务器调度争取更多时间来动态扩容。由于这一类的服务无法预估会有多少用户所以之前的服务器小组模式并不适用于这种方式需要更高一个级别的调度。我们分析一下场景对于直播来说用户常见的交互形式包括聊天、答题、点赞、打赏和购物考虑到这些形式的特点不同我们针对不同的关键点依次做分析。 
聊天信息合并 
聊天的内容普遍比较短为了提高吞吐能力通常会把用户的聊天内容放入分布式队列做传输这样能延缓写入压力。另外在点赞或大量用户输入同样内容的刷屏情境下我们可以通过大数据实时计算分析用户的输入并压缩整理大量重复的内容过滤掉一些无用信息。 压缩整理后的聊天内容会被分发到多个聊天内容分发服务器上直播间内用户的聊天长连接会收到消息更新的推送通知接着客户端会到指定的内容分发服务器群组里批量拉取数据拿到数据后会根据时间顺序来回放。请注意这个方式只适合用在疯狂刷屏的情况如果用户量很少可以通过长链接进行实时互动。 
答题瞬时信息拉取高峰 
除了交互流量极大的聊天互动信息之外还有一些特殊的互动如做题互动。直播间老师发送一个题目题目消息会广播给所有用户客户端收到消息后会从服务端拉取题目的数据。如果有 10w 用户在线很有可能导致瞬间有 10w 人在线同时请求服务端拉取题目。这样的数据请求量需要我们投入大量的服务器和带宽才能承受不过这么做这个性价比并不高。理论上我们可以将数据静态化并通过 CDN 阻挡这个流量但是为了避免出现瞬时的高峰推荐客户端拉取时加入随机延迟几秒再发送请求这样可以大大延缓服务器压力获得更好的用户体验。切记对于客户端来说这种服务如果失败了就不要频繁地请求重试不然会将服务端打沉。如果必须这样做那么建议你对重试的时间做退火算法以此保证服务端不会因为一时故障收到大量的请求导致服务器崩溃。如果是教学场景的直播有两个缓解服务器压力的技巧。第一个技巧是在上课当天把抢答题目提前交给客户端做预加载下载这样可以减少实时拉取的压力。第二个方式是题目抢答的情况老师发布题目的时候提前设定发送动作生效后 5 秒再弹出题目这样能让所有直播用户的接收端“准时”地收到题目信息而不至于出现用户题目接收时间不一致的情况。至于非抢答类型的题目用户回答完题目后我们可以先在客户端本地先做预判卷把正确答案和解析展示给用户然后在直播期间异步缓慢地提交用户答题结果到服务端以此保证服务器不会因用户瞬时的流量被冲垮。 
点赞客户端互动合并 
对于点赞的场景我会分成客户端和服务端两个角度带你了解。先看客户端很多时候客户端无需实时提交用户的所有交互因为有很多机械的重复动作对实时性要求没那么高。举个例子用户在本地狂点了 100 下赞客户端就可以合并这些操作为一条消息例如用户 3 秒内点赞 10 次。相信聪明如你可以把互动动作合并这一招用在更多情景比如用户连续打赏 100 个礼物。通过这个方式可以大幅度降低服务器压力既可以保证直播间的火爆依旧还节省了大量的流量资源何乐而不为。 
点赞服务端树形多层汇总架构 
我们回头再看看点赞的场景下如何设计服务端才能缓解请求压力。如果我们的集群 QPS 超过十万服务端数据层已经无法承受这样的压力时如何应对高并发写、高并发读呢微博做过一个类似的案例用途是缓解用户的点赞请求流量这种方式适合一致性要求不高的计数器如下图所示 树形读写缓存 
这个方式可以将用户点赞流量随机压到不同的写缓存服务上通过第一层写缓存本地的实时汇总来缓解大量用户的请求将更新数据周期性地汇总后提交到二级写缓存。之后二级汇总所在分片的所有上层服务数值后最终汇总同步给核心缓存服务。接着通过核心缓存把最终结果汇总累加起来。最后通过主从复制到多个子查询节点服务供用户查询汇总结果。另外说个题外话微博是 Redis 重度用户后来因为点赞数据量太大在 Redis 中缓存点赞数内存浪费严重可以回顾上一节课 jmalloc 兄弟算法的内容改为自行实现点赞服务来节省内存。 
打赏  购物服务端分片及分片实时扩容 
前面的互动只要保证最终一致性就可以但打赏和购物的场景下库存和金额需要提供事务一致性的服务。因为事务一致性的要求这种服务我们不能做成多层缓冲方式提供服务而且这种服务的数据特征是读多写多所以我们可以通过数据分片方式实现这一类服务如下图 hash 分片方式缓解流量 
看了图是不是很好理解我们可以按用户 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 临时调度用户流量等服务来实现动态的资源调配。 
流量调度DNS、全站加速及机房负载均衡 
上节课我们学习了如何从架构设计上应对流量压力像直播这类的服务不容易预估用户流量当用户流量增大到一个机房无法承受的时候就需要动态调度一部分用户到多个机房中。同时流量大了网络不稳定的可能性也随之增加只有让用户能访问就近的机房才能让他们的体验更好。综合上述考量这节课我们就重点聊聊流量调度和数据分发的关键技术帮你弄明白怎么做好多个机房的流量切换。直播服务主要分为两种流量一个是静态文件访问一个是直播流这些都可以通过 CDN 分发降低我们的服务端压力。对于直播这类读多写多的服务来说动态流量调度和数据缓存分发是解决大量用户在线互动的基础但是它们都和 DNS 在功能上有重合需要一起配合实现所以在讲解中也会穿插 CDN 的介绍。 
DNS 域名解析及缓存 
服务流量切换并没有想象中那么简单因为我们会碰到一个很大的问题那就是 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 节点会通过 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 是全局流量管理系统的简称。我画了一张工作原理图帮你加深理解 GTM 智能 DNS 解析 
当客户端请求服务域名时客户端先会请求 DNS 服务解析请求的域名。而客户端请求主域名 DNS 服务来解析域名时会请求到 GTM 服务的智能解析 DNS。相比传统技术GTM 还多了三个功能服务健康监控、多线路优化和流量负载均衡。首先是服务健康监控功能。GTM 会监控服务器的工作状态如果发现机房没有响应就自动将流量切换到健康的机房。在此基础上GTM 还提供了故障转移功能也就是根据机房能力和权重将一些用户流量转移到其他机房。其次是多线路优化功能国内宽带有不同的服务提供商移动、联通、电信、教育宽带不同的宽带的用户访问同提供商的网站入口 IP 性能最好如果跨服务商访问会因为跨网转发会加大请求延迟。因此使用 GTM 可以根据不同机房的 CDN 来源找到更快的访问路径。GTM 还提供了流量负载均衡功能即根据监控服务的流量及请求延迟情况来分配流量从而实现智能地调度客户端的流量。 
当 GTM 和 CDN 网站加速结合后会有更好的效果具体组合方式如下图所示 CDN  GTM 网络加速及故障转移 
由于 GTM 和 CDN 加速都是用了 CNAME 做转发我们可以先将域名指向 CDN通过 CDN 的 GSLB 和内网为客户端提供网络加速服务。而在 CDN回源时请求会转发到 GTM 解析经过 GTM 解析 DNS 后将 CDN 的流量转发到各个机房做负载均衡。当我们机房故障时GTM 会从负载均衡列表快速摘除故障机房这样既满足了我们的网络加速又实现了多机房负载均衡及更快的故障转移。不过即使使用了 CDNGTM还是会有一批用户出现网络访问缓慢现象这是因为很多 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即可实现业务无感的机房切换。 
内网建设系统如何降低业务复杂度 
数据引擎统一缓存数据平台 
任何一个互联网公司都会有几个核心盈利的业务我们经常会给基础核心业务做一些增值服务以此来扩大我们的服务范围以及构建产业链及产业生态但是这些增值服务需要核心项目的数据及交互才能更好地提供服务。但核心系统如果对增值业务系统做太多的耦合适配就会导致业务系统变得十分复杂如何能既让增值服务拿到核心系统的资源又能减少系统之间的耦合这节课我会重点带你了解一款内网主动缓存支撑的中间件通过这个中间件可以很方便地实现高性能实体数据访问及缓存更新。 
回顾临时缓存的实现 
我们先回顾下之前展示的临时缓存实现这个代码摘自之前的第二节课 // 尝试从缓存中直接获取用户信息
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 监控 实现 简单的主动推送数据缓存 
结合上图我们分析一下这个中间件的工作原理。我们通过 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 及缓存查询服务实现热点数据的识别即可实现多级缓存服务。可以说, 数据是我们系统的心脏如数据引擎能力足够强大能做的事情会变得更多。数据支撑平台最大的特点在于将我们的数据和各种数据引擎结合起来从而实现更强大的数据服务能力。大公司的核心系统通常会用多引擎组合的方式共同提供数据支撑数据服务甚至有些服务的服务端只需做配置就可以得到这些功能这样业务实现更轻量能给业务创造更广阔的增值空间。 
业务缓存元数据服务如何实现 
当你随手打开微博或者一个综合的新闻网站可以看到丰富的媒体文件图片、文本、音频、视频应有尽有一个页面甚至可能是由成百上千个文件组合而成。那这些文件都存在哪里呢通常来说低于 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 偏向服务器的服务分布式存储偏向存储文件的管理而对象存储偏向业务的应用。 
存储成本如何推算日志中心的实现成本 
前面我们比较过很多技术细心的你应该发现了比较时我们常常会考虑实现成本这一项。这是因为技术选型上的“斤斤计较”能够帮我们省下真金白银。那么你是否系统思考过到底怎么计算成本呢这节课我会结合日志中心的例子带你计算成本。之所以选日志中心主要有这两方面的考虑一方面是因为它重要且通用作为系统监控的核心组件几乎所有系统监控和故障排查都依赖日志中心大部分的系统都用得上另一方面日志中心是成本很高的项目计算也比较复杂如果你跟着我把课程里的例子拿下了以后用类似思路去计算其他组件也会容易很多。 
根据流量推算存储容量及投入成本 
在互联网服务中最大的变数就在用户流量上。相比普通的服务高并发的系统需要同时服务的在线人数会更多所以对这类系统做容量设计时我们就需要根据用户请求量和同时在线人数来推算系统硬件需要投入多少成本。很多系统在初期会用云服务实现日志中心但核心接口流量超过 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、内存、网络的投入以及系统隔离的成本。不同类型的系统我们的投入侧重点也是不一样的。比如读多写少的服务要重点“堆“内存和网络强一致服务更关注系统隔离和拆分写多读少的系统更加注重存储性能优化读多写多的系统更加关注系统的调度和系统类型的转变。尽管技术决策要考虑的因素非常多我们面临的业务和团队情况也各有不同。但通过这节课我希望能让你掌握成本推算的思维尝试结合计算来指导我们的计算决策。当你建议团队自建机房或者建议选择云服务的时候如果有一套这样的计算做辅助相信方案通过的概率也会有所提升。 
网关编程如何通过用户网关和缓存降低研发成本 
如果说用户的流量就像波涛汹涌的海浪那网关就是防御冲击的堤坝。在大型的互联网项目里网关必不可少是我们目前最好用的防御手段。通过网关我们能把大量的流量分流到各个服务上如果配合使用 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 方式以及通过用户中心接口鉴权的效果 
请求用户中心 API 鉴权方式和 SDK 实现自解方式对比 
如上图集成 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 还是用内网网关一直处于激烈讨论的阶段。而在我看来随着容器化的流行内网网关会迎来新的变革。服务发现、服务鉴权、流量调度、数据缓存、服务高可用、服务监控这些服务最终会统一成一套标准。如果现有的内网网关能降低复杂度未来会更胜一筹。 
性能压测压测不完善效果减一半 
高并发的系统很复杂所以对这样的系统做并发优化也相当有挑战。很多服务的局部优化不见得能真正优化整体系统的服务效果甚至有的尝试还会适得其反让服务变得不稳定。在这种情况下压测就显得更加重要了。通常来说通过压测可以帮我们做很多事儿比如确认单个接口、单台服务器、单组服务集群甚至是整个机房整体的性能方便我们判断服务系统的瓶颈在哪里。而且根据压测得出的结果也能让我们更清晰地了解系统能够承受多少用户同时访问为限流设置提供决策依据。这节课我们就专门聊聊性能压测里需要考虑哪些关键因素。 
压测与架构息息相关 
在压测方面我们很容易踩的一个坑就是盲目相信 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 并不一定能够反映我们的优化是否合理这一点需要结合业务架构来综合评估。我们来回顾一下课程里讲过的几个典型例子并行请求依赖服务优化成串行请求的服务虽然能够提高接口的响应速度但是会让内网压力更大临时缓存服务虽然能降低内网重复查询的压力但如果是低频率数据访问那么优化效果就很一般分片架构的服务压测时需要注意单片热点的问题不然压测虽然表现良好线上运转却可能会出问题。受参与计算的数据量影响大的接口要尤其注意真实系统环境和极端数据量的测试。除了对并行请求、临时缓存、分片架构、数据量这几个点做验证以外还建议做一些极端测试对服务的稳定性进行评估。数据量较多的接口压测时要时刻关注相关数据库压力及索引、缓存的命中率情况预防数据库出现压力过大、响应缓慢的问题。另外我们要在人少的时候停机做线上环境压测但是要预防压测期间产生的垃圾数据这里可以用影子库方式解决不过这需要所有业务配合需要提前做好协调确认。最后相比单接口的压测为了尽量模拟线上真实情况我带你了解了两种更综合的压测方式分别是全链路压测和流量回放测试。