百万级高并发系统,带你近距离体验上线过程
本文是鱼皮在腾讯实习期间,从零开始一周紧急上线百万高并发系统的相关经验、思路及感悟,分享给大家。
花 5 分钟阅读本文,你将收获:
1. 加深对实际工作环境、工作状态的了解
2. 学习高并发系统的设计思路、技术选型及理解
3. 学习工作中对接多方的沟通技巧
4. 学会与测试打配合的技巧
5. 学习紧急事故的处理方式
6. 事后如何进行归纳总结
7. 感受笔者爆肝工作的痛苦与挣扎
前言
从年前开始和导师二人接手了一个紧急项目,年前加班做完一期后项目效果显著,于是年后开工立刻加急开发二期,目标是一周上线。由于项目业务逻辑复杂、工期紧、人手缺、对接方多,难度很大,极具挑战性,因此和导师二人开始了 007 的爆肝工作。
远程办公无疑为 007 无休工作制提供了有利条件,那段时间,我做梦都在敲代码。
项目介绍
首先要介绍下负责的项目及系统。项目背景、业务等信息自然不能透露,这里剥离业务,仅介绍关键系统模型,如下图:
如图,我负责的是一个状态流转系统和查询系统,以及它们依赖的数据库服务。
状态流转系统的作用是按照逻辑修改数据库中某条数据的状态字段,并在修改成功后依据状态向其他业务侧发送通知。
查询系统,顾名思义就是从数据库中查询数据,包括最基础的鉴权、查询等功能。
先分析一下系统中一些难点:
1. 查询系统是一个高扇入服务,被其他各业务侧调用,会存在三个问题:
高并发:将各业务侧请求量聚集,经评估,会产生百万量级的高并发请求。
兼容性:如何设计一套 API,满足各业务侧需求的同时容易被理解。
对接复杂:要同时与多个业务侧的同学沟通来讨论接口,想想就是一件很复杂的事情。
2. 状态流转系统的业务逻辑相当复杂。
3. 状态流转系统和查询系统、其他业务侧之间存在交互(比如互相发送通知和调用),对时延、容错性、一致性的要求很高。
分析出了难点,在写代码之前,要先编写可行的技术方案。
设计思路
在实际工作中,编写详细的技术方案是非常有必要的。优秀的工程师会在技术方案中考虑到各种场景、评估各种风险、工作量估时、记录各种问题等,不仅帮助自己梳理思路、归纳总结,同时也给其他人提供了参照以及说服力(比如你预期7天上线,没有方案谁信你?)。
根据二八定理,复杂的系统中,编写技术方案、梳理设计思路的时间和实际敲代码开发的时间比例为 8 : 2。
设计遵循的原则是 “贴合业务”,没有最好的架构,只有最适合业务的架构。切忌过度设计!
此外,还要考虑项目的紧急程度和人力成本,先保证可用,再追求极致。
一些简单的设计这里就略过了,下面针对系统难点和业务需求,列举几个重点设计及技术选型:
1. 高并发
提到高并发,大家首先想到的是缓存和负载均衡,缺一不可。
负载均衡说白了就是 “砸钱,加机器!”,但是为公司省机器、节约成本是每位后端工程师的信仰,这就要靠技术选型和架构设计来实现了。目标是尽可能利用每台机器的资源,抗住最大的并发请求。
选型如下:
编程框架:选择轻量级的 Restful 框架 Jersey,搭配轻量级依赖注入库 Guice
Web服务器:选择高性能的轻量级 NIO 服务器 Grizzly
缓存:腾讯自研海量分布式存储系统 CKV+(支持Redis协议,有数据监控平台)
数据库分库分表:选用公司自研的基础设施,不细说了
负载均衡:轻量级反向代理服务器 Nginx 和 L5 负载均衡,百万并发需要增加十余台机器
CDN 及预热:能够支持高效的文件下载服务
其中,缓存是抗住高并发流量的关键,须重点设计。
缓存方案
1. 数据结构设计
用过缓存的同学都了解,关于缓存 Key 的设计是很重要的。根据业务来,保证缓存 key 之间不冲突、便于查找就好。此处我选择请求参数 + 接口唯一 id 来拼接 key。并且分页查询接口可复用全量查询接口的缓存。
2. 缓存降级
找不到对应 key / redis 连接失败时直接查库。
3. 缓存更新
当数据库发生修改时,需要对缓存进行删除。由于存在非必填的请求参数,因此缓存 key 可能是一个模糊值。比如有 a、b 两个请求参数,key 可能为 “a”,也可能为 “ab”。
针对请求字段固定(所有字段必填)的接口,更新缓存时,直接拼接出唯一的 key 进行删除即可。
而针对请求字段不固定(存在非必填字段)的接口,可使用 redis 的 scan 命令范围扫描(不要用 keys 命令!)或者通过循环拼接出所有可能的 key。比如使用 scan 命令清除所有 key 前缀为 user1 的缓存。
4. 缓存穿透
无论查询出的列表是否为空,都写入缓存。但在业务会返回多种错误码时,不建议采用这种方式,复杂度高,成本太大。
2. 兼容性
兼容性主要考察接口的设计,为兼容多个业务侧,需要将请求参数以及响应参数设置的尽可能灵活。在设计接口时,切忌一定要和所有的业务侧对齐,否则一个字段设计不当可能导致满盘皆输!
这里有三个技巧:
1. 提供可访问链接的文档,供调用方即时查阅(比如腾讯文档)。
2. 请求参数不能过多,且要易于理解,不能为了强制兼容而设置过于复杂的参数,必要时可针对某一业务侧定制接口。
3. 响应参数尽量多(多不是滥),要知道每次增加返回字段都要修改代码,而适当冗余的字段避免了此问题。
3. 消息通知
上面介绍难点时提到:状态流转系统与查询系统、其他业务侧存在互相发送通知的交互。当状态流转时,需要通知其他业务,还要查询系统立即更新缓存。对消息的实时性要求很高。
这里最初有两种方案:
1. 各系统提供回调接口,用于接收通知。能保证实时性,但是各系统间紧耦合,不利于扩展。
2. 使用消息队列,实现应用解耦及异步消息。
最后还是采取了第二种方案,并选用腾讯自研的 TubeMQ(万亿级分布式消息中间件,已开源Apache孵化),原因如下:
1. 状态流转系统的通知数据之后可能存在其他消费方,使用消息队列利于扩展,对代码侵入性也少。
2. 消息队列可持久化消息
3. TubeMQ 支持消费方负载均衡,性能高
4. TubeMQ 容量大,可存放万亿数量级消息
5. 支持公司自研组件,便于形成统一规范
在技术选型和确定方案时,不仅要关注当前的业务需求,也要有一定的前沿视角。
4. 风险评估
切忌,在选用中间件 / 框架前,要尽可能多的进行了解,评估其可能带来的风险。一般公司内都有自己的知识库,可以利用好内部资源或者找谷歌度娘。
这里我评估了 TubeMQ 带来的风险,从消息可靠性、消息顺序性、消息重复、监控告警等多个角度进行了分析,还是发现了一些可能的风险。比如当消费方消费数据状态改变的消息失败时,缓存未被及时更新,导致数据库和缓存中的数据不一致。
那么,如何规避风险呢?我从消息队列生产方和消费方的角度设计了消息可靠性和数据一致性的解决方案。
解决方案
生产方消息可靠性:
1. Tube 可保证消息一定送达,发送失败时会自动重发。
2. 发送消息结束时会触发回调,回调里可判断消息发送及确认状态,可将发送失败的消息放入队列,下次发送优先从队列里取。
消费方消息可靠性和数据一致性:
1.消费失败时进行最多三次重试
2.重试后仍消费失败,则记录日志,确保消息不丢失
3.通过定时任务读取日志,尝试再次消费失败消息,并进行告警
开发过程
其实开发过程没什么好说的,就是按照技术方案去敲代码。
这里也有几个小窍门:
1. 同时开发多个项目时,可以每个项目一个独立 Git 分支,合并的时候分批合并,否则别人阅读你提交的代码时会非常累!
2. 给每个请求做一些打点数据上报,比如请求量、请求时间、失败请求数,便于监控统计。
3. 多记录日志,详细清晰的日志可以帮助我们快速定位故障
问题解决
很多问题在本地开发时是察觉不到的,在测试及线上环境才会被发现。问题解决的过程就像坐过山车,经常的状态是:测试 => 开发 => 测试 => 上线 => 开发 => 测试,循环往复。
两个温馨小贴士:
1. 遇到问题时,千万不要慌,可以先深呼吸几口气,因为问题一定是可以解决的,解决不了那么你可能要被解决了!
2. 解决问题后,千万别激动,可以先深呼吸几口气,因为你还会产生新的问题,而且往往新问题更严重!
下面分享一些让鱼皮印象深刻的问题。
1. 事务提交时报错?
原因:事务中调用的函数里也有事务,因此事务里套了事务,破坏了隔离性。
解决:修改代码,保证事务隔离性。
2. 依赖包存在,项目启动却报错?
原因:存在多版本 jar 包,导致 Java 代码使用反射机制动态生成类时不知道使用哪个 jar 包里的类。
解决:删掉多余版本 jar 包。
3. 缓存未即时更新
原因:经排查,是由于实际的缓存 key 数量可达千万级,导致更新缓存时使用 scan 命令扫描的效率过低,长达20多秒!
解决:修改更新缓存的方案,不再使用 scan 命令,而是在业务代码中拼凑出所有可能的 keys,依次删除。
以为这个问题这样就结束了?不要忘记上面的小贴士:
“解决问题后,千万别激动,可以先深呼吸几口气,因为你还会产生新的问题,而且往往新问题更严重!”
4. 缓存仍未即时更新?
原因:某业务侧要求数据强一致性,缓存和数据库中的状态必须完全一致!而缓存虽然是毫秒级更新,但无法做到实时一致。
解决:为该业务侧定制一个接口,该接口不查询缓存,直接查数据库,保证查到的数据一定是最新值。
5. 请求卡死
服务运行一段时间后,发现所有的请求都被阻塞了!心脏受不了。
原因:使用 jstack 打印线程信息后分析 thread_dump 文件,发现是由于缓存类库 Jedis 未手动释放连接导致连接数耗尽,导致新的请求线程会不断等待 Jedis 连接释放,从而卡死。
解决:补充释放 Jedis 连接的代码即可。
6. 线上环境分析日志时突然告警,磁盘 IO 占用超过 99%!
原因:误用 cat 命令查看未分割的原始日志文件,由于日志文件太大(几十 GB),导致磁盘 IO 直接刷爆!
解决:使用 less、tail、head等命令代替 cat,并删除已备份的大日志文件。
7. 进程闪退
排查:通常 JVM 进程闪退是有错误日志的,但是并没有找到,排查陷入绝境。没办法,只能祈祷问题不再复现。后来问题真的没出现过了,谢谢!
原因:后来,经询问,是有人手动 kill 掉了这个进程。好的,***。
8. 线上环境的消息通知发送成功了,怎么没有预期的数据更新效果?
定位思路:先看消息是否被消费,再看对消息的处理是否正确。
排查:查看线上日志,发现消息并未被消费;但是查看监控界面,发现消息被测试环境的机器消费了!
原因:由于测试环境和线上环境属于同一个消费组,当消息到达时,同一个消费组只有一个消费者能够成功消费该消息,被测试环境消费掉了,导致线上环境数据没更新。
发现这个问题的时候,已经是上线前一天的深夜。再申请一个消费组已经来不及了,情急之下,只能先下掉测试环境的服务。第二天申请好消费组后,根据环境去区分使用哪个消费组就可以了,这样每个消费组都会独立消费消息,成功避免了消息竞争。
9. 报告!流量太大,撑不住啊!
原因:机器不够,需进行紧急扩容
解决:紧急新申请了 10 台机器,完成初始化配置,成功部署新机器后,成功增大了并发度。
小技巧:多个机器做相同操作时,有两种快捷的做法。
1. 利用 SSH 连接工具自带的并行操作功能,自动给所有机器键入命令( XShell 软件支持)
2. 配置好一台机器后,可使用 rsync 命令同步配置至其他机器
10. 上线前一天你跟我说接口设计有问题?
原因:沟通出现严重问题!
工作中,一些同事因为自身业务繁忙,可能在核对接口设计方案的时候没有注意。等他们忙完了,会反复 @ 你、私聊你询问。我们一定不要这样!
解决:紧急电话会议,拉群核对方案
11. 线上出 bug 了!
线上出 bug,是一件很大的事,必须紧急响应。在梦里也得给我爬起来!
原因:测试环境和线上环境未必完全一致,且测试环境未必能测出所有问题。因此验证时通常需要预发布环境,数据使用线上数据,但却是独立的服务器,保证不影响线上。
解决:紧急排查定位问题,三分钟成功修复!
修复 bug 有一定的技巧,分享下个人的排错路径:
截图 / 问题 => 请求 => bug 是否可复现,和测试紧密配合 => 数据 => 数据源(真实数据与接口数据是否一致) => 数据处理
解释一下:
通常发现问题的是运维、用户或者测试,他们会抛出一个问题或者问题的相关的截图,这时,我们要快速想到这个问题对应的功能(即对应的请求/接口),然后让问题描述者尽可能多的提供信息(比如请求参数、问题时间等)。
如果问题时间较久,看日志及监控不易排查,可以询问是否可以造一个复现该问题的case,这样只需观察最新的日志即可,方便排错。
定位到请求后,我们要分析请求及响应的哪些数据是异常的,即定位关键数据,然后定位数据来源(是从数据库查的,还是从缓存查的),并观察响应数据与真实数据源是否一致。如果不一致,可能是业务逻辑中对数据的处理出现了问题,再进一步去做分析。
高效沟通建议:描述问题,尽量用数据说话,给出截图的同时,要提供完整的数据、请求等信息,有助他人分析。
12. 线上出现部分错误数据
这是一个可以预见的问题。还好已经在项目中配置了邮件告警,能够报告错误数据的信息,错误数据量也不大。
解决:修复导致错误数据的 bug 后,编写程序循环所有错误信息并生成请求代码,然后手动执行请求代码,刷新线上不同步数据即可。
建议:设计时还是要尽可能考虑到风险,可以按照问题的严重程度做分级报警策略(短信 > 邮件 > 通讯软件)。
13. 线上机器 OOM!
上线三天后发现的问题,部分线上机器竟然出现了OOM(堆内存溢出)的情况,导致服务不可用。经排查,是使用的第三方中间件的当前版本存在 bug, 所以说在使用组件前要充分调研和风险评估,选择正确的版本。
血泪教训
1. 有问题一定尽可能在测试环境去解决,否则线上出问题对心脏很不友好。
2. 不要盲目乐观,以为上线就没问题,要多验证,保持警惕。
3. 使用第三方依赖时,一定要严格核对依赖版本号,确保稳定版本。使用老版本或版本不一致可能导致严重 bug!
上线后如果发现问题,会经历如下流程,我称它为 hapy 流程。
比如当发现 DB 服务的 bug 后,你只需要改 DB 服务的一行代码。然而还要做:
1. 修改DB服务的一行代码
2. 跑单元测试
3. DB服务打成依赖包
4. 修改“状态流转系统”、“查询系统”对DB服务的依赖包(改动版本号/更新本地缓存拉取最新包)
5. 重新发布“状态流转系统”、“查询系统”至测试环境
6. 可能还要重新交给测试的同学进行回归测试
7. 测试通过,再次提交“状态流转系统”、“查询系统”的代码,发起 CR(代码审查)
8. 找同事或 Leader 读代码,通过 CR
9. 合并分支
10. 发布 “状态流转系统”、“查询系统” 至线上环境,每发一台机器,都要进行一次验证(滚动部署)。
11. 再次发现新的bug
这是一件恶心到爆炸的事情,但是在第 2、6、8 步骤时,是存在空余等待时间的。这时我们可以做做其他工作,记录一下工作内容、问题等。
总结
首先总结一下这个项目各阶段的耗时:
理解需求:5%
开发:15%
沟通确认问题:30%
测试及验证:30%
上线及验证:20%
其中,修复 bug 贯穿后面的几个流程,大概占了总时间的60%。
项目过程存在的问题:
1. 前期未参与需求评审,了解的信息较少。
2. 上线前一天晚上,竟然还在临时对齐接口?这是在沟通方案阶段应该确认好的。
3. 大约 80% 的时间花在沟通、查询数据、提供数据及验证。
4. 自己没测试完,就开始串测,导致同一个 bug 被多方发现,反复 @,导致改 bug 效率低下。
5. 对自研中间件的不熟悉,导致花费的时间成本较高。
6. 全局观还不够,不能提前预见到一些可能的问题。
7. 对中间件调研不够,在最初未核对依赖版本号导致线上机器 OOM。
自我感觉良好的地方:
1. 和测试同学配合紧密,互相体谅,测试效率较高
2. 为查询系统编写了详细的接口文档,上传至公司知识库供实时查阅
3. 最快 3 分钟紧急修复线上 bug
4. 最快 30 分钟从接受需求到上线
5. 在发现中间件问题时,即时和对接方沟通,设计出了对其无任何影响的低成本解决方案
6. 积极帮助其他同学查询数据,排查问题
7. 编写脚本高效解决部分错误数据
成长与收获:
1. 抗压熬夜能力 ↑
2. 设计思维能力 ↑
3. 沟通能力 ↑
4. 解决问题能力 ↑
5. 高级命令熟悉度 ↑
6. 中间件熟悉度 ↑
7. 集群管理能力 ↑
8. 拒绝需求能力 ↑
9. 吐槽能力 ↑
10. 吹 🐂 能力 ↑
后续
项目上线后,通过总结复盘,发现了项目中值得优化的地方,也思考到了一些更健全的机制,将逐渐去实现。比如:
1. 两个系统中有部分相同的配置
目前采用复制粘贴的方式去同步相同的配置,这种方式的优点是比较简单。但缺点也很明显,如果一个系统的配置改了,而忘了修改另一个系统的配置,就会出现错误。
事实上,可以引入一个配置中心,集中管理多个系统的配置文件,并且支持手动修改、多环境、灰度、配置版本回退等功能。
可以采用阿里的 Nacos 或携程的 Apollo,提供了界面来管理配置。
2. 曾经的进程闪退问题,必须重视!
无法保证进程不闪退,但是可以对进程实时监控,并自动对闪退进程进行重启。
实现方式有两种:
1. 使用工具,例如 supervisor 或 monit,可以对进程进行管理和闪退重启
2. 编写 shell 脚本,再通过定时任务,实现周期性观察进程状态及重启。推荐将定时任务接入分布式任务调度平台,尤其当定时任务很多时,进行可视化的管理和方便的控制调度是必要的!
3. 消息队列可靠性保障
1. 消息重传机制:如方案所说,设计重传队列,再次发送时优先取重传队列中的消息发送。但注意要避免队列无限重传,须给每个消息设置重传次数阈值。
2. 邮件告警:如果消息重传次数超过阈值,直接发送邮件告警,不再将该消息入队。
工作真是简单而不简单,谁说后端只是 CRUD(增删改查)?
本文来自:鱼皮客栈
乐于输出干货的Java技术公众号:Garnett的Java之路。公众号内有大量的技术文章、海量视频资源、精美脑图,不妨来关注一下!回复【资料】领取大量学习资源和免费书籍!