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