大家好,我是IT孟德,You can call me Aman(阿瞒,阿弥陀佛的ē,Not阿门的ā),一个喜欢所有对象(热爱技术)的男人。我正在创作架构专栏,秉承ITer开源精神分享给志同道合(爱江山爱技术更爱美人)的朋友。专栏更新不求速度但求质量(曹大诗人传世作品必属精品,请脑补一下《短歌行》:对酒当歌,红颜几何?譬如媳妇,吾不嫌多...青青罗裙,一见动心,但为佳人,挂念至今...),用朴实无华、通俗易懂的图文将十六载开发和架构实战经验娓娓道来,让读者茅塞顿开、相见恨晚...如有吹牛,不吝赐教。关注wx公众号:IT孟德,您的每一次点赞和转发都是我继续码字的动力!
专栏文章推荐:
【背景介绍】
在青少年心理健康日益受到全社会高度重视的背景下,“心晴灯塔”平台(商密缘故,名字杜撰)应运而生,致力于成为K12教育阶段学生心理健康的坚实守护者。平台深度融合心理测评、危机预警干预、心理咨询辅导与家校共育等核心能力,通过AI虚拟人与大数据技术赋能学生心理健康工作全链条,为全国教育系统提供科学化、系统化一站式心理健康管理解决方案。当前平台已覆盖全国300+区县,学生日活跃峰值50万+人次,教师日均处理预警10,000+条,家长端心理周报打开率稳超80%,形成学生主动参与、教师高效响应、家长深度协同的可持续生态。
当前该平台的用户量还处于快速爬坡阶段,伴随着AI技术的突飞猛进,无论是功能还是系统架构持续在变,各种问题层出不穷。前期为了快速验证市场,缺少架构师和经验丰富的研发参与,导致系统基础架构简单,前瞻性设计不足,埋下大量隐患;另一方面详细设计、代码质量管控不规范,存在大量简单问题复杂化、业务实现逻辑混乱的现象。随着业务市场占位越来越多,用户和数据量越来越大,系统接口响应慢、数据混乱等问题频频发生,尤其是市区级大规模普测期间,浪费大量的前后场人员进行保障,高峰时期一天安排两个全人力研发值班仍手忙脚乱。种种问题都对系统稳定性构成严重威胁,全面的优化迫在眉睫。
类似现象在项目从0到1的阶段其实非常普遍,项目孵化阶段小步快跑,牺牲质量抢占市场。一旦业务成功,系统无法承载时推翻重构进行替换。虽然该项目业务和用户规模急剧增长超出预期,但项目预算和团队研测人力有限,所以在保证功能需求按计划发布的同时进行全方位的架构与非功能优化更加现实。
【优化策略】
平常我们都疲于业务编码,过程中因为经验不足或者研发周期紧凑等原因,容易忽视质量、性能等问题。本文主题突出“实战”,套用一些兵法谋略来讲解系统非功能优化的全过程。其中很多策略在我们平常开发中都有接触或用到,重点是要结合具体业务场景使用,且能用得恰到好处。
第一招:谋定后动,知止有得
系统非功能优化是一个复杂漫长的过程。付诸行动之前必须进行系统性地分析、评估和规划,找出系统存在的性能瓶颈和问题,根据系统的实际特点和需求制定一个详细、有针对性地优化计划和目标。这样可以避免盲目被动,更好地把握节奏和方向,最大限度地提高效率。“谋定后动,知止有得”的意思就是谋划准确周到而后行动,知道目的地才能够有所收获。就像开车没有目的地和导航,可能会迷失在纵横交错的立交桥中。
1、业务分析
首先需要进行全面的业务分析。通过梳理系统业务对象、用例以及业务流程,充分了解平台业务细节,识别出优化的关键路径、目标用户行为特征、核心业务流程、高流量页面等,为进一步优化提供依据。
心晴灯塔平台核心能力在于高效灵活的心理评测管理。市/区级教育主管部门及学校均可便捷发起测评任务,采用 “集中+自主”双轨模式:学校机房可组织统一集中普测,保障基础数据广度;学生也能通过小程序、学习机以及软硬一体终端设备随时随地与平台AI虚拟人互动,大幅提升参与便捷性与覆盖率。平台依托AI算法深度挖掘多渠道数据,智能生成多维度预警信息(如个体发展风险、情绪困扰等),同步生成覆盖学生个体、班级、学校乃至区/县等不同层级的详尽分析报告。基于精准的预警信号和分析报告,学校专职心理老师可通过平台快速定位预警学生,即时启动个性化分级干预:从线上留言疏导、个体咨询预约,到联动家庭协作跟进,实现针对性帮扶。针对区域教育管理者,平台配备强大管理 “驾驶舱” 视图,实时呈现区域内各校的平台应用深度、预警动态变化趋势及资源使用效率等关键指标,助力高效统筹规划与科学决策,推动区域心理健康教育协同发展。
从上述介绍可以看出,心晴灯塔的主要业务对象包含市/区级心理教研管理员、学校心理老师、班主任、学生、家长等,业务用例和流程围绕着心理测评、资源学习、心理咨询和预警干预展开。
2、瓶颈分析
熟悉业务之后就要通过对系统各个组成部分和核心业务进行分析,找出系统性能瓶颈所在,进而解决系统性能问题,提高其可靠性。瓶颈分析最直接的方式是对关键业务接口进行压力测试配合监控(数据库、jvm、内存、cpu、io等),识别出当前系统的承载能力和问题,为制定优化目标提供参考。下图数据是优化前基于多台64C128G的物理机压测,任务测评相关接口并发和响应时间远远低于目标值。实际业务50万用户的市级集中测评,答题接口QPS会超过2000。
关键接口压力测试数据
预警分析任务执行进程GC监控
另外通过上图jstat监控gc可见,执行一次36万测评记录的预警分析计划任务产生了53次FGC。而生成团体报告更是高达几百次FGC,生成时长长达10小时甚至1整天,这些都是关键业务中显而易见的瓶颈。
3、明确目标
系统非功能优化的目标是多方面的,主要包括提高系统的用户体验、稳定性和可靠性、资源的有效利用、提高系统吞吐量和效率以及减少响应时间。在确定优化目标时,需要根据不同的系统需求和使用场景,选择合适的、可衡量的性能指标,以确保指标的有效性和可行性。这样才能支撑业务持续发展,提高系统的性能表现和用户体验。
心晴灯塔平台最核心业务为心理测评,随着平台覆盖区县快速扩张,开学季多区域发布测评任务时效重叠致使学生集中使用。学生登录、获取任务、获取量表、提交题本答案、生成报告都存在超高并发场景,该流程任一环节存在性能问题都会引发重大生产事故。平台任务测评大多在学校机房统一开展,集中在早上9点~12点、下午2点~5点、晚上7点~9点三个上课时段。依据心晴灯塔历史测评活动评估,5个市级任务同时进行日活即可达到100万。以常用的单MHT量表(100道题)任务为例,优化前一个用户从登录到生成报告至少需发起104次服务端请求,80%的用户集中在上述8小时内完成测评,混合场景平均RPS为2888(可理解为并发请求数)。为保障系统的稳定性和提升用户体验,心晴灯塔系统需支撑2888并发的情况下,任务测评流程相关读接口响应时间<100ms,写接口<300ms。除高并发场景外,生成团体报告尤其是市级任务报告非常消耗资源,大数据量的计算存在频繁FGC甚至OOM风险,需保障50万学生参与的市级报告生成时服务器负载正常。
第二招:坚壁清野,排兵布阵
结合业务完成瓶颈分析并制定目标后,接下来就要分析系统的运行环境、系统组成,通观全局,打牢基础。就像要翻修一所房子,得先确认地基稳不稳,哪堵墙是承重墙,该加固加固,该搭架子就搭架子。“坚壁清野”出自《三国志》,指坚固壁垒,清除郊野的粮食房舍,使敌人无法攻进阵地。在系统优化过程中坚壁清野就是要优化硬件和基础资源,夯实系统的基础设施,修复系统的安全漏洞,以确保系统的基础架构稳固可靠。“排兵布阵”好比在系统优化和开发中的技术选型,需要兼顾业务特点、技术成熟度、性能和可扩展性、社区支持等因素,选择最适合的技术方案,才能保证系统的高效稳定运行。
1、完善基础环境
心晴灯塔平台所有服务最初全部部署在自建IDC机房,与公司其他业务共享服务器资源,每次配置变更、重大活动保障等存在互相干扰风险。且自建机房资源有限,无法根据市场推进和用户增长情况快速扩容。针对该问题,我们快速制定了从代码改造到性能压测全流程的迁移阿里云的方案并逐步实施,完成了心晴灯塔服务独立部署,可根据教育业务波峰波谷特点快捷伸缩实现资源最大化利用。
此外心晴灯塔平台一直用的是公司某业务线的二级域名。该域名有大量交付项目3000+子域名同时在用,经常被竞争对手和黑客当靶子攻击,还存在某域名因违规被举报连带所有域名的风险。随着心晴灯塔业务的发展和架构的完善,使用多个二级域名的场景也越来有必要。比如运营管理后台、计划任务管理后台、开放平台、网关等不同的业务功能组件使用不同的二级域名可以拥有独立的安全策略,有助于提高系统的安全性。多个二级域名更易于扩展,避免出现过度集中的流量压力,提高系统的稳定性。从技术和品牌保护的角度出发,决策了多个独立域名注册和备案使用。
2、升级基础服务
硬件和基础资源就像是城池和粮草,而软件和技术则犹如兵马和武器。作战时排兵布阵需要根据敌情、环境等因素选择合适的兵种、武器和战术,才能取得战斗的胜利。以心晴灯塔定时任务框架选型为例,心晴灯塔各维度的团体报告以及大量指标分析依赖定时任务跑数据,都是通过Spring内置的任务调度框架@Scheduled注解实现。该方式不支持分布式任务调度,存在数据幂等强依赖业务逻辑需加锁、任务执行失败不便追踪以及单点故障等问题。为解决这些问题,我们对任务系统进行改造,选用xxl-job通过多节点任务调度器实现高可用性和负载均衡,对大数据量计算进行分片以提高效率,通过可视化管理后台监控任务状态、查看执行日志等。
第三招:开源节流,弃车保帅
作为ITer,看到“开源”自然而然就想到Open Source。GitHub上托管着大量优秀的开源技术和框架,公司内部也有CBB货架,在遵循相关规范的前提下,复用现成的解决方案,可以避免重复造轮子,从而降低开发和优化成本。但在这里,“源”特指来源、源头。用户和平台服务通过基础设施和网络连接,就像星罗棋布的河流串联着沿线的港口和城市。“开源节流”可以理解为从源头开始优化流量、限制船只大小,保证水运航行畅通。系统请求来源包括网页、APP、物联设备、小程序等,非功能优化就是要保证客户端与服务端请求链路畅通。如果前端直接拒用户于千里之外,那么后端无论如何使劲也无济于事。当客户端存在加载慢、响应延迟等问题,常见的优化手段有压缩合并静态资源、客户端缓存、CDN、反向代理等,以减少Http请求和传输大小进行“节流”。在用户基础设施条件极差的情况下还可以“弃车保帅”,通过简化交互、屏蔽非必要功能的方式保障用户最基本的使用。
心晴灯塔任务测评大多在学校机房或课堂学习机上集中进行,经常会发生因学校带宽有限导致无法打开页面、答题中断等状况。随着平台在全国逐渐铺开,三四线城市和偏远地区学校网络条件差的情况更为普遍。针对该问题,通过压缩传输、减少请求、加速请求、简化交互等方式最大限度降低用户带宽开销,提升用户体验。
1、压缩传输
HTTP可以通过请求头Acceppt-Encoding声明浏览器支持的压缩格式,常用的格式有Gzip和Br,Gzip有着非常高压缩率和优秀的兼容性,而Br则是Google推出的一种压缩率更高的压缩格式。心晴灯塔平台需要兼容浏览器较多,所以选择了gzip格式。心晴灯塔前端采用主流vue框架开发,webpack打包时引用compression-webpack-plugin插件对符合条件的html、js、css等文件进行gz压缩。Vue代码片段如下:
configureWebpack: config => {
return {
plugins: [
new CompressionPlugin({
filename: '[path].gz[query]',
algorithm: 'gzip',
test: /\.js$|\.css$|\.html$/, //使用正则给匹配到的文件做压缩,jpg、png等图片无需压缩
threshold: 10240, //以字节为单位压缩超过此大小的文件,使用默认值10240
minRatio: 0.8, //最小压缩比率,官方默认0.8
deleteOriginalAssets: false
})
]
}
}
另外压缩的静态资源部署在服务端,只有nginx同步支持gzip,才能将资源压缩后传输到浏览器。Nginx配置示例如下:
# 开启gzip,关闭为off
gzip on;
# 是否在http header中添加Vary: Accept-Encoding,建议开启
gzip_vary on;
# gzip 压缩级别,1-9,数字越大压缩得越好,也越占用CPU时间,推荐6
gzip_comp_level 6;
# 设置压缩所需要的缓冲区大小
gzip_buffers 16 8k;
# 设置gzip压缩针对的HTTP协议版本
gzip_http_version 1.1;
# 选择压缩的文件类型,其值可以在 mime.types 文件中找到。
gzip_types text/plain text/css application/json application/javascript
# 启用gzip压缩的最小文件,小于设置值的文件将不会压缩
gzip_min_length 1k;
万事俱备,最后查看浏览器Response Headers中的Content-Encoding属性和被压缩文件的实际大小和传输大小验证压缩效果,心晴灯塔平台静态资源压缩比例在4到10倍之间,大大节省了服务器的网络带宽,提升浏览器的加载速度。
chrome浏览器http响应示例
此外,图片在 HTTP 传输中占比很高,但它本身已经被压缩过了,不能被 gzip、br继续压缩。去除图片里的拍摄时间、地点、机型等元数据,降低分辨率,缩小尺寸、选择高压缩率的格式都是很好的优化手段,比如有损格式用Jpeg,无损格式用 Webp 格式。
2、减少请求
在客户端带宽资源有限的情况下,为了减少网络延迟和页面加载时间,通过合并css/js文件、CSS Sprites(把一个页面涉及的所有零散小图有规则的合并成一张大图,然后通过CSS的background-image、background-repeat, background-position等属性实现图片显示)、按需加载/懒加载、客户端缓存等方式减少HTTP请求次数可以有效节流,从而提升用户体验。心晴灯塔主要从按需加载/懒加载以及客户端缓存方面进行了重点优化。
当前主流的开发方式都是前后端分离,在国内前端React、Vue、Angular三大框架中Vue占绝对优势。Vue 的特点是SPA-Single Page Application(单页应用程序),即只有第一次会加载页面, 以后的每次页面切换,只需要进行组件替换。该模式整体上减少了请求体积,加快页面响应速度,但初始加载时会把所有的组件和相关资源一次性加载完成。心晴灯塔平台登录页明显存在该情况,本来只是简单的登录交互,却加载了大量无关资源而特别臃肿,多余的资源占着茅坑不拉屎,造成页面初始化很慢。对组件进行切割,按需加载是解决该问题的根本。首先需要对页面代码进行拆分,剔除无效代码,将可以延迟加载的模块单独打包,通过import引用。然后将需要进行懒加载的子模块文件的引入语句(import引用)放到一个函数内部,在需要加载的时候再执行该函数,比如onclick、滑动至下一屏时触发,这样才真正实现按需加载,提升页面初始化速度。
按需加载还可以通过服务端配合实现,依据实际业务场景拆分或合并服务端接口,剔除接口不必要的数据。比如心晴灯塔课程资源列表页,页面只需要展示标题、缩略图等,研发人员贪图省事直接“select *”连同资源介绍文本一并返回,不仅增加网络传输开销,还会增加数据库IO操作,可能影响覆盖索引失效。类似的现象比比皆是,有必要做专项优化,所有查询接口按需返回。
接口合并往往辅以客户端缓存。为了使用户测评过程中刷新页面或退出返回能继续之前的进度,研发设计了实时调用服务端接口保存作答记录的逻辑。以广泛使用的MHT量表(100道题本)为例,一个用户调用100次接口才能完成作答,一个50万学生参与的市级任务单提交答案就得发起5000万次请求,再加上实时获取已提交答案的接口,就是1亿。如果任务包含多个量表,请求量就会更高,如此频繁的请求一方面加重了客户端请求网络开销,另一方面极大增加了服务端和数据库的并发压力。这种业务场景就特别适合借助客户端缓存合并接口,将用户每道题的答案缓存在客户端缓存中,刷新和重新进入答题交互直接从客户端缓存获取记录,避免实时从数据库获取最新作答记录,所有题本作答完成后一次性提交,因为题本和答案只用传输固定的编号,一次性传输的内容不会很大。经过优化后,再多道题的量表也只需要调用一次接口保存答案,降低出错率。而且只要用户不进行非常规的清理客户端缓存动作,在网络堵塞时还可以通过重试的机制确保答案提交成功。
浏览器local storage缓存示例
3、加速请求
提到加速,CDN一马当先。CDN通过内容分发拉近与用户的距离,不仅可以大幅提升资源响应速度,还能提高源服务器的安全性和减轻源服务器的压力。这么好的东西全世界都在用,心晴灯塔平台肯定不会错过,所以这里不再赘述,而是重点介绍HTTP/2。
HTTP/2拥有Header压缩、多路复用、二进制分帧、服务端推送等颠覆性特性。如果细心观察图6会发现request和response都是HTTP/2协议。HTTP/2每次发送请求前使用HPACK规范在之前发送过的请求头列表中查找已编码的请求头信息,对当前请求头进行重组,从而压缩了大量冗余的请求头帧(Header压缩)。如果说Header压缩微不足道,那么在一个TCP连接上并行处理多个请求的多路复用就比较炸裂了。HTTP协议是无状态的,每建立一个连接都需要进行三次握手,现在客户端交互都很复杂,频繁创建连接十分消耗资源。后来HTTP/1.1默认开启keep-alive,多次 HTTP 请求可以复用同一个 TCP 连接,从而减少创建、关闭多个 TCP 连接的开销。但由于HTTP/1.1中的数据传输是基于文本的,必须按顺序串行传输,想要并行传输依赖浏览器建立多个TCP连接实现,常见的chrome和firefox单域名最大并发是6个,然后又衍生出域名分片(假设使用3个域名,就有18个并发)来解决阻塞。HTTP/2协议采用二进制编码,将请求和响应数据分割为帧,其中帧对数据进行顺序标识。这样同域名下所有请求都在单个TCP连接上进行,该连接可以承载任意数量的双向数据流,浏览器收到数据之后,就可以按照帧首部的流标识对数据进行重组。多路复用代替了 HTTP/1.1 的序列和阻塞机制,极大地提高传输性能。
keepAlive与多路复用比较
HTTP/2 有许多优点,但也需要注意一些HTTP1.1中传统的优化手段可能会起反作用。比如多路复用和Header压缩使传输小文件的成本很低,所以对合并js/css、CSS Sprites图片合并的优化方式就失去了意义。而且资源合并还会降低浏览器缓存的可用性,只要一个小文件更新,整个缓存就完全失效,必须重新加载。所以HTTP/2协议中让资源的粒度变小,才能更好地发挥缓存的作用。
4、简化交互
简化交互就是为了最大限度保障极差条件下用户能继续使用的兜底方案。譬如国内一些偏远地区还没有5G,或者说为了吸引每个月流量包不够、手机性能不好的用户能更多地使用软件,主流的APP都会推出所谓的极速版。这就是典型的牺牲交互,保留关键功能的思想。心晴灯塔集中任务测评时,用户的主要任务是完成量表测评,当某些学校电脑硬件、网络带宽条件有限时,通过配置屏蔽该校用户在测评时效内视频学习、资源下载等权限以节约带宽。另外规划了只保留任务测评网页端极简版本,在极端情况下,用户可切换至该版本。学生登录后,直接进入测评任务量表,测评页面保证学生可正常作答即可,减少所有非必要元素,提交答案后只返回简单提示,无任何与测评流程无关的展示内容。
简化交互示例
第四招:避其锋芒,分而治之
开源节流不是一味地放任用户请求,遇到高并发或者流量暴增时,避其锋芒,分而治之可最大限度提升接口吞吐能力,保障服务可用。好比数倍于自己的敌军来犯时,正面硬刚显然是扛不住的,要么把敌人引入峡谷使其施展不开,要么把敌人打散逐个击破。“避其锋芒,分而治之”的思想一方面需要设置阈值对接口进行限流防止系统因请求过多而崩溃,必要时甚至直接熔断某个服务来保护核心业务。另一方面对于核心业务高并发和大数据场景,使用多线程或消息队列进行削峰、异步处理,在系统承载范围内分批、按序处理。
1、限流熔断
限流熔断通常是系统遭受CC攻击、业务流量异常等极端情况下采取的手段,可以集成Hystrix、Sentinel等成熟框架实现。心晴灯塔平台除了为内部应用提供服务外,还需对外提供资源、量表等接口服务。最初接入的第三方应用通过授权后直接调用的内部接口,相关接口迭代升级需要兼容多方、且第三方流量无法预测。为规避第三方直接调用心晴灯塔服务带来的巨大干扰,经综合评估,引入了Apisix网关。Apisix构建于NGINX + lua技术基础之上,具有高性能、可扩展性的优点,支持内置和自行开发的插件实现业务网关(如Gateway)服务发现、api管理、熔断限流等功能。心晴灯塔将对外接口剥离自营业务,录入到apisix,然后集成key-auth、apisixlimit-req、limit-conn、api-breaker等插件对第三方应用进行鉴权、限流和熔断配置,无需入侵业务代码,且支持多种限流策略,如并发请求数、时间范围内请求总数。
apisix流量插件示例
2、异步解耦
心晴灯塔大规模集中测评时,用户提交答案和生成个人报告并发非常高。前面虽然完成了单题提交合并为一次性提交,大幅降低了并发,但该接口写数据库时一次insert多条记录,还需要进行相关校验,数据库插入完成后又同步计算维度预警、生成个人报告,整个流程比较复杂,所以接口响应时间长,TPS低。另外心晴灯塔的用户依赖于公司的教育业务用户中台,为了满足用户变更(取消/新增授权、转班转校)同步刷新存量业务数据的需求,通过计划任务不间断全量拉取中台数据进行比对,效率非常低。针对这类问题,心晴灯塔一方面接入用户中台的数据分发组件,消费中心的用户变更消息同步变更业务数据,避免一窝蜂地处理全量数据造成的资源开销。另一方面新增了RocketMQ服务,用户答案先提交至MQ队列(题本过大需要对消息体进行压缩),消费端按序插入数据库,并异步完成后续业务逻辑,将答案存储、预警计算和报告生成业务分而治之提升了接口的响应速度,按消息队列先进先出顺序执行也缓解了数据库压力。
3、分批处理
分而治之还应用在大数据量计算场景。市区校集中测评任务结束后,平台通过计划任务生成各级别的团体报告,该报告涉及的分析指标多且复杂,尤其是市级报告涉及的数据量特别大,存在非常大的性能风险。
代码优化前示例
上图代码为分页从任务量表作答记录表里查询数据,加入allDtos集合,然后返回数据给下游做任务量表完成情况分析。从逻辑上讲并没有问题,但结合实际业务场景推敲,单量表测评任务,每个用户作答完成会生成一条记录,多量表就会对应多条记录。假设一个40万学生参与的双量表测评任务就是80万条记录,80万条记录放到一个集合里,稍微有点并发就触发FGC甚至OOM。其实研发人员也意识到了性能风险,加了最多查询1000页的限制,每页1000条上限就是10万。但在心晴灯塔实际的市区级任务测评中,单次用户量表作答记录数据超过10万的很多,加上这样的限制就变成了缺陷,直接导致后续数据计算都不准确。而且随着量表作答记录表数据增长,这段代码还会出现深度分页带来的慢查询问题。短短10多行代码,之所以出现这么多的性能问题,主要还是研发人员低估了业务发展的速度。在心晴灯塔团体报告的计算逻辑中,类似的大对象、深度分页问题很多,甚至出现比量表作答记录表数据量还大数十倍的量表维度预警数据也直接放入一个List对象中操作。对于这类大数据分析最简单的办法就是把数据同步至elasticSearch或OLAP数据库处理。但是心晴灯塔项目需要兼顾交付项目本地化部署,经常发生客户无法满足系统部署所需最低资源而进行架构改造,所以有必要保留原来的处理方式。性能问题则根据团体报告、任务分析等数据实时性要求分而治之。还是以上述任务量表完成情况分析为例,市区级心理教研员关注进行中任务完成情况时,大多数并不关心详细的人员名单,毕竟也不清楚张三李四到底是谁。与产品沟通后,虽保留了名单查询功能,但必须通过任务和学校进行筛选,缩减数据范围规避深度分页慢查询问题。另外市级任务量表完成率需要按区统计,且任务下发没有精确到人,进行中的任务参与人数随时变动,实时聚合运算非常吃力,这种情况就适合采用“时间换空间”的思想进行优化,通过计划任务按一定频率(用户可接受的延迟时间)做聚合查询,将结果写入中间表。聚合查询时同样需要规避大对象问题,通过条件限制分段处理。优化后代码如下图:
代码优化后示例
第五招:擒贼擒王,釜底抽薪
擒贼擒王,釜底抽薪都出自《三十六计》。擒贼擒王是指打垮敌军主力,擒拿敌军首领,使敌军彻底瓦解,釜底抽薪描述了要让水停止沸腾,就要抽掉锅底的柴火。两条谋略虽分属于攻战计和混战计,组合起来强调要抓主要矛盾,从根本上解决问题,有异曲同工之妙。对于业务系统而言,数据是其核心和基石,无论使用什么编程语言开发的接口始终离不开对数据的增删改查。毫不夸张地说,任何一个业务系统归根结底处理的都是数据,随着市场、用户日益增长和业务数据持续膨胀,最终的瓶颈也突出在数据库操作层面。因此针对数据库的性能调优至关重要,是系统优化之根本。前面讲的分而治之,分批处理本质上还是通过取巧的手段解决特定场景下的数据问题,并非万能的。数据库优化可从索引优化、SQL优化、参数优化、分库分表等多方面入手,重点解决I/O瓶颈。本章节以mysql为例,结合心晴灯塔实际业务阐述优化路径。
1、合理的数据库设计
数据库设计指根据需求在某一具体的数据库管理系统上,设计数据库的结构和建立数据库的过程,通常遵循第三范式进行设计。合理的第三范式设计可以降低数据的冗余程度,提高了数据的一致性和准确性。在应用第三范式的过程中,还需要权衡数据的范式和业务需求,以及性能之间的关系,选择最合适的表结构以方便增删改查等操作。
MHT量表维度
以心晴灯塔用户量表题本答案表为例,用户完成量表测评提交作答时每条答案都需要记录对应的量表维度,目前只有固定的3个维度,效度(通过该题判断用户作答是否有效)、总分(按总分维度生成报告时需纳入计算)、一般(按一般维度生成报告时需纳入计算,分为多个子维度,如MHT一般维度包含冲动倾向、恐怖倾向、孤独倾向、学习焦虑等,子维度之间互斥)。起初研发人员考虑到后期可能存在增加维度,只设计了一个字段用于关联题本所属维度,导致每条用户答案重复存储至少2条。按3NF要求答案应该与维度分离存储,变成一对多的关系,就符合非主键字段之间应该互相独立的标准。但业务在进行预警分析时,拿到题本答案数据本身就需要关联用户表获取更多的用户学校、性别和年龄,还要关联量表信息表、题本表获取计分,关联测评任务表等等,如果为了维度再扩展一张表,那么查询sql就更加复杂。综合考虑需求、范式和性能后,在用户题本答案表增加三个字段分别关联效度、总分和一般维度。调整表结构后还需治理存量数据,最终100万人使用MHT量表测评直接减少1亿条答案记录,大幅提升性能的同时节约了资源。
另外选择合适数据类型和长度也很重要。比如整型数据没有负数,可以直接指定为UNSIGNED无符号类型,存储最大值翻一倍;涉及货币金额存储推荐使用decimal类型来保证精度,也可以转换成最小单位分使用int类型存储,假设不带符号,小数保留2位,int类型可以存储最大的金额为4294967295分(42949672.95元),而Decimal类型存储42949672.95需要定义为(10,2)至少5字节才能满足;还有ip地址通常也推荐使用int类型存储,因为IPv4地址从0.0.0.0到255.255.255.255每一小节最多8位,转换成8位二进制后进行位或运算就是一个唯一的整数。以“192.168.75.6”为例,转换参考如下:
-
92左移24位:11000000 00000000 00000000 00000000
-
168左移16位:00000000 10101000 00000000 00000000
-
075左移08位:00000000 00000000 01001011 00000000
-
006左移00位:00000000 00000000 00000000 00000110
-
按位或结果 : 11000000 10101000 01001011 00000110
-
最后转换为int为带符号为-1062712570,不带符号为3232254726
计算机中的整数是用补码存储的,最高位为符号位。0则为正数,直接转为十进制即可。1代表为负数,需要先把二进制的值按位取反,然后加1得到负数绝对值的二进制码,然后转为十进制,加上负号即可。转换后原本需要7~15个字节的varchar存储的IP用一个固定4字节int就搞定。
这些例子特别基础,但在实际的开发过程中因为各种原因被忽视,所以定期全面审视数据库表设计非常有必要。经检查,心晴灯塔数据库时间类型不一(timestamp/datetime)、区分度很低的status、type等字段类型不一(int/char/enum/tinyint)、频繁当做查询条件的字段允许为Null等等问题很普遍,影响数据库的性能、存储空间、数据完整性。譬如timestamp当做用户生日字段类型会造成数据有误、数据迁移enum需要做转换且变更麻、容易出错、字段允许为null,不等于(!=)返回数据不准。针对该类问题,制定了统一规范进行专项整改。
2、sql&索引优化
作为服务端开发人员,几乎每天都在跟数据库打交道,常规的Sql优化、索引优化方法论大家都耳熟能详。但大多时候还是被动地分析生产库慢sql来定位问题、增加索引,此时已经是亡羊补牢,有可能已经造成了线上事故。如并发场景下,某大表缺乏合适的索引导致全表扫描引起数据库cpu负载过高,进而引发大量lock wait、服务崩溃。所以提前规划,主动预防十分关键,参考策略如下:
-
全面梳理已有业务sql,按表进行分组,方便后续比对分析
-
优先解决错综复杂的sql,降低复杂度
复杂sql示例
上面这条sql嵌套查询叠加多表关联、分组、排序、函数计算,一眼扫去,眼花缭乱。使用explain指令分析该sql可见Extra列描述多次出现Using temporary和 Using filesort,即用了临时表和排序。
正常情况嵌套查询和联表查询都在内存里运算,当关联数据量较大时,mysql会创建磁盘临时表,所以需要拆分sql或冗余字段尽量规避,必须要用的话遵循小表驱动大表的原则。比如说关联查询学校学生作答记录,作答数据量超过学生数量千百倍,通过学生去查作答记录肯定比通过作答记录过滤学生要快得多。在sql语句中,LEFT JOIN驱动表是左表,RIGHT JOIN驱动表是右表,INNER JOIN自动选择小表驱动;另外适当调整join_buffer_size(Join Buffer只存储要进行查询操作的相关列数据,默认不超过512k)和tmp_table_size也可以减少使用磁盘临时表的概率。
Group by也会用临时表。Sql查询每次用到临时表时Created_tmp_tables增加,如果临时表大小超过tmp_table_size(默认16M),则创建磁盘临时表,Created_tmp_disk_tables也增加。可以通过SHOW GLOBAL STATUS LIKE '%tmp%'和SHOW STATUS LIKE '%tmp%'分别查询全局和单次会话的临时表使用状态。Created_tmp_disk_tables/Created_tmp_tables<0.25属于较理想的状态,结合该数据调整tmp_table_size大小可减少磁盘IO。
mysql临时表状态示例
Group by还会像order by一样对数据进行排序。FileSort 排序一般在内存中进行,占用CPU 较多。如果需排序数据超过sort_buffer,会使用磁盘文件排序。sort_buffer_size是connection级参数,在每个connection需要buffer的时候,一次性分配的内存,所以并不是越大越好,否则高并发可能会耗尽系统内存。所以减少filesort可以通过索引来规避,索引可以保证数据的有序性,效率更高。
-
全局审视各业务表的索引
前面提到大多数系统都是被动的根据慢sql日志进行优化,能加索引就不动代码,能新增索引就不扩展原索引,从而导致索引越来越多,冗余索引也越来越多。索引是把双刃剑,加速查询效率,降低增删改效率,且需要额外的存储空间,所以并不是越多越好,尤其是要规避重复、冗余索引。基于第1步全局业务sql分组后,我们能更好地审视索引是否冗余、是否合理、是否可以合并。索引冗余也可以用pt-duplicate-key-checker分析。具体索引优化的方法网上一搜一大堆,不一一列举,这里强调几个容易忽略的点。
首先,通过Explain查看执行计划,type值从ALL< index< range< ref< eq_ref< const< system理论上扫描效率逐渐增强,优化时需要结合该属性精益求精,结合实际业务和性能要求能用覆盖索引就不要回表。InnoDB普通索引的叶子节点存储主键值,需要扫描两遍索引树,先通过普通索引定位到主键值id,再通过聚集索引定位到行记录。这个过程就叫“回表”。覆盖索引只需要在一棵索引树上就能获取所需的所有列数据,速度更快。比如在用户表的tel建了一个普通索引,通过tel查询用户名name会先查到主键ID(PK为聚集索引,没有PK则第一个 NOT NULL unique列是聚集索引),然后再通过ID查到name。如果直接建一个tel和name的组合索引,只需一次扫描就获取到了name。
其次,严格遵循最左前缀和降维原则。Mysql索引底层是B+树结构,按从左到右的顺序来建立搜索树的,比如索引`uk_a_b_c` (a,b,c) 兼容a、ab和abc,所以冗余索引纯属浪费资源。正因为索引对顺序敏感,创建索引还要遵循降维的原则。比如频繁根据任务、学校和量表查询学生作答记录,作答记录表的数据非常庞大,有必要建立一个包含任务ID,学校ID、量表ID的组合索引,这几个字段在索引中的顺序也会影响查询性能,遵循降维原则可以快速缩小查询范围。全表扫描时,mysql查询优化器会自动调整where条件顺序进行降维。
最后,要确保索引起作用。索引列上使用函数、表达式、隐式转换、NOT/IN/LIKE/OR、子查询缺少索引等等都可能导致索引失效。
-
sql优化效果验证
无论是join、group by、order by优化,还是索引优化完成后一定要验证效果。如果是测试库验证的话还需要参照生产库或评估实际业务量造出同等量级的铺底数据,只有在大数据量级下才能更好地对比优化效果,诸如索引列使用IN、NOT IN、OR以及字段允许为NUL的!=查询需要结合数据量级验证有效性。也可以开启optimizer_trace可以监控优化前后的创建磁盘临时表、进行磁盘文件排序等指标。
3、数据分片
数据库分片主要目的是解决单表数据量过大引起的频繁磁盘IO、索引维护成本高、锁竞争等性能问题。分片在中大型项目中非常普遍,有很多实践案例可以参考,需要注意的是拆分前先想清楚3个问题。
什么时候分?业界单表数据量阈值有2000万、1000万、500万等说法,具体需要综合考虑硬件、存储引擎、数据类型、索引设计、查询复杂度、业务特征等因素,譬如复杂的sql几十万数据就会出现瓶颈,简单查询上亿数据也不慢。
怎么分?通常按垂直和水平两个维度拆分,根据某字段按一定的规则(hash、范围)拆分为多个表即水平分表,比如将学生用户数据按主键ID取模拆分为多个表;根据某字段按一定规则将数据拆分到不同的库,每个库表结构一样即为水平分库,如对学生所属学校ID取模拆分为多个库;垂直分表就是将不同的业务数据存储到不同的数据库;垂直分表可以简单理解为将表字段拆分为主表和扩展表。在实际的应用中,需要结合具体业务场景选择合适的拆分方式,最大限度避免分片导致的跨库跨表查询。
如何实现?分库分表的实现方案可以归纳为Proxy模式和Client模式两种类型,代表中间件分别为Mycat和Sharding-JDBC。Mycat 是基于阿里Cobar 演变而来的一款开源分布式数据库中间件,介于数据库与应用之间,只需将数据库连接地址改为mycat的地址,然后通过mycat管理端配置分片策略即可实现分库分表、读写分离。对代码几乎无侵入,升级方便,但效率较低且运维成本增加。Sharding-JDBC属于ShardingSphere生态的一份子,定位为轻量Java框架。它使用客户端直连数据库,以jar包形式提供服务,基于AOP原理,在本地对sql进行拦截、解析、改写、路由和结果归集处理。
Sharding-JDBC中sql执行流程
心晴灯塔每一次规模较大的市级测评少则产生千万级业务数据,多则上亿。像用户量表答案记录、量表维度预警记录等表单学期数据量轻易就能突破千万,造成数据库慢查询(超过100ms)指数直线上升。为规避数据量增长带来的风险,心晴灯塔先采用垂直分库的方式将测评业务数据、基础量表和资源数据、公共运营数据拆分为3个不同的库。然后针对测评业务数据库继续采用水平分表的方式对测评任务记录、作答记录、维度预警记录等表依据用户的学校ID进行取模各分成32张表。最后采用水平分库的方式将测评业务数据库扩展为30+表结构库相同库,用户登录时根据省份ID路由至对应库,避免了跨库查询。考虑到灵活性,心晴灯塔采用了Sharding-JDBC的方案来实现。经过分片,一方面解决业务层面的耦合,不同的业务数据实现了分级管理,易于扩展和维护;另一方面降低了高并发场景下锁表概率,提升系统的稳定性和可靠性。值得注意的是,因为sql执行多了改写和路由的过程,水平分库后同等条件下单库的查询性能可能会有些许下降。心晴灯塔对业务表水平分库就是要应对多区域同时大规模测评的情况,而且随着业务继续扩大可随时增加mysql实例分摊压力,伸缩更加灵活。另外分片改造还需要有详细的存量数据割接实施方案。
水平分表对单库施压对比
多数项目除了数据库分片,往往还会结合读写分离来分摊单个mysql实例的压力。读写分离的基本思想就是主库负责insert、delete、update等事务性操作,从库负责select查询操作。因为数据库写操作相对比较耗时,会影响查询效率,所以读写分离特别适合读请求并发非常高的业务系统,对于数据要求强一致性的场景需要慎重。心晴灯塔业务比较贴合这种场景,只需要增加几行简单的Sharding-JDBC配置即可实现,但当前数据库为阿里云RDS主备模式,增加从实例、开启binlog都需要额外收费,当前完成分片后数据库查询性能问题缓解,所以暂时保留该优化项,后续根据业务扩张需要再开启。
4、缓存
从memcache到redis,缓存在软件系统中无处不在。它不但可以减少不必要的计算和IO操作,还能有效拦截大量请求直接冲击数据库,从而提升系统的并发处理能力和用户体验。网络上介绍缓存应用案例数不胜数,该小节主要结合心晴灯塔业务谈谈多级缓存、双写一致性以及缓存击穿、穿透和雪崩实践。
心晴灯塔的量表数据是测评业务的基础,测评过程中查询非常频繁,而且还通过开放平台对第三方和本地化项目提供相关接口服务。心晴灯塔量表都是经过千锤百炼后,层层审核之后才会发布,发布后变更的频率微乎其微,系统也会禁止修改进行中测评任务使用的基础量表。对于这种变更频率和实时性要求低的热点数据,特别适合存储在本地缓存中,所以量表数据采用了Caffeine和redis两级缓存。Caffeine是一个高性能的java本地缓存框架,减少了应用与远程缓存交互网络开销,性能非常高。大多情况下,如果远程缓存能够满足业务需求就没必要搞多级缓存,毕竟需要更复杂的设计保证多节点、一二级数据一致性。心晴灯塔的量表数据实时性要求不高,所以通过Caffeine的refreshAfterWrite方法定时自动刷新即可。经此优化,量表查询直接就由本地缓存返回数据,本地缓存未命中还有远程缓存redis,两级缓存都未命中请求才会打到数据库,这种概率极低。我们还集成了阿里巴巴开源的java缓存封装框架jetcache,通过统一的API和注解来实现多级缓存和自动刷新。
缓存的优点非常多,但缓存更新时存在数据库和缓存数据不一致的情况,可能会引发严重业务异常。目前比较常用的缓存更新策略是Cache Aside,即数据更新操作先更新数据库,然后直接删缓存;查询操作未命中缓存先从数据库取数据,再回写缓存。更新操作一定不能先删缓存,否则读写并发时,读操作会把老数据刷入缓存,数据还是不一致。另外通过重试机制确保缓存删除成功也非常关键。
Cache Aside策略
Cache Aside策略可以有效保证双写一致性,但并非绝对。读写并发场景下,读请求未命中缓存,取到数据库老数据后出现卡顿,等更新请求将新数据写入数据库并删除了缓存后,读请求才将老数据回写缓存。这种情况理论上是存在的,但在实际生产中发生的概率非常低,毕竟数据库读操作比写操作要快。如果一定要考虑到这种情况的话,可以采用延迟双删策略,保证读操作完成后再上一次缓存。在实际应用当中,需要结合具体的业务场景设计合适的缓存更新策略。心晴灯塔量表在上架时会写入缓存,只有下架状态才能变更,所以用户能看到的量表不存在读写并发。还有一个核心的用户任务缓存也是在市区校心理教研员发布任务时写入,在测评有效期内只能新增用户不能剔除,同样避免了缓存读写并发,所以只需要通过重试的机制保证数据库和缓存同时成功即可。
除了双写一致性的陷阱,使用缓存还有防止穿透、击穿和雪崩。
缓存穿透是指查询一个缓存中和数据库中都不存在的数据,导致每次查询都会透过缓存直接查库,用户可以利用这个疯狂发起请求对数据库施压。通常的解决方案有空值缓存和布隆过滤器。空值缓存较为轻量,但需要设置较短的过期时间来保证内存及时释放。布隆过滤器的方案相对较复杂,及写缓存时同时使用布隆过滤器对key进行标记(对key值进行多次hash运算,然后将下标全部标记为1),查询时先判断key是否存在,如果不存在直接返回空。但因为布隆过滤器的特性,可能会放过不存在的key,而且删除元素后也会增加误判概率。心晴灯塔数据缓存通过jetcache管理,所以直接使用@Cached(cacheNullValue=true)、@CreateCache(cacheNullValue=true)注解缓存空值解决。
缓存击穿是指当缓存中某个热点数据过期了,在该热点数据重新载入缓存之前,有大量的查询请求穿过缓存,直接查询数据库。在Cache Aside策略下一旦删除缓存,可能造成大量的请求同时尝试重建缓存,使数据库压力骤升,所以需要配合分布式锁来保证同一时间只有一个请求进行缓存重建。JetCache可以通过注解@CachePenetrationProtect实现了JVM内存锁级的击穿保护,使并发重建的请求限制在可控范围内。
缓存雪崩与击穿类似,是指当缓存中有大量的key同时过期或缓存服务宕机,导致大量的查询请求全部到达数据库,造成数据库查询压力骤增。针对key同时过期的情况,只需要将每个key设置不一样的有效期,比如心晴灯塔用户任务缓存在基准值的基础上增加了一个随机数。对于缓存服务宕机,则需要高可用部署架构来保障。而多级缓存可以同时降低两种情况导致雪崩的概率。
第六招:十面埋伏,防微杜渐
古代战争中大量的间谍、细作随时随地监视着敌军动向,发挥着不可替代的作用。即使是和平年代小到大街小巷的治安摄像头、公路上的交通监测设备,大到国家情报机构都在密切监控着社会系统的运转,它们是保证国家长治久安、百姓安居乐业必不可少的工具。随着业务越来越复杂,系统架构也会越来越复杂,不同组件之间的交互和依赖变得更加复杂,出错的概率和隐藏的风险也越来越大。所谓兵不厌诈,即使再强大的系统,总会存在逃逸的bug和莫名其妙的脏数据、加上网络攻击、硬件损坏等等防不胜防。最后的杀手锏就是“十面埋伏,防微杜渐”,建立健全的系统和业务监控,等着潜在问题自投罗网,来个瓮中捉鳖,将其扼杀于摇篮之中。
1、Metrics监控
Zabbix和Prometheus是目前比较流行的开源监控系统。Zabbix的成熟度高,在服务器相关监控方面占据绝对优势,但灵活性较差,监控数据的复杂度增加后定制难度大。Prometheus特别适用于微服务架构且扩展性较好,因为它对标准的时间序列数据有更好的支持和抽象,可以通过自定义exporter来获取和采集数据。Prometheus主要模块包含Server, Exporters, Pushgateway, Alertmanager, WebUI。server 通过静态文件配置或动态发现机制发现监控对象(targets),抓取(retrieval)指标数据(metrics),存入时序数据库(TSDB)、通过内置PromQL语言对外提供查询、聚合支持。Prometheus 本身不能直接监控服务,需要使用到不同Exporters组件采集数据库、硬件、消息中间件、存储系统、http服务器等数据并暴露通过HTTP接口暴露给server进行定时pull,如node_exporter采集内存/CPU/IO数据、jmx_exporter采集jvm数据、mysqld_exporter采集MySQL数据库状态。而应对瞬时任务无法通过pull方式拉取的场景,则需要将监控数据先push到PushGateway中间网关,然后再由Server拉取。 Alertmanager 是作为单独的服务运行,可以从多个Prometheus Server接收报警,并根据配置进行聚合、去重、降噪和路由,最后通过Email、企业微信发送通知。Grafana是一款开源数据可视化工具,有着功能齐全的度量仪表盘和图形编辑器,所以一般会使用grafana替换Prometheus默认的web ui。
Prometheus基础架构
经过对比,心晴灯塔平台选用了Prometheus采集系统和应用指标进行监控。监控范围覆盖了服务器内存、cpu和磁盘IO负载、Redis、RocketMQ、数据库、应用组件Jvm等方方面面。
Grafana大屏示例
2、Traces监控
心晴灯塔服务端由多个微服务组成,一次请求可能调用多个服务。服务之间的依赖关系也错综复杂,亟需一套调用链追踪系统对服务端调用进行全貌的监控。调用链将用户请求和业务逻辑的处理过程,各个组件之间的调用关系和参数传递情况等关键过程记录到日志,通过分析这些数据,可以快速定位故障和性能瓶颈的原因。常见的调用链工具包括Zipkin、Skywalking、OpenTelemetry等。为了降低接入成本,我们直接集成了公司内部自研的分布式追踪系统,可以清晰地追踪循环调用、慢链路等问题并及时干预。
调用链监控示例
3、业务监控
除了对系统和应用服务进行监控外,对业务进行监控也很重要。业务监控是指监测用户的操作和业务流程,了解业务状况是否正常。通常的做法就是编写脚本,模拟用户自动化调用接口,分析响应数据判断服务是否健康,公司已经有非常成熟的“拨测”方案。该方案可以实时地监测到接口故障并告警,但无法识别业务数据异常、MQ消费失败等场景。以心晴灯塔用户授权为例,经常发生测评过程中因为学校用户非正常变动未同步至心晴灯塔业务系统导致用户登录无权限,从而浪费前端的运营和后端研发保障人力。为此我们专门开发了一套工具按一定的频率比对中台和业务系统的用户数量,存在偏差时自动触发同步。同时该工具还可以一键检查用户无权限的原因是未授权还是学生端管控、自动重试MQ消费失败、收集程序报错并进行邮件告警。研发人员收到告警后可及时干预规避业务风险,真正实现了降本增效。
业务监控示例
【优化效果】
通过一系列的优化,心晴灯塔平台单用户单纯完成100道题的MHT量表测评只需调用5次服务端接口,百万日活混合场景RPS为从2888降到了111 ,关键接口1200并发TPS可达2000以上。在8C32G的阿里云环境中压测,响应时间和吞吐量较优化前提升10~30倍;30万+测评记录的市级任务预警分析、团体报告单次计划任务时长由数小时缩短至10分钟以内,且未发生FGC;另外线上问题和大型普测支撑人力同比减少50%以上,优化后大规模的普测后场研测人员无感知,几乎没有临场问题需要找到研发解决。
优化后压测报告
总之,非功能优化涉及代码质量、性能、安全、用户体验等方方面面。八仙过海,各显神通,只要能解决问题就是好办法。当然任何办法都要因地制宜,对症下药,与业务发展相匹配。方向错误、过度优化只会南辕北辙、适得其反,加重系统的复杂度,增加成本。
专栏文章推荐: