首页 > 技术交流 > 项目心得 | 一周爆肝上线百万高并发系统

项目心得 | 一周爆肝上线百万高并发系统

头像
木子鱼皮
编辑于 2020-02-22 17:19:31 APP内打开
赞 68 | 收藏 280 | 回复27 | 浏览5985

本文是个人(腾讯广告全栈毕业生)从零开始一周紧急上线百万高并发系统的相关经验、思路及感悟,在此记录分享。

后续持续更新,欢迎先🐴后看!

花5-10分钟阅读本文,你将收获:

  1. 加深对实际工作环境、工作状态的了解
  2. 学习高并发系统的设计思路、技术选型及理解
  3. 学习工作中对接多方的沟通技巧
  4. 学会与测试打配合的技巧
  5. 学习紧急事故的处理方式
  6. 事后如何进行归纳总结
  7. 感受笔者爆肝工作的痛苦与挣扎

前言

从年前开始和导师二人接手了一个加紧项目,年前加班做完一期后效果显著,于是开工后加急开发二期,目标是7天上线(后来延长至9天)。由于项目难度大、工期紧、人手缺、对接方多,极具挑战性,因此和导师二人开始了007的爆肝工作。

PS:007本意指一周七天随时灵活oncall。此处的007特指朝9晚0点后、无午休以及夜里做梦的时间都在工作的爆肝工作制。远程办公为007提供了有利条件。

图片说明

项目介绍

首先要介绍下负责的项目及系统。项目背景、业务等信息自然不能透露,这里剥离业务,介绍一下抽象出的关键系统模型,如下图:

如图,我负责的是一个状态流转系统和查询系统,以及它们依赖的DB服务。

状态流转系统的作用主要是按照逻辑修改DB中的状态值,并在修改成功时依据状态向其他业务侧发送通知。

查询系统,顾名思义就是查询我们负责的DB的值,包括最基础的鉴权、查询等功能。

先分析一下系统中一些难点:

  1. 明显,查询系统是一个高扇入服务,被各其他业务侧调用,必然会存在三个问题:
    • 高并发:各业务侧流量聚集,经评估,会产生百万量级的高并发流量
    • 兼容性:如何设计一套API,兼容各业务侧的同时易被理解
    • 对接复杂:要同时与4个业务侧的同学沟通,想想就是一件很复杂的事情
  2. 状态流转系统逻辑相当复杂(最后光逻辑就300多行代码)
  3. 状态流转系统与查询系统、其他业务侧存在交互(比如互相发送通知),对时延、一致性要求很高

分析出难点后,下面开始编写技术方案。

设计思路

在实际工作中,编写好的、详细的技术方案是非常有必要的。优秀的工程师会在技术方案中考虑到各种场景、评估各种风险、工作量估时、记录各种问题等,不仅帮助自己梳理思路、归纳总结,同时也给其他人提供了参照以及说服力(比如你预期7天上线,没有方案谁信你?)。

根据二八定理,复杂的系统中,可能编写技术方案、梳理设计思路的时间和实际敲代码开发的时间比例为8 : 2。

设计遵循的原则是”贴合业务“,没有最好的架构,只有最适合业务的架构。切忌过度设计!

此外,还要考虑项目的紧急程度和人力成本,先保证可用,再追求极致。

一些简单的设计这里就略过了,下面针对系统难点和业务需求,列举几个重点设计及技术选型:

1. 高并发

提到高并发,大家首先想到的是缓存和负载均衡,缺一不可。

负载均衡说白了就是“砸钱,加机器!”,但是为公司省机器、省成本应该是每位后端工程师的基本理念,就要靠技术选型和架构设计来实现了,目标是保证每台机器能抗住最大的并发流量

选型如下:

  1. 编程框架:选择轻量级的Restful框架Jersey,搭配轻量级依赖注入Guice(不用Spring,可以私信我问原因)
  2. Web服务器:选择性能最高的轻量级NIO服务器Grizzly(各服务器性能对比
  3. 缓存:CKV+ 腾讯自研海量分布式存储系统(支持Redis协议,有数据监控平台,已开源)
  4. DB分库分表:公司自研基础设施,不多说
  5. 负载均衡:轻量级反向代理服务器 Nginx,百万并发需要增加十余台机器
  6. 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. 兼容性

兼容性主要考察接口的设计,为兼容多个业务侧,需要将请求参数以及响应参数设置的尽可能灵活。在设计接口时,切忌一定要和所有的业务侧对齐,否则可能导致满盘皆输!

这里有三个技巧:

  1. 提供可访问链接的文档,供调用方即时查阅(一般公司都有知识库)。
  2. 请求参数不能过多,且要易于理解,不能为了强制兼容而设置过于复杂的参数,必要时可针对某一业务侧定制接口。
  3. 响应参数尽量多(多不是滥),要知道每次增加返回字段都要修改代码,而适当冗余的字段避免了此问题。

3. 消息通知

上面难点中提到:状态流转系统与查询系统、其他业务侧存在互相发送通知的交互,在查询系统收到通知后,要对缓存进行即时更新,因此对消息的实时性要求很高

这里最初有两种方案:

  1. 各系统提供回调接口,用于接收通知。能保证实时性,但是各系统间紧耦合,不利于扩展。
  2. 使用消息队列,实现应用解耦及异步消息。

最后还是果断采取了第二种方案,并选用公司自研 TubeMQ 万亿级分布式消息中间件(已开源Apache孵化),原因如下:

  1. 状态流转系统的通知数据之后可能存在其他消费方,使用消息队列利于扩展,对代码侵入性也少
  2. 消息队队列可持久化消息
  3. TubeMQ支持消费方负载均衡,性能高
  4. TubeMQ容量大,可存放万亿数量级消息
  5. 支持公司自研组件,便于形成统一规范(类似现在的全业务上云)

在技术选型和确定方案时,不仅要关注当前的业务需求,也要有一定的前沿视角。

***险评估

切忌,在使用中间件/框架前,要尽可能多的进行了解可能带来的风险,一般公司内都有KM(知识库),可利用好内部资源或者google!

这里我主要评估了TubeMQ带来的风险,做一些分享,非技术的同学建议直接跳过!

TubeMQ风险

1. 消息可靠性

Tube性能高,但是不保证消息的绝对可靠

Tube系统主要在两个地方可能会有数据丢失:

  1. 第一是Tube采取了Consumer信任模型,即数据一旦被Consumer拉取到本地,就默认会消费成功,如果Consumer在实际消费的过程中出现错误,则Tube并不负责恢复。
  2. 由于操作系统pagecache的利用,服务器断电或宕机而可能带来的数据丢失。

经评估,本业务需同时保障发送方及消费方的消息可靠性。

2. 消息顺序性

Tube沿用了Kafka的分区设计思想,而分区的数据消费之间是没有先后顺序关系的,而且Tube支持消息的异步方式发送;在这种方式下,网络并不能保证先发送的消息就一定会先到达服务端,所以Tube一般不提供顺序性的保证

经评估,本业务消息消费方允许消息非顺序。

3. 消息重复

Tube集群中,Consumer的消费位置信息由Broker端进行管理,所以在某些异常情况下,Broker可能无准确获得Consumer的实际消费情况而导致数据重复;另外就是出于性能考虑, Consumer的消费位置信息在每次变化时,并不会实时更新到持久化存储中,而是暂存于内存,周期性更新,如果此时broker宕机即会导致少量的数据重复

经评估,本业务消息消费方是相对幂等操作,可允许消息重复。

4. 监控告警

  1. 公司内部提供监控平台,但数据有五分钟延迟,非实时监控

  2. 提供了对消费方单分区滞后的告警,可在公司内部平台直接修改消费配置

那么,如何规避风险呢?设计了针对消息可靠性和数据一致性的解决方案。

消息可靠性方案

1. 生产方消息可靠性
  1. Tube可保证消息一定送达,发送失败时会自动重发。
  2. 发送消息结束时会触发回调,回调里可判断ACK状态,将发送失败的消息放入队列,下次发送优先从队列里取。
2. 消费方消息可靠性
  1. 消费失败时记录日志,确保消息不丢失

数据一致性方案

1. 设计消息补偿接口

消息消费失败时,会记录日志,人工排查日志,调用补偿接口再次消费消息。(其实也可以存db消息表,写任务去轮询消费,成本太高)

开发过程

其实开发过程没什么好说的,就是按照技术方案去敲代码。

这里也有几个要点:

  1. 同时开发多个项目时,每个项目一个独立Git分支,合并的时候分批合并,否则别人CR你代码的时候可能你就要打喷嚏了!
  2. 给每个请求做一些打点数据上报,比如请求量、请求时间,便于监控统计(这里我将数据上报至公司自研网关的系统,可视化监控)
  3. 多打日志,打详细清晰的日志,便于故障定位

高并发系统一定要做好完备的日志及监控措施!

问题解决

问题主要在测试及线上被发现,问题解决的过程就像坐过山车,经常的状态是:测试 => 开发 => 测试 => 上线 => 开发 => 测试,循环往复。。。

两个温馨小贴士:

  1. 遇到问题时,千万不要慌,可以先深呼吸几口气,因为问题一定是可以解决的,解决不了那么你可能要被解决了!
  2. 解决问题后,千万别激动,可以先深呼吸几口气,因为你还会产生新的问题,而且往往新问题更严重!

这里分享一些印象深刻的问题。

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台机器,完成初始化配置,成功部署服务后修改负载均衡服务配置,成功增大了并发度。

小技巧:多机器做重复操作时,有两种快捷的做法

  1. 利用ssh连接工具自带的并行操作功能,自动给所有机器键入命令(xshell支持)
  2. 配置好一台机器后,可使用rsync命令同步配置至其他机器(大数据的同学应该不陌生)

10. 为什么新增机器上的服务没有记录日志?

排查:先发请求测试服务是否可用。测试发现服务是可用的,只是没有记录日志。dump_thread发现,日志线程被持续阻塞。

原因:服务启动时,会激活TubeMQ消费者,上面提到,Tube消费者有消费组的概念,同组内的消费者会竞争消息(负载均衡),只有一个消费者会收到消息。由于消费组中的最大消费者数量等同于配置的数据分区数,导致新增机器的服务中,消费者订阅不成功,订阅线程持续占用日志资源(加锁不释放),导致日志线程被持续阻塞。

解决:另一个框架的日志能够生成,因此影响不大,暂未解决。

11. 上线前一天你跟我说接口设计有问题?

原因:沟通出现严重问题!

其实工作中,很多同事因为自身业务繁忙,可能在核对接口设计方案的时候不说话,周知的消息不看,给文档也不看。等他们忙完了,会反复@你、私聊你询问。我们一定不要这样!

解决:紧急电话会议,拉群核对方案

有时4个人能拉4个群。。。

12. 线上出bug了!!!

线上出bug,是一件很大的事,必须紧急响应。在梦里也得给我爬起来!

原因:测试环境和线上环境未必完全一致,且测试环境未必能测出所有问题。因此验证时通常需要预发布环境,数据使用线上数据,但却是独立的服务器,保证不影响线上。

解决:紧急排查定位问题,三分钟成功修复!

修复bug有一定的技巧,分享下个人的排错路径:

截图/问题 => 请求 => bug是否可复现,和测试紧密配合 => 数据 => 数据源(真实数据与接口数据是否一致) => 数据处理

解释一下:

通常发现问题的是运维、用户或者测试,他们会抛出一个问题或者问题的相关的截图,这时,我们要快速想到这个问题对应的功能(即对应的请求/接口),然后让问题描述者尽可能多的提供信息(比如请求参数、问题时间等)。

如果问题时间较久,看日志及监控不易排查,可以询问是否可以造一个复现该问题的case,这样只需观察最新的日志即可,方便排错。

定位到请求后,我们要分析请求及响应的哪些数据是异常的,即定位关键数据,然后定位数据来源(是从数据库查的,还是从缓存查的),并观察响应数据与真实数据源是否一致。如果不一致,可能是业务逻辑中对数据的处理出现了问题,再进一步去做分析。

高效沟通建议:描述问题,尽量用数据说话,别光截图,要提供完整的数据信息,有助他人分析。

13. 线上出现部分错误数据

这是一个可以预见的问题。还好已经在项目中配置了邮件告警,能够报告错误数据的信息,错误数据量也不大。

解决:修复导致错误数据的bug后,编写程序循环所有错误信息并生成请求list,然后手动发送请求,刷新线上不同步数据即可。

设计时还是要尽可能考虑到风险,可以按照问题的严重程度做分级报警策略(短信 > 邮件 > 通讯软件)。

14. 线上机器OOM!!!

上线三天后发现的问题,部分线上机器竟然出现了OOM(堆内存溢出)的情况,导致服务不可用。

感兴趣的同学请阅读:项目心得 | 深夜OOM

血泪教训

  1. 有问题一定尽可能在测试环境去解决,否则线上出问题对心脏很不友好
  2. 不要盲目乐观,以为上线就没问题,要多验证,保持警惕。

PS:上线后如果发现问题,会经历如下的流程,我称它为happy流程:

上线后的变更流程:

当发现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. 全局观还不够,不能提前预见到一些可能的问题。

自我感觉良好的地方:

  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脚本,再通过定时任务,实现周期性观察进程状态及重启。推荐将定时任务接入分布式任务调度平台,尤其当定时任务很多时,进行可视化的管理和方便的控制调度是必要的!

由于公司内部的平台比较完善,我选择第二种方式。

原来自己写过一个分布式邮件调度平台,参考了分布式任务调度平台的实现方式,大家有兴趣可以了解下原理,感觉对拓宽后端思路很有帮助。

工作简单而不简单,做着有意义的事就好~

================================
此处回答一些牛友们的问题,欢迎牛友们抛出疑问、指出不足、积极讨论。

问题讨论

1. 事务依赖的服务也有事务,怎么处理?

答:同一个数据访问对象(dao)开启多个事务才会出现此问题,处理方式是不使用依赖服务中有事务的方法。比如可以用for循环单条删除来代替批量删除。

2. 为什么不用主流的Spring + SpringMVC,而是用Jersey + Guice框架?

的确,Spring是主流,生态好,一般大家都会选择SpringBoot + Tomcat。但主流不代表适用于所有的业务场景,还是要贴合业务去做技术选型。其实这里应该拿SpringMVC去对标Jersey、Spring对标Guice,但实际上SpringMVC也依赖Spring(一家人)。

这里不用Spring有如下几个原因:

  1. 系统较小型。Spring和SpringMVC功能虽大而全,但是相对于Jersey,显得有些重量级,需要做更多的工作(比如编写配置文件等等)。
  2. 系统仅提供查询服务。而Jersey框架专门提供Restful风格接口,完全可以满足需求。
  3. 由于Jersey轻量的特性,支持手动注册接口,相对Spring更为灵活,与其他框架搭配使用也很方便(比如Guice、Grizzly),不易出现版本冲突。
  4. Guice可以在一个文件中手动注入管理所有依赖,便于查找。而用Spring的时候我们通常是在每个类上加注解扫包或者编写配置文件,虽然写起来爽的飞起,但非常不利于代码阅读(import *和lombok也是同理,最好不要用)!

这里也提一下用Grizzly服务器来替代Tomcat的好处,主要是:

  1. 性能更高,高并发场景表现更稳定。
  2. 操作管理方便,在代码中启动即可(虽然Tomcat也提供了embed版本)。

3. 负载均衡怎么实现?

企业负载均衡一般都是软硬结合(例如l5、Nginx)、四七层结合(例如lvs+keepalived、Nginx)。

要提高负载,首先增加机器或扩容(以设置更大的jvm内存),给每个机器部署相同的服务,然后配置负载均衡器,增加到新机器的路由即可。当然完成上述操作后要去做验证,看是否有请求路由到了新机器。

缓存和DB都是集群的,提供统一接口供查询服务调用,不用担心各机器的查询结果不一致。

27条回帖

回帖
加载中...
话题 回帖

相关热帖

技术交流近期热帖

近期精华帖

热门推荐