From c4bf92ea9dffb80a09b87afb38407f53efbec33b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E4=B9=BE?= Date: Wed, 16 Oct 2024 11:38:31 +0800 Subject: [PATCH] first commit --- .../033实践识别限界上下文.md | 114 ++++ ...践确定限界上下文的协作关系.md | 169 +++++ .../035实践EAS的整体架构.md | 128 ++++ ...帮开发团队提高设计水平吗?.md | 84 +++ ...词:领域驱动设计的不确定性.md | 106 +++ .../038什么是模型.md | 66 ++ .../039数据分析模型.md | 117 ++++ .../040数据设计模型.md | 351 ++++++++++ .../041数据模型与对象模型.md | 63 ++ .../042数据实现模型.md | 150 +++++ .../043案例培训管理系统.md | 307 +++++++++ .../044服务资源模型.md | 169 +++++ .../045服务行为模型.md | 173 +++++ .../046服务设计模型.md | 153 +++++ .../047领域模型驱动设计.md | 115 ++++ .../048领域实现模型.md | 135 ++++ .../049理解领域模型.md | 117 ++++ .../050领域模型与结构范式.md | 186 +++++ ...051领域模型与对象范式(上).md | 511 ++++++++++++++ ...052领域模型与对象范式(中).md | 290 ++++++++ ...053领域模型与对象范式(下).md | 143 ++++ .../054领域模型与函数范式.md | 416 ++++++++++++ ...领域驱动分层架构与对象模型.md | 160 +++++ .../056统一语言与领域分析模型.md | 75 +++ .../057精炼领域分析模型.md | 223 ++++++ .../058彩色UML与彩色建模.md | 166 +++++ .../059四色建模法.md | 135 ++++ ...案例订单核心流程的四色建模.md | 91 +++ .../061事件风暴与业务全景探索.md | 178 +++++ .../062事件风暴与领域分析建模.md | 101 +++ ...案例订单核心流程的事件风暴.md | 172 +++++ .../064表达领域设计模型.md | 184 +++++ .../065实体.md | 353 ++++++++++ .../066值对象.md | 316 +++++++++ .../067对象图与聚合.md | 173 +++++ .../068聚合设计原则.md | 172 +++++ .../069聚合之间的关系.md | 152 +++++ .../070聚合的设计过程.md | 219 ++++++ ...案例培训领域模型的聚合设计.md | 274 ++++++++ ...域模型对象的生命周期-工厂.md | 343 ++++++++++ ...模型对象的生命周期-资源库.md | 316 +++++++++ .../074领域服务.md | 188 ++++++ .../075案例领域设计模型的价值.md | 628 +++++++++++++++++ .../076应用服务.md | 193 ++++++ .../077场景的设计驱动力.md | 165 +++++ ...薪资管理系统的场景驱动设计.md | 324 +++++++++ .../079场景驱动设计与DCI模式.md | 202 ++++++ .../080领域事件.md | 145 ++++ .../081发布者—订阅者模式.md | 185 +++++ .../082事件溯源模式.md | 309 +++++++++ .../083测试优先的领域实现建模.md | 138 ++++ .../084深入理解简单设计.md | 432 ++++++++++++ ...理系统的测试驱动开发(上).md | 367 ++++++++++ ...理系统的测试驱动开发(下).md | 323 +++++++++ .../087对象关系映射(上).md | 315 +++++++++ .../088对象关系映射(下).md | 230 +++++++ .../089领域模型与数据模型.md | 358 ++++++++++ ...领域驱动设计对持久化的影响.md | 383 +++++++++++ .../091领域驱动设计体系.md | 219 ++++++ .../092子领域与限界上下文.md | 111 +++ .../093限界上下文的边界与协作.md | 251 +++++++ ...限界上下文之间的分布式通信.md | 240 +++++++ .../095命令查询职责分离.md | 119 ++++ .../096分布式柔性事务.md | 226 +++++++ .../097设计概念的统一语言.md | 104 +++ .../098模型对象.md | 153 +++++ ...099领域驱动设计参考过程模型.md | 127 ++++ .../100领域驱动设计的精髓.md | 234 +++++++ ...101实践员工上下文的领域建模.md | 245 +++++++ ...102实践考勤上下文的领域建模.md | 320 +++++++++ ...103实践项目上下文的领域建模.md | 301 +++++++++ ...104实践培训上下文的业务需求.md | 128 ++++ ...践培训上下文的领域分析建模.md | 103 +++ ...践培训上下文的领域设计建模.md | 251 +++++++ ...践培训上下文的领域实现建模.md | 410 +++++++++++ .../108实践EAS系统的代码模型.md | 622 +++++++++++++++++ ...后记:如何学习领域驱动设计.md | 53 ++ ...并发系统,技术实力的试金石.md | 116 ++++ ...的数据库表可能成为性能隐患.md | 211 ++++++ ...何解决数据更新缓存不同步?.md | 268 ++++++++ ...低用户身份鉴权的流量压力?.md | 186 +++++ ...何实现机房之间的数据同步?.md | 128 ++++ ...何保证多机房数据的一致性?.md | 128 ++++ ...拆分:如何合理地拆分系统?.md | 193 ++++++ ...决高并发下的库存争抢问题?.md | 305 +++++++++ ...:如何应对高并发流量冲击?.md | 144 ++++ ...务的2PC、TCC都是怎么实现的?.md | 382 +++++++++++ ...高并发写不推荐关系数据库?.md | 130 ++++ ...制一个分布式链路跟踪系统?.md | 448 ++++++++++++ ...ticsearch如何实现大数据检索?.md | 134 ++++ ...路跟踪实时计算中的实用算法.md | 252 +++++++ ...14跳数索引:后起新秀ClickHouse.md | 238 +++++++ ...用C++自实现链路跟踪?.md | 0 ...地缓存做服务会遇到哪些坑?.md | 165 +++++ ...编程订阅式缓存服务更有用?.md | 382 +++++++++++ ...通过架构设计缓解流量压力?.md | 158 +++++ ...DNS、全站加速及机房负载均衡.md | 194 ++++++ ...数据引擎:统一缓存数据平台.md | 166 +++++ ...缓存:元数据服务如何实现?.md | 177 +++++ ...何推算日志中心的实现成本?.md | 232 +++++++ ...户网关和缓存降低研发成本?.md | 150 +++++ ...测:压测不完善,效果减一半.md | 166 +++++ .../答疑课堂思考题答案(一).md | 151 +++++ .../结束语为者常成,行者常至.md | 84 +++ ...么你要学习高并发系统设计?.md | 99 +++ ...:它的通用设计方法是什么?.md | 110 +++ ...:我们为什么一定要这么做?.md | 123 ++++ ...(一):如何提升系统性能?.md | 147 ++++ ...二):系统怎样做到高可用?.md | 145 ++++ ...三):如何让系统易于扩展?.md | 91 +++ ...理时,面试官是在刁难你吗?.md | 11 + ...创建数据库连接的性能损耗?.md | 138 ++++ ...求增加时,如何做主从分离?.md | 118 ++++ ...增加时,如何实现分库分表?.md | 93 +++ ...分库分表后ID的全局唯一性?.md | 105 +++ ...数据库和NoSQL如何做到互补?.md | 140 ++++ ...动态数据的查询要如何加速?.md | 136 ++++ ...:如何选择缓存的读写策略?.md | 127 ++++ ...二):缓存如何做到高可用?.md | 147 ++++ ...(三):缓存穿透了怎么办?.md | 149 ++++ .../16CDN:静态资源如何加速?.md | 123 ++++ ...处理每秒上万次的下单请求?.md | 115 ++++ ...何保证消息仅仅被消费一次?.md | 161 +++++ ...消息队列系统中消息的延迟?.md | 148 ++++ ...,面试官究竟想要了解什么?.md | 21 + ...求的系统要做服务化拆分吗?.md | 115 ++++ ...化后,系统架构要如何改造?.md | 105 +++ ...如何实现毫秒级的服务调用?.md | 171 +++++ ...中心:分布式系统如何寻址?.md | 141 ++++ ...式组件的慢请求要如何排查?.md | 226 +++++++ ...样提升系统的横向扩展能力?.md | 149 ++++ ...关:系统的门面要如何做呢?.md | 127 ++++ ...跨地域的分布式系统如何做?.md | 111 +++ ...服务化系统的服务治理细节?.md | 160 +++++ ...眼睛:服务端监控要怎么做?.md | 128 ++++ ...户的使用体验应该如何监控?.md | 185 +++++ ...样设计全链路压力测试平台?.md | 125 ++++ ...千上万的配置项要如何管理?.md | 121 ++++ ...屏蔽非核心系统故障的影响?.md | 210 ++++++ ...发系统中我们如何操纵流量?.md | 133 ++++ ...要如何准备一场技术面试呢?.md | 19 + ...海量数据的计数器要如何做?.md | 151 +++++ ...万QPS下如何设计未读数系统?.md | 124 ++++ ...息流系统的推模式要如何做?.md | 97 +++ ...息流系统的拉模式要如何做?.md | 102 +++ ...加餐数据的迁移应该如何做?.md | 135 ++++ ...10道高并发系统设计题目自测.md | 21 + ...心”出发,我还有无数个可能.md | 87 +++ .../结束语学不可以已.md | 57 ++ ...知局限,进阶高级性能工程师.md | 110 +++ ...试人员无法对性能结果负责?.md | 265 ++++++++ ...概念:性能指标和场景的确定.md | 194 ++++++ ...性能分析,靠这七步都能搞定.md | 136 ++++ ...析决策树和查找瓶颈证据链?.md | 269 ++++++++ ...的方案是否还停留在形式上?.md | 505 ++++++++++++++ ...合真实业务场景的业务模型?.md | 256 +++++++ ...数据到底应该做成什么样子?.md | 341 ++++++++++ ...、在线和TPS到底是什么关系?.md | 438 ++++++++++++ ...何设计全局和定向监控策略?.md | 155 +++++ ...准场景需要注意哪些关键点?.md | 301 +++++++++ ...搞懂基础硬件设施的性能问题.md | 515 ++++++++++++++ ...二:如何平衡利用硬件资源?.md | 322 +++++++++ ...怎么判断线程中的Block原因?.md | 424 ++++++++++++ ...何解决网络软中断瓶颈问题?.md | 259 +++++++ ...:资源不足有哪些性能表现?.md | 455 +++++++++++++ ...优化和压力工具中的参数分析.md | 513 ++++++++++++++ ...数一定要符合真实业务特性?.md | 530 +++++++++++++++ ...么动态参数化逻辑非常重要?.md | 461 +++++++++++++ ...用JDBC池优化和内存溢出分析.md | 417 ++++++++++++ ...逻辑复杂,怎么做性能优化?.md | 437 ++++++++++++ ...析优化一个固定的技术组件?.md | 302 +++++++++ ...解决for循环产生的内存溢出?.md | 396 +++++++++++ ...场景成败的关键因素有哪些?.md | 176 +++++ ...rnetes资源分配不均衡怎么办?.md | 300 +++++++++ ...存对性能会有什么样的影响?.md | 423 ++++++++++++ ...业务积累量产生的瓶颈问题?.md | 368 ++++++++++ ...定磁盘不足产生的瓶颈问题?.md | 255 +++++++ ...异常场景的范围和设计逻辑?.md | 172 +++++ ...何模拟不同组件层级的异常?.md | 532 +++++++++++++++ .../30如何确定生产系统配置?.md | 304 +++++++++ ...怎么写出有价值的性能报告?.md | 278 ++++++++ ...程的系统是怎么搭建起来的?.md | 636 ++++++++++++++++++ .../结束语做真正的性能项目.md | 98 +++ 183 files changed, 39246 insertions(+) create mode 100644 专栏/领域驱动设计实践(完)/033实践识别限界上下文.md create mode 100644 专栏/领域驱动设计实践(完)/034实践确定限界上下文的协作关系.md create mode 100644 专栏/领域驱动设计实践(完)/035实践EAS的整体架构.md create mode 100644 专栏/领域驱动设计实践(完)/036「战术篇」访谈:DDD能帮开发团队提高设计水平吗?.md create mode 100644 专栏/领域驱动设计实践(完)/037「战术篇」开篇词:领域驱动设计的不确定性.md create mode 100644 专栏/领域驱动设计实践(完)/038什么是模型.md create mode 100644 专栏/领域驱动设计实践(完)/039数据分析模型.md create mode 100644 专栏/领域驱动设计实践(完)/040数据设计模型.md create mode 100644 专栏/领域驱动设计实践(完)/041数据模型与对象模型.md create mode 100644 专栏/领域驱动设计实践(完)/042数据实现模型.md create mode 100644 专栏/领域驱动设计实践(完)/043案例培训管理系统.md create mode 100644 专栏/领域驱动设计实践(完)/044服务资源模型.md create mode 100644 专栏/领域驱动设计实践(完)/045服务行为模型.md create mode 100644 专栏/领域驱动设计实践(完)/046服务设计模型.md create mode 100644 专栏/领域驱动设计实践(完)/047领域模型驱动设计.md create mode 100644 专栏/领域驱动设计实践(完)/048领域实现模型.md create mode 100644 专栏/领域驱动设计实践(完)/049理解领域模型.md create mode 100644 专栏/领域驱动设计实践(完)/050领域模型与结构范式.md create mode 100644 专栏/领域驱动设计实践(完)/051领域模型与对象范式(上).md create mode 100644 专栏/领域驱动设计实践(完)/052领域模型与对象范式(中).md create mode 100644 专栏/领域驱动设计实践(完)/053领域模型与对象范式(下).md create mode 100644 专栏/领域驱动设计实践(完)/054领域模型与函数范式.md create mode 100644 专栏/领域驱动设计实践(完)/055领域驱动分层架构与对象模型.md create mode 100644 专栏/领域驱动设计实践(完)/056统一语言与领域分析模型.md create mode 100644 专栏/领域驱动设计实践(完)/057精炼领域分析模型.md create mode 100644 专栏/领域驱动设计实践(完)/058彩色UML与彩色建模.md create mode 100644 专栏/领域驱动设计实践(完)/059四色建模法.md create mode 100644 专栏/领域驱动设计实践(完)/060案例订单核心流程的四色建模.md create mode 100644 专栏/领域驱动设计实践(完)/061事件风暴与业务全景探索.md create mode 100644 专栏/领域驱动设计实践(完)/062事件风暴与领域分析建模.md create mode 100644 专栏/领域驱动设计实践(完)/063案例订单核心流程的事件风暴.md create mode 100644 专栏/领域驱动设计实践(完)/064表达领域设计模型.md create mode 100644 专栏/领域驱动设计实践(完)/065实体.md create mode 100644 专栏/领域驱动设计实践(完)/066值对象.md create mode 100644 专栏/领域驱动设计实践(完)/067对象图与聚合.md create mode 100644 专栏/领域驱动设计实践(完)/068聚合设计原则.md create mode 100644 专栏/领域驱动设计实践(完)/069聚合之间的关系.md create mode 100644 专栏/领域驱动设计实践(完)/070聚合的设计过程.md create mode 100644 专栏/领域驱动设计实践(完)/071案例培训领域模型的聚合设计.md create mode 100644 专栏/领域驱动设计实践(完)/072领域模型对象的生命周期-工厂.md create mode 100644 专栏/领域驱动设计实践(完)/073领域模型对象的生命周期-资源库.md create mode 100644 专栏/领域驱动设计实践(完)/074领域服务.md create mode 100644 专栏/领域驱动设计实践(完)/075案例领域设计模型的价值.md create mode 100644 专栏/领域驱动设计实践(完)/076应用服务.md create mode 100644 专栏/领域驱动设计实践(完)/077场景的设计驱动力.md create mode 100644 专栏/领域驱动设计实践(完)/078案例薪资管理系统的场景驱动设计.md create mode 100644 专栏/领域驱动设计实践(完)/079场景驱动设计与DCI模式.md create mode 100644 专栏/领域驱动设计实践(完)/080领域事件.md create mode 100644 专栏/领域驱动设计实践(完)/081发布者—订阅者模式.md create mode 100644 专栏/领域驱动设计实践(完)/082事件溯源模式.md create mode 100644 专栏/领域驱动设计实践(完)/083测试优先的领域实现建模.md create mode 100644 专栏/领域驱动设计实践(完)/084深入理解简单设计.md create mode 100644 专栏/领域驱动设计实践(完)/085案例薪资管理系统的测试驱动开发(上).md create mode 100644 专栏/领域驱动设计实践(完)/086案例薪资管理系统的测试驱动开发(下).md create mode 100644 专栏/领域驱动设计实践(完)/087对象关系映射(上).md create mode 100644 专栏/领域驱动设计实践(完)/088对象关系映射(下).md create mode 100644 专栏/领域驱动设计实践(完)/089领域模型与数据模型.md create mode 100644 专栏/领域驱动设计实践(完)/090领域驱动设计对持久化的影响.md create mode 100644 专栏/领域驱动设计实践(完)/091领域驱动设计体系.md create mode 100644 专栏/领域驱动设计实践(完)/092子领域与限界上下文.md create mode 100644 专栏/领域驱动设计实践(完)/093限界上下文的边界与协作.md create mode 100644 专栏/领域驱动设计实践(完)/094限界上下文之间的分布式通信.md create mode 100644 专栏/领域驱动设计实践(完)/095命令查询职责分离.md create mode 100644 专栏/领域驱动设计实践(完)/096分布式柔性事务.md create mode 100644 专栏/领域驱动设计实践(完)/097设计概念的统一语言.md create mode 100644 专栏/领域驱动设计实践(完)/098模型对象.md create mode 100644 专栏/领域驱动设计实践(完)/099领域驱动设计参考过程模型.md create mode 100644 专栏/领域驱动设计实践(完)/100领域驱动设计的精髓.md create mode 100644 专栏/领域驱动设计实践(完)/101实践员工上下文的领域建模.md create mode 100644 专栏/领域驱动设计实践(完)/102实践考勤上下文的领域建模.md create mode 100644 专栏/领域驱动设计实践(完)/103实践项目上下文的领域建模.md create mode 100644 专栏/领域驱动设计实践(完)/104实践培训上下文的业务需求.md create mode 100644 专栏/领域驱动设计实践(完)/105实践培训上下文的领域分析建模.md create mode 100644 专栏/领域驱动设计实践(完)/106实践培训上下文的领域设计建模.md create mode 100644 专栏/领域驱动设计实践(完)/107实践培训上下文的领域实现建模.md create mode 100644 专栏/领域驱动设计实践(完)/108实践EAS系统的代码模型.md create mode 100644 专栏/领域驱动设计实践(完)/109后记:如何学习领域驱动设计.md create mode 100644 专栏/高并发系统实战课/00开篇词高并发系统,技术实力的试金石.md create mode 100644 专栏/高并发系统实战课/01结构梳理:大并发下,你的数据库表可能成为性能隐患.md create mode 100644 专栏/高并发系统实战课/02缓存一致:读多写少时,如何解决数据更新缓存不同步?.md create mode 100644 专栏/高并发系统实战课/03Token:如何降低用户身份鉴权的流量压力?.md create mode 100644 专栏/高并发系统实战课/04同城双活:如何实现机房之间的数据同步?.md create mode 100644 专栏/高并发系统实战课/05共识Raft:如何保证多机房数据的一致性?.md create mode 100644 专栏/高并发系统实战课/06领域拆分:如何合理地拆分系统?.md create mode 100644 专栏/高并发系统实战课/07强一致锁:如何解决高并发下的库存争抢问题?.md create mode 100644 专栏/高并发系统实战课/08系统隔离:如何应对高并发流量冲击?.md create mode 100644 专栏/高并发系统实战课/09分布式事务:多服务的2PC、TCC都是怎么实现的?.md create mode 100644 专栏/高并发系统实战课/10稀疏索引:为什么高并发写不推荐关系数据库?.md create mode 100644 专栏/高并发系统实战课/11链路追踪:如何定制一个分布式链路跟踪系统?.md create mode 100644 专栏/高并发系统实战课/12引擎分片:Elasticsearch如何实现大数据检索?.md create mode 100644 专栏/高并发系统实战课/13实时统计:链路跟踪实时计算中的实用算法.md create mode 100644 专栏/高并发系统实战课/14跳数索引:后起新秀ClickHouse.md create mode 100644 专栏/高并发系统实战课/15实践方案:如何用C++自实现链路跟踪?.md create mode 100644 专栏/高并发系统实战课/16本地缓存:用本地缓存做服务会遇到哪些坑?.md create mode 100644 专栏/高并发系统实战课/17业务脚本:为什么说可编程订阅式缓存服务更有用?.md create mode 100644 专栏/高并发系统实战课/18流量拆分:如何通过架构设计缓解流量压力?.md create mode 100644 专栏/高并发系统实战课/19流量调度:DNS、全站加速及机房负载均衡.md create mode 100644 专栏/高并发系统实战课/20数据引擎:统一缓存数据平台.md create mode 100644 专栏/高并发系统实战课/21业务缓存:元数据服务如何实现?.md create mode 100644 专栏/高并发系统实战课/22存储成本:如何推算日志中心的实现成本?.md create mode 100644 专栏/高并发系统实战课/23网关编程:如何通过用户网关和缓存降低研发成本?.md create mode 100644 专栏/高并发系统实战课/24性能压测:压测不完善,效果减一半.md create mode 100644 专栏/高并发系统实战课/答疑课堂思考题答案(一).md create mode 100644 专栏/高并发系统实战课/结束语为者常成,行者常至.md create mode 100644 专栏/高并发系统设计40问/00开篇词为什么你要学习高并发系统设计?.md create mode 100644 专栏/高并发系统设计40问/01高并发系统:它的通用设计方法是什么?.md create mode 100644 专栏/高并发系统设计40问/02架构分层:我们为什么一定要这么做?.md create mode 100644 专栏/高并发系统设计40问/03系统设计目标(一):如何提升系统性能?.md create mode 100644 专栏/高并发系统设计40问/04系统设计目标(二):系统怎样做到高可用?.md create mode 100644 专栏/高并发系统设计40问/05系统设计目标(三):如何让系统易于扩展?.md create mode 100644 专栏/高并发系统设计40问/06面试现场第一期:当问到组件实现原理时,面试官是在刁难你吗?.md create mode 100644 专栏/高并发系统设计40问/07池化技术:如何减少频繁创建数据库连接的性能损耗?.md create mode 100644 专栏/高并发系统设计40问/08数据库优化方案(一):查询请求增加时,如何做主从分离?.md create mode 100644 专栏/高并发系统设计40问/09数据库优化方案(二):写入数据量增加时,如何实现分库分表?.md create mode 100644 专栏/高并发系统设计40问/10发号器:如何保证分库分表后ID的全局唯一性?.md create mode 100644 专栏/高并发系统设计40问/11NoSQL:在高并发场景下,数据库和NoSQL如何做到互补?.md create mode 100644 专栏/高并发系统设计40问/12缓存:数据库成为瓶颈后,动态数据的查询要如何加速?.md create mode 100644 专栏/高并发系统设计40问/13缓存的使用姿势(一):如何选择缓存的读写策略?.md create mode 100644 专栏/高并发系统设计40问/14缓存的使用姿势(二):缓存如何做到高可用?.md create mode 100644 专栏/高并发系统设计40问/15缓存的使用姿势(三):缓存穿透了怎么办?.md create mode 100644 专栏/高并发系统设计40问/16CDN:静态资源如何加速?.md create mode 100644 专栏/高并发系统设计40问/17消息队列:秒杀时如何处理每秒上万次的下单请求?.md create mode 100644 专栏/高并发系统设计40问/18消息投递:如何保证消息仅仅被消费一次?.md create mode 100644 专栏/高并发系统设计40问/19消息队列:如何降低消息队列系统中消息的延迟?.md create mode 100644 专栏/高并发系统设计40问/20面试现场第二期:当问到项目经历时,面试官究竟想要了解什么?.md create mode 100644 专栏/高并发系统设计40问/21系统架构:每秒1万次请求的系统要做服务化拆分吗?.md create mode 100644 专栏/高并发系统设计40问/22微服务架构:微服务化后,系统架构要如何改造?.md create mode 100644 专栏/高并发系统设计40问/23RPC框架:10万QPS下如何实现毫秒级的服务调用?.md create mode 100644 专栏/高并发系统设计40问/24注册中心:分布式系统如何寻址?.md create mode 100644 专栏/高并发系统设计40问/25分布式Trace:横跨几十个分布式组件的慢请求要如何排查?.md create mode 100644 专栏/高并发系统设计40问/26负载均衡:怎样提升系统的横向扩展能力?.md create mode 100644 专栏/高并发系统设计40问/27API网关:系统的门面要如何做呢?.md create mode 100644 专栏/高并发系统设计40问/28多机房部署:跨地域的分布式系统如何做?.md create mode 100644 专栏/高并发系统设计40问/29ServiceMesh:如何屏蔽服务化系统的服务治理细节?.md create mode 100644 专栏/高并发系统设计40问/30给系统加上眼睛:服务端监控要怎么做?.md create mode 100644 专栏/高并发系统设计40问/31应用性能管理:用户的使用体验应该如何监控?.md create mode 100644 专栏/高并发系统设计40问/32压力测试:怎样设计全链路压力测试平台?.md create mode 100644 专栏/高并发系统设计40问/33配置管理:成千上万的配置项要如何管理?.md create mode 100644 专栏/高并发系统设计40问/34降级熔断:如何屏蔽非核心系统故障的影响?.md create mode 100644 专栏/高并发系统设计40问/35流量控制:高并发系统中我们如何操纵流量?.md create mode 100644 专栏/高并发系统设计40问/36面试现场第三期:你要如何准备一场技术面试呢?.md create mode 100644 专栏/高并发系统设计40问/37计数系统设计(一):面对海量数据的计数器要如何做?.md create mode 100644 专栏/高并发系统设计40问/38计数系统设计(二):50万QPS下如何设计未读数系统?.md create mode 100644 专栏/高并发系统设计40问/39信息流设计(一):通用信息流系统的推模式要如何做?.md create mode 100644 专栏/高并发系统设计40问/40信息流设计(二):通用信息流系统的拉模式要如何做?.md create mode 100644 专栏/高并发系统设计40问/加餐数据的迁移应该如何做?.md create mode 100644 专栏/高并发系统设计40问/期中测试10道高并发系统设计题目自测.md create mode 100644 专栏/高并发系统设计40问/用户故事从“心”出发,我还有无数个可能.md create mode 100644 专栏/高并发系统设计40问/结束语学不可以已.md create mode 100644 专栏/高楼的性能工程实战课/00开篇词打破四大认知局限,进阶高级性能工程师.md create mode 100644 专栏/高楼的性能工程实战课/01性能工程:为什么很多性能测试人员无法对性能结果负责?.md create mode 100644 专栏/高楼的性能工程实战课/02关键概念:性能指标和场景的确定.md create mode 100644 专栏/高楼的性能工程实战课/03核心分析逻辑:所有的性能分析,靠这七步都能搞定.md create mode 100644 专栏/高楼的性能工程实战课/04如何构建性能分析决策树和查找瓶颈证据链?.md create mode 100644 专栏/高楼的性能工程实战课/05性能方案:你的方案是否还停留在形式上?.md create mode 100644 专栏/高楼的性能工程实战课/06如何抽取出符合真实业务场景的业务模型?.md create mode 100644 专栏/高楼的性能工程实战课/07性能场景的数据到底应该做成什么样子?.md create mode 100644 专栏/高楼的性能工程实战课/08并发、在线和TPS到底是什么关系?.md create mode 100644 专栏/高楼的性能工程实战课/09如何设计全局和定向监控策略?.md create mode 100644 专栏/高楼的性能工程实战课/10设计基准场景需要注意哪些关键点?.md create mode 100644 专栏/高楼的性能工程实战课/11打开首页之一:一个案例,带你搞懂基础硬件设施的性能问题.md create mode 100644 专栏/高楼的性能工程实战课/12打开首页之二:如何平衡利用硬件资源?.md create mode 100644 专栏/高楼的性能工程实战课/13用户登录:怎么判断线程中的Block原因?.md create mode 100644 专栏/高楼的性能工程实战课/14用户信息查询:如何解决网络软中断瓶颈问题?.md create mode 100644 专栏/高楼的性能工程实战课/15查询商品:资源不足有哪些性能表现?.md create mode 100644 专栏/高楼的性能工程实战课/16商品加入购物车:SQL优化和压力工具中的参数分析.md create mode 100644 专栏/高楼的性能工程实战课/17查询购物车:为什么铺底参数一定要符合真实业务特性?.md create mode 100644 专栏/高楼的性能工程实战课/18购物车信息确定订单:为什么动态参数化逻辑非常重要?.md create mode 100644 专栏/高楼的性能工程实战课/19生成订单信息之一:应用JDBC池优化和内存溢出分析.md create mode 100644 专栏/高楼的性能工程实战课/20生成订单信息之二:业务逻辑复杂,怎么做性能优化?.md create mode 100644 专栏/高楼的性能工程实战课/21支付前查询订单列表:如何分析优化一个固定的技术组件?.md create mode 100644 专栏/高楼的性能工程实战课/22支付订单信息:如何高效解决for循环产生的内存溢出?.md create mode 100644 专栏/高楼的性能工程实战课/23决定容量场景成败的关键因素有哪些?.md create mode 100644 专栏/高楼的性能工程实战课/24容量场景之一:索引优化和Kubernetes资源分配不均衡怎么办?.md create mode 100644 专栏/高楼的性能工程实战课/25容量场景之二:缓存对性能会有什么样的影响?.md create mode 100644 专栏/高楼的性能工程实战课/26稳定性场景之一:怎样搞定业务积累量产生的瓶颈问题?.md create mode 100644 专栏/高楼的性能工程实战课/27稳定性场景之二:怎样搞定磁盘不足产生的瓶颈问题?.md create mode 100644 专栏/高楼的性能工程实战课/28如何确定异常场景的范围和设计逻辑?.md create mode 100644 专栏/高楼的性能工程实战课/29异常场景:如何模拟不同组件层级的异常?.md create mode 100644 专栏/高楼的性能工程实战课/30如何确定生产系统配置?.md create mode 100644 专栏/高楼的性能工程实战课/31怎么写出有价值的性能报告?.md create mode 100644 专栏/高楼的性能工程实战课/我们这个课程的系统是怎么搭建起来的?.md create mode 100644 专栏/高楼的性能工程实战课/结束语做真正的性能项目.md diff --git a/专栏/领域驱动设计实践(完)/033实践识别限界上下文.md b/专栏/领域驱动设计实践(完)/033实践识别限界上下文.md new file mode 100644 index 0000000..5761e4a --- /dev/null +++ b/专栏/领域驱动设计实践(完)/033实践识别限界上下文.md @@ -0,0 +1,114 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 033 实践 识别限界上下文 + 先启阶段的领域场景分析是一个艰难的过程,我们要从纷繁复杂的业务需求细节中抽象出全部的领域场景,并通过剖析这些场景来获得一致的领域概念,提炼出主要的用户活动,并转换为用统一语言表达的领域行为。在这个过程中,用例帮了我们的大忙。用例的形式其实等同于 Alberto 提出的“事件风暴”中的命令(Command)。命令的发起者是参与者(Actor),所不同的是事件风暴关注的命令比用例粒度更细,且它主要的设计驱动力是命令产生的事件(Event): + + + +事件风暴利用命令和事件驱动出领域驱动战术设计中的聚合(Aggregate)概念,Alberto 认为聚合对象(准确地说是聚合根实体对象)才是命令的真正发起者。于是,命令与事件就与聚合产生了依存关系: + + + +但我认为,如果从一开始的设计就进入到聚合层次,团队可能会陷入到太多纠缠的业务需求细节中。尤其是一些大型的复杂业务系统,要识别出来的命令何止千数!即使通过率先识别核心子领域,再对核心子领域的各种业务场景的命令进行分析,数量仍然客观。如果再加上团队与领域专家的沟通成本,这个事件风暴持续的时间就未免太过漫长了。 + +在寻找系统架构的解决方案时,我更看重限界上下文对边界的控制力,也就是说,在进行领域驱动设计时,首先进入的是战略阶段,而在战略阶段,我们首先要识别的是限界上下文。 + +通过边界识别限界上下文 + +对限界上下文的识别很难做到一蹴而就。通过对限界上下文的本质与价值的剖析,我们希望从业务边界、工作边界再到应用边界三个层次递进和演化的方式打磨限界上下文,使得它的边界和粒度更加合理,为整个系统的逻辑架构与物理架构奠定良好的基础。 + +业务边界的识别 + +领域场景分析中用例划定的主题边界可以作为识别限界上下文的起点。很明显,主题边界的粒度要大于聚合,但是否与限界上下文的边界重叠呢?我不能给出确定的答案,但毫无疑问,主题边界对应于限界上下文的“业务边界”。对于 EAS 系统而言,我们通过用例图可以得出如下的业务主题: + + + +正如前面识别业务主题的过程,我们通过语义相关性和功能相关性对用例进行了归类,这种归类就是“高内聚”原则的体现。可以确定,在相同主题内的用例其相关性要强于不同主题的用例。要识别限界上下文,除了用例级别的归类之外,我们还需要判断主题之间的相关性。如上图所示,我们获得的主题彼此之间存在非常明显的“亲疏”关系,更为“亲密”的主题会组成一个子领域。即使在同一个子领域中,主题之间的“亲疏”关系也有所不同。例如,在项目进度管理子领域中,“项目”主题与“问题”主题的相关性,无疑要比“项目成员”更为紧密。如果再深入分析“项目成员”主题,虽然它与“项目”主题之间存在一定的依赖关系,但从领域概念上,所谓的“项目成员”其实是用户的一种角色,而“项目组”则可以理解为是一个“组织”层级。如此说来,它更像是“组织”主题中组织与角色抽象的一个具体实现,如下图所示: + + + +显然,“项目”主题与“问题”主题代表了不同的用户目标,但各自的业务边界却是非常紧密的,可以考虑合并为一个限界上下文,至于项目与组织的业务边界就确定无疑需要分开了。这种亲疏关系的判断当然需要深入理解业务,但似乎也可以称之为一种设计感觉。这或许就是 Vernon 提到的所谓“经验”罢了。 + +那么有没有什么设计的原则或者依据呢?就以上述分析项目、问题、项目成员之间的亲疏关系为例,它们就好像居住在不同小区的居民。比如说项目和问题住在同一个小区,他们是邻居关系;项目成员住在另一个临近的小区,仍然是邻居,但相隔的距离更远。倘若临近小区的居民和这个小区居民之间又存在亲属关系,意义又有所不同。当一个小区的权益受到侵害时,同一个小区居民的利害关系是休戚与共的。当一个家族的权益受到侵害时,同为亲属的居民的利害关系又绑在一起了。所以,关系的“亲密”程度会因为你观察角度的不同而发生变化,关键在于你选择什么样的判断标准。 + +那么,为何要将“问题”归入“项目”上下文,而不是选择“项目成员”?除了因为项目成员与组织之间存在粘性之外,在概念上,“问题”其实属于“项目”的子概念,在层次上处于“劣势”地位。借用 Kent Beck 在设计上提出的单一抽象层次原则,项目与问题其实并没有处在同一个抽象层次。再以“招聘”主题和“储备人才”主题为例,你会发现二者就没有非常明显的“上下级”关系。它们之间的关系或许是比较紧密的,但彼此之间是平等的层次。 + +在运用“单一抽象层次原则”时,对主题的命名也会影响到对主题关系之间的判断。如果命名过于抽象,就可能产生该抽象的主题隐隐地包含了别的主题。以市场需求子领域中的“市场”主题和“合同”主题为例,从概念的归属来讲,似乎合同也应属于市场的范畴。故而带来两个设计选择: + + +要么将合同主题纳入到市场主题,进而形成一个市场上下文; +要么将市场主题命名为“订单”,订单与合同,显然两个领域概念处于同一层次。 + + +在划分主题时,我们还应该遵循正交原则,即主题之间存在唯一的依赖点,除了这个依赖点之外,主题的其他变化不应该影响到另一个主题。例如,为什么我要单独识别出“文件共享”主题与“通知”主题?就是因为在诸如“员工”、“储备人才”、“合同”等主题中都包含了文件上传下载的用例,在诸如“项目”、“合同”、“招聘”等主题中都包含了消息通知的用例。如果不分离出来,一旦文件上传下载的实现有变,或者消息通知的实现有变,都会影响到这些相关的业务主题,造成一种“霰弹式修改”的代码坏味,这就违背了“正交原则”。 + +通过对业务边界的分析,并运用单一抽象层次原则与正交原则,我们得到了 EAS 限界上下文的草案: + + + +工作边界的识别 + +从工作边界识别限界上下文是一个长期的过程,当然,这其中也牵涉到对需求变更和新需求加入时的柔性设计。对限界上下文进行开发的团队应尽量为特性团队(Feature Team),且遵循 2PTs 原则。倘若随着时间的推移,团队的规模越来越大,就是在传递限界上下文边界不合理的信号。正如前面所言,团队的每一个人都要像守护自己家庭一般守护好团队的工作边界。在我参与咨询的一些客户中,就有客户因为团队规模变得越来越大(接近 20 人)而取消了原来的每日站会。团队规模太大,交流成本变高,一个大团队的每日站会就会成为“鸡肋”。 + +那么,限界上下文与 2PTs 特性团队之间的映射关系究竟是怎样的呢?这似乎并没有定论,取决于团队成员的能力水平、限界上下文的复杂程度,也与系统的类型(项目还是产品)息息相关。 + +我认为,首先要避免一个限界上下文的工作边界过大,导致需要多个 2PTs 特性团队共同来完成,因为这会带来不必要的沟通成本。倘若出现了这种情况,说明我们需要继续分解限界上下文。那么,是否可以将多个限界上下文分配给一个特性团队呢?由于限界上下文的划分遵循了“高内聚、低耦合”的原则,只要我们规定好限界上下文之间的协作契约,就可以并行开发多个限界上下文。对于其中的一个限界上下文而言,无论它的特性有多少,只要用户故事的拆分保证了合适的开发粒度,考虑用户故事之间存在的业务依赖和技术依赖,每个限界上下文就必然存在一个最大并行开发度(Max Degree of Parallel Development,MDPD)。而对于一个特性团队而言,也存在一个最大并行开发度,其值可以借鉴精益看板提出的 WIP Limits(WIP 即 Work in Progress,在制品限制)。假设不考虑开发人员的结对,一个 2PTs 特性团队的在制品限制大约为 4~5,则限界上下文(Bounded Context,BC)遵循的公式为: + +[Math Processing Error]∑BC(MDPD)≈WIPLimits + +例如说,我们将订单、合同、客户主题都视为独立的限界上下文,并分配给一个 2PTs 特性团队,这个团队的 WIP Limits 为 5。如果订单上下文的最大并行开发度为 4,合同上下文的最大并行开发度为 2,客户上下文的最大并行开发度为 3,根据前面公式,就可以得到三个限界上下文的最大并行开发度之和为 9,这就远远大于了 WIP Limits,如果仍然保持这种工作分配,就会导致限界上下文的开发周期延长。反过来,如果我们只将合同上下文分配给该团队,又会造成特性团队开发人员的浪费。 + +当然,这种判断依据存在理想与现实的差异。例如,开发团队的人力资源是存在限制的,开发周期的长度也存在限制,在项目早期,也很难精确计算一个限界上下文的最大并行开发度。故而这个公式无法像数学公式那样给予精确的计算,但确乎可以作为限界上下文与开发团队映射关系的一个参考。 + +还有一个判断限界上下文工作边界划分是否合理的原则是:限界上下文是否允许进行并行开发。无法并行开发,则意味着限界上下文之间的依赖太强,违背了“高内聚、松耦合”原则。例如,在前面识别的 EAS 限界上下文草案中,抛开发布与迭代计划在功能优先级的考量,我们发现报表上下文与客户、合同、订单、项目、员工等上下文都存在非常强的依赖关系。如果这些上下文没有完成相关的特性功能,我们就很难去实现报表上下文,这就引起了我们对报表上下文的思考。由于报表上下文中的诸多统计报表其实是与各自的业务强相关的,例如,“查看项目统计报表”用例就只需要统计项目的信息,因而可以考虑将这些用例放到业务强相关的限界上下文中。 + +结合工作边界和业务边界,我们认为工作日志的业务边界过小,且从业务含义上看,它也可以视为是员工管理的其中一项子功能,因而决定将工作日志合并到员工上下文内部,作为该限界上下文的一个模块(Module)。虽然考勤也属于员工管理的范畴,但它需要访问考勤机外部硬件,且请假与出勤亦属于单独的一个业务方向,因而仍然保留了考勤上下文。 + +对于储备人才与招聘之间的关系,类似于工作日志之于员工,我们最初也想将储备人才合并到招聘上下文中,然而客户对需求的反馈打消了我们这一决策考量。因为该软件集团旗下还有一家软件学院,集团负责人希望将软件学院培养的软件开发专业的学生也纳入到企业的储备人才库中,这就需要 EAS 系统与学校的学生管理系统集成,影响了对储备人才的管理模式。这一需求一下子扩充了储备人才的领域内涵,为它的“独立”增加了有力的砝码。 + +一些限界上下文之间的依赖通过需求分析是无法呈现出来的,这就有赖于上下文映射对这种协作(依赖)关系的识别。一旦明确了这种协作关系,包括接口的定义与调用方式,就相当于在两个团队之间确定了交流与合作方式,可以利用 Mock 或 Stub 接口的方式解除这种依赖,实现并行开发。 + +通过工作边界识别限界上下文的一个重要出发点是激发团队成员对工作职责的主观判断,也就是在第 15 课提及的针对团队的“渗透性边界”,团队成员需要对自己负责开发的需求“抱有成见”,尤其是团队成员在面对需求变更或新增需求的时候。在 EAS 系统的设计开发过程中,客户提出了增加“员工培训”的需求,该需求要求人力资源部能够针对员工的职业规划制定培训计划,确定培训课程,并实现对员工培训过程的全过程管理。 + +由于考虑到这些功能与员工上下文有关,我们最初考虑将这些需求直接分配给员工上下文的特性团队。然而,团队的开发人员提出:这些功能虽然看似与员工有关,但实际上它是一个完全独立的“培训”领域,包括了培训计划(Training Plan)制定、培训提名(Nomination)、培训过程管理等业务知识,与员工管理完全是正交的。最终,我们为培训建立了一个专门的特性团队,同时,在架构中引入了培训(Training)上下文。 + +针对类似文件共享和通知这样一些属于支撑子领域或者通用子领域的限界上下文,粒度可能是不均匀的,互相之间又不存在关联。这时,我们应确保原定的限界上下文业务边界,然后视其粒度酌情分配给一个或多个特性团队,甚至部分限界上下文因为不牵涉到垂直业务功能,可能还需要创建组件团队(Component Team)。在有的项目中,提供支撑功能的底层实现面临较大的技术挑战,又或者底层功能在整个公司存在普适性,这时就可以单独抽离出来,形成公司范围内的框架或平台,这样的框架和平台就不再属于当前系统的范围了(属于 System Context 之外)。 + +根据需求变化以及对团队开发工作的分配,我们调整了限界上下文: + + + +我们将工作日志合并到了员工上下文,同时为了应对新需求的变更,增加了培训(Training)上下文,并暂时去掉了报表上下文。之所以说“暂时”,是因为还需要对其做一些技术层面的判断。 + +应用边界的识别 + +对应用边界的识别,就是从技术角度来考量限界上下文,包括考虑系统的质量属性,模块的重用性,对需求变化的应对以及如何处理遗留系统的集成等。 + +针对报表上下文留下来的遗留问题,我们与客户进行了需求上的确认,明确了集团决策层的需求,就是希望系统提供的统计报表能够准确及时地展现历史和当前的人才供需情况。显然,统计报表功能直接影响了系统的业务愿景,是系统的核心功能之一。我们需要花费更多精力来明确设计方案。通观报表上下文提供的用例行为,除了与职能部门管理工作有关的统计日报、周报和月报外,报表的统计结果实际上为集团领导进行决策提供了数据层面的辅助支持。要提供准确的数据统计,就需要对市场需求、客户需求、项目、员工、储备人才、招聘活动等数据做整体分析,这需要整个系统核心限界上下文的数据支持。倘若 EAS 的每个限界上下文并未采用微服务这种零共享架构,则整个系统的数据就可以存储在一个数据库中,无需进行数据的采集和同步,就可以在技术上支持统计分析。另一种选择就是引入数据仓库技术,无论我们选择何种架构模式,都可以采用诸如 ETL 形式完成对各个生产数据库以及日志文件的采集,由统一的数据仓库为统计分析提供数据支持。 + +虽然在分析工作边界时,我们认为报表上下文与其他限界上下文存在强依赖关系,无法支持并行开发,因而考虑将该上下文的功能按照业务相关性分配到其他限界上下文中。如今通过技术分析,虽然这种依赖性仍然存在,但该上下文包含的用例更多地体现了“决策分析”的特定领域。最终,我们还是决定保留该限界上下文,并更名为决策分析上下文。 + +在 EAS 系统中,我们从技术层面再一次讨论了员工上下文和储备人才上下文的边界。从业务相关性的角度看,员工属于员工管理的领域范畴,而储备人才并非正式员工,是招聘的目标。但是,从领域建模的角度讲,员工与储备人才的模型实在是太相似了,如下图所示: + + + +两个模型除了聚合根的名字不同之外,几乎是一致的。我们是否要对二者进行抽象呢?如下图所示: + + + +从面向对象的角度看,这种抽象是合理的,也能在一定程度上避免代码的重复开发。然而,这样的设计决定了我们不能将员工和储备人才放在两个不同的限界上下文中,否则就会导致二者的强耦合。若是放在同一个限界上下文,又违背了业务相关性。从技术实现的角度讲,我们必须要考虑员工和储备人才各自的持久化。即使它们在模型上保持了极大的相似度,但是除了一种场景即“从储备人才转为正式员工”用例需要将二者结合起来,其余场景二者都是完全隔离的。即使是这样的业务场景,一旦储备人才转为了正式员工,二者就不存在任何关系了。显然,它们模型相似但在业务上却是独立进化的,数据持久化的实现也必须是完全隔离的。因此,我们仍然保留了这两个独立的限界上下文。 + +在考虑通知上下文的实现时,基于之前系统上下文(System Context)的分析,EAS 系统要与集团现有的 OA 系统进行集成。为了实现二者的集成,我们了解了 OA 系统公开的服务接口,发现这些接口中已经提供了多种消息通知功能,包括站内消息、邮件通知和短消息通知。从业务需求上看,在进行流程处理时,发送的消息通知本身就将作为 OA 系统的待办项,由员工在 OA 系统中对其进行处理,技术实现上也没有必要对通知功能进行重复开发。于是,之前分析的通知上下文似乎就不再有存在的必要。但仔细思考,与 OA 系统集成的功能又该放在哪里呢?领域驱动设计建议将这种与第三方服务集成的功能放在防腐层中,但由于 EAS 系统有多个限界上下文都需要调用该功能,我们不可能在各自的限界上下文中重复创建防腐层。为了满足功能的重用性,就应该为该集成功能单独创建一个限界上下文。至于命名,应该将“通知(Notification)”更名为“OA 集成(OA Integration)”。 + +最终,我们得到了如下的限界上下文: + + + +即使经历了对业务边界、工作边界和应用边界的分析,我们仍然不敢保证现在得到的限界上下文就是合理的。毕竟,先启阶段进行的领域场景分析还比较粗略,我们也无法预判未来需求还会发生什么样的变化。因而,作为战略设计阶段核心的限界上下文解决方案是需要不断演进的。何况,我们还没有深入分析这些限界上下文之间的协作关系呢。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/034实践确定限界上下文的协作关系.md b/专栏/领域驱动设计实践(完)/034实践确定限界上下文的协作关系.md new file mode 100644 index 0000000..fdacb3e --- /dev/null +++ b/专栏/领域驱动设计实践(完)/034实践确定限界上下文的协作关系.md @@ -0,0 +1,169 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 034 实践 确定限界上下文的协作关系 + 通过上下文映射来确定限界上下文之间的协作关系,是识别限界上下文之后至为关键的一步。每个限界上下文都仅仅展示了整体架构全局视图的一角,只有将它们联合起来,才能产生合力,满足业务场景的需要。若这种协作关系处理不当,这种联合的合力反倒成了一种阻力,清晰的架构不见,限界上下文带给我们的“价值”就会因此荡然无存。 + +如果单从确定限界上下文之间的协作关系而论,要考量的设计要素包括: + + +限界上下文的通信边界 +采用命令、查询还是事件的协作机制 +定义协作接口 + + +通信边界、协作机制与上下文映射模式的选择息息相关。例如,通信边界采用进程内通信,就可能无需采用开放主机服务模式,甚至为了保证架构的简单性,也无需采用防腐层模式。如果采用命令和查询的协作机制,可能会采用客户方/供应方模式,如果采用事件的协作机制,则需要采用发布者/订阅者模式。 + +在识别限界上下文协作关系的阶段,是否需要定义协作的接口呢?我认为是必要的。一方面接口的定义直接影响到协作模式,也属于架构中体现“组件关系”的设计内容;另一方面通过要求对协作接口的定义,可以强迫我们思考各种协作的业务场景,避免做出错误的上下文映射。如果在这个阶段还未做好框架的技术选型,接口的设计就不应该与具体的框架技术绑定,而是给出体现业务价值的领域模型,换言之,就是定义好当前限界上下文的应用服务,因为应用服务恰好体现了用例的应用逻辑。 + +识别 EAS 的上下文映射 + +在领域驱动设计中,以“领域”为核心的设计思想应当贯穿整个过程始终,确定系统的上下文映射自然也不例外。实际上,整个领域驱动的战略设计实践是存在连贯关系的,我们不能因为进入一个新的阶段,就忘记了前面获得的成果。决定上下文映射的重要输入就包括基于领域场景分析获得的用例图,基于用例图获得的限界上下文。 + +根据用例识别协作关系 + +为避免出现上下文映射的疏漏,我们应该根据业务场景来分析各种限界上下文协作的关系。这时,先启阶段领域场景分析获得的用例图就派上用场了。为了确保设计的严谨,我们应该“遍历”所有的主用例,理解用例的目标与流程,再结合我们已经识别出来的限界上下文判断它们之间的关系。 + +由于用例图中的用例传递的信息量有限,我们在识别协作关系时,可以进一步确定详细的流程,绘制更为详细的用例图甚至活动图。用例的好处在于不会让你遗漏重要的业务场景,而用例图中的包含用例与扩展用例,往往是存在上下文协作的信号。当然,在识别上下文协作关系时,还需要注意其中的陷阱。正如在[第 3-9 课:辨别限界上下文的协作关系(上)]中提到的那样,要理解协作即依赖的本质,正确辨别这种依赖关系到底是领域行为或领域模型的依赖,还是数据导致的依赖,又或者与限界上下文的边界彻底无关。 + +以“创建需求订单”用例为例,它的完整用例图如下所示: + + + +主用例“创建需求订单”属于订单上下文,“指定客户需求承担者”属于客户上下文,“通知承担者”用例是“指定客户需求承担者”的扩展用例,但它实际上会通过 OA 集成上下文发送消息通知。若满足于这样的表面现象,可得出上下文映射(图中使用了六边形图例来表达限界上下文,但并不说明该限界上下文一定为微服务): + + + +然而事实上,在指定客户需求承担者时,订单上下文并非该用例的真正发起者,而是市场人员通过用户界面获得客户信息,再将选择的客户 ID 传递给了订单,订单上下文并不知道客户上下文。如此一来,消息通知的发送也将转为由订单上下文发起。于是,上下文映射变为: + + + +目前获得的上下文映射自然不会是最终方案。不同的用例代表不同的场景,产生的协作关系自然会有所不同。在“跟踪需求订单”用例中,需要在用户界面呈现需求订单状态,同时还将显示需求订单下所有客户需求的客户信息和承担者信息,这就需要分别求助于客户上下文和员工上下文。因此,订单上下文的上下文映射就修改为: + + + +“创建市场需求”用例图如下所示: + + + +除了需要在订单上下文中创建市场需求之外,还要通过文件共享上下文完成附件的上传。此外,操作订单时需要对用户进行身份认证。最终,订单上下文的上下文映射就演变为: + + + +有些限界上下文之间的关系是隐含的,需要透过用例去理解内在的业务流程才能探明这种关系。例如,“制定招聘计划”用例: + + + +当招聘专员制定好招聘计划时,会发送消息通知招聘计划审核人,这个审核人就是人力资源总监。然而此时的招聘上下文并不知道谁是人力资源总监,只能通过招聘专员所属部门的组织层级去获得人力资源总监(用户角色)的信息,再通过该角色对应的 EmployeeId 到员工上下文获取人力资源总监的联系信息,包括手机和邮箱地址。得到的上下文映射为: + + + +通过识别上下文映射,还会帮助我们甄别一些错误的限界上下文职责边界划定。例如,针对“添加项目成员”用例: + + + +通过前面对限界上下文的识别,我们认为项目成员作为一种用户角色,项目组作为一个组织层级,从概念关联性看更适合放在组织上下文。当项目经理通过用户界面添加项目成员时,其流程为: + + +前置条件与项目关联的项目组已经创建好 +选择要加入的项目组 +列出符合条件的员工清单 +选择员工加入到当前项目组 +通知该员工已成为项目组的项目成员 +将当前项目的信息追加到项目成员的项目经历中 + + +注意,列出员工清单的功能属于员工上下文,但该操作是通过用户界面发起对员工上下文的调用,组织上下文并不需要获取员工清单,而是用户界面传递给它的。在员工加入到当前项目组后,组织上下文需要通过 OA 集成发送通知消息,还要通过员工上下文来追加项目经历功能。基于这样的流程,得到的上下文映射为: + + + +然而考虑认证上下文,它又需要调用组织上下文提供的服务来判断用户是否属于某个部门或团队,这就在二者之间产生了上下游关系。由于认证上下文比较特殊,如果系统没有采用 API 网关,则作为通用子领域的限界上下文,会被多个核心子领域的限界上下文调用,其中也包括员工上下文与项目上下文,于是上下文映射就变为: + + + +为了更好地体现协作关系,我在上图增加了箭头,加粗了相关连线。可以清晰地看到,上图粗线部分形成了认证、组织与员工三个限界上下文之间的循环依赖,这是设计上的“坏味道”。导致这种循环依赖的原因,是因为与项目成员有关的用例被放到了组织上下文中,从而导致了它与员工上下文产生协作关系,这充分说明了之前识别的限界上下文仍有不足之处。组织结构是一种领域,管理的是部门、部门层次、角色等更为普适性的特性。换言之,即使不是在 EAS 系统,只要存在组织结构的需求,仍然需要该限界上下文。如此看来,项目成员的管理应属于更加特定的业务领域。在添加项目成员时,领域逻辑仍然属于项目上下文,但建立成员与项目组之间的关系,则应交给更为通用的组织上下文,形成二者的上下游关系。经过这样的更改后,“追加项目成员的项目经历”用例就由项目上下文向员工上下文直接发起调用请求: + + + +这个场景体现了上下文映射对限界上下文设计的约束和驱动作用。在调整了限界上下文的职责之后,避免了限界上下文之间的循环依赖,使得限界上下文的边界更加清晰,保证了它们之间的松散耦合,有利于整个系统架构的演化。 + +确定上下文协作模式 + +要确定上下文协作模式,首先需要明确限界上下文的通信边界,即确定为进程内通信还是进程间通信。采用进程间通信的限界上下文就是一个微服务。在[第 4-8 课:代码模型的架构决策]中,我总结了微服务的优势与不足。EAS 系统作为一个企业的内部系统,对并发访问与低延迟的要求并不高,可用性固然是一个系统该有的特质,但毕竟它不是“生死攸关”的一线生产系统,短时间出现故障不会给企业带来致命的打击或难以估量的损失。整体来看,在质量属性方面,除了安全与可维护性之外,系统并无特别高的要求。综上所述,我看不到需要建立微服务架构的任何理由。既然无需创建微服务架构,就不必遵守一个限界上下文一个数据库的约束,满足架构的简单原则,可以为整个 EAS 系统创建一个集中的数据库。 + +这一设计决策直接影响到决策分析上下文的实现方案。就目前的需求而言,我们似乎没有必要为实现该上下文的功能专门引入数据仓库。决策分析上下文具有如下特征: + + +访问的数据涵盖所有的核心子领域 +决策分析仅针对数据执行查询统计操作 + + +虽然决策分析上下文属于核心子领域,但针对这两个特征,我们决定“斩断”该上下文和其他上下文之间的业务耦合关系,让它直接访问数据库,并借鉴 CQRS 架构模式,不为它定义领域模型,而是创建一个薄的数据访问层,通过执行 SQL 语句完成高效直接的数据处理。 + +既然决定限界上下文之间采用进程内通信,我们该选择何种上下文映射模式呢?到上下文映射的“武器库”中看一看,原来我们不知不觉已经使用了“共享内核”模式,提取了文件共享上下文,同时还引入了扮演“防腐层”功能的 OA 集成上下文。 + +作为提供垂直领域功能的限界上下文,需要为前端的用户界面或其他客户端提供 RESTful 服务,于是为如下限界上下文建立“开放主机服务”: + + +订单上下文 +合同上下文 +客户上下文 +员工上下文 +考勤上下文 +招聘上下文 +储备人才上下文 +培训上下文(该上下文是项目开发中期针对需求变更引入) +项目上下文 +决策分析上下文 +资源上下文 +组织上下文 + + +既然采用了进程内通信,且针对这样的企业系统,演变为微服务架构的可能性较低,为了架构的简单性,针对以上限界上下文之间的协作,并无必要引入间接的防腐层。至于它与外部的 OA 系统之间的协作,已经由 OA 集成上下文提供了“防腐”功能。 + +我们是否需要采用“遵奉者”模式实现限界上下文之间的模型重用呢?同样是设计的取舍,简单还是灵活,重用还是清晰,这是一个问题!限界上下文的边界控制力会在架构中产生无与伦比的价值,它可以有效地保证系统架构的清晰度。如果为了简单与重用而纵容对模型的“滥用”,可能会导致系统变得越来越糟糕。对于采用进程内通信的限界上下文,运用“遵奉者”模式重用领域模型,就会失去限界上下文存在的意义,使之与战术设计中的模块(Module)没有什么区别了。说好的限界上下文保证领域概念的一致性呢?例如,合同上下文、项目上下文、订单上下文都需要通过员工上下文获得员工的联系信息,那么最好的方式不是直接重用员工上下文中的 Employee 模型对象,而是各自建立自己的模型对象 Employee 或 TeamMember,除了具有 EmployeeId 之外,可以只包含一个 Contact 属性: + + + +我们还需要确定限界上下文之间的调用机制,究竟是通过命令、查询还是事件?由于采用了进程内通信,限界上下文之间的协作方式应以同步的查询或命令机制为主。唯一的例外是将 OA 集成上下文定义为进程间通信的限界上下文,毕竟它的实现本身就是要跨进程调用 OA 系统。这个限界上下文要实现的功能都与通知有关,无论是短信通知、邮件通知还是站内通知,都没有副作用,且允许以异步形式调用,适合使用事件的调用机制。这种方式一方面解除了 OA 系统上下文与大多数限界上下文之间的耦合,另一方面也能够较好地保证 EAS 系统的响应速度,减轻主应用服务器的压力。唯一不足的是需要增加一台部署消息队列的服务器,并在一定程度增加了架构的复杂度。采用事件机制,意味着 OA 集成上下文采用了“发布者/订阅者”模式,其中 OA 集成上下文为订阅者: + + + +定义协作接口 + +定义协作接口的重要性在于保证开发不同限界上下文的特性团队能够并行开发,这相当于为团队规定了合作的契约。集成是痛苦的,无论团队成员能力有多么强,只要没有规定好彼此之间协作的接口,就有可能导致系统模块无法正确地集成,或者隐藏的缺陷无法及时发现,最严重的是破坏了限界上下文的边界。我们需要像保卫疆土一样去守护限界上下文的边界,如果不加以控制,任何风吹草动都可能酿成“边疆”的风云突变。 + +注意,现在定义的是限界上下文之间协作的接口,并非限界上下文所有的服务接口,也不包括限界上下文对外部资源的访问接口。协作接口完全可以根据之前确定的上下文映射获得。在上下文映射图中,每个协作关系都意味着一个接口,不同的上下文映射模式可能会影响到对这些接口的设计。例如,如果下游限界上下文通过开放主机服务模式与上游协作,就需要定义 RESTful 或 RPC 接口;如果下游限界上下文直接调用上游,意味着需要定义应用服务接口;如果限界上下文之间采用发布者/订阅者模式,需要定义的接口其实是事件(Event)。 + +对于 EAS 系统而言,我们已经确定除与 OA 集成上下文之间采用“发布者/订阅者”模式之外,其余限界上下文之间的协作都是“客户方/供应方”模式,且无需引入防腐层和开放主机服务,因此,要定义的协作接口其实就是各个限界上下文的应用服务接口。在定义协作接口时,我们只需要规定作为供应方的上游应用服务即可。如果采用事件机制,协作接口就应该是对事件的定义。 + +以订单上下文为例,它的上下文映射图为(与前面上下文映射的不同之处是将订单与 OA 集成之间的协作改为了事件机制): + + + +记录与订单上下文相关的协作接口如下表所示: + + + +在这个接口表中,我使用生产者(Producer)与消费者(Consumer)来抽象客户方/供应方模式与发布者/订阅者模式。表中的模式自然就是上下文映射模式。如有必要,也可以是多个模式的组合,比如客户方/供应方与开放主机服务之间的组合。当然,如果为开放主机服务,且发布语言为 RESTful,则后面的服务定义就应该是遵循 RESTful 服务定义的接口。 + +对于订单上下文与 OA 集成上下文之间的协作,正如前所述,我们采用了发布者/订阅者模式。因此,这里的协作接口实际上是对事件的定义。最初为了表达订单的领域概念,我将该事件定义为 OrderCompleted。回顾 OA 集成上下文的上下文映射,作为订阅者的 OA 集成上下文在接收到事件后,要做的事情都是将事件持有的内容转换为要发送消息通知的内容以及送达的地址,然后发送消息通知。显然,它订阅的事件应该是相同的,因为处理事件的逻辑完全相同。故而应该将 OrderCompleted 修改为 NotificationReady 事件。除了订单发布该事件外,合同、项目、组织等限界上下文都将发布该事件。 + +协作接口表格式并非固定或唯一。例如,我们也可以为每个接口定义详尽的描述: + + +接口, AuthenticationService +描述, 对操作用户进行身份认证 +命名空间, paracticeddd.eas.authcontext.application +方法, authenticate(userId): AuthenticatedResult +模式,客户方/供应方模式 +接口类型, 命令 + + +协作接口定义的格式不是重要的,关键还是在战略设计阶段需要重视对它们的定义。只有这样才能更好地保证限界上下文的边界,因为除了这些协作接口,限界上下文之间是不允许直接协作的。协作接口的定义也是上下文映射的一种落地实践,要避免上下文映射在战略设计中沦为一幅幅中看不中用的设计图。同时,通过它还可以更好地遵循统一语言,保证设计模型与领域模型的一致性。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/035实践EAS的整体架构.md b/专栏/领域驱动设计实践(完)/035实践EAS的整体架构.md new file mode 100644 index 0000000..1486126 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/035实践EAS的整体架构.md @@ -0,0 +1,128 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 035 实践 EAS 的整体架构 + 迄今为止,EAS 的战略设计算得上是万事俱备只欠东风了。为了得到系统的整体架构,我们还欠缺什么呢?所谓“架构”,是“以组件、组件之间的关系、组件与环境之间的关系为内容的某一系统的基本组织结构,以及指导上述内容设计与演化的原则”。之所以要确定系统的组件、组件关系以及设计与演化的原则,目的是通过不同层面的结构视图来促进团队的交流,为设计与开发提供指导。架构不仅仅是指我们设计产生的输出文档,还包括整个设计分析与讨论的过程,这个过程产生的所有决策、方案都可以视为是架构的一部分。例如,下图就是团队站在白板前进行面对面沟通时,针对系统需求以可视化形式给出的架构草案: + + + +像这样的可视化设计图同样是架构文档中的一部分。我们在先启阶段分析得到的系统上下文图、问题域、用例图以及限界上下文和上下文映射,也都是架构文档中的一部分。这些内容都可以对我们的设计与开发提供清晰直观的指导。 + +当然,若仅以如此方式交付架构未免有些随意,也缺乏系统性,会导致设计过程的挂一漏万,缺失必要的交流信息。领域驱动设计并没有明确给出架构的设计过程与设计交付物,限界上下文、分层架构、上下文映射仅仅作为战略设计的模式而存在。因此,我们可以参考一些架构方法,与领域驱动设计的战略设计结合。这其中,值得参考的是 Philippe Kruchten 提出的架构 4 + 1 视图模型(后被 RUP 采纳,因此通常称之为 RUP 4 + 1 视图),如下图所示: + + + +在这个视图模型中,场景视图正好对应我们的领域场景分析,之前获得的用例图正好展现了业务场景的一面。逻辑视图面向设计人员,在领域驱动设计中,通常通过限界上下文、上下文映射和分层架构描绘功能的模块划分以及它们之间的协作关系。进程视图体现了进程之间的调用关系,比如采用同步还是异步,采用串行还是并行。领域驱动设计由于是以“领域”为核心,对这方面的考量相对较弱。通常,我会建议采用风险驱动设计(Risk Driven Design),通过在架构设计前期识别系统的风险,以此来确定技术方案。我们对限界上下文通信边界的判断,恰好是一种对风险的应对,尤其是针对系统的可伸缩性、性能、高并发与低延迟等质量属性的考虑。一旦我们确定限界上下文为进程间通信时,就相当于引入了微服务架构风格,通过六边形架构与上下文映射可以部分表达进程视图。物理视图体现了系统的硬件与网络拓扑结构,六边形架构可以帮助我们确定系统的物理边界,并通过端口来体现限界上下文与外部环境之间的关系。至于开发视图,我们之前围绕着分层架构演进出来的代码模型就是整个系统在开发视图下的静态代码结构。综上所述,我们就为 RUP 4+1 视图与领域驱动设计建立了关联关系,如下表所示: + + + + +RUP 4+1 视图 +领域驱动设计的模式与实践 + + + + + +场景视图 +领域场景分析、用例图 + + + +逻辑视图 +限界上下文、上下文映射、分层架构 + + + +进程视图 +限界上下文、六边形架构、上下文映射 + + + +物理视图 +六边形架构 + + + +开发视图 +分层架构、代码模型 + + + + +EAS 的逻辑视图 + +可以说,我们对限界上下文的加强突破了原来对分层架构的认知。通常所谓的“分层架构”,相当于一个生日蛋糕,整个系统统一被划分为 N 层,如生日蛋糕中的水果层、奶油层和蛋糕层。在引入限界上下文的边界控制力后,每个限界上下文都可以有属于自己的分层架构,并通过应用层或北向网关暴露出协作的接口,满足限界上下文之间协作的需求,同时组合为一个整体为前端提供开放主机服务。 + +回到 EAS,我们完全可以为体现核心子领域的限界上下文建立领域驱动设计的分层架构,并突出领域模型的重要性。对于决策分析上下文,则借用 CQRS 模式,在体现北向网关的控制器之下,仅需定义一个薄薄的数据访问层即可。OA 集成上下文其实是一个由防腐层发展起来的限界上下文,且由于它与其他限界上下文的协作采用了发布者/订阅者模式,内部又需要调用 OA 系统的服务接口,因而领域层就只包含了领域事件以及对应的事件处理器(EventHandler),它的基础设施层则负责事件的订阅,并封装访问 OA 系统的客户端。 + +在分析文件共享上下文时,我们发现了它的特殊性。如果只考虑对各种类型文件的上传与下载,它更像是一个可以被多个限界上下文重用的公共组件。由于它会操作文件这样的外部资源,因而应作为组件放到整个系统的基础设施层。正如我在[第 3-1 课:理解限界上下文]中写到: + + +它并非某种固定的设计单元,我们不能说它就是模块、服务或组件,而是通过它来帮助我们做出高内聚、低耦合的设计。只要遵循了这个设计,则限界上下文就可能成为模块、服务或组件。 + + +因此,在识别限界上下文时,不要被模块、组件或服务限制了你的想象,更不要抛开自己对业务的理解凭空设计限界上下文。在识别出来的 EAS 限界上下文中,文件共享与认证上下文成为了组件,OA 集成上下文成为了服务,而诸如合同、订单、项目等上下文则成为了模块,这就是所谓“看山是山、看水是水”三重境界的道理。最终,EAS 系统的逻辑视图如下图所示: + + + +EAS 的逻辑视图分为两个层次的分层架构。 + + +系统层次的分层架构:该层次仅包含了领域层和基础设施层,这是因为控制器与应用层都与对应的限界上下文有关,不存在系统层次的开放主机服务。系统层次的领域层定义了支持领域驱动设计核心要素的模型对象,可以视为一个共享内核。基础设施层包含了文件共享、认证功能与事件发布,都是多个限界上下文需要调用的公共组件。 +限界上下文层次的分层架构:依据限界上下文领域逻辑复杂度的不同,选择不同的建模方式。如果采用领域模型的建模方式,则定义为经典的应用层、领域层和基础设施层。本身属于基础设施层的控制器被独立出来,定义为控制器层。所有层次皆与所属限界上下文的领域相关,区别在于它们的关注点不同。除决策分析上下文之外,其余限界上下文的基础设施层实际上都是 Repository 的实现,即 Gateway 模块中的 Persistance 模块。 + + +注意,之所以将“事件发布”放在系统层次的基础设施层,而非各个限界上下文的基础设施层,在于事件封装的逻辑完全一致,都是发布 NotificationReady 事件,这是之前在识别协作接口的时候确定下来的。同时,底层发布事件的通信机制也是完全相同的。将其封装到系统层次的基础设施层,就有利于相关限界上下文对它的重用。为了让限界上下文满足整洁架构,可以考虑在系统层次提供 interfaces 模块,保证抽象接口与具体实现的隔离。这个公开定义的接口会被各个限界上下文的领域对象调用。显然,对比事件发布与持久化,前者具有系统全局范围的通用性,后者则只服务于当前限界上下文。 + +由于决策分析上下文特殊的业务逻辑,我没有为其定义领域模型,因而在它的上下文边界中,只定义了控制层和数据访问层。统计分析的逻辑被直接封装在数据访问层中,避免了不必要的从数据模型到领域模型再到服务模型的转换,即数据访问层返回的结果就是控制器要返回的 Response 对象。 + +图中的粗实线框代表了进程边界,故而 OA 集成上下文与其他限界上下文不在同一个进程中。同样的,数据库与消息队列也处于不同的进程(甚至是不同的服务器)。粗虚线框代表了系统的逻辑边界,除了图中的第三方 OA 系统与前端模块,其余内容包括数据库都在 EAS 系统的逻辑边界中。 + +EAS 的进程视图 + +如前所述,架构的进程视图主要关注系统中处于不同进程中组件之间的调用方式。我们在前面通过限界上下文与上下文映射已经确定了各个限界上下文的通信边界以及它们之间的协作关系。除了与 OA 集成上下文之间将采用异步的事件发布机制之外,就只有前端向系统后端发起的 RESTful 请求,以及系统向数据库和文件发起的请求属于进程间通信。由于 EAS 系统在质量属性上没有特别的要求,在目前的架构设计中,暂不需要考虑并发访问。 + +在绘制系统的进程视图时,不需要将每个牵涉到进程间调用的用例场景都展现出来,而是将这些参与协作的组件以抽象方式表达一个典型的全场景即可。在我们这个系统中,主要包括 RESTful 请求、文件上传、消息通知与数据库访问,如下时序图所示: + + + +调用者在向 EAS 系统发起 http 请求时,首先会通过 Nginx 反向代理寻找到负载最小的 Web 应用服务器,并通过 REST 框架将请求路由给对应的控制器。从控制器开始一直到 Repository、UploadFileService 与 EventPublisher,所有的方法调用都在一个进程中,唯一不同的是诸如上传文件与发布事件等方法是非阻塞的异步方法。控制器是面向 REST 请求的北向网关,RepositoryRepository、UploadFileService 与 EventPublisher 则作为南向网关与系统边界之外的外部资源通信。其中,Repository 通过 JDBC 访问数据库,UploadFileService 通过 FTP 上传文件,EventPublisher 发布事件给消息队列,都发生在进程之间。基于这些访问协议,你可以清晰地看到六边形架构中端口和适配器的影子。 + +OA 集成上下文是一个单独部署在独立进程中的限界上下文,上下文之间的通信交给了消息队列。EventHandler 是其北向网关,通过它向消息队列订阅事件。RestClient 是其南向网关,通过它向第三方的 OA 系统发起 RESTful 请求。 + +整个进程视图非常清晰地表达了部署在不同进程之上的组件或子系统之间的协作关系,同时通过图例体现了领域驱动设计中的北向网关和南向网关与外部资源之间的协作。调用的方式是同步还是异步,也一目了然。 + +EAS 的物理视图 + +物理视图当然可以用专业的网络拓扑图来表示,不过在领域驱动设计中,我们还可以使用更具有美学意义的六边形,尤其是在微服务架构风格中,六边形的图例简直就是微服务的代言人。只是 EAS 并未使用微服务架构风格,但从通信边界来看,OA 集成上下文处于完全独立的进程,其他限界上下文则共享同一个进程。整个 EAS 系统的物理视图如下所示: + + + +物理视图与进程视图虽然都以进程边界为主要的设计单元,但二者的关注点不同。进程视图是动态的,体现的是外部环境、系统各个组件在进程之间的协作方式与通信机制;物理视图是静态的,主要体现系统各个模块以及系统外部环境的部署位置与部署方式。所以,物理视图的重点不在于展现它们彼此之间的关系,而是如何安排物理环境进行部署。为了指导部署人员的工作,又或者在项目早期评估系统的硬件环境与网络环境,通常需要在物理视图的说明下,进一步给出详细的拓扑图,以及各个组成部分的技术选型。例如,我们可以用节点(Node)部署形式详细说明 EAS 各个组成部分的部署: + + + +EAS 的开发视图 + +无论架构设计得多么优良、多么美好,每一张架构视图画得多么的漂亮与直观,如果没有开发视图为团队提供开发指导,建立一个规范的代码模型,并明确每个模块的职责,就有可能在开发实现过程中,事先设计良好的架构会慢慢地变形、慢慢地腐化,最终丧失了架构的清晰与一致。 + +我在为团队评审代码时,一直强调两个词:职责与清晰。倘若职责分配不合理,就可能引起模块之间的耦合与纠缠不清,进而伤害了架构的清晰度;倘若不随时把握架构的清晰度,就可能无法敏锐地察觉到架构的腐化,直到后来积重难返。如果说从一开始进行架构设计时,开发视图为混沌的开发指明了方向,那么在开发过程中一直保持开发视图的指导,就是时刻把握策马前行的缰绳,不至于像脱缰的野马胡冲乱撞,找不到北。 + +开发视图是与逻辑视图一脉相承的。在领域驱动设计中,分层架构与限界上下文是其根本,整洁架构思想则是最高设计原则。结合[第 28 课:代码模型的架构决策]以及本课程内容给出的 EAS 逻辑视图,可以得出如下的开发视图: + + + +由于 OA 集成上下文是一个单独的物理边界,因而它的开发视图是独立的。系统层面和限界上下文层面的开发模型属于同一个开发视图,这样的设计就可以让限界上下文的各个模块可以直接在进程内调用系统层面中各模块的类。 + +设计的道与术 + +领域驱动设计并非一种架构设计方法,但我们可以将多种架构设计的手段融合到该方法体系中。领域驱动设计具有开放性,正是因为这种开放与包容,才促进了它的演化与成长。但是,领域驱动设计毕竟不是一个无限放大的框,我们不能将什么技术方法都往里装,然后美其名曰这是“领域驱动设计”。领域驱动设计是以“领域”为核心驱动力的设计方法,此乃其根本要旨。同样,领域驱动设计也不是“银弹”,它无法解决软件设计领域中的所有问题,例如,在针对质量属性进行软件架构时,领域驱动设计就力有未逮了。这时我们就可以辅以其他设计手段,如通过风险驱动设计识别风险,确定解决或规避风险的技术方案。 + +软件需求千变万化,架构无单一的方法,需审时度势,分析问题域,结合对场景的判断做出技术的权衡与决策。在运用领域驱动设计对 EAS 进行战略设计时,我们固然沿着设计的主线识别出了系统的限界上下文,却没有“死脑筋”地硬要为 EAS 系统选择微服务架构风格。边界仍然值得重视,但究竟是进程内边界还是进程间边界,则需要量力而为。任何技术手段必有其双刃,利弊权衡其实就是一种变相的成本收益核算。对于 EAS,微服务的弊显然大于利。但是,这并不能说明通过限界上下文与微服务建立映射的思想是错误的。思想是“道”的层面,运用是“术”的层面。不理解思想本质,方法的运用就变得僵化而死板,只能是邯郸学步;仅把握思想精髓,却不能依势而为求得变通,不过是刻舟求剑。任何案例都只能展现运用“术”的一个方面,因此,我不希望通过 EAS 案例的讲解,把大家带入到僵化运用的死胡同。明其道,求其术,道引导你走在正确的方向,术帮助你走得更快更稳健,这是我在进行领域驱动战略设计时遵循的最高方针! + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/036「战术篇」访谈:DDD能帮开发团队提高设计水平吗?.md b/专栏/领域驱动设计实践(完)/036「战术篇」访谈:DDD能帮开发团队提高设计水平吗?.md new file mode 100644 index 0000000..37ca197 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/036「战术篇」访谈:DDD能帮开发团队提高设计水平吗?.md @@ -0,0 +1,84 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 036 「战术篇」访谈:DDD 能帮开发团队提高设计水平吗? + 相信很多朋友对领域驱动设计会有这样或那样的困惑,比如领域驱动设计是什么?它在工作中有什么作用?为什么国内关于这方面的书籍少之又少?…… 为了解决这些疑惑,有幸邀请到专家张逸老师来聊聊领域驱动设计,下面是 GitChat 独家采访记录。 + + +GitChat:在探讨领域驱动设计问题时,每个人都有每个人的认识,有的时候可能谁也无法说服对方,这时候该怎么办呢? + + +张逸:简单说,就是 show me your code。不管领域设计做得怎么样,最终都是要落地的,看实现的效果最有说服力。当然,为了保证交流的顺畅与效率,代码这种形式可能容易让人迷失到纷繁复杂的细节中去,因此还有一种方式就是 show me your model。 + +这里说的 model 就是领域模型。注意,团队在交流领域驱动设计问题时,不应该只是对建模活动的产出物进行讨论,建模的过程同样非常重要。现在诸如 Event Storming 等活动都非常强调利用可视化手段把业务专家与开发团队都包含进来,大家一块协作一块交流,并利用便利贴等工具直观地展现建模活动中的每一个步骤,可以更容易消除误会与分歧。 + + +GitChat:可以谈谈领域驱动设计的流程吗?比如是先建模?还是做设计?以及应用的场景是什么? + + +张逸:领域驱动设计强调的是将分析、设计与实现统一到一个领域模型中来,同时又相对清晰地划分为战略设计和战术设计两个阶段。当然,这两个阶段并非瀑布式的,而是迭代和演进的过程。 + +我认同领域模型对分析、设计与实现的统一,这个思想没有问题。但在我亲身经历的项目中,我还是发现由于沟通角色与建模目标的不同,分析、设计与实现在三个不同的活动是无法完全统一的,就好像在重构时不能实现新功能,这三顶帽子自然也不能同时戴起来。因此,我在《战术篇》中,清晰地将这三个活动称之为领域分析建模、领域设计建模与领域实现建模,它们各自的产出是领域分析模型、领域设计模型与领域实现模型,这三者合起来就是领域模型,而这个过程就是领域模型驱动设计。 + +之所以在模型驱动设计前面加上“领域”作为定语,是因为我认为二者不能划等号,例如采用数据模型的,同样是模型驱动设计。在《战术篇》中,我根据建模视角的不同,将其分别定义为数据模型驱动设计、服务模型驱动设计、领域模型驱动设计,并用了相当篇幅的内容分别介绍了这三种不同的模型驱动设计过程。 + + +GitChat:针对一些设计能力不足的开发团队,可以采用领域驱动设计来改进设计和编码质量吗? + + +张逸:我个人的观点,这二者之间有关系,但并非必要关系。领域驱动设计的关键不是设计能力,而是要抓住设计的驱动力,必须是领域,且必须要求领域专家参与到分析建模活动中来。 + +要说明的是,这个所谓“领域专家”不是一个头衔,也不是对技能级别的要求,它其实就是一个指代,代表“懂业务”的人:可以是客户,可以是 Product Owner,可以是业务分析师,可以是产品经理,也可以是懂业务的开发人员,甚至可以是一个负责业务分析的团队。 + +领域驱动设计能否成功,还是要看建模尤其是分析建模做得是否足够好,这其实是整个设计过程的上游。至于设计能力,则要看领域驱动设计与什么样的编程范式结合?常见的编程范式包括结构范式、对象范式和函数范式。因此这里的“设计能力不足”,究竟指的是哪方面的设计能力不足呢? + +当然,从主流的领域驱动设计来看,主要采用的还是对象范式的设计思想与领域驱动设计结合,这就要求团队掌握基本的面向对象设计能力。这方面能力不足的团队,确实会影响到最终的设计和编码质量。这是必须要正视的问题,因此我建议那些希望实践领域驱动设计的团队,不要忘了去提高团队的面向对象设计能力。 + +提升设计能力并非一朝一夕就可以做到。正是考虑到面向对象设计能力不足对领域驱动设计的影响,我在《战术篇》中尝试总结了一个相对固化的设计过程。这个过程结合了 DCI、职责驱动设计等设计方法,它不要求团队掌握太多面向对象设计思想、原则与模式,只要懂业务,完全可以以“知其然而不知其所以然”的方式去实践领域驱动设计。 + +这种方法不能让你的设计变得非常优秀,却可以保证你的设计不至于太糟糕,甚至可以说是不错的设计。 + + +GitChat:应用服务与领域服务的区别是什么呢? + + +张逸:这个是老生常谈的问题了。从分层架构的角度看,应用服务属于应用层,领域服务属于领域层。应用层是一个包装的外观,按照该层的职责来说,应用服务根本就不该干业务的活儿,它只是一个对外公开的接口而已。 + +从业务粒度看,应用服务的每个公开方法会对应一个具有业务价值的业务场景或者说用例。领域服务则不然,它实现了业务功能,这个业务功能或者是无状态的,又或者是因为需要协调多个聚合,又或者需要和外部资源协作。 + +在针对业务场景驱动设计时,应用服务的一个方法往往会暴露给调用者,然后它再将该请求委派给领域层的对象。一般要求领域服务的粒度要小,这样可以避免设计为事务脚本的过程方式,也可以在一定程度上避免贫血模型。 + +总结: + + +应用服务:一组面向业务场景的业务外观方法,只是一个对外提供接口、对内分配职责的协作对象,属于应用层。 +领域服务:一个领域服务对应最多一个业务场景,往往需要和聚合、Repository、甚至领域服务一起协作。 + + + +GitChat:《战略篇》与《战术篇》之间的区别是?有学习的先后顺序吗? + + +张逸:这两部分刚好对应领域驱动设计的战略设计与战术设计。 + +前者强调系统层面的架构模式,包括限界上下文、上下文映射、分层架构等,可以运用这些模式对整个系统的领域进行“分而治之”,从而降低业务复杂度,同时围绕“领域”为核心,建立业务复杂度与技术复杂度的边界。 + +后者强调领域层面的设计模式,以“模型驱动设计”为主线,贯穿分析、设计与编码实现这三个不同的建模活动,并引入领域驱动设计的战术设计要素,如实体、值对象、领域服务、领域事件、聚合、资源库、工厂等。 + +当然在我的《战术篇》中,我扩大了领域驱动战术设计的范畴,讲解了数据模型驱动与服务模型驱动,探讨了建模范式与编程范式之间的关系。同时,在设计过程中,我引入了职责驱动设计和 DCI 模式来阐释实体、值对象、领域服务与应用服务之间的协作关系。在编码实现过程中,我又引入了测试驱动开发来推进从设计模型到实现模型。 + +战略设计和战术设计并非单向的过程,而是一个迭代演进与不断融合的过程。整体来讲,前者更偏向于架构设计,后者更偏向于详细设计与编码。主要还是看读者的关注点与侧重点,并没有一个绝对的学习先后顺序。我个人还是建议先学习《战略篇》,虽然它更偏向于理论,难度更高一些,但是它毕竟概括了领域驱动设计的全貌。 + + +GitChat:「战略」这个课程分析了“EAS 系统”,在「战术」课程也介绍了“EAS 系统”,两者的侧重点有什么不同吗? + + +张逸:与战略设计和战术设计的侧重点一样,在「战术」课程中,会针对 EAS 系统进行领域建模,并最终对其进行编码实现。从内容来看,后者会更接地气一些,毕竟讲的是落地的实现。 + +将战略和战术的 EAS 系统案例结合起来,就是一个系统的完整设计案例了。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/037「战术篇」开篇词:领域驱动设计的不确定性.md b/专栏/领域驱动设计实践(完)/037「战术篇」开篇词:领域驱动设计的不确定性.md new file mode 100644 index 0000000..0d21e2d --- /dev/null +++ b/专栏/领域驱动设计实践(完)/037「战术篇」开篇词:领域驱动设计的不确定性.md @@ -0,0 +1,106 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 037 「战术篇」开篇词:领域驱动设计的不确定性 + 专栏背景 + +大家好,我是张逸,去年在 GitChat 平台上上线了一门[《领域驱动战略设计实践》]达人课,销量已过 4000 份,同时建了两个读者群,也邀请了十几位领域驱动设计方面的专家加入到了读者群,共同探讨和交流领域驱动设计的相关知识。 + +至今,两个群依然很活跃,每天都有许多问题抛出,同时又有许多问题得到了解答,还有更多的问题悬而未决,因为每个人都有自己心目中的“哈姆雷特”,谁也无法说服对方,谁也无法给出一个让所有人都认同的标准答案。这恰恰是领域驱动设计最棘手的一部分,当然,也是最让人神往的一部分——唯有不确定,方才值得去探索。 + +在探讨领域驱动战术设计的一些问题时,总会有人纠结:这个领域对象应该定义成实体,还是值对象?领域服务和应用服务的区别是什么?聚合的边界该怎么划分?于是,各种设计问题纷至沓来,问题越辩越糊涂,到了最后,已经脱离了最初探讨问题的场景,变成了“空对空导弹”一阵乱发射,最后蓦然回首,才发现目标已然消失了。 + +这是不合理的。在软件开发领域,没有什么一劳永逸的实现,也没有什么放之四海而皆准的标准,必须结合具体的业务场景做出合理的决策,无论建模和设计再怎么完美,也需要通过落地的检验才知道好还是坏。任何脱离具体业务场景的问题分析,都是空谈;任何不落地的完美方案,都是浮夸。领域驱动设计没有标准,有的只是持续不断的不确定性。 + +正所谓“以不变应万变”,我们要从实证主义的角度看待领域驱动设计,窃以为,只需守住三项基本原则即可: + + +必须通过领域建模来驱动设计 +领域专家或业务分析师必须参与到建模活动中 +设计必须遵循面向对象分析和设计的思想与原则 + + +只要做到这三点,领域驱动战术设计就不会做得太差,剩下的不足,就需要靠经验来填补了。 + +专栏框架 + +本专栏是我计划撰写的领域驱动设计实践系列的第二部分专栏,要解决的正是前面所提及的战术层面的设计问题。单以战术设计阶段来看,我个人认为 Eric Evans 做出的贡献并不多。在《领域驱动设计》一书中,他讲到了模型驱动设计与领域建模,却没有深入阐述该如何正确地进行领域建模;他引入的资源库模式和工厂模式,不过是面向对象设计原则的体现;至于模型的演化与重构带来的突破,其实更多是经验之谈,缺乏切实可行的方法。整体而言,Eric Evans 在战术设计要素方面,最为重要的洞见在于: + + +强调了领域建模的重要性,并与面向对象分析和设计的原则结合起来 +实体与值对象的区分,有利于我们明白模型对象的真相,并能够更好地避免贫血模型 +聚合提出了有别于模块粒度的边界,有效地保证了业务规则的不变性和数据的一致性 + + +不可否认,若要做到优良的领域驱动设计,建模和设计的经验是必不可少的,这需要多年的项目实战打磨方可萃取而成,但如果在开始之初,能有一些更为具体的方法作为指引,或许可以让掌握技能的周期大幅度缩短。此外,我还清楚地看到:许多领域驱动设计的门外汉,之所以迟迟不得其门而入,是因为他(她)们连最为基本的面向对象分析和设计的能力都不具备,因此,无法理解领域驱动的战术设计要素也就不足为奇了。关键在于,许多设计问题因为其不确定性,根本没有标准答案,没有任何人能给你指出明确的设计方法和设计思路。这时,就必须要吃透面向对象分析和设计的思想与原则,用它们来指导我们的设计,而不是死板的遵循领域驱动设计的模式。 + +针对一些设计能力不足的开发团队,若希望采用领域驱动设计来改进设计和编码质量,往往会适得其反,做出来的是一锅“夹生饭”。从理想角度讲,决定是否采用领域驱动设计,不在于团队成员的能力高低,而在于业务的复杂度。然而,我们又不得不面对现实,如果团队成员的设计能力差了,是做不好领域驱动设计的。因此,我在本专栏中,一方面分享了我的设计体验和方法,以帮助团队成员的成长,另一方面也给出了一个操作性强的设计过程,可以让基础相对薄弱的开发人员能够依样画葫芦,做出还算不错的设计与实现。 + +这些考虑帮助我确定了本专栏的基本思路,即以能学习和模仿的战术设计方法来弥补经验之不足,以设计思想和设计原则作为指导来解决争议之问题,以能够落地的解决方案来体现领域驱动设计之价值。 + +本专栏分为六部分,共 64 篇(含访谈录、开篇词)。 + + +第一部分:软件系统中的模型(第 39 ~ 56 篇) + + +全面讲解和对比软件系统的数据模型、服务模型和领域模型,以这些模型作为不同的设计驱动力,讲解不同的模型驱动设计之过程与利弊,从而得出领域模型驱动设计的优势以及它适用的业务场景。 + + +第二部分:领域分析模型(第 57 ~ 64 篇) + + +建立领域分析模型是领域模型驱动设计的起点和基础。领域分析过程是领域专家与开发团队合作最为紧密、沟通最为频繁的阶段,是领域驱动设计成败的关键。我将深入介绍名词动词法、分析模式、四色建模和事件风暴等重要的分析建模方法,在发现显式和隐式领域概念的基础上,建立高质量的领域分析模型。 + + +第三部分:领域设计模型(第 65 ~ 83 篇) + + +实体、值对象、领域服务、领域事件、资源库、工厂和聚合是组成领域驱动战术设计的核心内容,也是衡量领域驱动设计质量的分水岭。只有正确地理解了这些设计要素,才能正确地完成领域驱动战术设计。这其中扮演关键角色的其实是面向对象分析与设计。我将围绕着职责驱动设计讲解角色、职责与协作三者之间的关系,通过分辨职责来寻找合理的对象,并结合 DCI 模式与主流设计模式,以时序图作为主要的设计驱动力获得高质量的设计方案。 + + +第四部分:领域实现模型(第 84 ~ 91 篇) + + +领域实现模型帮助我们将领域设计模型落地,毕竟,只有交付可工作的软件才是软件开发的终极目标。除了要应对纷繁复杂的业务逻辑,我们还需要考虑如何与外部资源集成,实现数据持久化与消息通信等基础设施内容。在落地过程中,我们需要时刻维护业务复杂度与技术复杂度的边界,降低彼此的影响,同时还需要在编码层次提高代码的内建质量,包括代码的可读性、可重用性、可扩展性和可测试性。 + + +第五部分:融合:战略设计和战术设计(第 92 ~ 101 篇) + + +领域驱动设计虽然分为战略设计阶段和战术设计阶段,但这两个阶段并非完全割裂的井水不犯河水的独立过程。我在[《领域驱动战略设计实践》]专栏中介绍领域驱动设计过程时,就提到了这两个阶段的相辅相成与迭代的螺旋上升演进过程。我们必须将战略设计和战术设计融合起来,把分层架构、限界上下文、上下文映射与战术设计的诸要素融汇贯通,才能获得最佳的设计质量,并成为指导我们进行软件架构和设计的全过程。 + + +第六部分:EAS 系统的战术设计实践(第 102 ~ 109 篇) + + +继续沿用战略设计实践中使用的全真案例——EAS 系统,采用领域模型驱动设计的过程对系统进行分析建模、设计建模和实现建模,并最终结合战略设计的方案,形成完整的解决方案和代码实现。 + +综上,本专栏的内容并未完全遵照 Eric Evans 的《领域驱动设计》,不同的部分固然是我的一孔之见,未必正确,也未必遵守 Eric Evans 的设计思想,但我仍然不揣冒昧地进行了分享,不是因为我的无知者无畏,而是我认为针对具有不确定性的领域驱动设计,必须要容得下异见者,方能取得发展和突破。 + +为什么要学习领域驱动设计 + +如果你已经能设计出美丽优良的软件架构,如果你只希望脚踏实地做一名高效编码的程序员,如果你是一位注重用户体验的前端设计人员,如果你负责的软件系统并不复杂,那么,你确实不需要学习领域驱动设计! + +领域驱动设计当然并非“银弹”,自然也不是解决所有疑难杂症的“灵丹妙药”,请事先降低对领域驱动设计的不合现实的期望。我以中肯地态度总结了领域驱动设计可能会给你带来的收获: + + +领域驱动设计是一套完整而系统的设计方法,它能给你从战略设计到战术设计的规范过程,使得你的设计思路能够更加清晰,设计过程更加规范; +领域驱动设计尤其善于处理与领域相关的高复杂度业务的产品研发,通过它可以为你的产品建立一个核心而稳定的领域模型内核,有利于领域知识的传递与传承; +领域驱动设计强调团队与领域专家的合作,能够帮助团队建立一个沟通良好的团队组织,构建一致的架构体系; +领域驱动设计强调对架构与模型的精心打磨,尤其善于处理系统架构的演进设计; +领域驱动设计的思想、原则与模式有助于提高团队成员的面向对象设计能力与架构设计能力; +领域驱动设计与微服务架构天生匹配,无论是在新项目中设计微服务架构,还是将系统从单体架构演进到微服务设计,都可以遵循领域驱动设计的架构原则。 + + +专栏寄语 + +没有谁能够做到领域驱动设计的一蹴而就,一门专栏也不可能穷尽领域驱动设计的方方面面。从知识的学习到知识的掌握,进而达到能力的提升,需要一个漫长的过程。所谓“理论联系实际”虽然是一句大家耳熟能详的老话,但其中蕴含了颠扑不破的真理。我在进行领域驱动设计培训时,总会有学员希望我能给出数学公式般的设计准则或规范,似乎软件设计就像拼积木一般,只要遵照图示中给出的拼搭过程,不经思考就能拼出期待的模型。——这是不切实际的幻想。 + +要掌握领域驱动设计,就不要被它给出的概念所迷惑,而要去思索这些概念背后蕴含的原理,多问一些为什么。同时,要学会运用设计原则去解决问题,而非所谓的“设计规范”。我强烈建议读者诸君要学会对设计的本质思考,不要只限于对设计概念的掌握,而要追求对设计原则与方法的融汇贯通。只有如此,才能针对不同的业务场景灵活地运用领域驱动设计,而非像一个牵线木偶般遵照着僵硬的过程进行死板地设计。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/038什么是模型.md b/专栏/领域驱动设计实践(完)/038什么是模型.md new file mode 100644 index 0000000..22316e1 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/038什么是模型.md @@ -0,0 +1,66 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 038 什么是模型 + 从领域驱动的战略设计进入战术设计,简单说来,就是跨过系统视角的限界上下文边界进入它的内部,从分层架构的逻辑分层进入到每一层的内部。在思考内部的设计细节时,首先需要思考的问题就是:什么是模型(Model)? + +什么是模型 + +还是来看看 Eric Evans 对模型的阐述: + + +为了创建真正能为用户活动所用的软件,开发团队必须运用一整套与这些活动有关的知识体系。所需知识的广度可能令人望而生畏,庞大而复杂的信息也可能超乎想象。模型正是解决此类信息超载问题的工具。模型这种知识形式对知识进行了选择性的简化和有意的结构化。适当的模型可以使人理解信息的意义,并专注于问题。 + + +如何才能让“庞大而复杂的信息”变得更加简单,让分析人员的心智模型可以容纳这些复杂的信息呢?那就是利用抽象化繁为简,通过标准的结构来组织和传递信息,形成一致的可以进行推演的解决方案,这就是“模型”。模型反应了现实问题,表达了真实世界存在的概念,但它并不是现实问题与真实世界本身,而是分析人员对它们的一种加工与提炼。这就好比真实世界中的各种物质可以用化学元素来表达一般,例如流动的水是真实世界存在的物体,而“水(Water)”这个词则是该物体与之对应的概念,H_2O*H*2O 则是水的模型(同时,H_2O*H*2O 也是化学世界中的统一语言)。 + +模型往往会作为交流的有效工具,因而会要求用经济而直观的形式来表达,其中最常用的表现形式就是图形。例如轨道交通线网图: + + + +说明:本图来自本地宝的北京城市轨道交通线网图。 + +该交通线网图体现了模型的许多特点。 + + +首先它是抽象的。与地图不同,它并非现实世界中轨道交通线网的缩影,图中的每条轨道其实都是理想化的几何图形,以线段为主,仅仅展现了轨道线的方位、走向和距离。 +其次它利用了可视化的元素。这些元素实际上都是传递信息的信号量,例如使用不同的颜色来区分线路,使用不同大小的形状与符号来区分普通站点与中转站。模型还传递了重要的模型要素,如线路、站点、站点数量、站点距离、中转站以及方向,因为对于乘客而言,仅需要这些要素即可获得有用的路径规划与指导信息。 + + +针对现实世界的问题域建立抽象的模型形成解决方案,这个过程视软件复杂度而定,可能会非常漫长。这其间需要迭代的分析、设计和实现,逐步浮现出最终可行的方案,构建满足需求的软件。从问题域到解决方案域,或许有多种途径或手段,然而针对复杂问题域,通过建立抽象的模型来映射现实世界的多样性,就好似通过数学公式来求解一般,是实践证明可行的道路: + + + +模型的重要性并不体现在它的表现形式,而在于它传递的知识。它是从需求到编码实现的知识翻译器,通过它对杂乱无章的问题进行梳理,消除无关逻辑乃至次要逻辑的噪音,然后再按照知识语义进行归纳与分类,并遵循设计标准与规范建立一个清晰表达业务需求的结构。这个梳理、归纳与分类的过程就是建模的过程,建立的结构即为模型。建模过程与软件开发生命周期的各种不同的活动(Activity)息息相关,它们之间的关系大体如下图所示: + + + +建模活动用灰色的椭圆表示,它主要包括需求分析、软件架构、详细设计和编码与调试等活动,有时候,测试、集成与保障维护活动也会在一定程度上影响系统的建模。为了便于更好地理解建模过程,我将整个建模过程中主要开展的活动称之为“建模活动”,并统一归纳为分析活动、设计活动与实现活动。每一次建模活动都是对知识的一次提炼和转换,产出的成果就是各个建模活动的模型。 + + +分析活动:观察现实世界的业务需求,依据设计者的建模观点对业务知识进行提炼与转换,形成表达了业务规则、业务流程或业务关系的逻辑概念,建立分析模型。 +设计活动:运用软件设计方法进一步提炼与转换分析模型中的逻辑概念,建立设计模型,使得模型在满足需求功能的同时满足更高的设计质量。 +实现活动:通过编码对设计模型中的概念进行提炼与转换,建立实现模型,构建可以运行的高质量软件,同时满足未来的需求变更与产品维护。 + + +整个建模过程如下图所示: + + + +不同的建模活动会建立不同的模型,上图表达的建模过程体现了这三种模型的递进关系。但是,这种递进关系并不意味着分析、设计与实现形成一种前后相连的串行过程,而应该是分析中蕴含了设计,设计中夹带了实现,甚至在实现中回溯到设计,从而形成一种迭代的螺旋上升的演进过程。不过,在建模的某一个瞬间,针对同一问题,分析、设计与实现这三个活动不能同时进行,这就好似开发过程中不能同时戴上重构与功能实现这两顶帽子一般,它们其实是相互影响、不断切换与递进的关系。一个完整的建模过程,就是模型驱动设计(Model-Driven-Design)。 + +不仅仅是建模活动会对模型带来影响,设计者在面对业务需求时,关注的视角不同,抽象的设计思想不同,也会导致模型的不同,这就形成了从建模视角产生的模型分类。如果我们是以数据为核心,关注数据实体的样式和它们之间的关系,由此建立的模型就是“数据模型”。如果我们需要为系统外部的客户端提供服务,关注的是客户端发起的请求以及服务返回的响应,由此建立的模型就是“服务模型”。而领域驱动设计则强调以领域为中心,通过识别领域对象来表达业务系统的领域知识包括业务流程、业务规则和约束关系,由此建立的模型就是“领域模型”。这三种不同的模型,就是不同视角的模型驱动设计获得的结果。因此,整个模型驱动设计可以分为两个不同的维度来表现模型,即建模视角与建模活动。不同的建模视角驱动出不同的抽象模型,而不同的建模活动,也会获得不同抽象层次的模型。这两个维度表达的模型驱动设计如下图所示: + + + +无论分析模型、设计模型还是实现模型,它们皆是对现实世界的抽象,只是抽象的层次和目的不同罢了。如何观察现实世界,又可能影响我们最终获得的模型。当我们将现实世界视为由数据组成的系统时,就可以建立一个由数据实体概念组成的软件世界,并驱动着获得以数据模型为核心的解决方案。当我们将现实世界隐喻为一个 Web 系统时,现实世界的任何事物都是暴露给 Web 系统的资源,这就获得了以服务资源模型为核心的解决方案。当我们将现实世界认为是提供服务行为的容器,并由此产生与消费者的协作,就获得了以服务行为模型为核心的解决方案。当我们将现实世界看做是由核心领域与子领域组合而成的问题域时,我们就将围绕着领域模型为核心,驱动并指导着我们的设计,形成以领域模型为核心的解决方案。 + +Eric Evans 认为模型驱动设计是领域驱动设计中的一种模式。它并没有给出模型驱动设计的定义,只是提出“严格按照基础模型来编写代码,能够使代码更好地表达设计含义,并且使模型与实际的系统相契合。”但我认为,模型的范围要大于领域模型,设计过程也会因为建立模型的不同而各有不同的路径与方向。于是,数据视角产生数据模型驱动设计,服务视角产生服务模型驱动设计,领域视角则产生领域模型驱动设计。在模型驱动的设计过程中,我们获得的模型还将受到建模范式的影响,尤其针对设计与实现,建模范式就意味着设计思想与编程范式的不同,最后获得的模型可能会大相径庭。 + +因此,要理解和学习领域驱动设计,我们需要辨别各种模型的差异,理解建模范式对模型产生的影响,同样还要认识到:领域驱动设计不过是模型驱动设计中的一种罢了。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/039数据分析模型.md b/专栏/领域驱动设计实践(完)/039数据分析模型.md new file mode 100644 index 0000000..a75163b --- /dev/null +++ b/专栏/领域驱动设计实践(完)/039数据分析模型.md @@ -0,0 +1,117 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 039 数据分析模型 + 在 Eric Evans 提出领域驱动设计之前,对企业系统的分析设计多数采用数据模型驱动设计。如前所述,这种数据模型驱动设计就是站在数据的建模视角,逐步开展分析、设计与实现的建模过程。通过对数据的正确建模,设计人员就可以根据模型建立数据字典。数据模型会定义数据结构与关系,有效地消除数据冗余,保证数据的高效访问。由于软件系统的业务功能归根结底是对信息的处理,由此建立的数据模型也可以通过某种编程手段来实现,满足业务需求。 + +数据分析模型 + +数据建模过程中的分析活动会通过理解客户需求寻找业务概念建立实体(Entity)。在数据模型中,一个实体就是客户希望建立和存储的信息。这是一个抽象的逻辑概念,位于数据模型的最高抽象层。一个实体不一定恰好对应一个数据表,它甚至可能代表一个主题域。在识别实体的同时,我们还需要初步确定实体之间的关系。由于这个过程与数据库细节完全无关,因而称之为对数据的“概念建模”,建立的模型称之为实体关系模型。 + +经过数十年对数据建模的丰富与完善,这个领域已经出现了许多值得借鉴和重用的数据模型。其中,Len Silverston 的著作《数据模型资源手册》是最重要的模型参考手册。他通过对行业业务的梳理,建立了包括人与组织、产品、产品订购、装运、工作计划、发票等各个主题的数据模型。在确定系统的实体时,这些已有的数据模型可以作为我们的重要参考。 + +当然,每个软件系统的业务需求必然有其特殊性,除了对已有数据模型的参考,也有一些数据建模方法帮助我们获得实体关系模型。例如通过引入不同的用户视图创建不同的实体关系模型。用户视图的差异取决于业务能力的差异,例如,财务人员的观察视图显然不同于市场人员的观察视角,看到的数据信息显然也有所不同。这就像盲人摸象一般,虽然每个视角得到的实体关系模型只是大象的一部分,然而将这些代表不同人员不同观点的实体关系模型组合起来,就能形成整体的实体关系模型。 + +实体关系模型是数据建模的开始,目的是让我们可以从一开始抛开对数据库实现细节的考虑,寻找那些表达业务知识的重要概念,并明确它们之间的关系。但对数据模型的分析并不会就此止步,我们必须在分析阶段对实体做进一步细化,形成具体的数据表,并定义表属性,确定数据表之间的真实关系。这时获得的分析模型称之为“数据项模型”。实体关系模型与数据项模型之间的关系如下图所示: + + + +在数据建模过程中,越早确定数据库的细节越有利于数据模型的稳定。当今的软件开发,已经不是关系型数据库一统天下的时代。NoSQL 甚至 NewSQL 的诞生,让我们在选择持久化机制时有了更多选择。比较关系数据库和 NoSQL 数据库,前者是严格扁平的结构化数据,后者却是无样式的数据结构(Schemaless Data Structures),结构不同,建立的数据模型自然就有了天壤之别。一旦根据数据模型创建了物理的数据表,再调整数据模型,变化的成本就太高了。因此,究竟选择关系数据库还是 NoSQL 数据库,对确立数据项模型至关重要,我们需要分开讨论。 + +关系数据库的数据项模型 + +关系数据库体现了关系模型,形成了一种扁平的结构化数据,这就要求进一步规范数据表的粒度,将实体或主题域拆分为多个遵循数据库范式的数据表,明确一个数据表的主要属性。 + +数据库范式是面向数据的分析建模活动的一个关键约束。这些范式包括一范式(1NF)、二范式(2NF)、三范式(3NF)、BC 范式(BCNF)和四范式(4NF)。遵循这些范式可以保证数据表属性的原子性、避免数据冗余和传递依赖等。 + +例如在确定数据项时,该如何考虑避免数据冗余?这就需要合理地设计表以及表之间的关系。 + +假设一个公司的员工可能同时具有多个角色:运输科的张飞是科室的负责人,他又是供应科的客户,供应科会将运输的任务委托给他;同时,他还是一家大型超市的供应商,负责将货物运输给超市。显然,我们不能在一个数据库中为张飞创建三条冗余的数据记录。运输科主任、供应科客户和超市供应商都是张飞担任的角色,无论他担任了什么角色,他都是该公司的一名员工。 + +在创建数据模型时,应该将角色属性从员工剥离出去,分别形成数据表 t_employee 与 t_role;又因为员工和角色之间存在多对多的关系,需要引入一个关联表 t_employee_roles。这个数据模型如下图所示: + + + +当数据模型出现多对多关系时,之所以要引入一个关联表,是因为多对多关系会引入数据表之间的交叉关联。这个数据项模型中的 t_employee_role 并无映射的业务概念,引入该表,纯粹是数据库实现细节对模型产生的影响。 + +有时候,承载多对多关系的关联表也可以具有一些附加的属性,这样的关联表往往代表了业务逻辑中的一个业务概念,例如学生(Student)与课程(Course)之间的多对多关系,可以用课表(Curriculum)关联表来表达。Curriculum 属于学习领域的业务概念,但同时它又能有效解除 Student 与 Course 之间的交叉关联。 + +有的数据建模者甚至建议针对一对多关系也建立关联表,因为关联表的引入使得这种关系更容易维护。例如产品(Product)和图片(Picture)是一对多关系,直接定义 t_product 和 t_picture 数据表即可,但如果引入 t_product_picture 关联表,就可以在数据库层面更好地维护二者之间的关系。有时,一对多关系体现了父—子关系,例如订单(Order)与订单项(OrderItem),它们之间的一对多关系其实代表了“每个订单项必须是一个也只是一个订单的一部分”。 + +在确定数据项模型时,还需要考虑访问关系数据库的性能特性,从而决定数据的粒度与分割。通常,需要考虑数据表的规范化,避免设计出太多过小的包含少量数据的数据表。一个数据表的粒度较小,就会导致程序在访问数据库时频繁地在多张小表之间跳转。这个访问过程既要存取数据,又要存取索引以找到数据,导致I/O的过度消耗,影响到整体的性能。因此,数据模型很少具有一对一关系,即使现实世界的概念存在一对一关系,也应该尽量通过规范化将两张表的数据组织在一起,合到一个实体中。例如,我们说一位员工有一个家庭电话号码和工作电话号码,若站在领域概念角度,就应该建模为拥有两个不同电话号码(PhoneNumber)的员工(Employee)对象: + + + +数据模型却不能这样建立,因为我们需要考虑分开两张表带来的 I/O 开销。虽然家庭电话号码和工作电话号码都是相同的 PhoneNumber 类型,但却属于两个不同的属性,将它们合并放到 t_employee 数据表,并不会破坏数据库范式。 + +当然,这种合并并非必然,有时候还需要考虑数据访问的频率。例如一个银行账户,账户地址、开户日期与余额都是规范化的,按理就应该合并到 t_account 物理表中。但是,余额与其他两项属性的访问频率差异极大,为了使 I/O 效率更高,数据的存储更加紧凑,就应该将规范化的表分解为两个独立的表: + + + +NoSQL 的数据项模型 + +如果数据库选择了 NoSQL,数据项模型会有所不同。由于 NoSQL 数据库是一种无样式的数据结构(Schemaless Data Structures),这使得它对数据项模型的约束是最少的。诸如 MongoDB、Elasticsearch 这样的 NoSQL 数据库,它所存储的 JSON 文档,可以在属性中进行任意嵌套,形成一种能够自由存取的文档结构。所以 Martin Fowler 又将这样的 NoSQL 数据库称之为“文档型数据库”。 + +当然,即使是没有样式的 NoSQL,也无法做到随心所欲地建立数据模型,尤其针对表之间的关系,同样要受到实现机制的约束。例如在 MongoDB 中,可以选择使用 Link 或 Embedded 来维护关联关系,这时就需要结合具体业务场景来选择正确的关联关系。 + +假设我们要开发一个任务跟踪系统,需要能够查询分配给员工的任务。采用 Embedded 方式,Employee 数据模型如下所示: + +{ + name: 'Kate Monster', + ssn: '123-456-7890', + role: 'Manager', + tasks : [ + { number: '1234', name: 'Prepare MongoDB environment', dueDate: '2019-01-15' }, + { number: '1235', name: 'Import Test Data', dueDate: '2019-02-15' }, + ] +} + + + +如果需要查询员工的任务信息,就可以直接获得内嵌在员工内部的任务数组,无需执行多次查询。这时,选择 Embedded 就是合理的。倘若需要支持如下功能: + + +显示所有明天到期的任务 +显示所有未完成的任务 + + +显然,这两个功能要查询的任务与员工无关,而采用 Embedded 方式建立的数据模型却明确地表达了 Employee 与 Task 之间的父子关系,反而为任务的查询制造了障碍。倘若改用 Link 方式来建立二者之间的关联,情况就完全不同了: + +//Tasks +[ + { + _id: ObjectID('AAAA'), + number: 1234, + name: 'Prepare MongoDB environment', + dueDate: '2017-01-15' + }, + { + _id: ObjectID('BBBB'), + number: 1235, + name: 'Import Test Data', + dueDate: '2017-02-15' + }, +] + +//Employees +{ + _id: ObjectID('E00001'), + name: 'Kate Monster', + role: 'Manager', + tasks : [ + ObjectID('AAAA'), + ObjectID('BBBB') + ] +} + + + +通过 Link 建立的数据模型相当于关系数据库建立的主外键关系,去掉了嵌套关系,任务可以被独立查询,如前所述的功能就变得格外简单。但调整后的数据模型又不利于支持查询员工任务的场景了,它会因为关联的原因导致执行两次查询。 + +选择 Embedded 或 Link 不仅会影响执行效率和执行的简便性,还可能因为错误的建模方式导致数据的冗余。仍然以前面的任务跟踪系统为例,倘若一个任务可以分配给多个员工,就会从一对多关系变为多对多关系。由于 Embedded 方式是将 Task 的数据直接嵌入到 Employee 中,如果别的 Employee 包含了相同的 Task,就会导致 Task 数据的冗余。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/040数据设计模型.md b/专栏/领域驱动设计实践(完)/040数据设计模型.md new file mode 100644 index 0000000..3a82970 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/040数据设计模型.md @@ -0,0 +1,351 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 040 数据设计模型 + 通过分析活动获得的数据项模型,可以认为是数据分析模型,它确定了系统的主要数据表、关系及表的主要属性。到了建模的设计活动,就可以继续细化数据项模型这个分析模型,例如丰富每个表的列属性,或者确定数据表的主键与外键,确定主键的唯一性策略,最后将数据表映射为类对象。 + +丰富数据分析模型 + +若要丰富每个表的列属性,除了继续挖掘业务需求,寻找可能错过的属性或辨别分配错误的属性之外,在设计阶段还需要进一步确定这些属性对应的数据列,包括考虑列的类型及宽度,并为每个表定义一个主键列,或者定义由多个列组成的联合主键。 + +设计主键的目的更多出于查询检索及维护数据表之间关系的要求,而非表达业务含义。即使主键与业务对象要求的唯一标识有关,但生成主键值的业务规则却在数据模型中无法体现,除非需求明确实体的身份标识就是自增长。 + +例如,订单表的主键为订单 ID,它会作为订单的订单号。为了客服处理订单的便利性,需要订单号在保持尽可能短的前提下,要能帮助客服人员理解,这就需要订单号尽量与当前业务相结合,如渠道编号(包括平台、下单渠道、支付方式)、业务类型和时间信息等组成订单号的编码规则。无疑,订单号的编码规则在数据模型中是无法体现出来的。 + +在设计活动中,还需要根据业务需求与数据表的特性确定表的索引和约束。同时,还应该根据实现的需要确定是否需要为多个数据表建立视图。索引和视图都有利于提高数据库的访问性能,视图还能保障数据访问的安全性,约束则有利于保证数据的正确性和一致性。毫无疑问,这些机制其实皆与具体的数据库实现机制有关,但在数据建模的设计活动中却又不可避免。如果数据设计模型没有确定索引、约束,并明确标记数据表和视图,就无法给实现模型带来指导和规范。 + +数据设计模型的构成 + +建立数据设计模型,最主要的设计活动还是将数据表映射为类对象,以此来满足业务实现。这个过程称之为对象与数据的映射(Object-Relation Mapping,ORM)。 + +由于数据建模是自下而上的过程,首先确定了数据表以及之间的关系,然后再由此建立与之对应的对象,因此一种简单直接的方法是建立与数据表完全一一对应的类模型。对象的类型即为数据表名,对象的属性即为数据表的列。这样的类在数据设计模型中,目的在于数据的传输,相当于 J2EE 核心模式中定义的传输对象(Transfer Object)。 + +当然从另一方面来看,由于它映射了数据库的数据表,因而又可以作为持久化的数据,即持久化对象(Persistence Object)。至于操作数据的业务行为,针对基于关系数据库进行的建模活动而言,由于关系数据表作为一种扁平的数据结构,操作和管理数据最为直接高效的方式是使用 SQL。我们甚至可以认为 SQL 就是操作关系型数据表的领域特定语言(Domain Specific Language,DSL)。因此,在数据模型驱动设计过程中,SQL 会成为操作数据的主力,甚至部分业务也由 SQL 或 SQL 组成的存储过程来完成。 + +为了处理数据的方便,还可以利用 SQL 封装数据处理的逻辑,然后建立一个视图,例如: + +CREATE VIEW dbo.v_Forums_Forums + +AS + +SELECT dbo.Forums_Categories.CategoryID, dbo.Forums_Categories.CategoryName, dbo.Forums_Categories.CategoryImageUrl, + + dbo.Forums_Categories.CategoryPosition, dbo.Forums_Forums.ForumID, dbo.Forums_Forums.ForumName, dbo.Forums_Forums.ForumDescription, + + dbo.Forums_Forums.ForumPosition, + + (SELECT COUNT(*) + + FROM Forums_Topics + + WHERE Forums_Topics.ForumID = Forums_Forums.ForumID) AS ForumTopics, + + (SELECT COUNT(*) + + FROM Forums_Topics + + WHERE Forums_Topics.ForumID = Forums_Forums.ForumID) + + + (SELECT COUNT(*) + + FROM Forums_Replies + + WHERE Forums_Replies.ForumID = Forums_Forums.ForumID) AS ForumPosts, + + (SELECT MAX(AddedDate) + + FROM (SELECT ForumID, AddedDate + + FROM Forums_Topics + + UNION ALL + + SELECT ForumID, AddedDate + + FROM Forums_Replies) AS dates + + WHERE dates.ForumID = Forums_Forums.ForumID) AS ForumLastPostDate + +FROM dbo.Forums_Categories INNER JOIN + + dbo.Forums_Forums ON dbo.Forums_Categories.CategoryID = dbo.Forums_Forums.CategoryID + + + +如上所示,创建视图的 SQL 语句封装了对论坛主题数、回复数等数据的统计业务逻辑。 + +显然,遵循职责分离的原则,数据设计模型主要包含三部分的职责:业务逻辑、数据访问及数据。映射为对象模型,就是与数据表一一对应并持有数据的持久化对象,封装了 SQL 数据访问逻辑的数据访问对象(Data Access Object,DAO),以及满足业务用例需求的服务对象。三者之间的关系如下图所示: + + + +数据访问对象 + +数据访问对象属于 J2EE 核心模式中的一种,引入它的目的是封装数据访问及操作的逻辑,并分离持久化逻辑与业务逻辑,使得数据源可以独立于业务逻辑而变化。 + +《J2EE 核心模式》认为:“数据访问对象负责管理与数据源的连接,并通过此连接获取、存储数据。”一个典型的数据访问对象模式如下图所示: + + + +图中的 Data 是一个传输对象,如果将该 Data 定义为表数据对象,它可以处理表中所有的行,如 RecordSet,或者由 ADO.NET 中的 IDataReader 提供类似数据库游标的访问能力,就相当于运用了《企业应用架构模式》中的表数据入口(Table Data Gateway)模式。如果 Data 是这里提及的代表领域概念的持久化对象,则需要引入 ResultSet 到 Data 之间的映射器,这时就可以运用数据映射器(Data Mapper)模式。如下所示: + +public class Part { + private String name; + private String brand; + private double retailPrice; +} + +public class PartMapper { + public List findAll() throws Exception { + Connection conn = null; + try { + Class.forName(DRIVER_CLASS); + conn = DriverManager.getConnection(DB_URL, USER, PASSWORD); + Statement stmt = c.createStatement(); + ResultSet rs = stmt.executeQuery("select * from part"); + + List partList = new ArrayList(); + while (rs.next()) { + Part p = new Part(); + p.setName(rs.getString("name")); + p.setBrand(rs.getString("brand")); + p.setRetailPrice(rs.getDouble("retail_price")); + partList.add(p); + } + } catch(SQLException ex) { + throw new ApplicationException(ex); + } finally { + conn.close(); + } + } +} + + + +为了隔离数据库持久化逻辑,往往需要为数据访问对象定义接口,再以依赖注入的方式注入到服务对象中,保证数据源和数据访问逻辑的变化。如下接口定义就是数据访问对象的抽象: + +public interface MovieDao { + Movie findById(String id); + List findByYear(String year); + void delete(String id); + Movie create(String rating,String year,String title); + void update(String id,String rating,String year,String title); +} + + + +持久化对象 + +在数据设计模型中,持久化对象可以作为数据的持有者传递给服务、数据访问对象甚至是 UI 控件。早期的开发框架流行为持有数据的对象定义一个通用的数据结构,同时为 UI 控件提供绑定该数据结构的能力。如 ADO.NET 框架就定义了 DataSet、DataTable 等数据结构,ASP.NET Web Form 则提供绑定这些数据结构的能力。例如,我们要显示商品的类别,在 Web 前端就定义了属于 System.Web.UI.Page 类型的 Web 页面 CategoriesPage,它与数据访问对象以及持久化对象的交互如下图所示: + + + +图中的 DataTable 通过 CategoriesDAO 直接返回,它实际上是 ADO.NET 框架定义的通用类型。在一些 .NET 开发实践中,还可以定义强类型的 DataSet 或 DataTable,方法是定义一个代表业务概念的类,例如 Categories,让它派生自 DataTable 类。 + +随着轻量级容器的流行,越来越多的开发人员认识到持久化对象强依赖于框架带来的危害,POJO(Plain Old Java Object)和 POCO(Plain Old CLR Object)得到了大家的认可和重视。Martin Fowler 甚至将其称之为持久化透明(Persistence Ignorance,PI)的对象,用以形容这样的持久化对象与具体的持久化实现机制之间的隔离。理想状态下的持久化对象,不应该依赖于除开发语言平台之外的任何框架。 + +在《领域驱动设计与模式实战》一书中,Jimmy Nilsson 总结了如下特征,他认为这些特征违背了持久化透明的原则: + + +从特定的基类(Object 除外)进行继承 +只通过提供的工厂进行实例化 +使用专门提供的数据类型 +实现特定接口 +提供专门的构造方法 +提供必需的特定字段 +避免某些结构或强制使用某些结构 + + +这些特征无一例外地都是外部框架对于持久化对象的一种侵入。在 Martin Fowler 总结的数据源架构模式中,活动记录(Active Record)模式明显违背了持久化透明的原则,但因为它的简单性,却被诸如 Ruby On Rails、jOOQ、scalikejdbc 之类的框架运用。活动记录模式封装了数据与数据访问行为,这就相当于将数据访问对象与持久化对象合并到了一个对象中。由于数据访问逻辑存在许多通用的逻辑,许多数据访问框架都定义了类似 ActiveRecord 这样的超类,由其实现公共的数据访问方法,Ruby On Rails 还充分利用了 Ruby 元编程特性提供了更多的代码简化。例如定义客户 Client 的活动记录: + +class Client < ApplicationRecord + + has_one :address + + has_many :orders + + has_and_belongs_to_many :roles + +end + +# invoke +client = Client.order(:first_name).first + + + +Client 类继承了 ApplicationRecord 类,而框架通过 Ruby 的 missingMethod() 元数据编程和动态语言特性,使得调用者可以方便快捷地调用 order 与 first 等方法,完成对数据的访问。 + +使用 Scala 编写的 scalikejdbc 框架则利用代码生成器和组合方式来实现活动记录,例如 Report 类和伴生对象(companion object)的定义: + +case class Report( + + id: String, + + name: Option[String] = None, + + description: Option[String] = None, + + status: String, + + publishStatus: String, + + createdAt: DateTime, + + updatedAt: DateTime, + + createdBy: String, + + updatedBy: String, + + metaData: String) { + + def save()(implicit session: DBSession = Report.autoSession): Report = Report.save(this)(session) + + def destroy()(implicit session: DBSession = Report.autoSession): Unit = Report.destroy(this)(session) + +} + +object Report extends SQLSyntaxSupport[Report] { + + override val tableName = "reports" + + override val columns = Seq("id", "name", "description", "status", "publish_status", "created_at", "updated_at", "created_by", "updated_by", "meta_data") + + val r = Report.syntax("r") + + override val autoSession = AutoSession + + def find(id: String)(implicit session: DBSession = autoSession): Option[Report] = { + + withSQL { + + select.from(Report as r).where.eq(r.id, id) + + }.map(Report(r.resultName)).single.apply() + + } + + def findAll()(implicit session: DBSession = autoSession): List[Report] = { + + withSQL(select.from(Report as r)).map(Report(r.resultName)).list.apply() + + } + + def findBy(where: SQLSyntax)(implicit session: DBSession = autoSession): Option[Report] = { + + withSQL { + + select.from(Report as r).where.append(where) + + }.map(Report(r.resultName)).single.apply() + + } +} + + + +类 Report 并没有继承任何类,但却利用 Scala 的隐式参数依赖了框架定义的 DBSession,然后通过 Report 的伴生对象去继承名为 SQLSyntaxSupport[T] 的特性,以及组合调用了 withSQL 对象。显然,活动记录在满足了快速编码与代码重用的同时,也带来了与数据访问框架的紧耦合。 + +当持久化对象被运用到 CQRS 模式中时,查询端通过查询外观直接访问一个薄的数据层,如下图右端所示: + + + +这个薄的数据层通过数据访问对象结合 SQL 语句直接访问数据库,返回一个表数据记录 ResultSet,然后直接将其转换为 POJO 形式的数据传输对象(DTO)对象。这是因为查询端仅涉及到数据库的查询,因此并不需要持久化对象,至于添加、删除与修改则属于命令端,采用的是领域模型而非数据模型。 + +服务对象 + +由于持久化对象和数据访问对象都不包含业务逻辑,服务就成为了业务逻辑的唯一栖身之地。这时,持久化对象是数据的提供者,实现服务时就会非常自然地选择事务脚本(Transaction Script)模式。 + +《企业应用架构模式》对事务脚本的定义为: + + +使用过程来组织业务逻辑,每个过程处理来自表现层的单个请求。这是一种典型的过程式设计,每个服务功能都是一系列步骤的组合,从而形成一个完整的事务。注意,这里的事务代表一个完整的业务行为过程,而非保证数据一致性的事务概念。 + + +例如为一个音乐网站提供添加好友功能,就可以分解为如下步骤: + + +确定用户是否已经是朋友 +确定用户是否已被邀请 +若未邀请,发送邀请信息 +创建朋友邀请 + + +采用事务脚本模式定义的服务如下所示: + +public class FriendInvitationService { + public void inviteUserAsFriend(String ownerId, String friendId) { + try { + bool isFriend = friendshipDao.isExisted(ownerId, friendId); + if (isFriend) { + throw new FriendshipException(String.format("Friendship with user id %s is existed.", friendId)); + } + bool beInvited = invitationDao.isExisted(ownerId, friendId); + if (beInvited) { + throw new FriendshipException(String.format("User with id %s had been invited.", friendId)); + } + + FriendInvitation invitation = new FriendInvitation(); + invitation.setInviterId(ownerId); + invitation.setFriendId(friendId); + invitation.setInviteTime(DateTime.now()); + + User friend = userDao.findBy(friendId); + sendInvitation(invitation, friend.getEmail()); + + invitationDao.create(invitation); + } catch (SQLException ex) { + throw new ApplicationException(ex); + } + } +} + + + +不要因为事务脚本采用面向过程设计就排斥这一模式,相较于对编程范式的偏执,我认为 Martin Fowler 在书中说的一句话更加公道: + + +“不管你是多么坚定的面向对象的信徒,也不要盲目排斥事务脚本。许多问题本身是简单的,一个简单的解决方案可以加快你的开发速度,而且运行起来也会更快。” + + +即使采用事务脚本,我们也可以通过提取方法来改进代码的可读性。每个方法都提供了一定的抽象层次,通过方法的提取就可以在一定程度上隐藏细节,保持合理的抽象层次。这种方式被 Kent Beck 总结为组合方法(Composed Method)模式: + + +把程序划分为方法,每个方法执行一个可识别的任务 +让一个方法中的所有操作处于相同的抽象层 +这会自然地产生包含许多小方法的程序,每个方法只包含少量代码 + + +如上的 inviteUserAsFriend() 方法就可以重构为: + +public class FriendInvitationService { + public void inviteUserAsFriend(String ownerId, String friendId) { + try { + validateFriend(ownerId, friendId); + FriendInvitation invitation = createFriendInvitation(ownerId, friendId); + sendInvitation(invitation, friendId); + invitationDao.create(invitation); + } catch (SQLException ex) { + throw new ApplicationException(ex); + } + } +} + + + +在采用事务脚本时,同样需要考虑职责的分配,每个类应该围绕一个主题将相关的事务脚本组织在一起。为了更好地应对事务脚本的变化,可以考虑让一个事务脚本对应一个类,并通过识别事务脚本的共同特征,引入命令(Command)模式。例如推荐朋友事务脚本和推荐音乐事务脚本: + + + +当然,无论对于事务脚本做出怎样的设计改进,只要不曾改变事务脚本的过程设计本质,一旦业务逻辑变得更加复杂时,就会变得捉襟见肘。Martin Fowler 就认为: + + +“当事物一旦变得那么复杂时,就很难使用事务脚本保持一个一致的设计。” + + +解释为何事务脚本较难处理复杂业务,牵涉到对结构编程范式和对象编程范式之间的讨论,我会在后面进行专题探讨,这里不再赘述。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/041数据模型与对象模型.md b/专栏/领域驱动设计实践(完)/041数据模型与对象模型.md new file mode 100644 index 0000000..0c227d1 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/041数据模型与对象模型.md @@ -0,0 +1,63 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 041 数据模型与对象模型 + 在建立数据设计模型时,我们还需要注意表设计与类设计之间的差别,这事实上是数据模型与对象模型之间的差别。 + +数据模型与对象模型 + +我们首先来分析在设计时对冗余的考虑。前面在讲解数据分析模型时就提及,在确定数据项模型时,需要遵循数据库理论的设计范式,其中一个目的是避免数据冗余。但是,避免了数据冗余并不意味着代码能支持重用。例如,员工表与客户表都定义了“电子邮件”这个属性列。该属性列在业务含义上是完全相同的,但在数据表设计时,却只能分属于两个表不同的列,因为对于数据表而言,“电子邮件”列其实是原子的,属于 varchar 类型。 + +如果针对业务概念建立对象模型,需要遵循“高内聚低耦合”的设计原则,如果发现多个属性具有较强的相关性,需要将其整合起来共同定义一个类。例如国家、城市、街道和邮政编码等属性,它们都与地址相关,共同组成完整的地址概念,在对象模型中就可以定义 Address 类。 + +在数据模型中,关系数据表并不支持自定义类型,在设计时又需要支持一范式(1NF),即确保数据表的每一列保持原子性,就必须将这个内聚的组合概念进行拆分。例如,地址就不能作为一个整体被定义为数据表的一个列,因为系统需要访问地址中的城市信息,如果仅设计为一个地址列,就违背了一范式。这时,地址在数据模型中就成了一个分散的概念。若要保证其概念完整性,唯一的解决方案是将地址定义为一个独立的数据表;但这又会增加数据模型的复杂性,更会因为引入不必要的表关联而影响数据库的访问性能。正如 Jimmy Nilsson 所说:“关系模型是用来处理表格类型的基本数据的,这既有好的一面,也有坏的一面。面向对象模型很善于处理复杂数据。” + +针对同样的业务概念,我们可以对比数据模型与对象模型之间的差异。例如,员工、客户与地址的数据模型如下图所示: + + + +虽然员工与客户都定义了诸如 country、city 等地址信息,但它们是分散的,并被定义为数据表提供的基本类型,无法实现两个表对地址概念的重用。对象模型就完全不同了,它可以引入细粒度的类型定义来体现丰富的领域概念,封装归属于自己的业务逻辑,同时还提供了恰如其分的重用粒度: + + + +对比这两个模型,组成数据模型的数据表是一个扁平的数据结构,数据表中的每一列都是数据库的基本类型,而组成领域模型的类则具有嵌套的层次结构。在设计时,更倾向于建立细粒度对象来表达一个高度内聚的概念,如 Address 与 ZipCode 类。 + +在建立数据设计模型时,与数据表对应的持久化对象往往难以表达业务的约束规则。例如,运输(Shipping)与运输地址(ShippingAddress)满足“每个 Shipping 必须**有且只有一个 **ShippingAddress”这一业务规则。在数据模型中,可以通过在运输与运输地址之间创建关系来表达,例如在可视化的 ER 图中,用虚线代表任选,用实线代表强制。但这种关系连线虽然表达了这种约束关系,却没法显式地体现这一业务概念,除非在数据模型图中采用注解来说明。如果采用对象模型,就可以通过引入 ShippingSpecification 这个类型来体现这种约束逻辑。 + +从设计模型看,构成数据模型主体的数据库与数据表,明显存在粒度和边界的局限性。这种局限性在一定程度上影响了数据建模的质量。关系数据库的设计范式并没有从类型复用的角度去规定数据表的设计,由于关系表不支持自定义类型,无法支持 Jimmy Nilsson 所说的“复杂数据”,因此可以认为在数据模型中,数据表才是最小的复用单元。由于建立一个数据表存在 I/O 成本,会影响数据库的访问性能,因而在数据模型中,通常不建议为细粒度但又是高内聚的数据类型单独建立数据表,如前面给出的“地址”的例子。换言之,关系数据库的设计范式仅仅从数据冗余角度给予了设计约束,如果照搬数据模型去建立类模型,就有可能无法避免代码冗余。 + +对于一个数据库而言,关系数据库的表结构是扁平的,数据表之间可以建立关联,也可以隐式地通过一对多的关系表达具有层级的父子关系,但数据模型自身却无法体现这种层次。下图是 Apache OFBiz 项目中关于运输相关的数据模型: + + + +这个数据模型一共定义了 31 张数据表,这些表对应的业务概念上存在主从关系,以及强弱不同的耦合关系。例如,Shipment 表显然是主表,诸如 ShipmentAttribute、ShipmentStatus、ShipmentType 与 ShipmentItem 等都是围绕着 Shipment 表建立的从表。但是,数据模型自身却无法体现这种主从关系。我们之所以能识别出这种主从关系,其实是基于对数据表名的语义推断。通过语义推断,我们也能判断 Shipment 与 ShipmentItem 等表之间的关系要明显强于 Shipment 与 PicklistBin、Picklist、PicklistRole 等表之间的关系,但数据模型并没有清晰地表达这种边界。 + +究其原因,在关系数据库的数据模型中,数据库是最大的复用单元。设计数据库时,往往是一个库对应一个子系统或者一个微服务,而在数据库和数据表之间,缺少合适粒度的概念去维护数据实体的边界。它缺少领域驱动设计引入的聚合(Aggregate)、模块(Module)等各种粒度的边界概念。显然,扁平的关系型数据结构无法体现领域概念中丰富的概念层次。 + +NoSQL 的数据设计模型 + +NoSQL 数据库的设计模型就截然不同了,尤其是文档型的 NoSQL 数据库,能够通过定义嵌套关系的无模式数据表相当自然地体现对象图(Object Graph)的结构。因此,在针对 NoSQL 数据库建立数据设计模型时,就可以直接运用领域建模的设计原则,如引入聚合的概念来设计表模型。 + +Martin Fowler 在文章 Aggregate Oriented Database 中指出,NoSQL 数据库需要有效地将数据存储在分布式集群之上,而他则建议存储的基本数据单元应为领域驱动设计中的聚合(Aggregate),聚合的粒度天然地满足了诸如数据分片这样的分布式策略。Martin Fowler 以订单为例,说明了关系数据库与 NoSQL 数据库的不同,如下图所示: + + + +一个订单对象在关系数据库中需要被分解为多张数据表,但对于诸如 MongoDB、Elasticsearch 这样的数据库,则可以认为是一个聚合。因此,在设计 NoSQL 的数据模型时,可以运用领域驱动设计中聚合的设计原则。 + +我在设计一个报表系统的报表元数据管理功能时,选择了 Elasticsearch 作为存储元数据的数据库。在设计元数据管理的数据模型时,就通过聚合来思考元数据中 ReportCategory、Report 与 QueryCondition 三者之间的关系。 + +从业务完整性看,Report 虽属于 ReportCategory,但二者之间并没有强烈的约束关系,即不存在业务上的不变量(Invariant)。ReportCategory 可以没有 Report,成为一个空的分类;我们也可以撇开 ReportCategory,单独查询所有的 Report。倘若我们将 Report 放到 ReportCategory 聚合中,由于 Report 可能会被单独调用,聚合的边界保护反而成为了障碍,这样的设计并不合理。因此,ReportCategory 和 Report 应该属于两个不同的聚合。 + +分析 QueryCondition 与 Report 之间的关系,又有不同。当 QueryCondition 缺少 Report 对象后,还有存在意义吗?答案一目了然,没有 Report,就没有 QueryCondition。皮之不存毛将焉附!因此可以确定 Report 与 QueryCondition 应属于同一个聚合。于是,我们得到如下模型: + + + +这样设计获得的模型显然是一个领域模型。当我们将其以 JSON 的格式持久化到 Elasticsearch 的数据表时,又可以认为该模型同时就是 Elasticsearch 的数据模型。 + +这种面向文档的嵌套层次结构与对象模型更为相配,并在多数时候采用 JSON 结构来表达数据结构。JSON 数据结构在许多产品和项目中得到运用,一些传统的关系型数据库也开始向这个方向靠拢。例如,目前流行的开源关系数据库如 MySQL 和 PostgreSQL,都已支持 JSON 这样的文档型数据结构。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/042数据实现模型.md b/专栏/领域驱动设计实践(完)/042数据实现模型.md new file mode 100644 index 0000000..ab3d02b --- /dev/null +++ b/专栏/领域驱动设计实践(完)/042数据实现模型.md @@ -0,0 +1,150 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 042 数据实现模型 + SQL 与存储过程 + +倘若选择关系型数据库,组成数据实现模型的主力军是 SQL 语句,这是我们不得不面对的现实。毕竟,针对数据建模的实现者大多数担任 DBA 角色,他(她)们掌握的操作数据的利器就是 SQL。正如前面讲解数据分析模型时所说,SQL 语句相当于是操作关系数据表的领域特定语言(Domain Specific Language,DSL),使用 SQL 操作数据表更加直接而自然。 + +SQL 语句可以很强大,例如它同样提供了数据类型、流程控制、变量与函数定义。同时,还可以使用 SQL 来编写存储过程。从某种程度讲,存储过程也可以认为是事务脚本。如下的存储过程就封装了插入论坛类别的业务过程: + +CREATE PROCEDURE np_Forums_InsertCategory +@CategoryName varchar(100), +@CategoryImageUrl varchar(100), +@CategoryPosition int, +@CategoryID int OUTPUT + +AS +DECLARE @CurrID int +-- see if the category already exists +SELECT @CurrID = CategoryID + FROM Forums_Categories + WHERE CategoryName = @CategoryName +-- if not, add it + +IF @CurrID IS NULL + BEGIN + INSERT INTO Forums_Categories + (CategoryName, CategoryImageUrl, CategoryPosition) + VALUES (@categoryName, @CategoryImageUrl, @CategoryPosition) + SET @CategoryID = @@IDENTITY + IF @@ERROR > 0 + BEGIN + RAISERROR ('Insert of Category failed', 16, 1) + RETURN 99 + END + END + +ELSE + BEGIN + SET @CategoryID = -1 + END + + + +在我踏上软件开发道路之初,无论是讲解数据库编程的书籍,还是身边的资深程序员,都在告诉我应当优先考虑编写存储过程来封装访问数据的逻辑。在开发数据库管理系统的时代,这似乎成为了性能优化的箴言,然而这并非事实的真相。Eric Redmond 在《七周七数据库》中就写道: + + +“存储过程可以通过巨大的架构代价来取得巨大的性能优势。使用存储过程可以避免将数千行数据发送到客户端应用程序,但也让应用程序代码与该数据库绑定,因此,不应该轻易决定使用存储过程。” + + +我曾经经历过将 Sybase 上的大量存储过程迁移到 Oracle 的噩梦,也曾阅读和维护过长达二千多行的存储过程,真可以说是往事不堪回事,这使得我对存储过程始终抱有戒惧心理。Donald Knuth 于 1974 年在 ACM Journal 上发表的文章 Structured Programming with go to Statements 中写道: + + +“We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil.” + + +显然,在我们没有通过性能测试发现性能瓶颈之前就进行的性能优化,可谓“万恶之源”。存储过程确有性能优势,但在没有发现性能瓶颈时,因为性能而选择存储过程,意味着会牺牲代码的可维护性、可读性及可扩展性,带来的结果可能是得不偿失。 + +在分布式系统下,SQL 存储过程带来的性能优势消散殆尽。单机版的时代已经过去。当数据量与访问量变得越来越大时,存储过程带来的性能提升已经不足以解决性能问题。由于存储过程强耦合于数据库服务器,使得对它的改造受到了诸多限制。例如,根据 AKF 的立方体模型,若要实现系统的可伸缩性(Scalability),可以从三个维度对系统的对应层次进行分割: + + + +评估立方体的三个维度: + + +X 轴的可伸缩性代表了数据或服务的复制与负载均衡:数据层采用数据库集群以及读写分离来保证。此时,存储过程不会受到 X 轴分割的影响。 +Y 轴的可伸缩性代表了服务的切分:相当于采用微服务架构。为了避免数据库出现性能瓶颈,应遵循微服务的设计原则,保证每个微服务有专有的数据库。若拆分前的单体架构使用了存储过程,可能包含大量多表关联,当迁移到微服务架构时,可能因为分库的原因,需要对存储过程做出修改和调整。 +Z 轴的可伸缩性代表了数据的分片:这意味着需要针对同类型的数据进行分库,如果 SQL 封装在服务或数据访问对象中,可以通过类似 ShardingSphere 这样的框架对 SQL 进行自动解析,满足分库分表的能力;但如果是存储过程,就会受到影响。 + + +不管是 SQL 语句还是由 SQL 语句构成的存储过程,更擅长表达对数据表和关系的操作。例如一些跨表查询通过 SQL 的 JOIN 或者子查询会显得更加直接,查询条件用 WHERE 子句也极为方便,但在针对复杂的业务逻辑处理,SQL 的表达力就要远远弱于类似 Java 这样的语言了。 + +SQL 和存储过程的自动化测试一直是一个难题。虽然有的数据库提供了 SQL 单元测试的开发工具,例如 Oracle SQL Developer;但是,单元测试的本质是不依赖于外部资源,SQL 的目的又是访问数据库,这就使得 SQL 单元测试本身就是一个悖论。类似 STK/Unit 这样的工具可以通过编写测试脚本来实现 SQL 的自动化测试,但实际上它是通过编写存储过程来调用测试用例,因而具有存储过程天然的缺陷。SQL 的代码调试也是一个非常大的问题。总之,一旦业务需求变得越来越繁杂,又或者业务需求频繁发生变化,维护 SQL 与存储过程会变得极为痛苦。 + +如果使用关系数据库,唯一绕不开的 SQL 场景是数据库的创建,以及基础数据的生成。软件系统一旦上线投入到生产环境,最难以更改的其实是数据库,包括数据库样式和已有的数据。无论是升级还是迁移,数据库都是最复杂的一环。为了解决这一问题,就需要对数据库脚本进行版本管理。无论变更是大还是小,是影响数据库样式还是数据,每次变更都应该放在单独的脚本文件中,并进行版本控制。这就相当于为数据库标记了检查点(Checkpoint),使得我们既可以从头开始部署数据库环境,也可以从当前检查点开始升级或迁移。像 FlywayDB 框架就规定每次数据库变更都定义在一个单独的 SQL 文件中,文件名前缀为:V{N}__,N 代表版本号。例如: + + + +即使是一次小的变更,也应该在带有版本号的 SQL 脚本中体现出来,例如上图中的 V19 脚本,其实就是在 t_fields 表中添加了一个新列: + +ALTER TABLE t_fields ADD COLUMN format VARCHAR(200); + + + +当然,考虑到升级失败时的回退,还应该提供对应的回退脚本: + +ALTER TABLE t_fields DROP COLUMN format; + + + +对象关系映射 + +我在介绍数据设计模型时提到数据表与对象之间的关系,认为: + + +一种简单直接的方法是建立与数据表完全一一对应的类模型。 + + +这其实是数据模型驱动设计的核心原则,即它是按照数据表与关系来建模的,因此在数据模型驱动设计中,数据表与对象的映射非常简单:数据表即类型,一行数据就是一个对象。映射的持久化对象是一个典型的贫血模型。如果针对关系数据库建立了数据模型,却在定义对象类型时采用了面向对象的设计思想,那就不再是数据模型驱动,而是领域模型驱动。 + +数据表与对象之间的映射,主要体现在概念上的一一对应,对于关系的处理则有不同之处。Jimmy Nilsson 认为: + + +“在关系数据库中,关系是通过重复的值形成的。父表的主键作为子表的外键重复,这就有效地使子表的行‘指向’它们的父亲。因此,关系模型中的一切事物都是数据,甚至关系也是数据。” + + +在类模型中,这种主外键关系往往通过类的组合或聚合来体现,即一个对象包含了另外一个对象或对象的集合。注意,这里的聚合是面向对象设计思想中的概念,并非领域驱动设计中的聚合。因此,当数据访问对象从数据表中查询获得返回结果时,需要通过映射器来实现从数据表的行到对象的转换。 + +整体而言,当我们面对关系数据库进行数据建模时,数据实现模型实际上得到了简单处理,所有业务都通过服务、数据访问对象与持久化对象三者之间的协作来完成,三者各司其职: + + +持久化对象就是数据表的映射,并合理处理数据表之间的关系; +数据访问对象负责访问数据库,并完成返回结果集如 ResultSet 或 DataSet 到持久化对象的映射; +服务利用事务脚本或者存储过程来组织业务过程。 + + +当然,持久化对象与数据访问对象的组合可以有多种变化,我在前面讲解数据设计模型时已经介绍,这些变化对应的恰好是 Martin Fowler 在《企业应用架构模式》中总结的四种数据源架构模式: + + +表入口模式 +行入口模式 +活动记录 +数据映射器 + + +这种简单的职责分配可以让一个不具备面向对象设计能力的开发人员快速上手,尤其是在 ORM 框架的支持下,框架帮助开发人员完成了大部分工作:生成持久化对象,封装通用的数据访问行为,利用元数据或配置完成表到类的映射。留给开发人员要做的工作很简单,就是编写 SQL,然后在服务中采用事务脚本的方式实现业务过程。如果业务过程也用存储过程或者 SQL 实现,则数据模型驱动设计的实现模型就只剩下编写 SQL 了。这些工作甚至 DBA 就可以胜任。 + +如果对比数据设计模型与数据实现模型,我们发现二者没有非常清晰的界限。设计过程实际上取决于对 ORM 框架的选择。 + + +如果选择 MyBatis,则遵循数据映射器模式,为持久化对象建立 Mapper,并通过 Java 注解或 XML,配置数据表与持久化对象之间的映射及对 SQL 的内嵌; +如果选择 jOOQ,则遵循活动记录模式,利用代码生成器创建具有数据访问能力的持久化对象,采用类型安全的 SQL 编写访问数据的逻辑代码; +如果选择 Spring Data JPA,则是数据映射器与资源库模式的一种结合,通过框架提供的Java注解确保实体(即这里的持久化对象)与数据表的映射,编写资源库(即数据访问对象)对象,并通过继承 CrudRepository 类实现数据库的访问。 + + +一旦选定了框架,其实就开始了编码实现。只需要确定业务过程,就可以创建服务对象,并以过程式的方式实现业务逻辑,若需要访问数据库,就通过数据访问对象来完成查询与持久化的能力。 + +数据模型驱动设计的过程 + +显然,在数据模型驱动设计的过程中,设计模型与实现模型皆以分析模型为重要的参考蓝本。整个数据模型驱动设计的重头戏都压在了分析模型上。通过对业务知识的提炼,识别出实体和数据表,并正确地建立数据表之间的关系成为了数据建模最重要的工作。它是数据模型驱动设计的起点。因此,一个典型的数据模型驱动设计过程如下图所示: + + + +你可以说这是没有太多设计含金量的过程,但对于简单的业务系统而言,无疑这是简单高效的设计方法。除了数据库性能优化与数据库设计范式之外,它对团队开发人员几乎没有技术门槛,只需掌握三个技能:一门语言的基本语法和编程技巧、一个 ORM 框架的使用方法及基本的 SQL 编写能力——就这三板斧,足矣!这也正是为何数据模型驱动设计能够大行其道的主要原因,无他,门槛低而已。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/043案例培训管理系统.md b/专栏/领域驱动设计实践(完)/043案例培训管理系统.md new file mode 100644 index 0000000..0322af3 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/043案例培训管理系统.md @@ -0,0 +1,307 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 043 案例 培训管理系统 + 接下来,我会用数据模型驱动设计的方法设计一个培训管理系统。在这个系统平台上,管理员可以发布课程,注册会员可以浏览所有的课程信息,并选择将自己感兴趣的课程加入到期望列表中,还可以订阅课程,并完成在线支付。为了让我们将注意力集中在该系统的核心业务中,我省去了用户管理、权限管理等通用功能。 + +建立数据分析模型 + +首先,我们需要分析业务,从中识别出我们需要关注的实体和实体之间的关系。一种简单的做法是通过识别需求中的名词来获得实体,之后就可以辨别它们之间的关系。我们还可以通过引入不同的用户视图来创建不同的实体关系模型。 + +在数据模型驱动设计过程中,通常会利用需求功能规格说明书详细阐述需求,如: + + +管理员发布课程:管理员在发布课程时,需要提供课程名、类别、课程简介、目标收益、培训对象和课程大纲。每门课程还要给出课程费用、课程时长、课程排期和讲师的信息。讲师的信息包括讲师姓名、任职公司和职务以及老师的个人简历。 +注册会员浏览课程信息:注册会员可以搜索和浏览课程基本信息,也可以查看课程的详细信息。 +加入期望列表:注册会员在发现自己感兴趣的课程时,可以将其加入到期望列表中,除了不允许重复添加课程之外,没有任何限制。 +订阅课程:注册会员可以订阅课程。订阅课程时,应该确定具体的课程排期。课程一旦被注册会员订阅,就应该从期望列表中移除。注册会员可以订阅多个课程。 +购买课程:注册会员在订阅课程后,可以通过在线支付进行购买。购买课程时,需要生成课程订单。 + + +通过以上需求描述,很明显可以获得两个用户视图,即管理员和注册会员。分别针对这两种不同的用户,识别需求描述中代表业务概念的名词,可以获得如下实体关系模型: + + + +图中灰色实体恰好可以与需求功能描述中的名词相对应。黄色实体则针对业务做了进一步的提炼,按照培训领域的业务,将“注册会员”称为“学生(Student)”,将“一次课程订阅”称为“培训(Training)”。注意,课程(Course)与培训(Training)、订单(Order)之间的关系。课程是一个可以反复排期、反复订阅的描述课程基本信息和内容的实体。当学生在订阅课程时,需要确定具体的课程排期。一旦订阅,就成为了一次具有固定上课日期的培训。只有学生为这次培训支付了费用后,才会生成一个订单。 + +深化实体关系模型,需要确定数据表以及数据表之间的关系和属性,由此获得数据项模型,这需要继续深挖业务需求,由此获得数据实体的属性,并按照数据库范式对实体进行拆分,并合理安排数据表之间的关系。例如,针对课程实体,可以将其拆分为课程(Course)、日程(Calendar)与类别(Category)表。课程与日程、类别的关系是一对多的关系。针对订单实体,可以将其拆分为订单(Order)和订单项(OrderItem)。 + +数据项模型的主体是数据表以及数据表之间的关系。在培训系统中,我们欣喜地发现数据模型中的关系表不仅在于消除表之间的多对多关系,同时还体现了业务概念。例如,期望列表(WishList)体现了学生与课程之间的多对多关系,培训(Training)同样体现了这二者之间的多对多关系。 + +在梳理数据表之间的关系时,有时候会因为建立了更细粒度的数据表,而判断出之前的实体关系可能存在谬误。例如实体关系模型为支付(Payment)与培训、订单与培训之间建立了关系,但在数据项模型中,由于引入了订单项,它与培训存在一对一关系,从而解除了订单与培训之间的关系。在定义了支付的属性后,最终发现为支付与培训之间建立关系是没有意义的。最终,我们建立的数据项模型如下图所示: + + + +建立数据设计模型 + +在数据设计模型中,需要定义持久化对象、数据访问对象与服务对象。为简便起见,本例不考虑各个数据表增删改查的数据管理操作,而只需要设计如下业务功能: + + +添加课程到期望列表 +从期望列表中移除课程 +预订课程 +取消课程预订 +购买课程 + + +服务将完成这些业务功能。通常,我们需要根据业务功能所要操作的表来判断功能的承担者。例如“学生添加课程到期望列表”操作的是期望列表,这个功能就应该定义到 WishListService 服务中。要注意区分功能描述的概念名词与实际操作数据表的区别。例如,“学生预订课程”功能表面上是操作课程数据表,实际生成了一个培训和订单;“学生购买课程”表面上是操作课程数据表,但实际上是针对订单表和支付表进行操作,这两个功能就应该定义到 OrderService 服务中。 + +数据项模型中的每个数据表对应每个持久化对象,这些持久化对象本质上都是传输对象,仅提供业务操作的数据,不具备业务行为。访问数据库的行为都放在持久化对象对应的数据访问对象中,业务行为则由服务来封装。因此,针对以上业务功能得到的设计模型如下所示: + + + +这里,我使用了 UML 类图来表达数据设计模型,这样可以清晰地看到服务、数据访问对象与持久化对象之间的关系。例如 OrderService 依赖于 PayService、PaymentMapper、OrderMapper、TrainingMapper 和 OrderItemMapper,这些 Mapper 对象又各自依赖于对应的持久化对象。以 Mapper 结尾的对象扮演数据访问对象的角色,之所以这样命名,是沿用了 MyBatis 框架推荐的命名规范。选择 ORM 框架属于设计决策,仍然属于数据建模设计活动的一部分,而这个决策不仅会对设计模型带来影响,同时还会直接影响实现模型。 + +在定义数据设计模型时,还需要理清持久化对象之间的关联关系。数据表之间的关联关系往往通过主外键建立,例如在数据项模型中,t_course 表的主键为 id,在 t_wish_list 与 t_calendar 等表中则以 courseId 外键体现关联关系。在对象模型中,通常会通过对象引用的组合方式体现关联关系,如设计模型中 Order 与 OrderItem 之间的组合关系,Category、Teacher 和 Calendar 之间的组合关系。 + +建立数据实现模型 + +数据实现模型首先包含了创建数据表的脚本。我使用了 FlywayDB 框架,在 db-migration 目录下创建了 SQL 文件 V1__create_tables.sql。例如创建 t_course、t_student 及 t_wish_list 数据表: + +CREATE TABLE IF NOT EXISTS t_course( + id VARCHAR(36) PRIMARY KEY, + teacherId VARCHAR(36) NOT NULL REFERENCES t_teacher(id), + name VARCHAR(50) NOT NULL UNIQUE, + description VARCHAR(255) NOT NULL, + earning VARCHAR(255), + trainee VARCHAR(200), + outline TEXT, + price DECIMAL NOT NULL, + duration INT NOT NULL, + categoryId VARCHAR(36) NOT NULL REFERENCES t_category(id), + createdBy VARCHAR(36) NOT NULL REFERENCES t_administrator(id), + createdAt DATETIME NOT NULL, + updatedAt DATETIME NOT NULL +); + +CREATE TABLE IF NOT EXISTS t_student( + id VARCHAR(36) PRIMARY KEY, + name VARCHAR(50) NOT NULL, + email VARCHAR(50) NOT NULL, + mobilePhone VARCHAR(20) NOT NULL, + registeredTime DATETIME NOT NULL, + createdAt DATETIME NOT NULL, + updatedAt DATETIME NOT NULL +); + +CREATE TABLE IF NOT EXISTS t_wish_list( + studentId VARCHAR(36) NOT NULL REFERENCES t_student(id), + courseId VARCHAR(36) NOT NULL REFERENCES t_course(id), + PRIMARY KEY(studentId, courseId) +); + + + +我选择了 VARCHAR(32) 类型作为表的主键,它对应于 Java 的 UUID。t_wish_list 实际上是 t_student 与 t_course 的关联表,但也体现了业务概念。以上 SQL 脚本并没有创建索引,可以考虑在后续版本创建各个表的索引。 + +每个数据表对应的持久化对象都是一个贫血对象,可以使用 Lombok 来简化代码,例如 Order 类的定义: + +import lombok.Data; +import java.sql.Timestamp; +import java.util.List; +import java.util.UUID; + +@Data + +public class Order { + private String id; + private Student student; + private OrderStatus status; + private Timestamp placedTime; + private Timestamp createdAt; + private Timestamp updatedAt; + private List orderItems; + public Order() { + this.id = UUID.randomUUID().toString(); + } + public Order(String orderId) { + this.id = orderId; + } +} + +import java.sql.Timestamp; + +@Data + +public class OrderItem { + private String id; + private String orderId; + private Training training; + private Timestamp createdAt; + private Timestamp updatedAt; +} + + + +注意 Order 类与 Student 及 OrderItem 之间是通过对象引用来体现的,对比数据表,可以看到数据表模型与对象模型在处理关系上的区别: + +CREATE TABLE IF NOT EXISTS t_order( + id VARCHAR(36) PRIMARY KEY, + studentId VARCHAR(36) NOT NULL REFERENCES t_student(id), + status ENUM('New', 'Paid', 'Confirmed', 'Completed') NOT NULL, + placedTime DATETIME NOT NULL, + createdAt DATETIME NOT NULL, + updatedAt DATETIME NOT NULL +); + +CREATE TABLE IF NOT EXISTS t_order_item( + id VARCHAR(36) PRIMARY KEY, + orderId VARCHAR(36) NOT NULL REFERENCES t_order(id), + trainingId VARCHAR(36) NOT NULL REFERENCES t_training(id), + createdAt DATETIME NOT NULL, + updatedAt DATETIME NOT NULL +); + + + +如果订单的数据访问对象 OrderMapper 要根据 id 查询订单,就需要映射器实现 ResultSet 中每一行到 Order 的转换,其中还包括对 Student 与 OrderItem 对象的映射,而 OrderItem 又与 Training 对象有关,Training 对象又牵涉到 Course 与 Calendar。要支持数据到对象的转换,就需要定义数据表与持久化对象的映射关系,同时,访问数据表的 SQL 语句则需要执行关联查询,以获取横跨多个数据表的数据信息。 + +数据实现模型与我们选择的 ORM 框架有关。本例使用了 MyBatis 框架实现数据的持久化。该框架支持 Java 标记或 XML 文件来定义表与对象的映射关系,并允许嵌入访问数据表的 SQL 语句。倘若 SQL 语句比较复杂,一般建议使用 XML 映射文件。例如,在 OrderMapper 中定义根据 id 获取订单对象的方法,就可以定义数据访问对象。MyBatis 框架一般以 Mapper 后缀来命名数据访问对象,并要求定义为一个抽象接口。例如访问订单的数据访问对象 OrderMapper 接口: + +import org.apache.ibatis.annotations.Param; + +import xyz.zhangyi.practicejava.framework.mybatis.model.Order; + +public interface OrderMapper { + + Order getOrder(@Param("orderId") String orderId); + +} + + + +获取订单的方法是一个接口方法,可以直接交给服务对象调用,例如在OrderService中: + +@Component +@Transactional +@EnableTransactionManagement + +public class OrderService { + private OrderMapper orderMapper; + + @Autowired + + public void setOrderMapper(OrderMapper orderMapper) { + this.orderMapper = orderMapper; + } + + public Order getOrder(String orderId) { + Order order = orderMapper.getOrder(orderId); + + if (order == null) { + throw new ApplicationException(String.format("Order by id %s is not found", orderId)); + } + return order; + } +} + + + +实现代码非常简单,但在其背后,MyBatis 需要建立一个非常繁琐的映射文件来规定映射关系,并将 getOrder() 方法绑定到 SQL 语句之上。这个映射文件为 OrderMapper.xml 文件: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +这个映射文件展现了如何通过定义 来实现数据表到对象的映射。在 中,通过 实现了对象之间的一对一组合关系,通过 实现了一对多关系,通过为 指定 typeHandler 为 org.apache.ibatis.type.EnumTypeHandler 来处理枚举的映射。至于在 + select + id, ticketId, ownerId, ownerType, fromStatus, toStatus, operationType, operatorId, operatorName, operatedAt + from ticket_history + where ticketId = #{ticketId} and operatedAt = (select max(operatedAt) from ticket_history where ticketId = #{ticketId}) + + + + insert into ticket_history + (id, ticketId, ownerId, ownerType, fromStatus, toStatus, operationType, operatorId, operatorName, operatedAt) + values + ( + #{id}, + #{ticketId}, #{ticketOwner.employeeId}, #{ticketOwner.ownerType}, + #{stateTransit.from}, #{stateTransit.to}, #{operationType}, + #{operatedBy.operatorId}, #{operatedBy.name}, #{operatedAt} + ) + + + + delete from ticket_history where ticketId = #{ticketId} + + + + + +应用服务的一个公开方法对应了一个完整的领域场景,为其编写集成测试时,需要该领域场景各个任务的工作都已准备完毕。结合场景驱动设计与测试驱动开发,领域服务与聚合已经在应用服务之前实现,资源库或其他南向网关对象的接口定义也已确定,但它们的实现却不曾验证。为此,可以考虑在实现应用服务之前,先为南向网关对象的实现编写集成测试。例如,为 TicketHistoryRepository 编写的集成测试如下: + +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration("/spring-mybatis.xml") +public class TicketHistoryRepositoryIT { + @Autowired + private TicketHistoryRepository ticketHistoryRepository; + private final TicketId ticketId = TicketId.from("18e38931-822e-4012-a16e-ac65dfc56f8a"); + + @Before + public void setup() { + ticketHistoryRepository.deleteBy(ticketId); + + StateTransit availableToWaitForConfirm = from(Available).to(WaitForConfirm); + LocalDateTime oldTime = LocalDateTime.of(2020, 1, 1, 12, 0, 0); + TicketHistory oldHistory = createTicketHistory(availableToWaitForConfirm, oldTime); + ticketHistoryRepository.add(oldHistory); + + StateTransit toConfirm = from(WaitForConfirm).to(Confirm); + LocalDateTime newTime = LocalDateTime.of(2020, 1, 1, 13, 0, 0); + TicketHistory newHistory = createTicketHistory(toConfirm, newTime); + ticketHistoryRepository.add(newHistory); + } + + @Test + public void should_return_latest_one() { + Optional latest = ticketHistoryRepository.latest(ticketId); + + assertThat(latest.isPresent()).isTrue(); + assertThat(latest.get().getStateTransit()).isEqualTo(from(WaitForConfirm).to(Confirm)); + } +} + + + +考虑到集成测试需要准备测试环境,执行效率也要低于单元测试,故而需要将单元测试和集成测试分为两个不同的构建阶段。 + +远程服务的编码实现 + +在实现了应用服务之后,继续逆流而上,编写作为北向网关的远程服务。如果是定义 REST 服务,需要遵循 REST 服务接口的设计原则。例如 TicketResource 的实现: + +@RestController +@RequestMapping("/tickets") +public class TicketResource { + private Logger logger = Logger.getLogger(TicketResource.class.getName()); + + @Autowired + private NominationAppService nominationAppService; + + @PutMapping + public ResponseEntity nominate(@RequestBody NominationRequest nominationRequest) { + if (Objects.isNull(nominationRequest)) { + logger.log(Level.WARNING,"Nomination Request is Null."); + return new ResponseEntity<>(HttpStatus.BAD_REQUEST); + } + try { + nominationAppService.nominate(nominationRequest); + return new ResponseEntity<>(HttpStatus.ACCEPTED); + } catch (ApplicationException e) { + logger.log(Level.SEVERE, "Exception raised by nominate REST Call.", e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } +} + + + +虽然服务接口定义并不相同,选择的 HTTP 动词也不相同,但这仅仅是接口定义的差异,每个 REST 资源类服务方法的实现却是大同小异的,即执行对应应用服务的方法,捕获异常,根据执行结果返回带有不同状态码的值。为了避免繁琐代码的编写,应用层定义的应用异常类别就派上了用场,利用 catch 捕获不同类型的应用异常,就可以实现相似的执行逻辑。为此,我在 eas-core 模块中定义了一个 Resources 辅助类: + +public class Resources { + private static Logger logger = Logger.getLogger(Resources.class.getName()); + + private Resources(String requestType) { + this.requestType = requestType; + } + + private String requestType; + private HttpStatus successfulStatus; + private HttpStatus errorStatus; + private HttpStatus failedStatus; + + public static Resources with(String requestType) { + return new Resources(requestType); + } + + public Resources onSuccess(HttpStatus status) { + this.successfulStatus = status; + return this; + } + + public Resources onError(HttpStatus status) { + this.errorStatus = status; + return this; + } + + public Resources onFailed(HttpStatus status) { + this.failedStatus = status; + return this; + } + + public ResponseEntity execute(Supplier supplier) { + try { + T entity = supplier.get(); + return new ResponseEntity<>(entity, successfulStatus); + } catch (ApplicationValidationException ex) { + logger.log(Level.WARNING, String.format("The request of %s is invalid", requestType)); + return new ResponseEntity<>(errorStatus); + } catch (ApplicationDomainException ex) { + logger.log(Level.WARNING, String.format("Exception raised %s REST Call", requestType)); + return new ResponseEntity<>(failedStatus); + } catch (ApplicationInfrastructureException ex) { + logger.log(Level.SEVERE, String.format("Fatal exception raised %s REST Call", requestType)); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + public ResponseEntity execute(Runnable runnable) { + try { + runnable.run(); + return new ResponseEntity<>(successfulStatus); + } catch (ApplicationValidationException ex) { + logger.log(Level.WARNING, String.format("The request of %s is invalid", requestType)); + return new ResponseEntity<>(errorStatus); + } catch (ApplicationDomainException ex) { + logger.log(Level.WARNING, String.format("Exception raised %s REST Call", requestType)); + return new ResponseEntity<>(failedStatus); + } catch (ApplicationInfrastructureException ex) { + logger.log(Level.SEVERE, String.format("Fatal exception raised %s REST Call", requestType)); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } +} + + + +execute() 方法的不同重载对应于是否返回响应消息对象的场景。不同异常类别对应的状态码由调用者传入。为了有效地记录日志信息,需要由调用者提供本服务请求的描述。在引入 Resources 类后,TicketResource 的服务实现为: + +@RestController +@RequestMapping("/tickets") +public class TicketResource { + private Logger logger = Logger.getLogger(TicketResource.class.getName()); + + @Autowired + private NominationAppService nominationAppService; + + @PutMapping + public ResponseEntity nominate(@RequestBody NominationRequest nominationRequest) { + return Resources.with("nominate ticket") + .onSuccess(ACCEPTED) + .onError(BAD_REQUEST) + .onFailed(INTERNAL_SERVER_ERROR) + .execute(() -> nominationAppService.nominate(nominationRequest)); + } +} + + + +而 TrainingResource 的实现则为: + +@RestController +@RequestMapping("/trainings") +public class TrainingResource { + private Logger logger = Logger.getLogger(TrainingResource.class.getName()); + + @Autowired + private TrainingAppService trainingAppService; + + @GetMapping(value = "/{id}") + public ResponseEntity findBy(@PathVariable String id) { + return Resources.with("find training by id") + .onSuccess(HttpStatus.OK) + .onError(HttpStatus.BAD_REQUEST) + .onFailed(HttpStatus.NOT_FOUND) + .execute(() -> trainingAppService.trainingOf(id)); + } +} + + + +显然经过这样的重构,可以有效地规避远程服务代码不必要的相似代码重复。 + +为了保证远程服务的正确性,应考虑为远程服务编写集成测试或契约测试。若选择 Spring Boot 作为 REST 框架,可利用 Spring Boot 提供的测试沙箱 spring-boot-starter-test 为远程服务编写集成测试,或者选择 Pact 之类的测试框架为其编写消费者驱动的契约测试(Consumer-Driven Contract Test)。如果要面向前端定义控制器(Controller),还可考虑引入 GraphQL 定义服务,这些服务为前端组成了 BFF(Backend For Frontend)服务。此外,还可以引入 Swagger 为这些远程服务定义 API 文档。 + +EAS 系统的代码模型 + +应用服务与消息契约对象定义在应用层,远程服务虽然处于后端分层架构的顶层,但其本质仍然是基础设施层的北向网关。在定义代码模型时,可以根据分层架构的要素划分模块或包,也可以根据领域驱动设计的模式来划分。EAS 系统的代码模型如下图所示: + + + +以下是对代码模型的详细说明: + +* eas-ddd:项目名称为 EAS + * eas-training:以项目名称为前缀,命名限界上下文对应的模块 + * eas.trainingcontext:限界上下文的命名空间,以 context 为后缀 + * application:应用层 + * pl:即 Published Language 的缩写,该命名空间下的类为消息契约对象,也可以认为是 DTO,乃开发主机服务的发布语言 + * domain:领域层,其内部按照聚合边界进行命名空间划分,每个聚合内的实体、值对象以及它对应的领域服务和资源库接口都定义在同一个聚合内部 + * gateway:即基础设施层,包含了北向网关和南向网关 + * acl:南向网关,Anti-Corruption Layer 的缩写,作为防腐层,需要将接口和实现分离 + * interfaces:除 Repository 之外的所有南向网关接口定义 + * impl:包含了 Repository 实现的所有南向网关的实现 + * ohs:北向网关,Open Host Service 的缩写,皆为远程服务,根据服务的不同可以分为 resources、controllers、providers 以及事件的 publishers + + + +EAS 即使作为一个单体架构,仍然需要清晰地为每个限界上下文定义单独的模块,其中,eas-core 作为共享内核,包含了系统层次的领域内核与基础设施公共组件。EAS 项目的 pom 文件体现了这些模块的定义: + + + 4.0.0 + + xyz.zhangyi.ddd + eas + 1.0-SNAPSHOT + pom + + + eas-core + eas-employee + eas-attendance + eas-project + eas-training + eas-entry + + + + + +eas-entry 是整个系统的主程序入口,它仅仅定义了一个 EasApplication 类: + +package xyz.zhangyi.ddd.eas; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +@SpringBootApplication +@EnableTransactionManagement +public class EasApplication { + public static void main(String[] args) { + SpringApplication.run(EasApplication.class, args); + } +} + + + +通过它可以为整个系统启动一个服务。Spring Boot 需要的配置也定义在 eas-entry 模块的 resource\ 文件夹下。该入口加载的所有远程服务均定义在各个限界上下文的内部,保证了每个限界上下文的架构完整性。 + +正如我在 5-10《领域驱动设计的精髓》总结的边界层次,限界上下文的边界要高于分层的边界,体现在代码模型中,应该是先有限界上下文的模块,再有限界上下文内部的分层。若需要将逻辑分层也定义为模块,这些层次的模块应作为限界上下文模块的子模块。如下的代码模型需要竭力避免: + + +application + + +trainingcontext +ticketcontext +… + +domain + + +trainingcontext +ticketcontext +… + +gateway + + +acl + + +impl + + +persistence +trainingcontext +ticketcontext +… + + + + + +只要保证了限界上下文边界在分层边界之上,就清晰地维护了整个系统的内外层次。当我们需要将一个单体架构迁移到微服务架构时,就能降低架构的迁移成本。事实上,若遵循这里建议的代码模型,你会发现:两种迥然不同的架构风格其实拥有完全相同的代码模型。执行架构迁移时,影响到的仅仅包含: + + +与单体架构不同,需要为每个微服务提供一个主程序入口,即去掉 eas-entry 模块,为每个限界上下文(微服务)定义一个 Application 类 +修改 gateway\acl\impl\client 的实现,将进程内的通信改为跨进程通信 +修改数据库的配置文件,让 DB 的 url 指向不同的数据库 +调整应用层的事务处理机制,考虑使用分布式柔性事务 + + +以上修改皆不影响领域层代码,包括领域层的产品代码与测试代码的已有实现。领域层代码作为整洁架构分层的内核,体现了它一如既往的稳定性。 + +EAS 的设计与开发流程 + +到此为止,我们实现了 EAS 系统相关限界上下文从聚合内的实体与值对象到领域服务、应用服务和远程服务的编码实现。毋庸置疑,面向场景的领域模型驱动设计过程,是一个有着清晰而固化的软件开发流程。 + +领域分析建模使用了一种有形的模型语言将无形的软件需求呈现出来,跨过了从现实世界到模型世界的鸿沟;领域设计建模则从整体出发,细节入手,在限界上下文、领域层和聚合的边界控制下对领域分析模型进行分解,形成一个个作用不同的“原子”构件;到领域实现建模时,再用编程语言赋予这些“原子”构件活动和运行的能力,并将它们组装起来,在测试的保护下,缝合成天衣无缝的整体,最后以外部服务的形式暴露给消费者。EAS 的整体案例体现了领域驱动战术设计的全过程。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/109后记:如何学习领域驱动设计.md b/专栏/领域驱动设计实践(完)/109后记:如何学习领域驱动设计.md new file mode 100644 index 0000000..739b5f7 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/109后记:如何学习领域驱动设计.md @@ -0,0 +1,53 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 109 后记:如何学习领域驱动设计 + 《领域驱动设计实践》课程后记 + +幸好,领域驱动设计(Domain Driven Design,DDD)不是一门容易衰亡的软件方法学。我从 2017 年 11 月写下本课程的第一个字到现在完成整个课程,已有两年多的时间了,好在 DDD 在这两年后依然算是一门“显学”,虽然它之耀眼更多地是在微服务与中台光芒的映衬下。 + +在这两年多备尝艰辛的写作过程中,我对于 DDD 的理解也在不断地蜕变与升华。当我敲完课程的最后一个字后,不由感叹自己终于可以浮出水面呼吸一口新鲜空气了;可是,隐隐又有一种意犹未尽的感觉。这或许囿于自己的学识有限,让课程内容留下不少遗憾的缘故吧!这些遗憾只有留待纸质书的写作去弥补和完善了。所以我还不能放松,在未来时间里,我将从头到尾再次审视课程的内容,以课程内容为蓝本去芜存菁,更有体系地梳理 DDD 的知识,争取打造一本 DDD 的原创精品。我给自己设置的时间期限是半年。如此算来,为了这一本 DDD 书籍,真可以说是“三年磨一剑”了! + +从战略到战术,DDD 给出了诸多关于软件架构、设计、建模与编码的方法和模式,以用于应对业务复杂度。然而,许多开发人员对于 DDD 的价值仍然心存疑惑,相反,对于它的难以理解难以学习倒是确信不疑,甚至有人惊呼 DDD 是“反人类的难懂”。这正是现实给了 DDD 沉痛的当头一击啊! + +从 2004 年 Eric Evans 出版《领域驱动设计》一书以来,已有十五余载。实事求是说,DDD 的推进与项目落地真的是举步维艰。个中原因,难以说清。DDD 是否真正反人类的难懂可以另说,但它是在反“早期的开发传统”,却是毋庸置疑。这一开发传统就是从实现技术出发,由数据驱动软件设计。软件开发人员往往擅长解决技术难题,却不善于(或者说不愿意)理清复杂的领域逻辑,对领域概念进行抽象。领域建模本身是一个主观思考的结果,这也带来优劣判定的不可衡量。 + +只要克服对 DDD 的畏难情绪(甚至是反感情绪),其实,DDD 的学习并没有想象的那么困难。最大的挑战在于如何落地?当一个企业或者一个团队希望选择 DDD 帮助他们提升软件设计与开发质量时,他们是否想过: + + +团队有没有专门的业务分析师,或者领域专家? +是否组建了特性团队,并以迭代的方式进行开发? +是否愿意以可视化的工作坊形式沟通需求,确定统一语言? +是否创造了足够的条件让特性团队的所有成员与角色能够面对面地高效沟通? +是否愿意为打造高质量的核心领域模型而为成本买单? + + +这些问题并非 DDD 能解决的,但却是成功实施 DDD 时需要确保的场外因素!因此,DDD 实施成败的关键,不仅在于 DDD 的本身,还在于企业或团队能力成熟度是否达到了实施 DDD 的要求!这也正是我为何在课程中提出“领域驱动设计能力评估模型(DDD Capability Assesment Model,DCAM)”的原因所在。 + +我眼中的 DDD 已经超越了软件设计技术的范畴,它更像是一门哲学!何谓“哲学”,可以理解为是对人生、世界乃至宇宙的智慧思考。而 DDD 就是对软件世界的一种思考形式,它提出以抽象的领域模型去反映混乱的现实需求世界,以有序、合规、演进的方式去打造满足业务需求的软件世界,并尽量将技术因素推出这个世界的大气层边界之外。简言之,DDD 是我们观察软件世界的态度! + +因此,对于学习 DDD 的开发人员而言,第一重要的不是掌握 DDD 的模式,而是要改变分析思维与设计思维的方式。将这种思维方式运用到软件项目开发过程中,就是我在课程中提到的“领域模型驱动设计”,它的核心内容可以通过层层推进的形式汇集为如下三句话: + + +以领域为分析建模的驱动力 +以场景为设计建模的驱动力 +以任务为实现建模的驱动力 + + +如何理解这三句话? + +当你在开始领域模型驱动设计时,必须在分析建模阶段抛开实现技术对你的影响,与需求分析人员、测试人员一起单纯针对“领域”进行分析建模,即提炼与抽象领域概念,并以统一语言和模型的形式来表达。在设计建模阶段,围绕着一个完整的“场景”开展设计工作。需求分析人员为“场景”编写用户故事,测试人员为“场景”编写验收标准,开发人员则开始解剖“场景”,将其分解为组合任务与原子任务,然后各自分配给不同的角色构造型。到了实现建模,就针对这些任务定义测试用例,开始测试驱动开发,由内至外到达应用服务时,再将它们集成起来。显然,领域模型驱动设计就是针对领域开展的“合而分分而合”的解构过程。 + +同时,必须谨记:领域模型驱动设计的基础是限界上下文。在领域驱动设计的战略阶段,同样是一个“合而分分而合”的解构过程:将领域分解为限界上下文,再通过上下文映射联合限界上下文共同实现多个领域场景。 + +以上内容正是我言犹未尽想要表达的精髓。学习领域驱动设计,就需要抓住 DDD 的根本和精髓。你需要理解什么是限界上下文,它带来的价值是什么;你需要理解如何进行领域建模,统一语言在其中扮演了什么样的角色;你需要理解为何领域驱动设计提倡以领域为驱动力,为什么需要领域专家参与到项目开发中来。提升了对这些内容的认识后,再去学习 DDD 给出的设计模式,学习我在课程中给出的固化设计过程,如场景驱动设计,然后找三两个不曾实施 DDD 的项目,寻两三个实施了 DDD 的项目,相互对比其模型与代码,你绝对会有一种醍醐灌顶的感觉。当然,这些都需要你沉下心来细心体会,认真思考,还需要你广泛涉猎更多软件设计与开发的知识,如此方能打通 DDD 的任督二脉。 + +至于团队实施 DDD,则不仅在于你个人的 DDD 知识与能力,而在于我前面提及的“场外因素”。企业或团队若期望在项目中实施 DDD,首先需要利用 DCAM 评估一下团队的能力成熟度,再来决策做不做 DDD,怎么做 DDD,并着手培养团队成员的 DDD 能力。《领域驱动设计实践》这门课程可以在一定程度提高读者的 DDD 能力,却无法确保成功实施 DDD 的场外因素。 + +课程写作结束了。战略篇一共 34 章,15 万 5 千字;战术篇一共 71 章,35 万 1 千字;合计 105 章,共 50 万 6 千余字,加上两篇开篇词与这篇可以称为写后感的后记,共 108 章,算是凑齐了一百零单八将。如此成果也足可慰藉我为之付出的两年多艰辛时光!不过,我的 DDD 征程还未结束,接下来的半年时间,我将和人民邮电出版社异步图书的杨海玲女士合作,重新整理本课程内容,为出版 DDD 原创精品(希望是国内的第一本 DDD 原创图书)而奋斗!至于书名,就暂定为《解构领域驱动设计,Domain Driven Design Explained》。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/00开篇词高并发系统,技术实力的试金石.md b/专栏/高并发系统实战课/00开篇词高并发系统,技术实力的试金石.md new file mode 100644 index 0000000..8579e15 --- /dev/null +++ b/专栏/高并发系统实战课/00开篇词高并发系统,技术实力的试金石.md @@ -0,0 +1,116 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 00 开篇词 高并发系统,技术实力的试金石 + 你好,我是徐长龙,欢迎加入我的高并发实战课。 + +我目前在极客时间担任架构师一职,在此之前从事架构已有十几年,曾就职于穷游网、微博、好未来,主要做老系统的高并发迁移与改造,对RPC建设、服务化、框架、分布式链路跟踪监控以及Kubernetes管理平台拥有丰富的经验。 + +我个人对计算机技术有浓厚的兴趣,始终在主动学习各种技术,早年曾活跃在Swoole社区、PHP开发者大会。 + +作为一名一线技术老兵,回顾我这么多年职业生涯的发展关键节点,总是和“高并发系统改造”密切相关。 + +为什么大厂这么重视高并发? + +说起高并发系统,你可能既熟悉又陌生。 + +熟悉是因为我们生活中常用的服务都属于高并发系统,比如淘宝、微博、美团、饿了么、12306、滴滴等等。 + +说它陌生,则是因为现实中只有少部分研发同学才能真正接触到这类系统,更多同学的刚需可能会局限于大厂面试。比如你是否也刷过这些问题: + +1.为什么百万并发系统不能直接使用MySQL服务?- +2.为什么Redis内存相比磁盘,需要用更多的空间?- +3.怎么保证条件查询缓存的数据一致性?- +4.为什么高级语言不能直接做业务缓存服务? + +那么大厂究竟关注的是什么呢?我们又该怎么看待高并发? + +无论问题多么花哨,归根结底其实就一句话:大厂看重的是你解决问题的思路和方法,而支撑你去完美回应这些的是更深层次的系统设计方向和原理。 + +比如说,上面我们提到的为什么百万并发不能直接使用MySQL服务,没有足够积累的话,你回答的大概是因为太高的并发查询会导致MySQL缓慢,然后简单地讲讲如何用缓存抵挡流量。 + +但是如果你面的是更高级别的岗位,面试官想要的其实是让你讲讲MySQL数据库为什么不能提供这么大的并发服务,同时你需要深入一起讨论下分布式数据库索引、存储、数据分片、存算分离等相关知识。 + +我们知道,互联网服务的核心价值就是流量,流量越大,平台的可能性和空间就越大,所以这也是为什么大厂倾向于有高并发经验的研发。2014年后,互联网迈入高并发时代,大厂与创业公司之间的技术壁垒一直在不断加码,高并发相关人才从早几年的趋势已然成为如今的大厂标配。 + +近几年云服务厂商的基础建设越来越成熟,他们直接提供了无感的分布式服务支撑,这进一步减少了我们亲自动手实践的机会,这会导致很多架构师的工作只剩下选厂商、选服务、如何快速接入和如何节省成本。 + +所以我们需正视,高并发在大厂与小厂之间确实建起了一道墙,想跨越它,系统学习底层知识、实践高并发场景就是必经之路。 + +进阶高并发,最重要的是项目级实战 + +那具体怎么跨越?可以参考我的经历。 + +2007年我刚毕业那会儿,国内的技术环境还谈不上什么高并发,我的工作局限在小流量场景,最多就是想想代码的可复用性和业务逻辑的完整性,而市场上最不缺的就是我这个阶段的研发。被套牢在业务逻辑实现里的日子,我开始关注各种技术,但对开源和系统底层的认识还很浅薄,也不知道该怎么去加深这些知识。 + +直到我加入穷游网,实际主持老系统高并发改造工作,在RPC建设时,因为RPC性能瓶颈我碰了一鼻子灰,才真正发现了差距。 + +之前的一些技巧,不见得适用于更高要求的系统。小流量场景里无伤大雅的问题,系统规模变大后都可能被无限放大,这会给脆弱的系统造成“致命打击”。在高并发场景中,你会发现很多网上开源的自我介绍,跟实践验证的结果大相径庭。 + +这段经历,让我看问题的思路和视角有了一个很大的转变。为了弥补自己的不足,我阅读了大量计算机系统著作,恶补底层知识。在相关技术社区与同好激烈地讨论,在项目中我动手实测过大量的开源,也对他们提了很多改进issue建议。 + +总之,学习、实践、交流多管齐下,还是非常有成效的,很快我加入了微博广告部,从事基础架构方面的相关工作。 + +微博是我的一个黄金成长期,在这里体验了不少“有趣但变态的需求”,这里常常就给两台服务器。就要你去开发服务微博全网的业务,还要求你不能崩。期间我还参与建设了很多实用有趣的服务,这让我从三百多人的广告部脱颖而出,得到了珍贵的晋升机会。也是这段经历,让我真正转向基础服务研发,在数据服务和高并发服务方面积累了更多经验。 + +后来,我陆陆续续收到很多公司或朋友的邀请,为各种系统提供服务改造优化方面的指导。有的系统迁移改造好比蚂蚁搬家,断断续续花了两年多的时间;有的系统崩溃,公司损失达到千万元,叫我去救火;有的系统谁都拆不动,没有人说得清到底该怎么优化…… + + + +所以你清楚进阶路径了吗?学习、实践、交流会是最实用的方法,最终帮助你建立系统化的思维。 + +你可以先从手边的项目开始,比如对你所在企业的现有系统进行高并发改造,注意不要只阅读理论,而是要一边分析实践,一边用压测去验证。风险可控的话,推荐你可以先找一些无关紧要的小系统实践。 + +如何实践高并发? + +那么具体如何改造呢?后面这四步最关键:识别系统类型、完善监控系统、梳理改造要点、小步改造验证。 + +以第一步为例,我们可以按照数据特征给系统归类,分别为读多写少、强一致性、写多读少、读多写多这四种类型。确定了系统的类型,就等同于确定了具体的优化方向。 + +而这个专栏就会针对这四个优化方向,带你梳理关键改造点。无论你需要构建高并发系统,还是面临业务流量增长或是系统改造升级,都能在这里找到参考。 + +这里我梳理了课程的知识结构图,下面结合图解说明一下课程的设计思路: + + + +读多写少的系统 + +我会以占比最高的“读多写少”系统带你入门,梳理和改造用户中心项目。这类系统的优化工作会聚焦于如何通过缓存分担数据库查询压力,所以我们的学习重点就是做好缓存,包括但不限于数据梳理、做数据缓存、加缓存后保证数据一致性等等工作。 + +另外,为了帮你从单纯的业务实现思想中“跳出来”,我们还会一起拓展下主从同步延迟和多机房同步的相关知识,为后续学习分布式和强一致打好基础。 + +强一致性的电商系统 + +这一章我们会以最典型的电商系统为例,学习要求更高的强一致性系统。 + +这类系统的主要挑战是承接高并发流量的同时,还要做好系统隔离性、事务一致性以及库存高并发争抢不超卖。我会和你详细讨论拆分实践的要点,让你加深对系统隔离、同步降级和库存锁等相关内容的认识,弄明白分布式事务组件的运作规律。了解这些,你会更容易看透一些基础架构组件的设计初衷。 + +写多读少的系统如何做链路跟踪 + +接下来是高并发写系统,它涉及大量数据如何落盘、如何传输、存储、压缩,还有冷热数据的切换备份、索引查询等多方面问题,我会一一为你展开分析。我还会给你分享一个全量日志分布式链路跟踪系统的完整案例,帮你熟悉并发写场景落地的方方面面。 + +另外,行业内写高并发的服务通常需要借助一些开源才能实现,我还会介绍一些相关开源实现原理和应用方向,完善你的“兵器库”。 + +读多写多的直播系统 + +读多写多系统是最复杂的系统类型,就像最火热的游戏、直播服务都属于这个类型。其中很多技术都属于行业天花板级别,毕竟线上稍有点问题,都极其影响用户体验。 + +这类系统数据基本都是在内存中直接对外服务,同时服务都要拆成很小的单元,数据是周期落到磁盘或数据库,而不是实时更新到数据库。因此我们的学习重点是如何用内存数据做业务服务、系统无需重启热更新、脚本引擎集成、脚本与服务互动交换数据、直播场景高并发优化、一些关于网络优化CDN和DNS、知识以及业务流量调度、客户端本地缓存等相关知识。 + +第五章 内网建设案例讲解 + +最后一章,我精选了一些案例,也是我特别添加的,这里既有让人眼前一亮的项目方案,也有很多有趣实用的设计,主要目的是帮助你开拓视野,未来能自行实现一些基础服务设计。 + +对于流量刚成长起来的业务,这一章很有参考价值,能让你的系统在后续业务流量增长时,扛住需求冲击并能快速解决问题。同时,相信你对头部开源解决方案也会有更深的理解。 + +一起到达目的地之后,我希望你已经有了更加宏观的视野,通过多项目实践系统了解了高并发。在面临各类相关问题时,能针对不同类型的系统,实现更匹配业务需求和技术条件的改造优化。 + +高并发不会是区别大厂、小厂工程师的标准,却是检验技术实力的一道关。课程搭建的学习场景是个良好起点,为你创造机会提高能力,期待看到你未来的成长突破! + +留言区和我聊聊你学习高并发的痛点吧,或许你遇到的困难已经在课程中有了答案,我也可以做针对性的加餐,我们一起交流学习。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/01结构梳理:大并发下,你的数据库表可能成为性能隐患.md b/专栏/高并发系统实战课/01结构梳理:大并发下,你的数据库表可能成为性能隐患.md new file mode 100644 index 0000000..8530667 --- /dev/null +++ b/专栏/高并发系统实战课/01结构梳理:大并发下,你的数据库表可能成为性能隐患.md @@ -0,0 +1,211 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 01 结构梳理:大并发下,你的数据库表可能成为性能隐患 + 你好,我是徐长龙,欢迎进入第一章节的学习。 + +这一章我们主要讲解怎么对读多写少的系统进行高并发优化,我会拿用户中心作为例子,带你来看改造的几个要点。 + +用户中心是一个典型的读多写少系统,可以说我们大部分的系统都属于这种类型,而这类系统通过缓存就能获得很好的性能提升。并且在流量增大后,用户中心通常是系统改造中第一个要优化的模块,因为它常常和多个系统重度耦合,所以梳理这个模块对整个系统后续的高并发改造非常重要。 + +今天这节课,我会带你对读多写少的用户中心做数据整理优化,这会让数据更容易缓存。数据梳理是一个很重要的技巧,任何老系统在做高并发改造时都建议先做一次表的梳理。 + +因为老系统在使用数据库的时候存在很多问题,比如实体表字段过多、表查询维度和用途多样、表之间关系混乱且存在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`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_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做文章检索等。这种可横向扩容的服务能大大降低数据库查询压力,但唯一缺点就是很难实现数据的强一致性,需要人工检测、核对两个系统的数据。 + +实体关系表 + +接下来我们再谈谈实体之间的关系。 + + + +在关系类型数据中,我强烈建议额外用一个关系表来记录实体间m:n的关联关系,这样两个实体就不用因为相互依赖关系,导致难以维护。 + +在对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 '0' + `extra` 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`), +) ENGINE=InnoDB AUTO_INCREMENT=1 +DEFAULT CHARSET=utf8mb4 +COLLATE=utf8mb4_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快速匹配的实体,以及通过关系快速查询的数据,适合放在长期缓存当中; +通过组合条件筛选统计的数据,也可以放到临时缓存,但是更新有延迟; +数据增长量大或者跟设计初衷不一样的表数据,这种不适合、也不建议去做做缓存。 + + + + +思考题 + +请你思考一下,用户邀请其他用户注册的记录,属于历史记录还是关系记录? + +欢迎你在留言区与我交流讨论,我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/02缓存一致:读多写少时,如何解决数据更新缓存不同步?.md b/专栏/高并发系统实战课/02缓存一致:读多写少时,如何解决数据更新缓存不同步?.md new file mode 100644 index 0000000..78ecc10 --- /dev/null +++ b/专栏/高并发系统实战课/02缓存一致:读多写少时,如何解决数据更新缓存不同步?.md @@ -0,0 +1,268 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 02 缓存一致:读多写少时,如何解决数据更新缓存不同步? + 你好,我是徐长龙,我们继续来看用户中心性能改造的缓存技巧。 + +上节课我们对数据做了归类整理,让系统的数据更容易做缓存。为了降低数据库的压力,接下来我们需要逐步给系统增加缓存。所以这节课,我会结合用户中心的一些业务场景,带你看看如何使用临时缓存或长期缓存应对高并发查询,帮你掌握高并发流量下缓存数据一致性的相关技巧。 + +我们之前提到过,互联网大多数业务场景的数据都属于读多写少,在请求的读写比例中,写的比例会达到百分之一,甚至千分之一。 + +而对于用户中心的业务来说,这个比例会更大一些,毕竟用户不会频繁地更新自己的信息和密码,所以这种读多写少的场景特别适合做读取缓存。通过缓存可以大大降低系统数据层的查询压力,拥有更好的并发查询性能。但是,使用缓存后往往会碰到更新不同步的问题,下面我们具体看一看。 + +缓存性价比 + +缓存可以滥用吗?在对用户中心优化时,一开始就碰到了这个有趣的问题。 + +就像刚才所说,我们认为用户信息放进缓存可以快速提高性能,所以在优化之初,我们第一个想到的就是将用户中心账号信息放到缓存。这个表有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 '0' + `update_time` int(10) NOT NULL DEFAULT '0', + `create_time` int(10) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_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:"id"` + Uid int `gorm:"column:uid;type:int(4);NOT NULL" json:"uid"` + NickName string `gorm:"column:nickname;type:varchar(32) unsigned;NOT NULL" json:"nickname"` + Status int16 `gorm:"column:status;type:tinyint(4);default:1;NOT NULL" json:"status"` + CreateTime int64 `gorm:"column:create_time;type:bigint(11);NOT NULL" json:"create_time"` + UpdateTime 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才行。 + + + +最后,我再给你介绍一种方式:异步脚本遍历数据库刷新所有相关缓存。这个方式适用于两个系统之间同步数据,能够减少系统间的接口交互;缺点是删除数据后,还需要人工删除对应的缓存,所以更新会有延迟。但如果能配合订阅更新消息广播的话,可以做到准同步。 + + + +长期热数据缓存 + +到这里,我们再回过头看看之前的临时缓存伪代码,它虽然能解决大部分问题,但是请你想一想,当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方式预防临时缓存被大量请求穿透,以防热点数据在从临时缓存切换成热点之前,击穿缓存,导致数据库崩溃。 + +读多写少的缓存技巧我还画了一张导图,如下所示: + + + +思考题 + +1.使用Bloom Filter识别热点key时,有时会识别失误,进而导致数据没有找到,那么如何避免这种情况呢? + +2.使用Bloom Filter只能添加新key,不能删除某一个key,如果想更好地更新维护,有什么其他方式吗? + +欢迎你在留言区与我交流讨论,我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/03Token:如何降低用户身份鉴权的流量压力?.md b/专栏/高并发系统实战课/03Token:如何降低用户身份鉴权的流量压力?.md new file mode 100644 index 0000000..48d4cca --- /dev/null +++ b/专栏/高并发系统实战课/03Token:如何降低用户身份鉴权的流量压力?.md @@ -0,0 +1,186 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 03 Token:如何降低用户身份鉴权的流量压力? + 你好,我是徐长龙,这节课我们来看看如何用token算法降低用户中心的身份鉴权流量压力。 + +很多网站初期通常会用Session方式实现登录用户的用户鉴权,也就是在用户登录成功后,将这个用户的具体信息写在服务端的Session缓存中,并分配一个session_id保存在用户的Cookie中。该用户的每次请求时候都会带上这个ID,通过ID可以获取到登录时写入服务端Session缓存中的记录。 + +流程图如下所示: + + + +这种方式的好处在于信息都在服务端储存,对客户端不暴露任何用户敏感的数据信息,并且每个登录用户都有共享的缓存空间(Session Cache)。 + +但是随着流量的增长,这个设计也暴露出很大的问题——用户中心的身份鉴权在大流量下很不稳定。因为用户中心需要维护的Session Cache空间很大,并且被各个业务频繁访问,那么缓存一旦出现故障,就会导致所有的子系统无法确认用户身份,进而无法正常对外服务。 + +这主要是由于Session Cache和各个子系统的耦合极高,全站的请求都会对这个缓存至少访问一次,这就导致缓存的内容长度和响应速度,直接决定了全站的QPS上限,让整个系统的隔离性很差,各子系统间极易相互影响。 + +那么,如何降低用户中心与各个子系统间的耦合度,提高系统的性能呢?我们一起来看看。 + +JWT登陆和token校验 + +常见方式是采用签名加密的token,这是登录的一个行业标准,即JWT(JSON Web 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"` //用户id + NickName string `json:"nickname"` //昵称 + jwt.StandardClaims //私有部分 +} + +// 生成JWT token +func GenToken(userId uint64, nickname string) (string, error) { + c := TokenPayLoad{ + UserId: userId, //uid + NickName: nickname, //昵称 + //这里可以追加一些其他加密的数据进来 + //不要明文放敏感信息,如果需要放,必须再加密 + + //私有部分 + StandardClaims: jwt.StandardClaims{ + //两小时后失效 + ExpiresAt: time.Now().Add(2 * time.Hour).Unix(), + //颁发者 + Issuer: "geekbang", + }, + } + //创建签名 使用hs256 + token := 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 { + //请求下用户中心,问问这个人禁登陆没 + //....略具体 + + //重新发放token + token, err := GenToken(.....) + if err != nil { + return nil, err + } + //更新返回cookie中token + resp.setCookie("xxxx", token) +} + + +这段代码只是对当前的token做了超时更换。JWT对离线App端十分友好,因为App可以将它保存在本地,在使用用户信息时直接从本地解析出来即可。 + +安全建议 + +最后我再啰嗦几句,除了上述代码中的注释外,在使用JWT方案的时候还有一些关键的注意事项,这里分享给你。 + +第一,通讯过程必须使用HTTPS协议,这样才可以降低被拦截的可能。 + +第二,要注意限制token的更换次数,并定期刷新token,比如用户的access_token每天只能更换50次,超过了就要求用户重新登陆,同时token每隔15分钟更换一次。这样可以降低token被盗取后给用户带来的影响。 + +第三,Web用户的token保存在cookie中时,建议加上httponly、SameSite=Strict限制,以防止cookie被一些特殊脚本偷走。 + +总结 + +传统的Session方式是把用户的登录信息通过SessionID统一缓存到服务端中,客户端和子系统每次请求都需要到用户中心去“提取”,这就会导致用户中心的流量很大,所有业务都很依赖用户中心。 + +为了降低用户中心的流量压力,同时让各个子系统与用户中心脱耦,我们采用信任“签名”的token,把用户信息加密发放到客户端,让客户端本地拥有这些信息。而子系统只需通过签名算法对token进行验证,就能获取到用户信息。 + +这种方式的核心是把用户信息放在服务端外做传递和维护,以此解决用户中心的流量性能瓶颈。此外,通过定期更换token,用户中心还拥有一定的用户控制能力,也加大了破解难度,可谓一举多得。 + +其实,还有很多类似的设计简化系统压力,比如文件crc32校验签名可以帮我们确认文件在传输过程中是否损坏;通过Bloom Filter可以确认某个key是否存在于某个数据集合文件中等等,这些都可以大大提高系统的工作效率,减少系统的交互压力。这些技巧在硬件能力腾飞的阶段,仍旧适用。 + +思考题 + +用户如果更换了昵称,如何快速更换token中保存的用户昵称呢? + +欢迎你在留言区与我交流讨论,我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/04同城双活:如何实现机房之间的数据同步?.md b/专栏/高并发系统实战课/04同城双活:如何实现机房之间的数据同步?.md new file mode 100644 index 0000000..1a401da --- /dev/null +++ b/专栏/高并发系统实战课/04同城双活:如何实现机房之间的数据同步?.md @@ -0,0 +1,128 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 04 同城双活:如何实现机房之间的数据同步? + 你好,我是徐长龙。今天我们来看看用户中心改造的另一个阶段:构建多机房。 + +在业务初期,考虑到投入成本,很多公司通常只用一个机房提供服务。但随着业务发展,流量不断增加,我们对服务的响应速度和可用性有了更高的要求,这时候我们就要开始考虑将服务分布在不同的地区来提供更好的服务,这是互联网公司在流量增长阶段的必经之路。 + +之前我所在的公司,流量连续三年不断增长。一次,机房对外网络突然断开,线上服务全部离线,网络供应商失联。因为没有备用机房,我们经过三天紧急协调,拉起新的线路才恢复了服务。这次事故影响很大,公司损失达千万元。 + +经过这次惨痛的教训,我们将服务迁移到了大机房,并决定在同城建设双机房提高可用性。这样当一个机房出现问题无法访问时,用户端可以通过HttpDNS接口快速切换到无故障机房。 + +为了保证在一个机房损坏的情况下,另外一个机房能直接接手流量,这两个机房的设备必须是1:1采购。但让其中一个机房长时间冷备不工作过于浪费,因此我们期望两个机房能同时对外提供服务,也就是实现同城双机房双活。 + +对此,我们碰到的一个关键问题就是,如何实现同城双活的机房数据库同步? + +核心数据中心设计 + +因为数据库的主从架构,全网必须只能有一个主库,所以我们只能有一个机房存放更新数据的主库,再由这个机房同步给其他备份机房。虽然机房之间有专线连接,但并不能保证网络完全稳定。如果网络出现故障,我们要想办法确保机房之间能在网络修复后快速恢复数据同步。 + +有人可能会说,直接采用分布式数据库不就得了。要知道改变现有服务体系,投入到分布式数据库的大流中需要相当长的时间,成本也非常高昂,对大部分公司来说是不切实际的。所以我们要看看怎么对现有系统进行改造,实现同城双活的机房数据库同步,这也是我们这节课的目标。 + +核心数据库中心方案是常见的实现方式,这种方案只适合相距不超过50公里的机房。 + + + +在这个方案中,数据库主库集中在一个机房,其他机房的数据库都是从库。当有数据修改请求时,核心机房的主库先完成修改,然后通过数据库主从同步把修改后的数据传给备份机房的从库。由于用户平时访问的信息都是从缓存中获取的,为了降低主从延迟,备份机房会把修改后的数据先更新到本地缓存。 + +与此同时,客户端会在本地记录下数据修改的最后时间戳(如果没有就取当前时间)。当客户端请求服务端时,服务端会自动对比缓存中对应数据的更新时间,是否小于客户端本地记录的修改时间。 + +如果缓存更新时间小于客户端内的修改时间,服务端会触发同步指令尝试在从库中查找最新数据;如果没有找到,就把从主库获取的最新数据放到被访问机房的缓存中。这种方式可以避免机房之间用户数据更新不及时的问题。 + + + +除此之外,客户端还会通过请求调度接口,让一个用户在短期内只访问一个机房,防止用户在多机房间来回切换的过程中,数据在两个机房同时修改引发更新合并冲突。 + +总体来看,这是一个相对简单的设计,但缺点也很多。比如如果核心机房离线,其他机房就无法更新,故障期间需要人工切换各个proxy内的主从库配置才能恢复服务,并且在故障过后还需要人工介入恢复主从同步。 + +此外,因为主从同步延迟较大,业务中刚更新的数据要延迟一段时间,才能在备用机房查到,这会导致我们业务需要人工兼顾这种情况,整体实现十分不便。 + +这里我给你一个常见的网络延迟参考: + + +同机房服务器:0.1 ms +同城服务器(100公里以内) :1ms(10倍 同机房) +北京到上海: 38ms(380倍 同机房) +北京到广州:53ms(530倍 同机房) + + +注意,上面只是一次RTT请求,而机房间的同步是多次顺序地叠加请求。如果要大规模更新数据,主从库的同步延迟还会加大,所以这种双活机房的数据量不能太大,并且业务不能频繁更新数据。 + +此外还要注意,如果服务有强一致性的要求,所有操作都必须在主库“远程执行”,那么这些操作也会加大主从同步延迟。 + +除了以上问题外,双机房之间的专线还会偶发故障。我碰到过机房之间专线断开两小时的情况,期间只能临时用公网保持同步,但公网同步十分不稳定,网络延迟一直在10ms~500ms之间波动,主从延迟达到了1分钟以上。好在用户中心服务主要以长期缓存的方式存储数据,业务的主要流程没有出现太大问题,只是用户修改信息太慢了。 + +有时候,双机房还会偶发主从同步断开,对此建议做告警处理。一旦出现这种情况,就发送通知到故障警报群,由DBA人工修复处理。 + +另外,我还碰到过主从不同步期间,有用户注册自增ID出现重复,导致主键冲突这种情况。这里我推荐将自增ID更换为“由SnowFlake算法计算出的ID”,这样可以减少机房不同步导致的主键冲突问题。 + +可以看到,核心数据库的中心方案虽然实现了同城双机房双活,但是人力投入很大。DBA需要手动维护同步,主从同步断开后恢复起来也十分麻烦,耗时耗力,而且研发人员需要时刻关注主从不同步的情况,整体维护起来十分不便,所以我在这里推荐另外一个解决方案:数据库同步工具Otter。 + +跨机房同步神器:Otter + +Otter是阿里开发的数据库同步工具,它可以快速实现跨机房、跨城市、跨国家的数据同步。如下图所示,其核心实现是通过Canal监控主库MySQL的Row binlog,将数据更新并行同步给其他机房的MySQL。 + + + +因为我们要实现同城双机房双活,所以这里我们用Otter来实现同城双主(注意:双主不通用,不推荐一致要求高的业务使用),这样双活机房可以双向同步: + + + +如上图,每个机房内都有自己的主库和从库,缓存可以是跨机房主从,也可以是本地主从,这取决于业务形态。Otter通过Canal将机房内主库的数据变更同步到Otter Node内,然后经由Otter的SETL整理后,再同步到对面机房的Node节点中,从而实现双机房之间的数据同步。 + +讲到这里不得不说一下,Otter是怎么解决两个机房同时修改同一条数据所造成的冲突的。 + +在Otter中数据冲突有两种:一种是行冲突,另一种是字段冲突。行冲突可以通过对比数据修改时间来解决,或者是在冲突时回源查询覆盖目标库;对于字段冲突,我们可以根据修改时间覆盖或把多个修改动作合并,比如a机房-1,b机房-1,合并后就是-2,以此来实现数据的最终一致性。 + +但是请注意,这种合并方式并不适合库存一类的数据管理,因为这样会出现超卖现象。如果有类似需求,建议用长期缓存解决。 + +Otter不仅能支持双主机房,还可以支持多机房同步,比如星形双向同步、级联同步(如下图)等。但是这几种方式并不实用,因为排查问题比较困难,而且当唯一决策库出现问题时,恢复起来很麻烦。所以若非必要,不推荐用这类复杂的结构。 + + + +另外,我还要强调一点,我们讲的双活双向同步方案只适合同城。一般来说,50~100公里以内的机房同步都属于同城内。 + +超过这个距离的话,建议只做数据同步备份,因为同步延迟过高,业务需要在每一步关注延迟的代价过大。如果我们的业务对一致性的要求极高,那么建议在设计时,把这种一致性要求限制在同一个机房内,其他数据库只用于保存结果状态。 + +那为什么机房间的距离必须是100公里以内呢?你看看Otter对于不同距离的同步性能和延迟参考,应该就能理解了。 + +具体表格如下所示: + + + +为了提高跨机房数据同步的效率,Otter对用于主从同步的操作日志做了合并,把同一条数据的多次修改合并成了一条日志,同时对网络传输和同步策略做了滑窗并行优化。 + +对比MySQL的同步,Otter有5倍的性能提升。通过上面的表格可以看到,通过Otter实现的数据同步并发性能好、延迟低,只要我们将用户一段时间内的请求都控制在一个机房内不频繁切换,那么相同数据的修改冲突就会少很多。 + +用Otter实现双向同步时,我们的业务不需要做太多改造就能适应双主双活机房。具体来说,业务只需要操作本地主库,把“自增主键”换成“snowflake算法生成的主键”、“唯一索引互斥”换成“分布式互斥锁”,即可满足大部分需求。 + +但是要注意,采用同城双活双向同步方案时,数据更新不能过于频繁,否则会出现更大的同步延迟。当业务操作的数据量不大时,才会有更好的效果。 + +说到这里,我们再讲一讲Otter的故障切换。目前Otter提供了简单的主从故障切换功能,在Manager中点击“切换”,即可实现Canal和数据库的主从同步方式切换。如果是同城双活,那关于数据库操作的原有代码我们不需要做更改,因为这个同步是双向的。 + +当一个机房出现故障时,先将故障机房的用户流量引到正常运转的机房,待故障修复后再恢复数据同步即可,不用切换业务代码的MySQL主从库IP。切记,如果双活机房有一个出现故障了,其他城市的机房只能用于备份或临时独立运行,不要跨城市做双活,因为同步延迟过高会导致业务数据损坏的后果。 + +最后,我再啰嗦一下使用Otter的注意事项:第一,为了保证数据的完整性,变更表结构时,我们一般会先从从库修改表结构,因此在设置Otter同步时,建议将pipeline同步设置为忽略DDL同步错误;第二,数据库表新增字段时,只能在表结尾新增,不能删除老字段,并且建议先把新增字段同步到目标库,然后再同步到主库,因为只有这样才不会丢数据;第三,双向同步的表在新增字段时不要有默认值,同时Otter不支持没有主键的表同步。 + +总结 + +机房之间的数据同步一直是行业里的痛,因为高昂的实现代价,如果不能做到双活,总是会有一个1:1机器数量的机房在空跑,而且发生故障时,没有人能保证冷备机房可以马上对外服务。 + +但是双活模式的维护成本也不低,机房之间的数据同步常常会因为网络延迟或数据冲突而停止,最终导致两个机房的数据不一致。好在Otter对数据同步做了很多措施,能在大多数情况下保证数据的完整性,并且降低了同城双活的实现难度。 + +即使如此,在业务的运转过程中,我们仍然需要人工梳理业务,避免多个机房同时修改同一条数据。对此,我们可以通过HttpDNS调度,让一个用户在某一段时间内只在一个机房内活跃,这样可以降低数据冲突的情况。 + +而对于修改频繁、争抢较高的服务,一般都会在机房本地做整体事务执行,杜绝跨机房同时修改导致同步错误的发生。 + +相信未来随着行业的发展,多活机房的同步会有更好的解决方案,今天的内容就讲到这里,期待你在留言区与我互动交流! + +思考题 + +如果Otter同步的链路是环形的,那么如何保证数据不会一直循环同步下去? + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/05共识Raft:如何保证多机房数据的一致性?.md b/专栏/高并发系统实战课/05共识Raft:如何保证多机房数据的一致性?.md new file mode 100644 index 0000000..1dc0d18 --- /dev/null +++ b/专栏/高并发系统实战课/05共识Raft:如何保证多机房数据的一致性?.md @@ -0,0 +1,128 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 05 共识Raft:如何保证多机房数据的一致性? + 你好,我是徐长龙。 + +上节课我们讲了如何通过Otter实现同城双活机房的数据库同步,但是这种方式并不能保证双机房数据双主的事务强一致性。 + +如果机房A对某一条数据做了更改,B机房同时修改,Otter会用合并逻辑对冲突的数据行或字段做合并。为了避免类似问题,我们在上节课对客户端做了要求:用户客户端在一段时间内只能访问一个机房。 + +但如果业务对“事务+强一致”的要求极高,比如库存不允许超卖,那我们通常只有两种选择:一种是将服务做成本地服务,但这个方式并不适合所有业务;另一种是采用多机房,但需要用分布式强一致算法保证多个副本的一致性。 + +在行业里,最知名的分布式强一致算法要属Paxos,但它的原理过于抽象,在使用过程中经过多次修改会和原设计产生很大偏离,这让很多人不确定自己的修改是不是合理的。而且,很多人需要一到两年的实践经验才能彻底掌握这个算法。 + +随着我们对分布式多副本同步的需求增多,过于笼统的Paxos已经不能满足市场需要,于是,Raft算法诞生了。 + +相比Paxos,Raft不仅更容易理解,还能保证数据操作的顺序,因此在分布式数据服务中被广泛使用,像etcd、Kafka这些知名的基础组件都是用Raft算法实现的。 + +那今天这节课我们就来探寻一下Raft的实现原理,可以说了解了Raft,就相当于了解了分布式强一致性数据服务的半壁江山。几乎所有关于多个数据服务节点的选举、数据更新和同步都是采用类似的方式实现的,只是针对不同的场景和应用做了一些调整。 + +如何选举Leader? + +为了帮你快速熟悉Raft的实现原理,下面我会基于 Raft官方的例子,对Raft进行讲解。 + + + +如图所示,我们启动五个Raft分布式数据服务:S1、S2、S3、S4、S5,每个节点都有以下三种状态: + + +Leader:负责数据修改,主动同步修改变更给Follower; +Follower:接收Leader推送的变更数据; +Candidate:集群中如果没有Leader,那么进入选举模式。 + + +如果集群中的Follower节点在指定时间内没有收到Leader的心跳,那就代表Leader损坏,集群无法更新数据。这时候Follower会进入选举模式,在多个Follower中选出一个Leader,保证一组服务中一直存在一个Leader,同时确保数据修改拥有唯一的决策进程。 + +那Leader服务是如何选举出来的呢?进入选举模式后,这5个服务会随机等待一段时间。等待时间一到,当前服务先投自己一票,并对当前的任期“term”加 1 (上图中term:4就代表第四任Leader),然后对其他服务发送RequestVote RPC(即请求投票)进行拉票。 + + + +收到投票申请的服务,并且申请服务(即“发送投票申请的服务”)的任期和同步进度都比它超前或相同,那么它就会投申请服务一票,并把当前的任期更新成最新的任期。同时,这个收到投票申请的服务不再发起投票,会等待其他服务邀请。 + +注意,每个服务在同一任期内只投票一次。如果所有服务都没有获取到多数票(三分之二以上服务节点的投票),就会等当前选举超时后,对任期加1,再次进行选举。最终,获取多数票且最先结束选举倒计时的服务会被选为Leader。 + +被选为Leader的服务会发布广播通知其他服务,并向其他服务同步新的任期和其进度情况。同时,新任Leader会在任职期间周期性发送心跳,保证各个子服务(Follwer)不会因为超时而切换到选举模式。在选举期间,若有服务收到上一任Leader的心跳,则会拒绝(如下图S1)。 + + + +选举结束后,所有服务都进入数据同步状态。 + +如何保证多副本写一致? + +在数据同步期间,Follower会与Leader的日志完全保持一致。不难看出,Raft算法采用的也是主从方式同步,只不过Leader不是固定的服务,而是被选举出来的。 + +这样当个别节点出现故障时,是不会影响整体服务的。不过,这种机制也有缺点:如果Leader失联,那么整体服务会有一段时间忙于选举,而无法提供数据服务。 + +通常来说,客户端的数据修改请求都会发送到Leader节点(如下图S1)进行统一决策,如果客户端请求发送到了Follower,Follower就会将请求重定向到Leader。那么,Raft是怎么实现同分区数据备份副本的强一致性呢? + +- +具体来讲,Leader成功修改数据后,会产生对应的日志,然后Leader会给所有Follower发送单条日志同步信息。只要大多数Follower返回同步成功,Leader就会对预提交的日志进行commit,并向客户端返回修改成功。 + +接着,Leader在下一次心跳时(消息中leader commit字段),会把当前最新commit的Log index(日志进度)告知给各Follower节点,然后各Follower按照这个index进度对外提供数据,未被Leader最终commit的数据则不会落地对外展示。 + +如果在数据同步期间,客户端还有其他的数据修改请求发到Leader,那么这些请求会排队,因为这时候的Leader在阻塞等待其他节点回应。 + + + +不过,这种阻塞等待的设计也让Raft算法对网络性能的依赖很大,因为每次修改都要并发请求多个节点,等待大部分节点成功同步的结果。 + +最惨的情况是,返回的RTT会按照最慢的网络服务响应耗时(“两地三中心”的一次同步时间为100ms左右),再加上主节点只有一个,一组Raft的服务性能是有上限的。对此,我们可以减少数据量并对数据做切片,提高整体集群的数据修改性能。 + +请你注意,当大多数Follower与Leader同步的日志进度差异过大时,数据变更请求会处于等待状态,直到一半以上的Follower与Leader的进度一致,才会返回变更成功。当然,这种情况比较少见。 + +服务之间如何同步日志进度? + +讲到这我们不难看出,在Raft的数据同步机制中,日志发挥着重要的作用。在同步数据时,Raft采用的日志是一个有顺序的指令日志WAL(Write 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获取最新的数据返回给客户端。可见,保证数据强一致性的代价很大。 + + + +你可能会好奇:如何在业务使用时保证读取数据的强一致性呢?其实我们之前说的Raft同步等待Leader commit log index的机制,已经确保了这一点。我们只需要向Leader正常提交数据修改的操作,Follower读取时拿到的就一定是最新的数据。 + +总结 + +很多人都说Raft是一个分布式一致性算法,但实际上Raft算法是一个共识算法(多个节点达成共识),它通过任期机制、随机时间和投票选举机制,实现了服务动态扩容及服务的高可用。 + +通过Raft采用强制顺序的日志同步实现多副本的数据强一致同步,如果我们用Raft算法实现用户的数据存储层,那么数据的存储和增删改查,都会具有跨机房的数据强一致性。这样一来,业务层就无需关心一致性问题,对数据直接操作,即可轻松实现多机房的强一致同步。 + +由于这种方式的同步代价和延迟都比较大,建议你尽量在数据量和修改量都比较小的场景内使用,行业里也有很多针对不同场景设计的库可以选择,如:parallel-raft、multi-paxos、SOFAJRaft等,更多请参考Raft的底部开源列表。 + + + +思考题 + +最后,请你思考一下,为什么Raft集群成员增减需要特殊去做? + +欢迎你在留言区与我交流讨论,我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/06领域拆分:如何合理地拆分系统?.md b/专栏/高并发系统实战课/06领域拆分:如何合理地拆分系统?.md new file mode 100644 index 0000000..3b3c13c --- /dev/null +++ b/专栏/高并发系统实战课/06领域拆分:如何合理地拆分系统?.md @@ -0,0 +1,193 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 06 领域拆分:如何合理地拆分系统? + 你好,我是徐长龙。 + +从这一章开始,我们一起看看怎么对数据一致性要求极高的系统做高并发改造。在这个章节中,我会以极具代表性的电商系统为例,对改造的技术关键点进行讲解。 + +一般来说,强一致性的系统都会牵扯到“锁争抢”等技术点,有较大的性能瓶颈,而电商时常做秒杀活动,这对系统的要求更高。业内在对电商系统做改造时,通常会从三个方面入手:系统拆分、库存争抢优化、系统隔离优化。 + +今天这节课我们先来热个身,学习一些系统拆分的技巧。我们知道,电商系统有很多功能需要保持数据的强一致性,我们一般会用锁确保同一时间只有一个线程在修改。 + +但这种方式会让业务处理的并行效率很低,还很容易影响系统的性能。再加上这类系统经常有各种个性活动需求,相关功能支撑需要不断更新迭代,而这些变更往往会导致系统脱离原来的设计初衷,所以在开发新需求的同时,我们要对系统定期做拆分整理,避免系统越跑越偏。这时候,如何根据业务合理地拆分系统就非常重要了。 + +案例背景 + +为了帮你掌握好系统拆分的技巧,我们来看一个案例。有一次,我受朋友邀请,希望我帮他优化系统。 + +他们是某行业知名电商的供货商,供应链比较长,而且供应品类和规格复杂。为确保生产计划平滑运转,系统还需要调配多个子工厂和材料商的生产排期。 + +原本调配订单需要电话沟通,但这样太过随机。为了保证生产链稳定供货,同时提高协调效率,朋友基于订单预订系统增加了排期协商功能,具体就是将 “排期” 作为下订单主流程里的一个步骤,并将协商出的排期按照日历样式来展示,方便上游供应商和各个工厂以此协调生产周期。 + +整个供货协商流程如下图所示: + + + +如图,上游项目会先发布生产计划(或采购计划),供货商根据计划拆分采购列表(分单),并联系不同的工厂协调做预排期(预约排期)。之后,上游采购方对工厂产品进行质量审核,然后下单支付、确认排期。 + +工厂根据确认好的排期制定采购材料计划,并通知材料供货商分批供货,开始分批生产制造产品。每当制造好一批产品后,工厂就会通知物流按批次发货到采购方(即供货商),同时更新供货商系统内的分批订单信息。接着,上游对产品进行验收,将不合格的产品走退换流程。 + +但系统运行了一段时间后朋友发现,由于之前系统是以订单为主体的,增加排期功能后还是以主订单作为聚合根(即主要实体),这就导致上游在发布计划时需要创建主订单。 + +而主订单一直处于开启状态,随着排期不断调整和新排期的不断加入,订单数据就会持续增加,一年内订单数据量达到了一亿多条。因为数据过多、合作周期长,并且包含了售后环节,所以这些数据无法根据时间做归档,导致整个系统变得越来越慢。 + +考虑到这是核心业务,如果持续存在问题影响巨大,因此朋友找我取经,请教如何对数据进行分表拆分。但根据我的理解,这不是分表分库维护的问题,而是系统功能设计不合理导致了系统臃肿。于是经过沟通,我们决定对系统订单系统做一次领域拆分。 + +流程分析整理 + +我先梳理了主订单的API和流程,从上到下简单绘制了流程和订单系统的关系,如下图所示: + + + +可以看到,有多个角色在使用这个“订单排期系统”。通过这张图与产品、研发团队进行沟通,来确认我理解的主要流程的数据走向和系统数据依赖关系都没有问题。 + +接着我们将目光放在了订单表上,订单表承载的职能过多,导致多个流程依赖订单表无法做数据维护,而且订单存在多个和订单业务无关的状态,比如排期周期很长,导致订单一直不能关闭。我们在第1节课讲过,一个数据实体不要承担太多职能,否则很难管理,所以我们需要对订单和排期的主要实体职能进行拆分。 + +经过分析我们还发现了另一个问题,现在系统的核心并不是订单,而是计划排期。原订单系统在改造前是通过自动匹配功能实现上下游订单分单的,所以系统的主要模块都是围绕订单来流转的。而增加排期功能后,系统的核心就从围绕订单实现匹配分单,转变成了围绕排期产生订单的模式,这更符合业务需要。 + +排期和订单有关联关系,但职能上有不同的方向用途,排期只是计划,而订单只为工厂后续生产运输和上游核对结果使用。这意味着系统的模块和表的设计核心已经发生了偏移,我们需要拆分模块才能拥有更好的灵活性。 + +综上所述,我们总体的拆分思路是:要将排期流程和订单交付流程完全拆分开。要知道在创业公司,我们做的项目一开始的设计初衷常常会因为市场需求变化,逐渐偏离原有设计,这就需要我们不断重新审视我们的系统,持续改进,才能保证系统的完善。 + +因为担心研发团队摆脱不了原有系统的思维定势,拆分做得不彻底,导致改版失败,所以我对角色和流程做了一次梳理,明确了各个角色的职责和流程之间的关系。我按角色及其所需动作画出多个框,将他们需要做的动作和数据流穿插起来,如下图所示: + + + +基于这个图,我再次与研发、产品沟通,找出了订单与排期在功能和数据上的拆分点。具体来讲,就是将上游的职能拆分为:发布进货计划、收货排期、下单、收货/退换;而供货商主要做协调排期分单,同时提供订单相关服务;工厂则主要负责生产排期、生产和售后。这样一来,系统的流程就可以归类成几个阶段: + +1.计划排期协调阶段- +2.按排期生产供货+周期物流交付阶段- +3.售后服务调换阶段 + +可以看到,第一个阶段不牵扯订单,主要是上游和多个工厂的排期协调;第二、三阶段是工厂生产供货和售后,这些服务需要和订单交互,而上游、工厂和物流的视角是完全不同的。 + +基于这个结论,我们完全可以根据数据的主要实体和主要业务流程(订单ID做聚合根,将流程分为订单和排期两个领域)将系统拆分成两个子系统:排期调度系统、订单交付系统。 + +在计划排期协调阶段,上游先在排期调度系统内提交进货计划和收货排期,然后供货商根据上游的排期情况和进货需求,与多家合作工厂协调分单和议价。多方达成一致后,上游对计划排期和工厂生产排期进行预占。 + +待上游正式签署协议、支付生产批次定金后,排期系统会根据排期和工厂下单在订单系统中产生对应的订单。同时,上游、供货商和工厂一旦达成合作,后续可以持续追加下单排期,而不是将合作周期限制在订单内。 + +在排期生产供货阶段,排期系统在调用订单系统的同时,会传递具体的主订单号和订单明细。订单明细内包含着计划生产的品类、个数以及每期的交付量,工厂可以根据自己的情况调整生产排期。产品生产完毕后,工厂分批次发送物流进行派送,并在订单系统内记录交付时间、货物量和物流信息。同时,订单系统会生成财务信息,与上游财务和仓库分批次地对账。 + + + +这么拆分后,两个系统把采购排期和交付批次作为聚合根,进行了数据关联,这样一来,整体的订单流程就简单了很多。 + +总体来讲,前面对业务的梳理都以流程、角色和关键动作这三个元素作为分析的切入点,然后将不同流程划分出不同阶段来归类分析,根据不同阶段拆分出两个业务领域:排期和订单,同时找出两个业务领域的聚合根。经过这样大胆的拆分后,再与产品和研发论证可行性。 + +系统拆分从表开始 + +经历了上面的过程,相信你对按流程和阶段拆分实体职责的方法,已经有了一定的感觉,这里我们再用代码和数据库表的视角复盘一下该过程。 + +一般来说,系统功能从表开始拆分,这是最容易实现的路径,因为我们的业务流程往往都会围绕一个主要的实体表运转,并关联多个实体进行交互。在这个案例中,我们将订单表内关于排期的数据和状态做了剥离,拆分之前的代码分层如下图所示: + + + +拆分之后,代码分层变成了这样: + + + +可以看到,最大的变化就是订单实体表的职责被拆分了,我们的系统代码随之变得更加简单,而且同一个订单实体被多个角色交叉调用的情况也完全消失了。在拆分过程中,我们的依据有三个: + + +数据实体职能只做最核心的一件事,比如订单只管订单的生老病死(包括创建、流程状态更改、退货、订单结束); + +业务流程归类按涉及实体进行归类,看能否分为多个阶段,比如“协调排期流程进行中”、“生产流程”、“售后服务阶段”; + +由数据依赖交叉的频率决定把订单划分成几个模块,如果两个模块业务流程上交互紧密,并且有数据关联关系,比如Join、调用A必然调用B这种,就把这两个模块合并,同时保证短期内不会再做更进一步的拆分。 + + + + +一个核心的系统,如果按实体表职责进行拆分整理,那么它的流程和修改难度都会大大降低。 + +而模块的拆分,也可以通过图6,从下往上去看。如果它们之间的数据交互不是特别频繁,比如没有出现频繁的Join,我们就将系统分成四个模块。如图7所示,可以看到这四个模块之间相对独立,各自承担一个核心的职责。同时,两个实体之间交互没有太大的数据关联,每个模块都维护着某个阶段所需的全部数据,这么划分比较清晰,也易于统一管理。 + +到这里,我们只需要将数据和流程关系都梳理一遍,确保它们之间的数据在后续的统计分析中没有频繁数据Join,即可完成对表的拆分。 + +但如果要按业务划分模块,我还是建议从上到下去看业务流程,来决定数据实体拆分(领域模型设计DDD)的领域范围,以及各个模块的职责范围。 + +越是底层服务越要抽象 + +除了系统的拆分外,我们还要注意一下服务的抽象问题。很多服务经常因业务细节变更需要经常修改,而越是底层服务,越要减少变更。如果服务的抽象程度不够,一旦底层服务变更,我们很难确认该变更对上游系统的影响范围。 + +所以,我们要搞清楚哪些服务可以抽象为底层服务,以及如何对这些服务做更好的抽象。 + +因为电商类系统经常对服务做拆分和抽象,所以我就以这类系统为例为你进行讲解。你可能感到疑惑:电商系统为什么要经常做系统拆分和服务抽象呢? + +这是因为电商系统最核心且最复杂的地方就是订单系统,电商商品有多种品类(sku+spu),不同品类的筛选维度、服务、计量单位都不同,这就导致系统要记录大量的冗余品类字段,才能保存好用户下单时的交易快照。所以我们需要频繁拆分整理系统,避免这些独有特性影响到其他商品。 + +此外,电商系统不同业务的服务流程是不同的。比如下单购买食品,与下单定制一个柜子完全不同。在用户购买食品时,电商只需要通知仓库打包、打物流单、发货、签收即可;而用户定制柜子则需要厂家上门量尺寸、复尺、定做、运输、后续调整等。所以,我们需要做服务抽象,让业务流程更标准、更通用,避免变更过于频繁。 + +正是由于业务服务形态存在不同的差异,订单系统需要将自己的职能控制在“一定范围”内。对此,我们应该考虑如何在满足业务需求的情况下,让订单表的数据职能最小。 + +事实上,这没有绝对的答案,因为不同行业、不同公司的业务形态都是不同的,这里我举几个常见的抽象思路供你参考。 + +被动抽象法 + +如果两个或多个服务使用同一个业务逻辑,就把这个业务逻辑抽象成公共服务。比如业务A更新了逻辑a,业务B也会同步使用新的逻辑a,那么就将这个逻辑a放到底层抽象成一个公共服务供两个服务调用。这种属于比较被动的抽象方式,很常见,适合代码量不大、维护人员很少的系统。 + +对于创业初期主脉络不清晰的系统,利用被动抽象法很容易做抽象。不过,它的缺点是抽象程度不高,当业务需要大量变更时,需要一定规模的重构。 + +总的来说,虽然这种方式的代码结构很贴近业务,但是很麻烦,而且代码分层没有规律。所以,被动抽象法适用于新项目的探索阶段。 + + + +这里说一个题外话,同层级之间的模块是禁止相互调用的。如果调用了,就需要将两个服务抽象成公共服务,让上层对两个服务进行聚合,如上图中的红X,拆分后如下图所示: + + + +这么做是为了让系统结构从上到下是一个倒置的树形,保证不会出现引用交叉循环的情况,否则会让项目难以排查问题,难以迭代维护,如果前期有大量这样的调用,当我们做系统改造优化时只能投入大量资源才能解决这个问题。 + +动态辅助表方式 + +这个方式适用于规模稍微大一点的团队或系统,它的具体实现是这样的:当订单系统被几个开发小组共同使用,而不同业务创建的主订单有不同的type,不同的type会将业务特性数据存储在不同的辅助表内,比如普通商品保存在表order和表order_product_extra中,定制类商品的定制流程状态保存在order_customize_extra中。 + +这样处理的好处是更贴近业务,方便查询。但由于辅助表有其他业务数据,业务的隔离性比较差,所有依赖订单服务的业务常会受到影响,而且订单需要时刻跟着业务改版。所以,通过这种方式抽象出来的订单服务已经形同虚设,一般只有企业的核心业务才会做类似的定制。 + + + +强制标准接口方式 + +这种方式在大型企业比较常见,其核心点在于:底层服务只做标准的服务,业务的个性部分都由业务自己完成,比如订单系统只有下单、等待支付、支付成功、发货和收货功能,展示的时候用前端对个性数据和标准订单做聚合。 + +用这种方式抽象出的公共服务订单对业务的耦合性是最小的,业务改版时不需要订单跟随改版,订单服务维护起来更容易。只是上层业务交互起来会很难受,因为需要在本地保存很多附加的信息,并且一些流转要自行实现。不过,从整体来看,对于使用业务多的系统来说,因为业务导致的修改会很少。 + + + +通过上面三种方式可以看出,业务的稳定性取决于服务的抽象程度。如果底层经常更改,那么整个业务就需要不断修改,最终会导致业务混乱。所以,我个人还是推荐你使用强制标准接口方式,这也是很多公司的常见做法。虽然很难用,但比起经常重构整个系统总要好一些。 + +你可能很奇怪,为什么不把第一种方式一口气设计好呢?这是因为大部分的初创业务都不稳定,提前设计虽然能让代码结构保持统一,但是等两年后再回头看,你会发现当初的设计已经面目全非,我们最初信心满满的设计,最后会成为业务的绊脚石。 + +所以,这种拆分和架构设计需要我们不定期回看、自省、不断调整。毕竟技术是为业务服务的,业务更重要,没有人可以保证项目初期设计的个人中心不会被改成交友的个人门户。 + +总之,每一种方法并非绝对正确,我们需要根据业务需求来决策用哪一种方式。 + +总结 + +业务拆分的方法有很多,最简单便捷的方式是:先从上到下做业务流程梳理,将流程归类聚合;然后从不同的领域聚合中找出交互所需主要实体,根据流程中主要实体之间的数据依赖程度决定是否拆分(从下到上看);把不同的实体和动作拆分成多个模块后,再根据业务流程归类,划分出最终的模块(最终汇总)。 + +这个拆分过程用一句话总结就是:从上往下看流程,从下往上看模块,最后综合考虑流程和模块的产出结果。用这种方式能快速拆出模块范围,拆分出的业务也会十分清晰。 + + + +除了拆分业务外,我们还要关注如何抽象服务。如果底层业务变更频繁,就会导致上层业务频繁修改,甚至出现变更遗漏的情况。所以,我们要确保底层服务足够抽象,具体有很多种办法,比如被动拆分法、动态辅助表方式、标准抽象方式。这几种方式各有千秋,需要我们根据业务来决策。 + + + +通常,我们的业务系统在初期都会按照一个特定的目标来设计,但是随着市场需求的变化,业务系统经过不断改版,往往会偏离原有的设计。 + +虽然我们每次改版都实现了既定需求,但也很容易带来许多不合理的问题。所以,在需求稳定后,一般都会做更合理的改造,保证系统的完整性,提高可维护性。很多时候,第一版本不用做得太过精细,待市场验证后明确了接下来的方向,再利用留出足够的空间改进,这样设计的系统才会有更好的扩展性。 + +思考题 + +我们这节课中的有些概念与DDD是重合的,但是仍有一些细小的差异,请你对比一下MVC三层方式和DDD实现的差异。 + +欢迎你在留言区与我交流讨论,我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/07强一致锁:如何解决高并发下的库存争抢问题?.md b/专栏/高并发系统实战课/07强一致锁:如何解决高并发下的库存争抢问题?.md new file mode 100644 index 0000000..e0c92fc --- /dev/null +++ b/专栏/高并发系统实战课/07强一致锁:如何解决高并发下的库存争抢问题?.md @@ -0,0 +1,305 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 07 强一致锁:如何解决高并发下的库存争抢问题? + 你好,我是徐长龙。 + +这节课我会给你详细讲一讲高并发下的库存争抢案例,我相信很多人都看到过相关资料,但是在实践过程中,仍然会碰到具体的实现无法满足需求的情况,比如说有的实现无法秒杀多个库存,有的实现新增库存操作缓慢,有的实现库存耗尽时会变慢等等。 + +这是因为对于不同的需求,库存争抢的具体实现是不一样的,我们需要详细深挖,理解各个锁的特性和适用场景,才能针对不同的业务需要做出灵活调整。 + +由于秒杀场景是库存争抢非常经典的一个应用场景,接下来我会结合秒杀需求,带你看看如何实现高并发下的库存争抢,相信在这一过程中你会对锁有更深入的认识。 + +锁争抢的错误做法 + +在开始介绍库存争抢的具体方案之前,我们先来了解一个小知识——并发库存锁。还记得在我学计算机的时候,老师曾演示过一段代码: + +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操作,再放回到缓存当中,这是个错误做法。 + + + +如上图,原因是多个线程一起读取的时候,多个线程同时读到的是5,set回去时都是6,实际每个线程都拿到了库存,但是库存的实际数值并没有累计改变,这会导致库存超卖。如果你需要用这种方式去做,一般建议加一个自旋互斥锁,互斥其他线程做类似的操作。 + +不过锁操作是很影响性能的,在讲锁方式之前,我先给你介绍几个相对轻量的方式。 + +原子操作 + +在高并发修改的场景下,用互斥锁保证变量不被错误覆盖性能很差。让一万个用户抢锁,排队修改一台服务器的某个进程保存的变量,这是个很糟糕的设计。 + +因为锁在获取期间需要自旋循环等待,这需要不断地循环尝试多次才能抢到。而且参与争抢的线程越多,这种情况就越糟糕,这期间的通讯过程和循环等待很容易因为资源消耗造成系统不稳定。 + +对此,我会把库存放在一个独立的且性能很好的内存缓存服务Redis中集中管理,这样可以减少用户争抢库存导致其他服务的抖动,并且拥有更好的响应速度,这也是目前互联网行业保护库存量的普遍做法。 + +同时,我不建议通过数据库的行锁来保证库存的修改,因为数据库资源很珍贵,使用数据库行锁去管理库存,性能会很差且不稳定。 + +前面我们提到当有大量用户去并行修改一个变量时,只有用锁才能保证修改的正确性,但锁争抢性能很差,那怎么降低锁的粒度、减少锁的争枪呢? + + + +如上图,其实我们可以将一个热门商品的库存做拆分,放在多个key中去保存,这样可以大幅度减少锁争抢。 + +举个例子,当前商品库存有100个,我们可以把它放在10个key中用不同的Redis实例保存,每个key里面保存10个商品库存,当用户下单的时候可以随机找一个key进行扣库存操作。如果没库存,就记录好当前key再随机找剩下的9个key,直到成功扣除1个库存。 + +除了这种方法以外,我个人更推荐的做法是使用Redis的原子操作,因为原子操作的粒度更小,并且是高性能单线程实现,可以做到全局唯一决策。而且很多原子操作的底层实现都是通过硬件实现的,性能很好,比如文稿后面这个例子: + +redis> decr prod_1475_stock_1 +14 + + +incr、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实现多个库存扣减,但库存不够三个只有两个的情况,仍需要让产品给个建议看看是否继续交易,同时在开始的时候用LLEN(O(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 10ms,10次失败后返回失败给用户 +//获取锁后设置超时时间,防止进程崩溃后没有释放锁导致问题 +//如果获取锁失败会返回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线程\\times(1000ms\\div10ms)次 = 10000 ops\] +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实现: + +connect('127.0.0.1', 6379); +$result = $redis->eval($script, array("prod_stock", 3), 1); +echo $result; + + +通过这个方式,我们可以远程注入各种连贯带逻辑的操作,并且可以实现一些补库存的操作。 + +总结 + +这节课,我们针对库存锁争抢的问题,通过Redis的特性实现了六种方案,不过它们各有优缺点。 + +- +以上这些方法可以根据业务需要组合使用。 + +其实,我们用代码去实现锁定扣库存也能够实现库存争抢功能,比如本地CAS乐观锁方式,但是一般来说,我们自行实现的代码会和其他业务逻辑混在一起,会受到多方因素影响,业务代码会逐渐复杂,性能容易失控。而Redis是独立部署的,会比我们的业务代码拥有更好的系统资源去快速解决锁争抢问题。 + +你可能发现我们这节课讲的方案大多数只有一层“锁”,但很多业务场景实际存在多个锁的情况,并不是我不想介绍,而是十分不推荐,因为多层锁及锁重入等问题引入后会导致我们系统很难维护,一个小粒度的锁能解决我们大部分问题,何乐而不为呢? + +思考题 + +1.请你思考一下,通过原子操作+拆开库存方式实现库存方案时,如何减少库存为0后接口缓慢的问题? + +2.我们这节课的内容并不仅仅在讲库存,还包含了大量可实现的锁的使用方式,请你分享一些实践过程中常见但不容易被发现的精妙设计。 + +欢迎你在评论区与我交流讨论,我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/08系统隔离:如何应对高并发流量冲击?.md b/专栏/高并发系统实战课/08系统隔离:如何应对高并发流量冲击?.md new file mode 100644 index 0000000..65eff7c --- /dev/null +++ b/专栏/高并发系统实战课/08系统隔离:如何应对高并发流量冲击?.md @@ -0,0 +1,144 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 08 系统隔离:如何应对高并发流量冲击? + 你好,我是徐长龙,今天我想跟你聊聊如何做好系统隔离。 + +我曾经在一家教育培训公司做架构师,在一次续报活动中,我们的系统出现了大规模崩溃。在活动开始有五万左右的学员同时操作,大量请求瞬间冲击我们的服务器,导致服务端有大量请求堆积,最终系统资源耗尽停止响应。我们不得不重启服务,并对接口做了限流,服务才恢复正常。 + +究其原因,我们习惯性地将公用的功能和数据做成了内网服务,这种方式虽然可以提高服务的复用性,但也让我们的服务非常依赖内网服务。当外网受到流量冲击时,内网也会受到放大流量的冲击,过高的流量很容易导致内网服务崩溃,进而最终导致整个网站无法响应。 + +事故后我们经过详细复盘,最终一致认为这次系统大规模崩溃,核心还是在于系统隔离性做得不好,业务极易相互影响。 + + + +如果系统隔离性做得好,在受到大流量冲击时,只会影响被冲击的应用服务,即使某个业务因此崩溃,也不会影响到其他业务的正常运转。这就要求我们的架构要有能力隔离多个应用,并且能够隔离内外网流量,只有如此才能够保证系统的稳定。 + +拆分部署和物理隔离 + +为了提高系统的稳定性,我们决定对系统做隔离改造,具体如下图: + +- +也就是说,每个内、外网服务都会部署在独立的集群内,同时每个项目都拥有自己的网关和数据库。而外网服务和内网必须通过网关才能访问,外网向内网同步数据是用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"` // 课程id + Sku string `json:"sku"` // 课程规格 sku + ClassId int32 `json:"class_id"` // 班级id + Amount int32 `json:amount,string` // 金额,分 + Uid int64 `json:uid,string` // 用户uid + Action 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是能够长时间保存消息的。如果在同步过程中出现问题,除了通过日志对故障进行修复外,我们还可以将故障期间的流量进行重放(重放要保证同步幂等)。 + +这个特性让我们可以做很多灵活的操作,甚至可以在流量高峰期,暂时停掉内网消费服务,待系统稳定后再开启,落地用户的交易。 + +除了数据同步外,我们还需要对内网的流量做到掌控,我们可以通过动态控制线程数来实现控制内网流量的速度。 + +好,今天这节课就讲到这里,相信你已经对“如何做好系统隔离”这个问题有了比较深入的理解,期望你在生产过程中能具体实践一下这个方案。 + +总结 + +系统的隔离需要我们投入大量的时间和精力去打磨,这节课讲了很多会对系统稳定性产生影响的关键特性,让我们整体回顾一下。 + +为了实现系统的隔离,我们在外网服务和内网服务之间设立了接口网关,只有通过网关才能调用内网接口服务。并且我们设定了在大流量冲击期间,用熔断内网接口的交互方式来保护内网。而外网所需的所有数据,在活动开始之前都要通过内网脚本推送到商城本地的缓存中,以此来保证业务的运转。 + +同时,外网成功成交的订单和同步信息通过分布式、可实时扩容和可回放的消息队列投递到了内网,内网会根据内部负载调整消费线程数来实现流量可控的消息消费。由此,我们实现了两个系统之间的同步互动。 + +我把这节课的关键知识画成了导图,供你参考:- + + +思考题 + +用什么方法能够周期检查出两个系统之间不同步的数据? + +欢迎你在留言区与我交流讨论,我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/09分布式事务:多服务的2PC、TCC都是怎么实现的?.md b/专栏/高并发系统实战课/09分布式事务:多服务的2PC、TCC都是怎么实现的?.md new file mode 100644 index 0000000..5c93a62 --- /dev/null +++ b/专栏/高并发系统实战课/09分布式事务:多服务的2PC、TCC都是怎么实现的?.md @@ -0,0 +1,382 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 09 分布式事务:多服务的2PC、TCC都是怎么实现的? + 你好,我是徐长龙,今天这节课我们聊聊分布式事务。 + +目前业界流行微服务,DDD领域驱动设计也随之流行起来。DDD是一种拆分微服务的方法,它从业务流程的视角从上往下拆分领域,通过聚合根关联多个领域,将多个流程聚合在一起,形成独立的服务。相比由数据表结构设计出的微服务,DDD这种方式更加合理,但也加大了分布式事务的实现难度。 + +在传统的分布式事务实现方式中,我们普遍会将一个完整的事务放在一个独立的项目中统一维护,并在一个数据库中统一处理所有的操作。这样在出现问题时,直接一起回滚,即可保证数据的互斥和统一性。 + +不过,这种方式的服务复用性和隔离性较差,很多核心业务为了事务的一致性只能聚合在一起。 + +为了保证一致性,事务在执行期间会互斥锁定大量的数据,导致服务整体性能存在瓶颈。而非核心业务要想在隔离要求高的系统架构中,实现跨微服务的事务,难度更大,因为核心业务基本不会配合非核心业务做改造,再加上核心业务经常随业务需求改动(聚合的业务过多),结果就是非核心业务没法做事务,核心业务也无法做个性化改造。 + +也正因为如此,多个系统要想在互动的同时保持事务一致性,是一个令人头疼的问题,业内很多非核心业务无法和核心模块一起开启事务,经常出现操作出错,需要人工补偿修复的情况。 + +尤其在微服务架构或用DDD方式实现的系统中,服务被拆分得更细,并且都是独立部署,拥有独立的数据库,这就导致要想保持事务一致性实现就更难了,因此跨越多个服务实现分布式事务已成为刚需。 + +好在目前业内有很多实现分布式事务的方式,比如2PC、3PC、TCC等,但究竟用哪种比较合适呢?这是我们需要重点关注的。因此,这节课我会带你对分布式事务做一些讨论,让你对分布式事务有更深的认识,帮你做出更好的决策。 + +XA协议 + +在讲分布式事务之前,我们先认识一下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方式为例进行讲解。 + + + +如上图所示,在协调两个服务Application 1和Application 2时,业务会先请求事务协调器创建全局事务,同时生成全局事务的唯一标识xid,然后再在事务协调器里分别注册两个子事务,生成每个子事务对应的xid。这里说明一下,xid由gtrid+bqual+formatID组成,多个子事务的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是事务主ID,bqual是子事务标识 +# 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/sql" + "fmt" + _ "github.com/go-sql-driver/mysql" + "strconv" + "time" +) +func main() { + // 库存的连接 + stockDb, err := sql.Open("mysql", "root:paswd@tcp(127.0.0.1:3306)/shop_product_stock") + if err != nil { + panic(err.Error()) + } + defer stockDb.Close() + //订单的连接 + orderDb, err := sql.Open("mysql", "root:paswd@tcp(127.0.0.1:3307)/shop_order") + if err != nil { + panic(err.Error()) + } + defer orderDb.Close() + //钱包的连接 + moneyDb, err := sql.Open("mysql", "root:paswd@tcp(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 stock=stock-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 money=money-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/sql" + "fmt" + _ "github.com/go-sql-driver/mysql" + "strconv" + "time" +) +func main() { + // 库存的连接 + stockDb, err := sql.Open("mysql", "root:123456@tcp(127.0.0.1:3306)/shop_product_stock") + if err != nil { + panic(err.Error()) + } + defer stockDb.Close() + //订单的连接 + orderDb, err := sql.Open("mysql", "root:123456@tcp(127.0.0.1:3307)/shop_order") + if err != nil { + panic(err.Error()) + } + defer orderDb.Close() + //钱包的连接 + moneyDb, err := sql.Open("mysql", "root:123456@tcp(127.0.0.1:3308)/user_money_bag") + if err != nil { + panic(err.Error()) + } + defer moneyDb.Close() + + // 生成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) + } + }() + + //调用API扣款,api内执行xa start、sql、xa end、xa prepare + if _, 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事务的优点: + + +并发能力高,且无长期资源锁定; +代码入侵实现分布式事务回滚,开发量较大,需要代码提供每个阶段的具体操作; +数据一致性相对来说较好; +适用于订单类业务,以及对中间状态有约束的业务。 + + +当然,它的缺点也很明显: + + +只适合短事务,不适合多阶段的事务; +不适合多层嵌套的服务; +相关事务逻辑要求幂等; +存在执行过程被打断时,容易丢失数据的情况。 + + +总结 + +通常来讲,实现分布式事务要耗费我们大量的精力和时间,硬件上的投入也不少,但当业务真的需要分布式事务时,XA协议可以给我们提供强大的数据层支撑。 + +分布式事务的实现方式有多种,常见的有2PC、3PC、TCC等。其中,2PC可以实现多个子事务统一提交回滚,但因为要保证数据的一致性,所以它的并发性能不好。而且2PC没有超时的机制,经常会将很多XA子事务遗漏在数据库中。 + +3PC虽然有超时的机制,但是因为交互过多,事务经常会出现超时的情况,导致事务的性能很差。如果3PC多次尝试失败超时后,它会尝试回滚,这时如果回滚也超时,就会出现丢数据的情况。 + +TCC则可以提前预定事务中需要锁定的资源,来减少业务粒度。它使用普通事务即可完成分布式事务协调,因此相对地TCC的性能很好。但是,提交最终事务和回滚逻辑都需要支持幂等,为此需要人工要投入的精力也更多。 + +目前,市面上有很多优秀的中间件,比如DTM、Seata,它们对分布式事务协调做了很多的优化,比如过程中如果出现打断情况,它们能够自动重试、AT模式根据业务修改的SQL自动生成回滚操作的SQL,这个相对来说会智能一些。 + +此外,这些中间件还能支持更复杂的多层级、多步骤的事务协调,提供的流程机制也更加完善。所以在实现分布式事务时,建议使用成熟的开源加以辅助,能够让我们少走弯路。 + +思考题 + +现在市面上有诸多分布式实现方式,你觉得哪一种性能更好? + +欢迎在留言区与我交流讨论,我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/10稀疏索引:为什么高并发写不推荐关系数据库?.md b/专栏/高并发系统实战课/10稀疏索引:为什么高并发写不推荐关系数据库?.md new file mode 100644 index 0000000..2e1fc38 --- /dev/null +++ b/专栏/高并发系统实战课/10稀疏索引:为什么高并发写不推荐关系数据库?.md @@ -0,0 +1,130 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 10 稀疏索引:为什么高并发写不推荐关系数据库? + 你好,我是徐长龙。 + +从这一章起,我们来学习如何优化写多读少的系统。说到高并发写,就不得不提及新分布式数据库HTAP,它实现了OLAP和OLTP的融合,可以同时提供数据分析挖掘和关系查询。 + +事实上,HTAP的OLAP并不是大数据,或者说它并不是我们印象中每天拿几T的日志过来用于离线分析计算的那个大数据。这里更多的是指数据挖掘的最后一环,也就是数据挖掘结果对外查询使用的场景。 + +对于这个范围的服务,在行业中比较出名的实时数据统计分析的服务有ElasticSearch、ClickHouse,虽然它们的QPS不高,但是能够充分利用系统资源,对大量数据做统计、过滤、查询。但是,相对地,为什么MySQL这种关系数据库不适合做类似的事情呢?这节课我们一起分析分析。 + +B+Tree索引与数据量 + +MySQL我们已经很熟悉了,我们常常用它做业务数据存储查询以及信息管理的工作。相信你也听过“一张表不要超过2000万行数据”这句话,为什么会有这样的说法呢? + +核心在于MySQL数据库的索引,实现上和我们的需求上有些冲突。具体点说,我们对外的服务基本都要求实时处理,在保证高并发查询的同时,还需要在一秒内找出数据并返回给用户,这意味着对数据大小以及数据量的要求都非常高高。 + +MySQL为了达到这个效果,几乎所有查询都是通过索引去缩小扫描数据的范围,然后再回到表中对范围内数据进行遍历加工、过滤,最终拿到我们的业务需要的数据。 + +事实上,并不是MySQL不能存储更多的数据,而限制我们的多数是数据查询效率问题。 + +那么MySQL限制查询效率的地方有哪些?请看下图: + + + +众所周知,MySQL的InnoDB数据库的索引是B+Tree,B+Tree的特点在于只有在最底层才会存储真正的数据ID,通过这个ID就可以提取到数据的具体内容,同时B+Tree索引最底层的数据是按索引字段顺序进行存储的。 + +通过这种设计方式,我们只需进行1~3次IO(树深度决定了IO次数)就能找到所查范围内排序好的数据,而树形的索引最影响查询效率的是树的深度以及数据量(数据越独特,筛选的数据范围就越少)。 + +数据量我么很好理解,只要我们的索引字段足够独特,筛选出来的数据量就是可控的。 + +但是什么会影响到索引树的深度个数呢?这是因为MySQL的索引是使用Page作为单位进行存储的,而每页只能存储16KB(innodb_page_size)数据。如果我们每行数据的索引是1KB,那么除去Page页的一些固定结构占用外,一页只能放16条数据,这导致树的一些分支装不下更多数据时,我么就需要对索引的深度再加一层。 + +我们从这个Page就可以推导出:索引第一层放16条,树第二层大概能放2万条,树第三层大概能放2400万条,三层的深度B+Tree按主键查找数据每次查询需要3次IO(一层索引在内存,IO两次索引,最后一次是拿数据)。 + +不过这个2000万并不是绝对的,如果我们的每行数据是0.5KB,那么大概在4000万以后才会出现第四层深度。而对于辅助索引,一页Page能存放1170个索引节点(主键bigint8字节+数据指针6字节),三层深度的辅助索引大概能记录10亿条索引记录。 + +可以看到,我们的数据存储数量超过三层时,每次数据操作需要更多的IO操作来进行查询,这样做的后果就是查询数据返回的速度变慢。所以,很多互联网系统为了保持服务的高效,会定期整理数据。 + + + +通过上面的讲解,相信你已经对整个查询有画面感了:当我们查询时,通过1~3次IO查找辅助索引,从而找到一批数据主键ID。然后,通过MySQL的MMR算法将这些ID做排序,再回表去聚簇索引按取值范围提取在子叶上的业务数据,将这些数据边取边算或一起取出再进行聚合排序后,之后再返回结果。 + +可以看到,我们常用的数据库之所以快,核心在于索引用得好。由于加工数据光用索引是无法完成的,我们还需要找到具体的数据进行再次加工,才能得到我们业务所需的数据,这也是为什么我们的字段数据长度和数据量会直接影响我们对外服务的响应速度。 + +同时请你注意,我们一个表不能增加过多的索引,因为索引太多会影响到表插入的性能。并且我们的查询要遵循左前缀原则来逐步缩小查找的数据范围,而不能利用多个CPU并行去查询索引数据。这些大大限制了我们对大数据的处理能力。 + +另外,如果有数据持续高并发插入数据库会导致MySQL集群工作异常、主库响应缓慢、主从同步延迟加大等问题。从部署结构上来说,MySQL只有主从模式,大批量的数据写操作只能由主库承受,当我们数据写入缓慢时客户端只能等待服务端响应,严重影响数据写入效率。 + +看到这里,相信你已经理解为什么关系型数据库并不适合太多的数据,其实OLAP的数据库也不一定适合大量的数据,正如我提到的OLAP提供的服务很多也需要实时响应,所以很多时候这类数据库对外提供服务的时候,计算用的数据也是做过深加工的。但即使如此,OLAP和OLTP底层实现仍旧有很多不同。 + +我们先来分析索引的不同。OLTP常用的是B+Tree,我们知道,B+tree索引是一个整体的树,当我们的数据量大时会影响索引树的深度,如果深度过高就会严重影响其工作效率。对于大量数据,OLAP服务会用什么类型的索引呢? + +稀疏索引LSM Tree与存储 + +这里重点介绍一下LSM索引。我第一次见到LSM Tree还是从RocksDB(以及LevelDB)上看到的,RocksDB之所以能够得到快速推广并受到欢迎,主要是因为它利用了磁盘顺序写性能超绝的特性,并以较小的性能查询代价提供了写多读少的KV数据存储查询服务,这和关系数据库的存储有很大的不同。 + +为了更好理解,我们详细讲讲Rocksdb稀疏索引是如何实现的,如下图所示: + +我们前面讲过,B+Tree是一个大树,它是一个聚合的完整整体,任何数据的增删改都是在这个整体内进行操作,这就导致了大量的随机读写IO。 + +RocksDB LSM则不同,它是由一棵棵小树组成,当我们新数据写入时会在内存中暂存,这样能够获得非常大的写并发处理能力。而当内存中数据积累到一定程度后,会将内存中数据和索引做顺序写,落地形成一个数据块。 + +这个数据块内保存着一棵小树和具体的数据,新生成的数据块会保存在Level 0 层(最大有几层可配置),Level 0 层会有多个类似的数据块文件。结构如下图所示: + + + +每一层的数据块和数据量超过一定程度时,RocksDB合并不同Level的数据,将多个数据块内的数据和索引合并在一起,并推送到Level的下一层。通过这个方式,每一层的数据块个数和数据量就能保持一定的数量,合并后的数据会更紧密、更容易被找到。 + +这样的设计,可以让一个Key存在于多个Level或者数据块中,但是最新的常用的数据肯定是在Level最顶部或内存(0~4层,0为顶部)中最新的数据块内。 + + + +而当我们查询一个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,会让我们的业务拥有更多的可能。 + +思考题 + +最后,请你思考一下:列存储数据库为什么能够提高OLAP查找性能? + +欢迎你在留言区与我交流讨论,我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/11链路追踪:如何定制一个分布式链路跟踪系统?.md b/专栏/高并发系统实战课/11链路追踪:如何定制一个分布式链路跟踪系统?.md new file mode 100644 index 0000000..696e47f --- /dev/null +++ b/专栏/高并发系统实战课/11链路追踪:如何定制一个分布式链路跟踪系统?.md @@ -0,0 +1,448 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 11 链路追踪:如何定制一个分布式链路跟踪系统 ? + 你好,我是徐长龙,这节课我们讲一讲如何实现分布式链路跟踪。 + +分布式链路跟踪服务属于写多读少的服务,是我们线上排查问题的重要支撑。我经历过的一个系统,同时支持着多条业务线,实际用上的服务器有两百台左右,这种量级的系统想排查故障,难度可想而知。 + +因此,我结合ELK特性设计了一套十分简单的全量日志分布式链路跟踪,把日志串了起来,大大降低了系统排查难度。 + +目前市面上开源提供的分布式链路跟踪都很抽象,当业务复杂到一定程度的时候,为核心系统定制一个符合自己业务需要的链路跟踪,还是很有必要的。 + +事实上,实现一个分布式链路跟踪并不难,而是难在埋点、数据传输、存储、分析上,如果你的团队拥有这些能力,也可以很快制作出一个链路跟踪系统。所以下面我们一起看看,如何实现一个简单的定制化分布式链路跟踪。 + +监控行业发展现状 + +在学习如何制作一个简单的分布式链路跟踪之前,为了更好了解这个链路跟踪的设计特点,我们先简单了解一下监控行业的现状。 + +最近监控行业有一次大革新,现代的链路跟踪标准已经不拘泥于请求的链路跟踪,目前已经开始进行融合,新的标准和我们定制化的分布式链路跟踪的设计思路很相似,即Trace、Metrics、日志合并成一套系统进行建设。 + + + +在此之前,常见监控系统主要有三种类型:Metrics、Tracing和Logging。 + + + +常见的开源Metrics有Zabbix、Nagios、Prometheus、InfluxDb、OpenFalcon,主要做各种量化指标汇总统计,比如监控系统的容量剩余、每秒请求量、平均响应速度、某个时段请求量多少。 + +常见的开源链路跟踪有Jaeger、Zipkin、Pinpoint、Skywalking,主要是通过分析每次请求链路监控分析的系统,我么可以通过TraceID查找一次请求的依赖及调用链路,分析故障点和传导过程的耗时。 + + + + + +而常见的开源Logging有ELK、Loki、Loggly,主要是对文本日志的收集归类整理,可以对错误日志进行汇总、警告,并分析系统错误异常等情况。 + +这三种监控系统可以说是大服务集群监控的主要支柱,它们各有优点,但一直是分别建设的。这让我们的系统监控存在一些割裂和功能重复,而且每一个标准都需要独立建设一个系统,然后在不同界面对同一个故障进行分析,排查问题时十分不便。 + +随着行业发展,三位一体的标准应运而生,这就是 OpenTelemetry 标准(集成了OpenCensus、OpenTracing标准)。这个标准将Metrics+Tracing+Logging集成一体,这样我们监控系统的时候就可以通过三个维度综合观测系统运转情况。 + +常见OpenTelemetry开源项目中的Prometheus、Jaeger正在遵循这个标准逐步改进实现OpenTelemetry 实现的结构如下图所示: + + + +事实上,分布式链路跟踪系统及监控主要提供了以下支撑服务: + + +监控日志标准 +埋点SDK(AOP或侵入式) +日志收集 +分布式日志传输 +分布式日志存储 +分布式检索计算 +分布式实时分析 +个性化定制指标盘 +系统警告 + + +我建议使用ELK提供的功能去实现分布式链路跟踪系统,因为它已经完整提供了如下功能: + + +日志收集(Filebeat) +日志传输(Kafka+Logstash) +日志存储(Elasticsearch) +检索计算(Elasticsearch + Kibana) +实时分析(Kibana) +个性定制表格查询(Kibana) + + +这样一来,我只需要制定日志格式、埋点SDK,即可实现一个具有分布式链路跟踪、Metrics、日志分析系统。 + +事实上,Log、Metrics、trace三种监控体系最大的区别就是日志格式标准,底层实现其实是很相似的。既然ELK已提供我们需要的分布式相关服务,下面我简单讲讲日志格式和SDK埋点,通过这两个点我们就可以窥见分布式链路跟踪的全貌。 + +TraceID单次请求标识 + +可以说,要想构建一个简单的Trace系统,我们首先要做的就是生成并传递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为 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协议放在Header; +RPC协议放在meta中传递; +队列可以放在消息体的Header中,或直接在消息体中传递; +其他特殊情况下可以通过网址请求参数传递。 + + +那么应用内多线程和多协程之间如何传递TraceID呢?一般来说,我们会通过复制一份Context传递进入线程或协程,并且如果它们之前是并行关系,我们复制之后需要对下发之前的RPCID计数器加1,并把前缀和计数器合并成新的前缀,以此区分并行的链路。 + +除此之外,我们还做了一些特殊设计,当我们的请求中带一个特殊的密语,并且设置类似X-DEBUG Header等于1时,我们可以开启在线debug模式,在被调用接口及所有依赖的服务都会输出debug级别的日志,这样我们临时排查线上问题会更方便。 + +日志类型定义 + +可以说,只要让日志输出当前的TraceId和RPCID(SpanID),并在请求所有依赖资源时把计数传递给它们,就完成了大部分的分布式链路跟踪。下面是我定制的一些日志类型和日志格式,供你参考: + +## 日志类型 + +* 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的都建议保存为字符串类型,每个字段必须是固定数据类型,选填内容如果没有内容就直接不输出。 + +这样设计其实是为了适配Elasticsearch+Kibana,Kibana提供了日志的聚合、检索、条件检索和数值聚合,但是对字段格式很敏感,不是数值类型就无法聚合对比。 + +下面我给你举一个例子用于链路跟踪和监控,你主要关注它的类型和字段用途。 + +{ + "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?id=9527", + "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,在请求服务端时就会带上它。通过这个日志,我们可以分析不同地域访问服务的性能,也可以用作用户行为日志,仅仅需添加我们的日志类型即可。 + +上面的日志例子基本把我们依赖的资源情况描述得很清楚了。另外,我补充一个技巧,性能记录日志可以将被请求的接口也记录成一个日志,记录自己的耗时等信息,方便之后跟请求方的请求日志对照,这样可分析出两者之间是否有网络延迟等问题。 + +除此之外,这个设计还有一个核心要点:研发并不一定完全遵守如上字段规则生成日志,业务只要保证项目范围内输出的日志输出所有必填项目(TraceID,RPCID/SpanID,TimeStamp),同时保证数值型字段功能及类型稳定,即可实现trace。 + +我们完全可以汇总日志后,再对不同的日志字段做自行解释,定制出不同业务所需的统计分析,这正是ELK最强大的地方。 + +为什么大部分设计都是记录依赖资源的日志呢?原因在于在没有IO的情况下,程序大部分都是可控的(侧重计算的服务除外)。只有IO类操作容易出现不稳定因素,并且日志记录过多也会影响系统性能,通过记录对数据源的操作能帮助我们排查业务逻辑的错误。 + +我们刚才提到日志如果过多会影响接口性能,那如何提高日志的写吞吐能力呢?这里我为你归纳了几个注意事项和技巧: + +1.提高写线程的个数,一个线程写一个日志,也可以每个日志文件单独放一个磁盘,但是你要注意控制系统的IOPS不要超过100; + +2.当写入日志长度超过1kb时,不要使用多个线程高并发写同一个文件。原因参考 append is not Atomic,简单来说就是文件的append操作对于写入长度超过缓冲区长度的操作不是原子性的,多线程并发写长内容到同一个文件,会导致日志乱序; + +3.日志可以通过内存暂存,汇总达到一定数据量或缓存超过2秒后再落盘,这样可以减少过小日志写磁盘系统的调用次数,但是代价是被强杀时会丢日志; + +4.日志缓存要提前malloc使用固定长度缓存,不要频繁分配回收,否则会导致系统整体缓慢; + +5.服务被kill时,记得拦截信号,快速fsync内存中日志到磁盘,以此减少日志丢失的可能。 + +“侵入式埋点SDK”VS“AOP方式埋点” + +最后,我们再说说SDK。事实上,使用“ELK+自定义的标准”基本上已经能实现大多数的分布式链路跟踪系统,使用Kibana可以很快速地对各种日志进行聚合分析统计。 + +虽然行业中出现过很多链路跟踪系统服务公司,做了很多APM等类似产品,但是能真正推广开的服务实际占少数,究其原因,我认为是以下几点: + + +分布式链路跟踪的日志吞吐很大,需要耗费大量的资源,成本高昂; +通用分布式链路跟踪服务很难做贴近业务的个性化,不能定制的第三方服务不如用开源; +分布式链路跟踪的埋点库对代码的侵入性大,需要研发手动植入到业务代码里,操作很麻烦,而且不够灵活。 +另外,这种做法对语言也有相关的限制,因为目前只有Java通过动态启动注入agent,才实现了静态语言AOP注入。我之前推广时,也是统一了内网项目的开源框架,才实现了统一的链路跟踪。 + + +那么如果底层代码不能更新,如何简单暴力地实现链路跟踪呢? + +这时候我们可以改造分级日志,让它每次在落地的时候都把TraceId和RPCID(或SpanID)带上,就会有很好的效果。如果数据底层做了良好的封装,我们可以在发起请求部分中写一些符合标准性能的日志,在框架的统一异常处理中也注入我们的标准跟踪,即可实现关键点的监控。 + +当然如果条件允许,我们最好提供一个标准的SDK,让业务研发伙伴按需调用,这能帮助我们统一日志结构。毕竟手写很容易格式错乱,需要人工梳理,不过即使混乱,也仍旧有规律可言,这是ELK架构的强大之处,它的全文检索功能其实不在乎你的输入格式,但是数据统计类却需要我们确保各个字段用途固定。 + +最后再讲点其他日志的注意事项,可能你已经注意到了,这个设计日志是全量的。很多链路跟踪其实都是做的采样方式,比如Jaeger在应用本地会部署一个Agent,对数据暂存汇总,统计出每个接口的平均响应时间,对具有同样特征的请求进行归类汇总,这样可以大大降低服务端压力。 + +但这么做也有缺点,当我们有一些小概率的业务逻辑错误,在采样中会被遗漏。所以很多核心系统会记录全量日志,周边业务记录采样日志。 + +由于我们日志结构很简单,如有需要可以自行实现一个类似Agent的功能,降低我们存储计算压力。甚至我们可以在服务端本地保存原始日志7天,当我们查找某个Trace日志的时候,直接请求所有服务器在本地查找。事实上,在写多读少的情况下,为了追一个Trace详细过程而去请求200个服务器,这时候即使等十秒钟都是可以接受的。 + +最后,为了方便理解,这里给你提供一个我之前写的laravel框架的Aop trace SDK 例子 laravel-aop-trace 供你参考 + +总结 + +系统监控一直是服务端重点关注的功能,我们常常会根据链路跟踪和过程日志,去分析排查线上问题。也就是说,监控越是贴近业务、越定制化,我们对线上业务运转情况的了解就越直观。 + +不过,实现一个更符合业务的监控系统并不容易,因为基础运维监控只会监控线上请求流量、响应速度、系统报错、系统资源等基础监控指标,当我们要监控业务时,还需要人工在业务系统中嵌入大量代码。而且,因为这些服务属于开源,还要求我们必须对监控有较深的了解,投入大量精力才可以。 + +好在技术逐渐成熟,通用的简单日志传输索引统计服务开始流行,其中最强的组合就是ELK。通过这类分布式日志技术,能让我们轻松实现个性化监控需求。日志格式很杂乱也没关系,只要将TraceID和RPCID(或SpanID)在请求依赖资源时传递下去,并将沿途的日志都记录对应的字段即可。也正因如此,ELK流行起来,很多公司的核心业务,都会依托ELK自定义一套自己的监控系统。 + +不过这么做,只能让我们建立起一个粗旷的跟踪系统,后续分析的难度和投入成本依然很大,因为ELK需要投入大量硬件资源来帮我们处理海量数据,相关知识我们后续章节再探讨, + +思考题 + +请你思考一下,既然我们通过ELK实现Trace那么简单,为什么会在当年那么难实现? + +欢迎你在评论区与我交流讨论,我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/12引擎分片:Elasticsearch如何实现大数据检索?.md b/专栏/高并发系统实战课/12引擎分片:Elasticsearch如何实现大数据检索?.md new file mode 100644 index 0000000..3e37fc9 --- /dev/null +++ b/专栏/高并发系统实战课/12引擎分片:Elasticsearch如何实现大数据检索?.md @@ -0,0 +1,134 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 12 引擎分片:Elasticsearch如何实现大数据检索? + 你好,我是徐长龙。 + +上节课我们看到了ELK对日志系统的强大支撑,如果没有它的帮助,我们自己实现分布式链路跟踪其实是十分困难的。 + +为什么ELK功能这么强大?这需要我们了解ELK中储存、索引等关键技术点的架构实现才能想清楚。相信你学完今天的内容,你对大数据分布式的核心实现以及大数据分布式统计服务,都会有更深入的理解。 + +Elasticsearch架构 + +那么ELK是如何运作的?它为什么能够承接如此大的日志量? + +我们先分析分析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会把一些不经常读取的索引挪到冷数据区,以此提高数据存储的性价比。而且我建议你创建索引时按天创建索引,这样查询时。我们可以通过时间范围来降低扫描数据量。 + + + +另外,Elasticsearch服务为了保证读写性能可扩容,Elasticsearch对数据做了分片,分片的路由规则默认是通过日志DocId做hash来保证数据分布均衡,常见分布式系统都是通过分片来实现读写性能的线性提升。 + +你可以这样理解:单个节点达到性能上限,就需要增加Data服务器节点及副本数来降低写压力。但是,副本加到一定程度,由于写强一致性问题反而会让写性能下降。具体加多少更好呢?这需要你用生产日志实测,才能确定具体数值。 + +Elasticsearch的两次查询 + +前面提到多节点及多分片能够提高系统的写性能,但是这会让数据分散在多个Data节点当中,Elasticsearch并不知道我们要找的文档,到底保存在哪个分片的哪个segment文件中。 + +所以,为了均衡各个数据节点的性能压力,Elasticsearch每次查询都是请求所有索引所在的Data节点,查询请求时协调节点会在相同数据分片多个副本中,随机选出一个节点发送查询请求,从而实现负载均衡。 + +而收到请求的副本会根据关键词权重对结果先进行一次排序,当协调节点拿到所有副本返回的文档ID列表后,会再次对结果汇总排序,最后才会用 DocId去各个副本Fetch具体的文档数据将结果返回。 + +可以说,Elasticsearch通过这个方式实现了所有分片的大数据集的全文检索,但这种方式也同时加大了Elasticsearch对数据查询请求的耗时。下图是协调节点和副本的通讯过程: + + + +除了耗时,这个方式还有很多缺点,比如查询QPS低;网络吞吐性能不高;协助节点需要每次查询结果做分页;分页后,如果我们想查询靠后的页面,要等每个节点先搜索和排序好该页之前的所有数据,才能响应,而且翻页跨度越大,查询就越慢…… + +为此,ES限制默认返回的结果最多1w条,这个限制也提醒了我们不能将Elasticsearch的服务当作数据库去用。 + +还有一点实践的注意事项,这种实现方式也导致了小概率个别日志由于权重太低查不到的问题。为此,ES提供了search_type=dfs_query_then_fetch参数来应对特殊情况,但是这种方式损耗系统资源严重,非必要不建议开启。 + +除此之外,Elasticsearch的查询有query and fetch、dfs query and fetch、dfs query then fetch三种,不过它们和这节课主线关联不大,有兴趣的话你可以课后自己了解一下。 + +Elasticsearch的倒排索引 + +我们再谈谈Elasticsearch的全文检索的倒排索引。 + +Elasticsearch支持多种查询方式不仅仅是全文检索,如数值类使用的是BKD Tree,Elasticsearch的全文检索查询是通过Lucene实现的,索引的实现原理和OLAP的LSM及OLTP的B+Tree完全不同,它使用的是倒排索引(Inverted Index)。 + + + + + +一般来说,倒排索引常在搜索引擎内做全文检索使用,其不同于关系数据库中的B+Tree和B-Tree 。B+Tree和B-Tree 索引是从树根往下按左前缀方式来递减缩小查询范围,而倒排索引的过程可以大致分四个步骤:分词、取出相关DocId、计算权重并重新排序、展示高相关度的记录。 + +首先,对用户输入的内容做分词,找出关键词;然后,通过多个关键词对应的倒排索引,取出所有相关的DocId;接下来,将多个关键词设计索引ID做交集后,再根据关键词在每个文档的出现次数及频率,以此计算出每条结果的权重,进而给列表排序,并实现基于查询匹配度的评分;然后就可以根据匹配评分来降序排序,列出相关度高的记录。 + +下面,我们简单看一下Lucene具体实现。 + + + +如上图,Elasticsearch集群的索引保存在Lucene的segment文件中,segment文件格式相关信息你可以参考 segment格式,其中包括行存、列存、倒排索引。 + +为了节省空间和提高查询效率,Lucene对关键字倒排索引做了大量优化,segment主要保存了三种索引: + + +Term Index(单词词典索引):用于关键词(Term)快速搜索,Term index是基础Trie树改进的FST(Finite 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)、倒排索引、词权重、匹配权重、分词、异步同步、数据一致性检测等。这些行业中的优秀设计,值得我们做拓展了解,推荐你课后自行探索。 + +思考题 + +如果让你实现一个Elasticsearch,你觉得需要先解决的核心功能是什么? + +欢迎你在评论区与我交流讨论,我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/13实时统计:链路跟踪实时计算中的实用算法.md b/专栏/高并发系统实战课/13实时统计:链路跟踪实时计算中的实用算法.md new file mode 100644 index 0000000..20a299b --- /dev/null +++ b/专栏/高并发系统实战课/13实时统计:链路跟踪实时计算中的实用算法.md @@ -0,0 +1,252 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 13 实时统计:链路跟踪实时计算中的实用算法 + 你好,我是徐长龙。 + +前几节课我们了解了ELK架构,以及如何通过它快速实现一个定制的分布式链路跟踪系统。不过ELK是一个很庞大的体系,使用它的前提是我们至少要有性能很好的三台服务器。 + +如果我们的数据量很大,需要投入的服务器资源就更多,之前我们最大一次的规模,投入了大概2000台服务器做ELK。但如果我们的服务器资源很匮乏,这种情况下,要怎样实现性能分析统计和监控呢? + +当时我只有两台4核8G服务器,所以我用了一些巧妙的算法,实现了本来需要大量服务器并行计算,才能实现的功能。这节课,我就给你分享一下这些算法。 + +我先把实时计算的整体结构图放出来,方便你建立整体印象。 + + + +从上图可见,我们实时计算的数据是从Kafka拉取的,通过进程实时计算统计 Kafka的分组消费。接下来,我们具体看看这些算法的思路和功用。 + +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 内容有数字 有字符混合 直接认为是md5 + if (($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_GET +PUT geekbang.com/user/1002312/info => geekbang.com/user/*num*/info_PUT +DELETE 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监控的时间块粒度是3~5秒一个,直到最近几年出现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 ( + "fmt" + "github.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 对比 测试字符串2 + fmt.Printf("Comparison of 0 1 : %d\n", simhash.Compare(hashes[0], hashes[1])) + //测试字符串1 对比 测试字符串3 + fmt.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 模块的内容。 + +思考题 + +基于这节课讲到的算法和思路,SQL如何做聚合归类去重? + +欢迎你在留言区和我交流讨论,我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/14跳数索引:后起新秀ClickHouse.md b/专栏/高并发系统实战课/14跳数索引:后起新秀ClickHouse.md new file mode 100644 index 0000000..5d58abc --- /dev/null +++ b/专栏/高并发系统实战课/14跳数索引:后起新秀ClickHouse.md @@ -0,0 +1,238 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 14 跳数索引:后起新秀ClickHouse + 你好,我是徐长龙。 + +通过前面的学习,我们见识到了Elasticsearch的强大功能。不过在技术选型的时候,价格也是重要影响因素。Elasticsearch虽然用起来方便,但却有大量的硬件资源损耗,再富有的公司,看到每月服务器账单的时候也会心疼一下。 + +而ClickHouse是新生代的OLAP,尝试使用了很多有趣的实现,虽然仍旧有很多不足,比如不支持数据更新、动态索引较差、查询优化难度高、分布式需要手动设计等问题。但由于它架构简单,整体相对廉价,逐渐得到很多团队的认同,很多互联网企业加入社区,不断改进ClickHouse。 + +ClickHouse属于列式存储数据库,多用于写多读少的场景,它提供了灵活的分布式存储引擎,还有分片、集群等多种模式,供我们搭建的时候按需选择。 + +这节课我会从写入、分片、索引、查询的实现这几个方面带你重新认识ClickHouse。在学习过程中建议你对比一下Elasticsearch、MySQL、RocksDB的具体实现,想想它们各有什么优缺点,适合什么样的场景。相信通过对比,你会有更多收获。 + +并行能力CPU吞吐和性能 + +我先说说真正使用ClickHouse的时候,最让我意料不到的地方。 + +我们先选个熟悉的参照物——MySQL,MySQL在处理一个SQL请求时只能利用一个CPU。但是ClickHouse则会充分利用多核,对本地大量数据做快速的计算,因此ClickHouse有更高的数据处理能力(2~30G/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合并。 + + + +如上图所示,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 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的查询工作流程: + +1.根据查询条件,查询过滤出要查询需要读取的data part 文件夹范围; + +2.根据data part 内数据的主键索引、过滤出要查询的granule; + +3.使用skip index 跳过不符合的granule; + +4.范围内数据进行计算、汇总、统计、筛选、排序; + +5.返回结果。 + +我补充说明一下,上面这五步里,只有第四步里的几个操作是并行的,其他流程都是串行。 + +在实际用上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;如果硬件和人员都少,建议买云服务的云分布式数据库去做,需要根据团队具体情况来合理地决策。 + +我还特意为你整理了一张评估表格,贴在了文稿里。 + + + +思考题 + +ClickHouse是不能轻易修改删除数据的,那我们要如何做历史数据的清理呢? + +期待你在留言区与我互动交流!如果觉得这节课内容还不错,请推荐、分享给更多朋友。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/15实践方案:如何用C++自实现链路跟踪?.md b/专栏/高并发系统实战课/15实践方案:如何用C++自实现链路跟踪?.md new file mode 100644 index 0000000..e69de29 diff --git a/专栏/高并发系统实战课/16本地缓存:用本地缓存做服务会遇到哪些坑?.md b/专栏/高并发系统实战课/16本地缓存:用本地缓存做服务会遇到哪些坑?.md new file mode 100644 index 0000000..4d7fb91 --- /dev/null +++ b/专栏/高并发系统实战课/16本地缓存:用本地缓存做服务会遇到哪些坑?.md @@ -0,0 +1,165 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 16 本地缓存:用本地缓存做服务会遇到哪些坑? + 你好,我是徐长龙。 + +这一章我们来学习如何应对读多写多的系统。微博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的内存管理方式,它用虚拟内存的方式管理内存,因此每个运行的进程都有自己的虚拟内存空间。 + +回过头来看,我们对外提供缓存数据服务时,如果想提供更高效的并发读写服务,就需要把数据放在本地内存中,一般会实现为一个进程内的多个线程来共享缓存数据。不过在这个过程中,我们还会遇到缺页问题,我们一起来看看。 + + + +如上图所示,我们的服务在Linux申请的内存不会立刻从物理内存划分出来。系统数据修改时,才会发现物理内存没有分配,此时CPU会产生缺页中断,操作系统才会以page为单位把物理内存分配给程序。系统这么设计,主要是为了降低系统的内存碎片,并且减少内存的浪费。 + +不过系统分配的页很小,一般是4KB,如果我们一次需要把1G的数据插入到内存中,写入数据到这块内存时就会频繁触发缺页中断,导致程序响应缓慢、服务状态不稳定的问题。 + +所以,当我们确认需要高并发读写内存时,都会先申请一大块内存并填0,然后再使用,这样可以减少数据插入时产生的大量缺页中断。我额外补充一个注意事项,这种申请大内存并填0的操作很慢,尽量在服务启动时去做。 + +前面说的操作虽然立竿见影,但资源紧张的时候还会有问题。现实中很多服务刚启动就会申请几G的内存,但是实际运行过程中活跃使用的内存不到10%,Linux会根据统计将我们长时间不访问的数据从内存里挪走,留出空间给其他活跃的内存使用,这个操作叫Swap Out。 + +为了降低 Swap Out 的概率,就需要给内存缓存服务提供充足的内存空间和系统资源,让它在一个相对专用的系统空间对外提供服务。 + +但我们都知道内存空间是有限的,所以需要精心规划内存中的数据量,确认这些数据会被频繁访问。我们还需要控制缓存在系统中的占用量,因为系统资源紧张时OOM会优先杀掉资源占用多的服务,同时为了防止内存浪费,我们需要通过LRU淘汰掉一些不频繁访问的数据,这样才能保证资源不被浪费。 + +即便这样做还可能存在漏洞,因为业务情况是无法预测的。所以建议对内存做定期扫描续热,以此预防流量突增时触发大量缺页中断导致服务卡顿、最终宕机的情况。 + +程序容器锁粒度 + +除了保证内存不放冷数据外,我们放在内存中的公共数据也需要加锁,如果不做互斥锁,就会出现多线程修改不一致的问题。 + +如果读写频繁,我们常常会对相应的struct增加单条数据锁或map锁。但你要注意,锁粒度太大会影响到我们的服务性能。 + +因为实际情况往往会和我们预计有一些差异,建议你在具体使用时,在本地多压测测试一下。就像我之前用C++ 11写过一些内存服务,就遇到过读写锁性能反而比不上自旋互斥锁,还有压缩传输效率不如不压缩效率高的情况。 + +那么我们再看一下业务缓存常见的加锁方式。 + + + +为了减少锁冲突,我常用的方式是将一个放大量数据的经常修改的map拆分成256份甚至更多的分片,每个分片会有一个互斥锁,以此方式减少锁冲突,提高并发读写能力。 + + + +除此之外还有一种方式,就是将我们的修改、读取等变动只通过一个线程去执行,这样能够减少锁冲突加强执行效率,我们常用的Redis就是使用类似的方式去实现的,如下图所示: + + + +如果我们接受半小时或一小时全量更新一次,可以制作map,通过替换方式实现数据更新。 + +具体的做法是用两个指针分别指向两个map,一个map用于对外服务,当拿到更新数据离线包时,另一个指针指向的map会加载离线全量数据。加载完毕后,两个map指针指向互换,以此实现数据的批量更新。这样实现的缓存我们可以不加互斥锁,性能会有很大的提升。 + + + + + +当然行业也存在一些无锁的黑科技,这些方法都可以减少我们的锁争抢,比如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算法转成的int,value保存的内容是数据所在的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分片去应对线上压力。当然这种方式,我们同样需要考虑清楚如何做数据同步。 + +除了今天讲的踩坑点,内存对外服务的过程中,我们还会碰到一些其他问题,我们下节课再展开。 + +思考题 + +使用了大数组来保存数据,用offset+length实现的数据缓存,有什么办法修改数据? + +欢迎你在评论区与我交流讨论,我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/17业务脚本:为什么说可编程订阅式缓存服务更有用?.md b/专栏/高并发系统实战课/17业务脚本:为什么说可编程订阅式缓存服务更有用?.md new file mode 100644 index 0000000..e8e2135 --- /dev/null +++ b/专栏/高并发系统实战课/17业务脚本:为什么说可编程订阅式缓存服务更有用?.md @@ -0,0 +1,382 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 17 业务脚本:为什么说可编程订阅式缓存服务更有用? + 你好,我是徐长龙。 + +我们已经习惯了使用缓存集群对数据做缓存,但是这种常见的内存缓存服务有很多不方便的地方,比如集群会独占大量的内存、不能原子修改缓存的某一个字段、多次通讯有网络损耗。 + +很多时候我们获取数据并不需要全部字段,但因为缓存不支持筛选,批量获取数据的场景下性能就会下降很多。这些问题在读多写多的场景下,会更加明显。 + +有什么方式能够解决这些问题呢?这节课,我就带你了解另外一种有趣的数据缓存方式——可编程订阅式缓存服务。学完今天的内容,相信你会对缓存服务如何做产生新的思考。 + +缓存即服务 + +可编程订阅式缓存服务的意思是,我们可以自行实现一个数据缓存服务直接提供给业务服务使用,这种实现能够根据业务的需要,主动缓存数据并提供一些数据整理和计算的服务。 + +自实现的数据缓存服务虽然繁琐,但同时也有很多优势,除去吞吐能力的提升,我们还可以实现更多有趣的定制功能,还有更好的计算能力,甚至可以让我们的缓存直接对外提供基础数据的查询服务。 + + + +上图是一个自实现的缓存功能结构,可以说这种缓存的性能和效果更好,这是因为它对数据的处理方式跟传统模式不同。 + +传统模式下,缓存服务不会对数据做任何加工,保存的是系列化的字符串,大部分的数据无法直接修改。当我们使用这种缓存对外进行服务时,业务服务需要将所有数据取出到本地内存,然后进行遍历加工方可使用。 + +而可编程缓存可以把数据结构化地存在map中,相比传统模式序列化的字符串,更节省内存。 + +更方便的是,我们的服务无需再从其他服务取数据来做计算,这样会节省大量网络交互耗时,适合用在实时要求极高的场景里。如果我们的热数据量很大,可以结合RocksDB等嵌入式引擎,用有限的内存提供大量数据的服务。 + +除了常规的数据缓存服务外,可编程缓存还支持缓存数据的筛选过滤、统计计算、查询、分片、数据拼合。关于查询服务,我补充说明一下,对外的服务建议通过类似Redis的简单文本协议提供服务,这样会比HTTP协议性能会更好。 + +Lua脚本引擎 + +虽然缓存提供业务服务能提高业务灵活度,但是这种方式也有很多缺点,最大的缺点就是业务修改后,我们需要重启服务才能够更新我们的逻辑。由于内存中保存了大量的数据,重启一次数据就需要繁琐的预热,同步代价很大。 + +为此,我们需要给设计再次做个升级。这种情况下,lua脚本引擎是个不错的选择。lua是一个小巧的嵌入式脚本语言,通过它可以实现一个高性能、可热更新的脚本服务,从而和嵌入的服务高效灵活地互动。 + +我画了一张示意图,描述了如何通过lua脚本来具体实现可编程缓存服务: + + + +如上图所示,可以看到我们提供了Kafka消费、周期任务管理、内存缓存、多种数据格式支持、多种数据驱动适配这些服务。不仅仅如此,为了减少由于逻辑变更导致的服务经常重启的情况,我们还以性能损耗为代价,在缓存服务里嵌入了lua脚本引擎,借此实现动态更新业务的逻辑。 + +lua引擎使用起来很方便,我们结合后面这个实现例子看一看,这是一个Go语言写的嵌入lua实现,代码如下所示: + +package main + +import "github.com/yuin/gopher-lua" + +// VarChange 用于被lua调用的函数 +func VarChange(L *lua.LState) int { + lv := L.ToInt(1) //获取调用函数的第一个参数,并且转成int + L.Push(lua.LNumber(lv * 2)) //将参数内容直接x2,并返回结果给lua + return 1 //返回结果参数个数 +} + +func main() { + L := lua.NewState() //新lua线程 + defer L.Close() //程序执行完毕自动回收 + + // 注册lua脚本可调用函数 + // 在lua内调用varChange函数会调用这里注册的Go函数 VarChange + L.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 main + +import ( + "fmt" + "github.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.Mutex + saved []*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获取一个LState + L := luaPool.Get() + //任务执行完毕后,将LState放回pool + defer 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 + // 用来告诉lua,method和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为 1 + person := &Person{L.CheckString(1)} + // 创建一个lua userdata对象用于传递数据 + // 一般 userdata包装的都是go的struct,table是lua自己的对象 + ud := L.NewUserData() + ud.Value = person //将 go struct 放入对象中 + // 设置这个lua对象类型为 person type + L.SetMetatable(ud, L.GetTypeMetatable(luaPersonTypeName)) + // 将创建对象返回给lua + L.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 { + //检测第一个参数是否为其他语言传递的userdata + ud := 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 LState + L := 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脚本的方式来加载服务。- +另外,gopher-lua还提供了模块功能,帮助我们更好地管理脚本和代码,有兴趣的话可以自行深入,参考资料在这里。 + +缓存预热与数据来源 + +了解了lua后,我们再看看服务如何加载数据。服务启动时,我们需要将数据缓存加载到缓存中,做缓存预热,待数据全部加载完毕后,再开放对外的API端口对外提供服务。 + +加载过程中如果用上了lua脚本,就可以在服务启动时对不同格式的数据做适配加工,这样做也能让数据来源更加丰富。 + +常见的数据来源是大数据挖掘周期生成的全量数据离线文件,通过NFS或HDFS挂载定期刷新、加载最新的文件。这个方式适合数据量大且更新缓慢的数据,缺点则是加载时需要整理数据,如果情况足够复杂,800M大小的数据要花1~10分钟方能加载完毕。 + +除了使用文件方式外,我们也可以在程序启动后扫数据表恢复数据,但这么做数据库要承受压力,建议使用专用的从库。但相对磁盘离线文件的方式,这种方式加载速度更慢。 + +上面两种方式加载都有些慢,我们还可以将 RocksDB 嵌入到进程中,这样做可以大幅度提高我们的数据存储容量,实现内存磁盘高性能读取和写入。不过代价就是相对会降低一些查询性能。 + +RocksDB的数据可以通过大数据生成RocksDB格式的数据库文件,拷贝给我们的服务直接加载。这种方式可以大大减少系统启动中整理、加载数据的时间,实现更多的数据查询。 + +另外,如果我们对于本地有关系数据查询需求,也可以嵌入 SQLite 引擎,通过这个引擎可以做各种关系数据查询,SQLite的数据的生成也可以通过工具提前生成,给我们服务直接使用。但你要注意这个数据库不要超过10w条数据,否则很可能导致服务卡顿。 + +最后,对于离线文件加载,最好做一个CheckSum一类的文件,用来在加载文件之前检查文件的完整性。由于我们使用的是网络磁盘,不太确定这个文件是否正在拷贝中,需要一些小技巧保证我们的数据完整性,最粗暴的方式就是每次拷贝完毕后生成一个同名的文件,内部记录一下它的CheckSum,方便我们加载前校验。 + +离线文件能够帮助我们快速实现多个节点的数据共享和统一,如果我们需要多个节点数据保持最终一致性,就需要通过离线+同步订阅方式来实现数据的同步。 + +订阅式数据同步及启动同步 + +那么,我们的数据是如何同步更新的呢? + +正常情况下,我们的数据来源于多个基础数据服务。如果想实时同步数据的更改,我们一般会通过订阅binlog将变更信息同步到Kafka,再通过Kafka的分组消费来通知分布在不同集群中的缓存。 + +收到消息变更的服务会触发lua脚本,对数据进行同步更新。通过lua我们可以触发式同步更新其他相关缓存,比如用户购买一个商品,我们要同步刷新他的积分、订单和消息列表个数。 + +周期任务 + +提到任务管理,不得不提一下周期任务。周期任务一般用于刷新数据的统计,我们通过周期任务结合lua自定义逻辑脚本,就能实现定期统计,这给我们提供了更多的便利。 + +定期执行任务或延迟刷新的过程中,常见的方式是用时间轮来管理任务,用这个方式可以把定时任务做成事件触发,这样能轻松地管理内存中的待触发任务列表,从而并行多个周期任务,无需使用sleep循环方式不断查询。对时间轮感兴趣的话,你可以点击这里查看具体实现。 + +另外,前面提到我们的很多数据都是通过离线文件做批量更新的,如果是一小时更新一次,那么一小时内新更新的数据就需要同步。 + +一般要这样处理:在我们服务启动加载的离线文件时,保存离线文件生成的时间,通过这个时间来过滤数据更新队列中的消息,等到我们的队列任务进度追到当前时间附近时,再开启对外数据的服务。 + +总结 + +读多写多的服务中,实时交互类服务非常多,对数据的实时性要求也很高,用集中型缓存很难满足服务所需。为此,行业里多数会通过服务内存数据来提供实时交互服务,但这么做维护起来十分麻烦,重启后需要恢复数据。为了实现业务逻辑无重启的更新,行业里通常会使用内嵌脚本的热更新方案。 + +常见的通用脚本引擎是lua,这是一个十分流行且方便的脚本引擎,在行业中,很多知名游戏及服务都使用lua来实现高性能服务的定制化业务功能,比如Nginx、Redis等。 + +把lua和我们的定制化缓存服务结合起来,即可制作出很多强大的功能来应对不同的场景。由于lua十分节省内存,我们在进程中开启成千上万的lua小线程,甚至一个用户一个LState线程对客户端提供状态机一样的服务。 + +用上面的方法,再结合lua和静态语言交换数据相互调用,并配合上我们的任务管理以及各种数据驱动,就能完成一个几乎万能的缓存服务。推荐你在一些小项目中亲自实践一下,相信会让你从不同视角看待已经习惯的服务,这样会有更多收获。 + +思考题 + +如何让Go的协程访问一个LState保存的数据? + +欢迎你在留言区与我交流讨论,我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/18流量拆分:如何通过架构设计缓解流量压力?.md b/专栏/高并发系统实战课/18流量拆分:如何通过架构设计缓解流量压力?.md new file mode 100644 index 0000000..c63612d --- /dev/null +++ b/专栏/高并发系统实战课/18流量拆分:如何通过架构设计缓解流量压力?.md @@ -0,0 +1,158 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 18 流量拆分:如何通过架构设计缓解流量压力? + 你好,我是徐长龙。 + +今天,我会以直播互动为例,带你看看读多写多的情况下如何应对流量压力。- +一般来说,这种服务多数属于实时互动服务,因为时效性要求很高,导致很多场景下,我们无法用读缓存的方式来降低核心数据的压力。所以,为了降低这类互动服务器的压力,我们可以从架构入手,做一些灵活拆分的设计改造。 + +事实上这些设计是混合实现对外提供服务的,为了让你更好地理解,我会针对直播互动里的特定的场景进行讲解。一般来说,直播场景可以分为可预估用户量和不可预估用户量的场景,两者的设计有很大的不同,我们分别来看看。 + +可预估用户量的服务:游戏创建房间 + +相信很多玩对战游戏的伙伴都有类似经历,就是联网玩游戏要先创建房间。这种设计主要是通过设置一台服务器可以开启的房间数量上限,来限制一台服务器能同时服务多少用户。 + +我们从服务器端的资源分配角度分析一下,创建房间这个设计是如何做资源调配的。创建房间后,用户通过房间号就可以邀请其他伙伴加入游戏进行对战,房主和加入的伙伴,都会通过房间的标识由调度服务统一分配到同一服务集群上进行互动。 + +这里我提示一下,开房间这个动作不一定需要游戏用户主动完成,可以设置成用户开启游戏就自动分配房间,这样做不但能提前预估用户量,还能很好地规划和掌控我们的服务资源。 + +如何评估一个服务器支持多少人同时在线呢? + +我们可以通过压测测出单台服务器的服务在线人数,以此精确地预估带宽和服务器资源,算出一个集群(集群里包括若干服务器)需要多少资源、可以承担多少人在线进行互动,再通过调度服务分配资源,将新来的房主分配到空闲的服务集群。 + +最后的实现效果如下所示: + + + +如上图所示,在创建房间阶段,我们的客户端在进入区域服务器集群之前,都是通过请求调度服务来进行调度的。调度服务器会定期接收各组服务器的服务用户在线情况,以此来评估需要调配多少用户进入到不同区域集群;同时客户端收到调度后,会拿着调度服务给的token去不同区域申请创建房间。 + +房间创建后,调度服务会在本地集群内维护这个房间的列表和信息,提供给其他要加入游戏的玩家展示。而加入的玩家同样会接入对应房间的区域服务器,与房主及同房间玩家进行实时互动。 + +这种通过配额房间个数来做服务器资源调度的设计,不光是对战游戏里,很多场景都用了类似设计,比如在线小课堂这类教学互动的。我们可以预见,通过这个设计能够对资源做到精准把控,用户不会超过我们服务器的设计容量。 + +不可预估用户量的服务 + +但是,有很多场景是随机的,我们无法把控有多少用户会进入这个服务器进行互动。 + +全国直播就无法确认会有多少用户访问,为此,很多直播服务首先按主播过往预测用户量。通过预估量,提前将他们的直播安排到相对空闲的服务器群组里,同时提前准备一些调度工具,比如通过控制曝光度来延缓用户进入直播,通过这些为服务器调度争取更多时间来动态扩容。 + +由于这一类的服务无法预估会有多少用户,所以之前的服务器小组模式并不适用于这种方式,需要更高一个级别的调度。 + +我们分析一下场景,对于直播来说,用户常见的交互形式包括聊天、答题、点赞、打赏和购物,考虑到这些形式的特点不同,我们针对不同的关键点依次做分析。 + +聊天:信息合并 + +聊天的内容普遍比较短,为了提高吞吐能力,通常会把用户的聊天内容放入分布式队列做传输,这样能延缓写入压力。 + +另外,在点赞或大量用户输入同样内容的刷屏情境下,我们可以通过大数据实时计算分析用户的输入,并压缩整理大量重复的内容,过滤掉一些无用信息。 + + + +压缩整理后的聊天内容会被分发到多个聊天内容分发服务器上,直播间内用户的聊天长连接会收到消息更新的推送通知,接着客户端会到指定的内容分发服务器群组里批量拉取数据,拿到数据后会根据时间顺序来回放。请注意,这个方式只适合用在疯狂刷屏的情况,如果用户量很少可以通过长链接进行实时互动。 + +答题:瞬时信息拉取高峰 + +除了交互流量极大的聊天互动信息之外,还有一些特殊的互动,如做题互动。直播间老师发送一个题目,题目消息会广播给所有用户,客户端收到消息后会从服务端拉取题目的数据。 + +如果有10w用户在线,很有可能导致瞬间有10w人在线同时请求服务端拉取题目。这样的数据请求量,需要我们投入大量的服务器和带宽才能承受,不过这么做这个性价比并不高。 + +理论上我们可以将数据静态化,并通过CDN阻挡这个流量,但是为了避免出现瞬时的高峰,推荐客户端拉取时加入随机延迟几秒,再发送请求,这样可以大大延缓服务器压力,获得更好的用户体验。 + +切记对于客户端来说,这种服务如果失败了,就不要频繁地请求重试,不然会将服务端打沉。如果必须这样做,那么建议你对重试的时间做退火算法,以此保证服务端不会因为一时故障收到大量的请求,导致服务器崩溃。 + +如果是教学场景的直播,有两个缓解服务器压力的技巧。第一个技巧是在上课当天,把抢答题目提前交给客户端做预加载下载,这样可以减少实时拉取的压力。 + +第二个方式是题目抢答的情况,老师发布题目的时候,提前设定发送动作生效后5秒再弹出题目,这样能让所有直播用户的接收端“准时”地收到题目信息,而不至于出现用户题目接收时间不一致的情况。 + +至于非抢答类型的题目,用户回答完题目后,我们可以先在客户端本地先做预判卷,把正确答案和解析展示给用户,然后在直播期间异步缓慢地提交用户答题结果到服务端,以此保证服务器不会因用户瞬时的流量被冲垮。 + +点赞:客户端互动合并 + +对于点赞的场景,我会分成客户端和服务端两个角度带你了解。 + +先看客户端,很多时候,客户端无需实时提交用户的所有交互,因为有很多机械的重复动作对实时性要求没那么高。 + +举个例子,用户在本地狂点了100下赞,客户端就可以合并这些操作为一条消息(例如用户3秒内点赞10次)。相信聪明如你,可以把互动动作合并这一招用在更多情景,比如用户连续打赏100个礼物。 + +通过这个方式可以大幅度降低服务器压力,既可以保证直播间的火爆依旧,还节省了大量的流量资源,何乐而不为。 + +点赞:服务端树形多层汇总架构 + +我们回头再看看点赞的场景下,如何设计服务端才能缓解请求压力。 + +如果我们的集群QPS超过十万,服务端数据层已经无法承受这样的压力时,如何应对高并发写、高并发读呢?微博做过一个类似的案例,用途是缓解用户的点赞请求流量,这种方式适合一致性要求不高的计数器,如下图所示: + + + +这个方式可以将用户点赞流量随机压到不同的写缓存服务上,通过第一层写缓存本地的实时汇总来缓解大量用户的请求,将更新数据周期性地汇总后,提交到二级写缓存。 + +之后,二级汇总所在分片的所有上层服务数值后,最终汇总同步给核心缓存服务。接着,通过核心缓存把最终结果汇总累加起来。最后通过主从复制到多个子查询节点服务,供用户查询汇总结果。 + +另外,说个题外话,微博是Redis重度用户,后来因为点赞数据量太大,在Redis中缓存点赞数内存浪费严重(可以回顾上一节课 jmalloc兄弟算法的内容),改为自行实现点赞服务来节省内存。 + +打赏&购物:服务端分片及分片实时扩容 + +前面的互动只要保证最终一致性就可以,但打赏和购物的场景下,库存和金额需要提供事务一致性的服务。 + +因为事务一致性的要求,这种服务我们不能做成多层缓冲方式提供服务,而且这种服务的数据特征是读多写多,所以我们可以通过数据分片方式实现这一类服务,如下图: + + + +看了图是不是很好理解?我们可以按用户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临时调度用户流量等服务,来实现动态的资源调配。 + + +思考题 + +既然CDN能够缓存我们的静态数据,那么它是如何识别到我们本地的静态数据有更新的呢? + +欢迎你在评论区与我交流讨论,我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/19流量调度:DNS、全站加速及机房负载均衡.md b/专栏/高并发系统实战课/19流量调度:DNS、全站加速及机房负载均衡.md new file mode 100644 index 0000000..3216918 --- /dev/null +++ b/专栏/高并发系统实战课/19流量调度:DNS、全站加速及机房负载均衡.md @@ -0,0 +1,194 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 19 流量调度:DNS、全站加速及机房负载均衡 + 你好,我是徐长龙。 + +上节课我们学习了如何从架构设计上应对流量压力,像直播这类的服务不容易预估用户流量,当用户流量增大到一个机房无法承受的时候,就需要动态调度一部分用户到多个机房中。 + +同时,流量大了网络不稳定的可能性也随之增加,只有让用户能访问就近的机房,才能让他们的体验更好。 + +综合上述考量,这节课我们就重点聊聊流量调度和数据分发的关键技术,帮你弄明白怎么做好多个机房的流量切换。 + +直播服务主要分为两种流量,一个是静态文件访问,一个是直播流,这些都可以通过CDN分发降低我们的服务端压力。 + +对于直播这类读多写多的服务来说,动态流量调度和数据缓存分发是解决大量用户在线互动的基础,但是它们都和DNS在功能上有重合,需要一起配合实现,所以在讲解中也会穿插CDN的介绍。 + +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内网用最短最快的网络链路,将用户请求转发到我们的机房服务器。 + +相比客户端从外省经由多个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是全局流量管理系统的简称。我画了一张工作原理图帮你加深理解: + + + +当客户端请求服务域名时,客户端先会请求DNS服务解析请求的域名。而客户端请求主域名DNS服务来解析域名时,会请求到 GTM服务的智能解析DNS。 + +相比传统技术,GTM还多了三个功能:服务健康监控、多线路优化和流量负载均衡。 + +首先是服务健康监控功能。GTM会监控服务器的工作状态,如果发现机房没有响应,就自动将流量切换到健康的机房。在此基础上,GTM还提供了故障转移功能,也就是根据机房能力和权重,将一些用户流量转移到其他机房。 + +其次是多线路优化功能,国内宽带有不同的服务提供商(移动、联通、电信、教育宽带),不同的宽带的用户访问同提供商的网站入口IP性能最好,如果跨服务商访问会因为跨网转发会加大请求延迟。因此,使用GTM可以根据不同机房的CDN来源,找到更快的访问路径。 + +GTM还提供了流量负载均衡功能,即根据监控服务的流量及请求延迟情况来分配流量,从而实现智能地调度客户端的流量。 + +当GTM和CDN网站加速结合后会有更好的效果,具体组合方式如下图所示: + + + +由于GTM和CDN加速都是用了CNAME做转发,我们可以先将域名指向CDN,通过CDN的GSLB和内网为客户端提供网络加速服务。而在CDN回源时请求会转发到GTM解析,经过GTM解析DNS后,将CDN的流量转发到各个机房做负载均衡。 + +当我们机房故障时,GTM会从负载均衡列表快速摘除故障机房,这样既满足了我们的网络加速,又实现了多机房负载均衡及更快的故障转移。 + +不过即使使用了CDN+GTM,还是会有一批用户出现网络访问缓慢现象,这是因为很多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,即可实现业务无感的机房切换。 + +思考题 + +视频、WebSocket这一类长链接如何动态切换机房? + +欢迎你在留言区与我交流讨论,我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/20数据引擎:统一缓存数据平台.md b/专栏/高并发系统实战课/20数据引擎:统一缓存数据平台.md new file mode 100644 index 0000000..a71896d --- /dev/null +++ b/专栏/高并发系统实战课/20数据引擎:统一缓存数据平台.md @@ -0,0 +1,166 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 20 数据引擎:统一缓存数据平台 + 你好,我是徐长龙。 + +通过前四章,我们已经了解了不同类型的系统如何优化,其中有哪些关键技术点。不过除了这些基础知识之外,我们还要了解大型互联网是如何设计支撑一个高并发系统的。所以,在这个章节里我精选了几个案例,帮助你打开视野,看看都有哪些实用的内网服务设计。 + +任何一个互联网公司都会有几个核心盈利的业务,我们经常会给基础核心业务做一些增值服务,以此来扩大我们的服务范围以及构建产业链及产业生态,但是这些增值服务需要核心项目的数据及交互才能更好地提供服务。 + +但核心系统如果对增值业务系统做太多的耦合适配,就会导致业务系统变得十分复杂,如何能既让增值服务拿到核心系统的资源,又能减少系统之间的耦合? + +这节课我会重点带你了解一款内网主动缓存支撑的中间件,通过这个中间件,可以很方便地实现高性能实体数据访问及缓存更新。 + +回顾临时缓存的实现 + +我们先回顾下之前展示的临时缓存实现,这个代码摘自之前的第二节课。 + +// 尝试从缓存中直接获取用户信息 +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来监控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及缓存查询服务实现热点数据的识别,即可实现多级缓存服务。 + +可以说,数据是我们系统的心脏,如数据引擎能力足够强大,能做的事情会变得更多。数据支撑平台最大的特点在于,将我们的数据和各种数据引擎结合起来,从而实现更强大的数据服务能力。 + +大公司的核心系统通常会用多引擎组合的方式,共同提供数据支撑数据服务,甚至有些服务的服务端只需做配置就可以得到这些功能,这样业务实现更轻量,能给业务创造更广阔的增值空间。 + +思考题 + +L1缓存使用BloomFilter来减少L1缓存查询,那么BloomFilter的hash列表如何更新到客户端呢? + +欢迎你在留言区与我交流讨论,我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/21业务缓存:元数据服务如何实现?.md b/专栏/高并发系统实战课/21业务缓存:元数据服务如何实现?.md new file mode 100644 index 0000000..47a183d --- /dev/null +++ b/专栏/高并发系统实战课/21业务缓存:元数据服务如何实现?.md @@ -0,0 +1,177 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 21 业务缓存:元数据服务如何实现? + 你好,我是徐长龙。 + +当你随手打开微博或者一个综合的新闻网站,可以看到丰富的媒体文件,图片、文本、音频、视频应有尽有,一个页面甚至可能是由成百上千个文件组合而成。 + +那这些文件都存在哪里呢?通常来说,低于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偏向服务器的服务,分布式存储偏向存储文件的管理,而对象存储偏向业务的应用。 + +思考题 + +分布式存储通过文件块作为单位来保存管理小文件,当我们对文件内容进行更新时,如何刷新这个文件的内容呢? + +今天的这节课就到这里,期待和你在留言区里交流。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/22存储成本:如何推算日志中心的实现成本?.md b/专栏/高并发系统实战课/22存储成本:如何推算日志中心的实现成本?.md new file mode 100644 index 0000000..cb09239 --- /dev/null +++ b/专栏/高并发系统实战课/22存储成本:如何推算日志中心的实现成本?.md @@ -0,0 +1,232 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 22 存储成本:如何推算日志中心的实现成本? + 你好,我是徐长龙。 + +前面我们比较过很多技术,细心的你应该发现了,比较时我们常常会考虑实现成本这一项。这是因为技术选型上的“斤斤计较”,能够帮我们省下真金白银。那么你是否系统思考过,到底怎么计算成本呢? + +这节课,我会结合日志中心的例子带你计算成本。 + +之所以选日志中心,主要有这两方面的考虑:一方面是因为它重要且通用,作为系统监控的核心组件,几乎所有系统监控和故障排查都依赖日志中心,大部分的系统都用得上;另一方面日志中心是成本很高的项目,计算也比较复杂,如果你跟着我把课程里的例子拿下了,以后用类似思路去计算其他组件也会容易很多。 + +根据流量推算存储容量及投入成本 + +在互联网服务中,最大的变数就在用户流量上。相比普通的服务,高并发的系统需要同时服务的在线人数会更多,所以对这类系统做容量设计时,我们就需要根据用户请求量和同时在线人数,来推算系统硬件需要投入多少成本。 + +很多系统在初期会用云服务实现日志中心,但核心接口流量超过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元,顺序写性能大概能达到3~5GB/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/s(10000mbps/8bit = 1250MB/s),同样实际速度无法达到理论值,实际能跑到 900MB/s 左右,即 7200 mbps。 + +再回头来看,之前提到的高峰期日志的数据吞吐量是多少呢?是这样计算的: + +30W QPS * 1KB = 292.96MB/s + +刚才说了,千兆网卡速度是100MB/s,这样四台服务器分摊勉强够用。但如果出现多倍的流量高峰还是不够用,所以还是要升级下网络设备,也就是换万兆网卡。 + +不过万兆网卡要搭配更好的三层交换机使用,才能发挥性能,最近几年已经普及这种交换机了,也就是基础建设里就包含了交换机的成本,所以这里不再专门计算它的投入成本。 + +先前计算硬件成本时,我们说过每组服务器要存三个副本,这样算起来有三块万兆光口网卡就足够了。但是为了稳定,我们不会让网卡跑满来对外服务,最佳的传输速度大概保持在 300~500 MB/s就可以了,其他剩余带宽留给其他服务或应急使用。这里推荐一个限制网络流量的配置——QoS,你有兴趣可以课后了解下。 + +12台服务器分3组副本(每个副本存一份全量数据),每组4台服务器,每台服务器配置1块万兆网卡,那么每台服务器平时的网络吞吐流量就是: + +292.96MB/s (高峰期日志的数据吞吐量) / 4台服务器 = 73MB/S + +可以说用万兆卡只需十分之一,即可满足日常的日志传输需求,如果是千兆网卡则不够。看到这你可能有疑问,千兆网卡速度不是100MB/s,刚才计算吞吐流量是73MB/s,为什么说不够呢? + +这是因为我们估算容量必须留有弹性,如果用千兆网卡,其实是接近跑满的状态,一旦稍微有点波动就会卡顿,严重影响到系统的稳定性。 + +另一方面,实际使用的时候,日志中心不光是满足基础的业务使用,承担排查问题的功能,还要用来做数据挖掘分析,否则投入这么大的成本建设日志中心,就有些得不偿失了。 + +我们通常会利用日志中心的闲置资源,用做限速的大数据挖掘。联系这一点,相信你也就明白了,我们为什么要把日志保存三份。其实目的就是通过多个副本来提高并发计算能力。不过,这节课我们的重点是演示如何计算成本,所以这里就点到为止了,有兴趣的话,你可以课后自行探索。 + +总结 + +这节课我们主要讨论了如何通过请求用户量评估出日志量,从而推导计算出需要多少服务器和费用。 + + + +你可以先自己思考一下,正文里的计算过程还有什么不足。 + +其实,这个计算只是满足了业务现有的流量。现实中做估算会更加严谨,综合更多因素,比如我们在拿到当前流量的计算结果后,还要考虑后续的增长。这是因为机房的空间有限,如果我们不能提前半年规划出服务器资源情况,之后一旦用户流量增长了,却没有硬件资源,就只能“望洋兴叹”,转而用软件优化方式去硬扛突发de 情况。 + +当然了,根据流量计算硬盘和服务器的投入,只是成本推算的一种思路。如果是大数据挖掘,我们还需要考虑CPU、内存、网络的投入以及系统隔离的成本。 + +不同类型的系统,我们的投入侧重点也是不一样的。比如读多写少的服务要重点“堆“内存和网络;强一致服务更关注系统隔离和拆分;写多读少的系统更加注重存储性能优化;读多写多的系统更加关注系统的调度和系统类型的转变。 + +尽管技术决策要考虑的因素非常多,我们面临的业务和团队情况也各有不同。但通过这节课,我希望能让你掌握成本推算的思维,尝试结合计算来指导我们的计算决策。当你建议团队自建机房,或者建议选择云服务的时候,如果有一套这样的计算做辅助,相信方案通过的概率也会有所提升。 + +思考题 + +1.建设日志中心,使用云厂商的服务贵还是自己建设的贵? + +2.大数据挖掘服务如何计算成本? + +期待你在留言区和我交流互动,也推荐你把这节课分享给更多同事、朋友。我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/23网关编程:如何通过用户网关和缓存降低研发成本?.md b/专栏/高并发系统实战课/23网关编程:如何通过用户网关和缓存降低研发成本?.md new file mode 100644 index 0000000..c173ff7 --- /dev/null +++ b/专栏/高并发系统实战课/23网关编程:如何通过用户网关和缓存降低研发成本?.md @@ -0,0 +1,150 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 23 网关编程:如何通过用户网关和缓存降低研发成本? + 你好,我是徐长龙。 + +如果说用户的流量就像波涛汹涌的海浪,那网关就是防御冲击的堤坝。在大型的互联网项目里,网关必不可少,是我们目前最好用的防御手段。通过网关,我们能把大量的流量分流到各个服务上,如果配合使用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方式,以及通过用户中心接口鉴权的效果:- + + +如上图,集成SDK可以让业务自行校验用户身份而无需请求用户中心,但是SDK会有多个版本,后续用户中心升级会碰到很大阻力,因为要兼顾我们所有的“用户”业务。 + +SDK属于植入对方项目内的组件,为了确保稳定性,很多项目不会频繁升级修改组件的版本,这导致了用户中心很难升级。每一次基础服务的大升级,都需要大量的人力配合同步更新服务的SDK,加大了项目的维护难度。 + +那么除了使用SDK以外,还有什么方式能够避免这种组件的耦合呢?这里我分享一种有趣的设计,那就是把用户登陆鉴权的功能放在网关。 + +我用画图的方式描述了请求过程,你可以对照示意图听我继续分析。 + + + +结合上图,我们来看看这个实现的工作流程。用户业务请求发到业务接口时,首先会由网关来鉴定请求用户的身份。 + +如果鉴定通过,用户的信息就会通过header传递给后面的服务,而业务的API无需关注用户中心的实现细节,只需接收header中附带的用户信息即可直接工作。如果业务上还要求用户必须登录才能使用,我们可以在业务中增加一个对请求header是否有uid的判断。如果没有uid,则给前端返回统一的错误码,提醒用户需要先登陆。 + +不难看出,这种鉴权服务的设计,解耦了业务和用户中心这两个模块。用户中心如果有逻辑变更,也不需要业务配合升级。 + +除了常见的登陆鉴权外,我们可以对一些域名开启RBAC服务,根据不同业务的需要定制不同的RBAC、ABAC服务,并且通过网关对不同的用户开启不同的权限以及灰度测试等功能。 + +内网网关服务 + +了解了外网的两种妙用,我们再看看内网的功能。它可以提供失败重试服务和平滑重启机制,我们分别来看看。 + +失败重试 + +当我们的项目发布升级期间需要重启,或者发生崩溃的故障,服务会短暂不可用。这时如果有用户发出服务请求,会因为后端没有响应返回504错误,这样用户体验很不好。 + +面对这种情况,我们可以利用内网网关的自动重试功能,这样在请求发到后端,并且服务返回500、403或504错误时,网关不会马上返回错误,而是让请求等待一会儿后,再次重试,或者直接返回上次的缓存内容。这样就能实现业务热更新的平滑升级,让服务看起来更稳定,用户也不会对线上升级产生明显感知。 + +平滑重启 + +接下来,我再说说平滑重启的机制。 + +在我们的服务升级时,可以不让服务进程收到kill信号后直接退出,而是制作平滑重启功能,即先让服务停止接收新的请求,等待之前的请求处理完成,如果等待超过10秒则直接退出。 + + + +通过这个机制,用户请求处理就不会被中断,这样就能保证正在处理中的业务事务是完整的,否则很有可能会导致业务事务不一致,或只做了一半的情况。 + +有了这个重试和平滑重启的机制后,我们可以随时在线升级发布我们的代码,发布新的功能。不过开启这个功能后,可能会屏蔽一些线上的故障,这时候可以配合网关服务的监控,来帮我们检测系统的状态。 + +内外网关综合应用 + +前面我们说了外网网关和内网网关独立提供的功能,接下来我们再看看二者的综合应用。 + +服务接口缓存 + +首先来看网关接口缓存功能,也就是利用网关实现一些接口返回内容的缓存,适合用在服务降级场景,用它短暂地缓解用户流量的冲击,或者用于降低内网流量的冲击。 + +具体实现如下图所示: + + + +结合上图,我们可以看到,网关实现的缓存基本都是用临时缓存 + TTL 方式实现的。当用户请求服务端时,被缓存的API如果之前已经被请求过,并且缓存还没有过期的话,就会直接返回缓存内容给客户端。这个方式能大大降低后端的数据服务压力。 + +不过每一种技术选择,都是反复权衡的结果,这个方式是牺牲了数据的强一致性才实现的。另外,这个方式对缓存能力的性能要求比较高,必须保证网关缓存可以扛得住外网流量的QPS。 + +如果想预防穿透流量过多,也可以通过脚本定期刷新缓存数据,网关查到相关缓存就直接返回,如果没有命中,才会将真正请求到服务器后端服务上并缓存结果。这样实现的方式更加灵活,数据的一致性会更好,只是实现起来需要人力去写好维护代码。- + + +当然这种缓存的数据长度建议不超过5KB(10w 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还是用内网网关,一直处于激烈讨论的阶段。而在我看来,随着容器化的流行,内网网关会迎来新的变革。服务发现、服务鉴权、流量调度、数据缓存、服务高可用、服务监控这些服务,最终会统一成一套标准。如果现有的内网网关能降低复杂度,未来会更胜一筹。 + +思考题 + +为什么内网都在用网关或实现服务发现功能,而不用内网DNS服务来实现这一功能呢? + +期待你在留言区和我交流互动,也推荐你把这节课分享给更多同事、朋友。我们下节课见! + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/24性能压测:压测不完善,效果减一半.md b/专栏/高并发系统实战课/24性能压测:压测不完善,效果减一半.md new file mode 100644 index 0000000..51bb3e2 --- /dev/null +++ b/专栏/高并发系统实战课/24性能压测:压测不完善,效果减一半.md @@ -0,0 +1,166 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 24 性能压测:压测不完善,效果减一半 + 你好,我是徐长龙。 + +之前我们讨论了很多高并发改造思路和设计。 + +高并发的系统很复杂,所以对这样的系统做并发优化也相当有挑战。很多服务的局部优化,不见得能真正优化整体系统的服务效果,甚至有的尝试还会适得其反,让服务变得不稳定。 + +在这种情况下,压测就显得更加重要了。通常来说,通过压测可以帮我们做很多事儿,比如确认单个接口、单台服务器、单组服务集群甚至是整个机房整体的性能,方便我们判断服务系统的瓶颈在哪里。而且根据压测得出的结果,也能让我们更清晰地了解系统能够承受多少用户同时访问,为限流设置提供决策依据。 + +这节课,我们就专门聊聊性能压测里,需要考虑哪些关键因素。 + +压测与架构息息相关 + +在压测方面,我们很容易踩的一个坑就是盲目相信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并不一定能够反映我们的优化是否合理,这一点需要结合业务架构来综合评估。 + +我们来回顾一下课程里讲过的几个典型例子: + + +并行请求依赖服务优化成串行请求的服务,虽然能够提高接口的响应速度,但是会让内网压力更大; +临时缓存服务虽然能降低内网重复查询的压力,但如果是低频率数据访问,那么优化效果就很一般; +分片架构的服务压测时需要注意单片热点的问题,不然压测虽然表现良好,线上运转却可能会出问题。 +受参与计算的数据量影响大的接口,要尤其注意真实系统环境和极端数据量的测试。 + + +除了对并行请求、临时缓存、分片架构、数据量这几个点做验证以外,还建议做一些极端测试,对服务的稳定性进行评估。数据量较多的接口,压测时要时刻关注相关数据库压力及索引、缓存的命中率情况,预防数据库出现压力过大、响应缓慢的问题。 + +另外,我们要在人少的时候停机做线上环境压测,但是要预防压测期间产生的垃圾数据,这里可以用影子库方式解决,不过这需要所有业务配合,需要提前做好协调确认。 + +最后,相比单接口的压测,为了尽量模拟线上真实情况,我带你了解了两种更综合的压测方式,分别是全链路压测和流量回放测试。 + +思考题 + +如何保证上线前的单元测试里,测试生产的数据不会污染线上环境? + +期待你在留言区和我交流互动,也推荐你把这节课分享给更多同事、朋友。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/答疑课堂思考题答案(一).md b/专栏/高并发系统实战课/答疑课堂思考题答案(一).md new file mode 100644 index 0000000..c3a73ed --- /dev/null +++ b/专栏/高并发系统实战课/答疑课堂思考题答案(一).md @@ -0,0 +1,151 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 答疑课堂 思考题答案(一) + 你好,我是编辑小新。 + +今天是一节答疑课。我们的专栏已经步入尾声。除了紧跟更新节奏的第一批同学,也很开心看到有更多新朋友加入到这个专栏的学习中。 + +很多同学的留言也是这门课的亮丽风景,给专栏增色了不少。大部分的疑问,老师都在留言区里做了回复,期待更多同学在留言区里分享经验,提出问题或尝试解答他人的疑问,我们来共建一个共同学习、积极交流的良好氛围。 + +为了给你留下足够的思考和研究的时间,我们选择用加餐的方式,公布每节课的参考答案,也会精选一些优秀同学的答案展示出来。这里要提醒一下,建议你先做了自己的思考后,再核对答案。另外每节课都有超链接,方便你跳转回顾。 + +第一节课 + +Q:请你思考一下,用户邀请其他用户注册的记录,属于历史记录还是关系记录? + +A:用户邀请其他用户注册的记录,我认为属于关系记录。 + +虽然这种记录有历史记录特征,但是被邀请注册的用户只能被邀请一次,所以总量是可控的。同时,这种表的用途很明确,表内记录的是关系记录,查询时会按邀请人或被邀请人uid进行查询。 + +留言区里也有不少精彩的答案,推荐你去看看。比如@移横为固的答案,这里我也复制过来: + + +一开始觉得注册邀请表应该作为历史表。思考了下作为关系表也是可以的。 + +在满足下面的注册邀请前提下: + +1.邀请人用类似二维码分享方式,注册人主动扫码注册(不使用点对点邀请,被邀请人可能不接受)。 + +2.只能注册成功一次。- +这样每一条邀请记录都是一个用户的注册记录:可以定义如下字段:(邀请者,注册人,注册时间,邀请方式)。- +表的字段结构都非常简单,记录的总量最多就是账号量,并不会随时间不断膨胀。因此可以胜任关系表的查询需求。 + +在实际项目中,我们会遇到很多类似情况,需要我们预防超出预期的操作,核心在于我们怎么约束使用表的人,以及我们要怎么用表里的数据。 + + +第二节课 + +Q1:使用BloomFilter识别热点key时,有时会识别失误,进而导致数据没有找到,那么如何避免这种情况呢? + +A1:有一个特殊方法能降低概率,原始key通过BloomFilter 检测一次,md5后再通过另外一个BloomFilter再测一次。 + +Q2:使用BloomFilter只能添加新key,不能删除某一个key,如果想更好地更新维护,有什么其他方式吗? + +A2:请参考Redis的Cuckoo Filter的实现。 + +第三节课 + +Q:用户如果更换了昵称,如何快速更换token中保存的用户昵称呢? + +A:在更换用户昵称,同时更换修改端的token。如果我们的用户有多个客户端,那么可以利用缓存更新提及的Version版本号,让客户端定期检测判断token是否需要更换。 + +对于这个问题,置顶留言里@徐曙辉同学的回答也很有趣: + + +如果我来做快速更换昵称的功能,有两种方式: + +a.在用户修改昵称后,内存中加入一个用户标识,解析token后读取该标识,有则返回特定code,让客户端重新拿token。甚至可以不用客户端参与,返回301重定向到获取新token的路由。 + +b. token里面不存用户信息,只存用户ID,需要用户信息的时候从缓存读。 + + +徐同学的第一个解法很暴力,但是很有趣。 + +第二个方式也很有意思,这里我也补一个应用技巧:我们可以通过设定固定网址 user/用户uid/heaer.jpg方式,直接获取用户头像,这样也不用考虑更新问题了。 + +围绕着我的补充,这个话题还有后续讨论,我也一并展示出来,仍然是徐同学的回答: + + +按这样做,头像可以http://xx.com/user/用户ID/header.jpg,静态文件可以,因为反正都是远程http渲染。但是昵称和其他信息都这样处理,每一项都放到远程地址性能不是很好,是不是可以http://xxx.com/user/用户ID/info.json,再反序列化呢? + +这样确定是占了额外的存储空间,优点是不用查DB和缓存,减少它们的压力,在Web应用中,用户信息读取挺频繁。 + + +我认为这个思路很优秀,建议尽量使用对象存储做。关于对象存储的话题,你还可以参考第二十一节课的内容,我在里面详细分享了对象存储如何管理小文件和大文本。 + +第四节课 + +Q:如果 Otter 同步的链路是环形的,那么如何保证数据不会一直循环同步下去? + +A:Otter在事务头尾插入同步标识,解析时会通过这个方式防止发起方再执行同样事务。 + +第五节课 + +Q:请你思考一下,为什么Raft集群成员增减需要特殊去做? + +A:这是一个复杂的话题,核心在于,增减成员在加入后需要同步数据,并且会参与竞选。我觉得后面这篇文章分析得相对完整,你可以点击这里查看原文。 + +第六节课 + +Q:这节课中的有些概念与 DDD 是重合的,但是仍有一些细小的差异,请你对比一下 MVC 三层方式和 DDD 实现的差异。 + +A:这个问题没有标准答案。我们结合同学的回答一起看看。 + +@Geek_994f3b同学的回答是这样的: + + +我个人觉得两者只是作用域范围不同,从程序的角度看,MVC模式用在线程间(单体应用),而DDD用在进程间(微服务),那么MVC + RPC协议 + 业务拆分 ≈ DDD(个人愚见:),像是在单体上多套了一层。 + + +@徐曙辉同学的回答是这样的: + + +MVC是项目目录功能分层设计,偏框架,而DDD更多是业务实体领域和彼此之间的关系,偏业务。 + + +再补充一下我的想法,建议结合贫血模型和充血模型区别,以及领域模型和Service的区别来考虑这个问题。 + +第七节课 + +Q1:请你思考一下,通过原子操作+拆开库存方式实现库存方案时,如何减少库存为0 后接口缓慢的问题? + +A1:我们可以再设置一个key,标注还有哪些key有库存。 + +Q2:我们这节课的内容并不仅仅在讲库存,还包含了大量可实现的锁的使用方式,请你分享一些实践过程中常见但不容易被发现的精妙设计。 + +A2:这道题没有标准答案,希望你做一个有心人,在实践中多多关注各种锁的有趣设计。 + +第八节课 + +Q:用什么方法能够周期检查出两个系统之间不同步的数据? + +A:在数据上增加修改时间或版本号,每次更新的时候同步更新版本号,通过版本号能够很好地帮助我们识别哪个数据是最新的。 + +我们再看看@LecKeyt同学的回答: + + +每条数据都有唯一的数据标识(一般是自增id,或者有规律一串数字唯一id),而且一般都是小到大,根据这个最大值应该就能判断出来。如果数据不同步应该找到对应数据节点做补偿操作。 + + +看到他的回答后,我又追加了一个提问“如何避免更新操作同一条数据”?你也可以自行思考一下,再继续往下看。 + +后面的回答同样来自@LecKeyt同学: + + +更新带来的数据不一致的情况,我个人认为要看具体业务,如果是实时性要求不高的可以用事件队列处理,如果要求强一致性那最好的方式应该是分布式事务保证了。 + + +第九节课 + +Q:现在市面上有诸多分布式实现方式,你觉得哪一种性能更好? + +A:建议考虑使用AT或Seata方式。 + +以上就是用户中心和电商系统这两个章节的思考题答案,希望能带给你一些启发。接下来,老师还会针对剩余的课后思考题,以及你的提问来作出解答。有任何问题,还是跟以前一样,欢迎你在留言区多多互动。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统实战课/结束语为者常成,行者常至.md b/专栏/高并发系统实战课/结束语为者常成,行者常至.md new file mode 100644 index 0000000..52cc7e5 --- /dev/null +++ b/专栏/高并发系统实战课/结束语为者常成,行者常至.md @@ -0,0 +1,84 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 结束语 为者常成,行者常至 + 你好,我是徐长龙。 + +今天是2022年的最后一个月,我们也来到了这门课的最后一讲,很荣幸能陪你度过这几个月的学习时间。感谢你的一路相伴,有很多伙伴的留言的内容很有趣,也给我带来了一些启发。 + +高并发系统里每一种优化技术,后面都蕴含着许多优秀的思想,这些内容提炼出来是一个很耗精力的过程。 + +这半年以来,每天晚上我不是在写稿、改稿,就是在查资料,忙到12点也是常态。说实话,写这个专栏远比我想象中更难,需要不断整理自己的思路、优化表达,还要反复查证各类资料料,尽量保证讲到的知识准确无误。 + +在实践中,我们做系统优化常常要综合考虑、多次试错,才有可能找到适合的解决方法,并没有四海皆准的通用法门。解决一个问题时,会有很多个选项,不同的选项背后又会关联更多的分支,就像是小径分岔的花园。 + +因此,我觉得比起穷尽各种细节问题,帮你搭建完善的知识体系,形成理解系统的正确思路更为关键。 + +开发一个系统,我们首先考虑的就是用户量,然后分析用户的主要动作,并根据这些分析数据量以及核心频繁调用的功能是什么(功能是读的多,还是写的多),以此判断出我们的系统类型和优化方向。 + +确定了优化方向,剩下的工作,就是结合不同类型的系统特点来做设计和优化。这也是这个专栏的设计思路:高并发的优化主流方案与案例为主,实践技巧为辅。 + +现在,让我再次回顾一下,这几个月来,我们都学到了哪些内容,希望能对你沉淀知识有所帮助。 + +- +首先,我们从互联网服务里最常见的读多写少系统开始入手。这种系统结构简单、维护方便、成本可控,优化时主要的挑战就是保证数据一致性。 + +为了做好缓存的数据一致性,我们可以尝试优化数据表结构,并处理好临时缓存和长期缓存的刷新机制。在读多写少系统下,能按ID查询的数据性能最好,关系查询及统计计算放在缓存的话,维护起来会比较难。 + +按照读多写少系统的发展规律,用户登陆的脱耦合改造也是一个“必经关卡”,这里我们的优化思路是通过签名实现去中心化。到了读多写少的系统演进的后期,多机房数据同步建设也是重要话题,通过Raft共识算法等分布式核心知识的学习,我们为后续进阶学习打下基础。 + +相比读多写少的系统,强一致性系统需要在保证数据一致性同时,追求更好的性能。我们选择了最典型的电商秒杀系统来分析,秒杀系统常常和多系统耦合,难以管理,所以合理拆分系统非常必要。另外,我们还需要深入了解锁,灵活地使用锁来实现库存争抢功能。 + +这类系统的优化改造中,需要斩断秒杀服务与内网的耦合,并处理好多服务协调的事务。最近微服务的流行,让分布式事务变成了必备组件,我们通过深入研究了2PC以及TCC这两个例子之后,就能了解大部分分布式事务的实现思路。 + +至于写多读少的系统,则是监控系统及日志中心的核心支撑。 + +优化时主要有两个挑战:一个是协调多服务器,提供线性分片来提升读写性能;另一个是理解分布式服务如何应对写压力,并在此基础上做好分布式数据服务的存储和查询。我们以日志中心为例,明白了几大数据引擎的实现和原理,就能更深入地理解分布式存储、查询、扩容的核心思路。 + +此外,我还分享了如何在资源有限的情况下,实现分布式链路跟踪系统,只有做好了监控,才能更好地观测、理解我们的系统。这样的探索有助于理解一些大厂的实践思路,建议你课后多多尝试这些技巧。 + +读多写多的系统是难度最高的一类服务,可以说是我们行业的天花板,对服务响应要求更高,还得处理好系统的高可用问题。 + +由于集中式缓存很难满足这类服务的需要,我们往往为了做好数据缓存做出各种尝试。然而无论是本地缓存,还是分流架构(比如引入脚本引擎集成),实现起来都有重重挑战,稍不留意就会踩坑。为了尽力避免业务服务缓存应对高并发读写的情况,我们还要设法合理拆分架构,并优化流量调度。 + +除了上面这四大类系统,我们还在内网建设这个部分讨论了对象存储、日志中心成本估算、如何巧妙使用网关、如何落地压测等问题。相信这些能帮你打开视野、开阔思路,更重要的是让你从容应对业务流量增长带来的冲击。这里再次感谢同学们的留言,很多内容也激发了我的灵感,尤其是在写内网建设这部分的时候,我结合留言里的疑问做了不少优化。 + +也许你接触的系统,现在并没有那么大的流量,但我仍然认为,高并发是未来方向。这和大多数互联网公司的业务发展规律有关:很多业务在前期,会通过算法和硬件来应对性能问题,但流量变大之后,硬件维护成本会居高不下;即便很多大型互联网公司,系统也需要持续优化改进,才能匹配日益增长的业务需要。 + +大部分业务系统会优化成读多写少的类型,这类架构设计都会以缓存、分布式队列为主,硬扛常见的用户流量压力,相对而言,这种服务最省钱。 + +强一致性的交易系统,则是通过缩小争抢强一致的服务及数据粒度,并不断拆分隔离系统,以此分散流量,最终控制成本。 + +如果是基础服务优化,那么我们只能通过隔离、算法、队列、动态调配基础服务器资源,降低存储周期等方式来硬扛流量压力,降低我们的成本。待业务成熟后,会逐渐优化掉一些无用业务流程及功能,节省基础服务的投入。 + +至于读多写多的核心业务,我们前期只能投入大量硬件资源,并配合CDN做流量调度。不过等到流量降低时或市场成熟时,还是会尝试把一些服务降成读多写少。 + +专栏里,为了给你呈现一条足够清晰的知识脉络,我把系统按数据特征分了四大类。然而实际的系统里可能会更加复杂,所以你在区分系统类型的时候很可能会产生困惑:这个服务、这个系统到底属于上面哪种类型? + +事实上,当你碰到这种情况的时候,就需要思考一下:这些不同类型的服务是否需要单独拆开、分别部署?一个系统如果混合使用多个运行模式,其组合复杂度会随着时间呈指数增长,逐渐让系统往失控的方向靠近。 + +理想状态是业务系统设计得更简单一些,让模块更加垂直。如果确实存在混合情况,要么就选一种类型作为主要优化方向,要么就把系统彻底拆开。不同类型的系统,优化方向并不一样,相应的配套设施、服务器对各层的投入比例,还有基础支撑要求都不一样,混在一起不但浪费硬件资源,而且不好维护。 + +说到这里,我们可以再想想优化的核心价值到底是什么?是让服务跑得更快?还是让功能更好用?还是让我们的设计更简洁统一?我觉得这些答案都对,但除此之外还有一个更核心的价值——节省成本。 + +你会发现,各种优化和新技术都在试图降低成本,无论解法是拆分、解耦还是集成。而监控和规范流程也是同样的道理,看似这些没能直接节约成本,但却能尽早发现系统隐患,间接降低我们解决问题的成本,减少潜在损失。 + +所以,相信在未来高并发优化会发挥出更大的价值,节省成本,正是高并发优化的核心竞争力。 + +课程的学习告一段落,到了说再见的时候。这门课可以说是高并发系统学习的一个索引,如果你能认真阅读、深挖,不断思考、实践,你的未来会拥有更大的空间。 + +如果遇到了什么问题,也欢迎继续在留言区里给我留言。 + +行业一直在飞速发展,各种技术不断更新换代,等待我们的是浩如烟海的知识海洋,一起努力前行吧。 + +高并发系统的优化无法毕其功于一役,个人的成长精进也一样。为者常成,行者常至,愿你我都能在未来的事业中披荆斩棘,所向披靡,再会! + +最后,我还给你准备了一份毕业问卷,希望你能花两三分钟填写一下,非常期待能听到你对这门课的反馈。 + + + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/00开篇词为什么你要学习高并发系统设计?.md b/专栏/高并发系统设计40问/00开篇词为什么你要学习高并发系统设计?.md new file mode 100644 index 0000000..12fa858 --- /dev/null +++ b/专栏/高并发系统设计40问/00开篇词为什么你要学习高并发系统设计?.md @@ -0,0 +1,99 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 00 开篇词 为什么你要学习高并发系统设计? + 你好,我是唐扬,现在在美图公司任职技术专家,负责美图秀秀社区的研发、优化和运行维护工作。从业十年,我一直在从事社区系统研发、架构设计、系统优化的工作,期间参与研发过三个 DAU 过千万的大型高并发系统。在这三个项目中,我参与了业务系统的开发和改造,也参与和主导过像 RPC 框架、分布式消息系统、注册中心等中间件系统的研发,对于高并发系统设计的各个方面都有所涉猎。 + +我见证了系统从初期构建,到承接高并发大流量的全过程,并在其中积累了大量的系统演进经验。我认为,虽说每家公司所处的行业不同,业务场景不同,但是设计和优化的思想却是万变不离其宗。 + +这些经验是一个个的“小套路”,它们相互联系,形成一套指引我们进行高并发系统设计的知识体系,其中包括了理论知识的讲解、问题场景的介绍、问题分析的过程,以及解决问题的思路。当你掌握这些“套路”之后,就能明确地知道,系统处于某一个阶段时,可能会面临的问题,然后及时找到架构升级优化的思路解决这些问题,提升系统性能。 + +从今天起,我会在“极客时间”上分享这些“套路”,和你一起分析问题原因,探讨解决方案,让你学有所用! + +为什么要学习高并发系统设计? + +在解答“为什么要学习高并发系统设计”之前,我想让你思考几个问题: + + +在微博中,明星动辄拥有几千万甚至上亿的粉丝,你要怎么保证明星发布的内容让粉丝实时地看到呢? +淘宝双十一,当你和上万人一起抢购一件性价比超高的衣服时,怎么保证衣服不会超卖? +春运时我们都会去 12306 订购火车票,以前在抢票时经常遇到页面打不开的情况,那么如果你来设计 12306 系统,要如何保证在千万人访问的同时也能支持正常抢票呢? + + +这些问题是你在设计和实现高并发系统时经常会遇到的痛点问题,都涉及如何在高并发场景下做到高性能和高可用,掌握这些内容,你开发的产品可以为用户提供更好的使用体验,你的技术能力也能有一个质的变化。 + +高并发系统设计知识,是你获取大厂 Offer 必不可少的利器 + +不可否认的是,目前的经济形势不好,很多公司(比如阿里、腾讯、今日头条)一方面在减少招聘的人员数量,另一方面也期望花费了人力成本之后可以给公司带来更大的价值。那么对于公司来说,仅仅懂得 CRUD 的程序员就不如有高并发系统设计经验的程序员有吸引力了。 + +所以当你去面试时,面试官会要求你有高并发设计经验,有的面试官会询问你的系统在遭遇百万并发时可能有哪些瓶颈点,以及有什么优化思路等问题,为的就是检验你是否真的了解这方面的内容。 + +那么进不了大厂,没有高并发的场景,这些设计的经验又要从何处来呢?这就是鸡生蛋蛋生鸡的问题了。我能肯定的是,当你学习这门课程,掌握了这方面的技术之后,大厂的 Offer 将不再遥不可及。 + +不要囿于公司现有的业务场景,你的能力,绝不止于此 + +那你可能会说:“我在小公司工作,小公司的系统并发不高,流量也不大,学习高并发系统设计似乎有些多此一举。”但我想说的是,公司业务流量平稳,并不表示不会遇到一些高并发的需求场景。 + +就拿电商系统中的下单流程设计技术方案为例。在每秒只有一次调用的系统中,你只需要关注业务逻辑本身就好了:查询库存是否充足,如果充足,就可以到数据库中生成订单,成功后锁定库存,然后进入支付流程。 + + + +这个流程非常清晰,实现也简单,但如果要做一次秒杀的活动,配合一些运营的推广,你会发现下单操作的调用量可能达到每秒 10000 次! + +10000 次请求同时查询库存,是否会把库存系统拖垮?如果请求全部通过,那么就要同时生成 10000 次订单,数据库能否抗住?如果抗不住,我们要如何做?这些问题都可能出现,并让之前的方案不再适用,此时你就需要设计新的方案。 + +除此之外,同样是缓存的使用,在低并发下你只需要了解基本的使用方式,但在高并发场景下你需要关注缓存命中率,如何应对缓存穿透,如何避免雪崩,如何解决缓存一致性等问题,这就增加了设计方案的复杂度,对设计者能力的要求也会更高。所以,为了避免遇到问题时手忙脚乱,你有必要提前储备足够多的高并发知识,从而具备随时应对可能出现的高并发需求场景的能力。 + +我身边有很多在小公司打拼闯荡,小有建树的朋友,他们无一不经历过低谷期,又一一开拓了一片天地,究其原因,是因为他们没有将目光放在现有的业务场景中,而是保持着对于新技术的好奇心,时刻关注业界新技术的实现原理,思考如何使用技术来解决业务上的问题。 + +他们虽然性格很不同,但不甘于现状,突破自己的信念却是一致的。我相信,你也一定如此。所以完成业务需求,解决产品问题不应该是你最终的目标,提升技术能力和技术视野才应是你始终不变的追求。 + +计算机领域里虽然知识点庞杂,但很多核心思想都是相通的 + +举个例子,消息队列是高并发系统中常见的一种组件,它可以将消息生产方和消费方解耦,减少突发流量对于系统的冲击。但如果你的系统没有那么高的流量,你就永远不会使用消息队列了吗?当然不是。 + +系统模块要做到高内聚、低解耦,这是系统的基本设计思想,和是否高并发无关,而消息队列作为主要的系统解耦方式,应该是你技术百宝囊中一件不可或缺的制胜法宝。 + +又比如,缓存技术蕴含的是空间换时间的思想;压缩体现的是时间换空间的思想;分布式思想也最初体现在 CPU 的设计和实现上……这些内容,都是高并发系统设计中的内容,而我希望在这个课程中,帮你把握这些核心思想,让你触类旁通,举一反三。 + +所以,高并发系统设计无论是对于初入职场的工程师了解基本系统设计思想,还是对于有一定工作经验的同学完善自身技能树,为未来可能遇见的系统问题做好技术储备,都有很大的帮助。 + +也许你会担心知识点不成体系;担心只讲理论,没有实际的场景;担心只有空洞的介绍,没有干货。放心!我同样考虑了这些问题并在反复思考之后,决定以一个虚拟的系统为主线,讲解在流量和并发不断提升的情况下如何一步步地优化它,并在这个过程中穿插着讲解知识点,这样通过场景、原理、实践相结合的方式,来帮助你更快、更深入地理解和消化。 + +总体来说,学完这次课程,你会有三个收获: + + +掌握高并发系统设计的“套路”; +理解基本的系统设计思想,对新的知识触类旁通,举一反三; +突破技术的瓶颈,突破所处平台的限制,具备一个优秀架构师的资质。 + + +课程设计 + +我将课程划分了三个模块来讲解,分别是:基础篇、演进篇和实战篇。 + +基础篇主要是一些基本的高并发架构设计理念,你可以把它看作整个课程的一个总纲,建立对高并发系统的初步认识。 + +演进篇是整个课程的核心,主要讲解系统支持高并发的方法。我会用一个虚拟的系统,带你分析当随着前端并发增加,这个系统的变化,以及你会遇到的一系列痛点问题。比如数据查询的性能瓶颈,缓存的高可用问题,然后从数据库、缓存、消息队列、分布式服务和维护这五个角度来展开,针对问题寻找解决方案,让你置身其中,真真切切地走一遍系统演进的道路。 + +实战篇将以两个实际案例,带你应用学到的知识应对高并发大流量的冲击。 + +一个案例是如何设计承担每秒几十万次用户未读数请求的系统。之所以选择它,是因为在大部分的系统中未读数都会是请求量最大、并发最高的服务,在微博时 QPS 会达到每秒 50 万次。同时,未读数系统的业务逻辑比较简单,在你了解设计方案的时候也不需要预先对业务逻辑有深入了解;另一个例子是信息流系统的设计,它是社区社交产品中的核心系统,业务逻辑复杂且请求量大,方案中几乎涉及高并发系统设计的全部内容。 + +下面是这个课程的目录,你能快速了解整个课程的知识体系。 + + + +写在最后 + +课程从原理到实战,以案例作为主线,涵盖了高并发系统设计的整个知识体系。只要你一步一步地坚持学习,课后多加思考,多练习,相信你的系统设计能力一定能够得到很大的提升,职业发展路径也会走得愈加宽阔。 + +最后,欢迎你在留言区和我说一说自己的情况,想要了解高并发的哪些内容,或者在高并发方面存在的一些困惑,一是方便我在后面的讲解中有所侧重地展开介绍,一是希望你在跟着我一起学完这个课程后,再回顾此前的问题,能够发现自己切实地成长与提高,这是我最希望看到的。 + +期待你的留言,也感谢你的信任,未来的三个月里,我们一起沟通、探讨、进步。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/01高并发系统:它的通用设计方法是什么?.md b/专栏/高并发系统设计40问/01高并发系统:它的通用设计方法是什么?.md new file mode 100644 index 0000000..319c05e --- /dev/null +++ b/专栏/高并发系统设计40问/01高并发系统:它的通用设计方法是什么?.md @@ -0,0 +1,110 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 01 高并发系统:它的通用设计方法是什么? + 我们知道,高并发代表着大流量,高并发系统设计的魅力就在于我们能够凭借自己的聪明才智设计巧妙的方案,从而抵抗巨大流量的冲击,带给用户更好的使用体验。这些方案好似能操纵流量,让流量更加平稳得被系统中的服务和组件处理。 + +来做个简单的比喻吧。 + +从古至今,长江和黄河流域水患不断,远古时期,大禹曾拓宽河道,清除淤沙让流水更加顺畅;都江堰作为史上最成功的的治水案例之一,用引流将岷江之水分流到多个支流中,以分担水流压力;三门峡和葛洲坝通过建造水库将水引入水库先存储起来,然后再想办法把水库中的水缓缓地排出去,以此提高下游的抗洪能力。 + +而我们在应对高并发大流量时也会采用类似“抵御洪水”的方案,归纳起来共有三种方法。 + + +Scale-out(横向扩展):分而治之是一种常见的高并发系统设计方法,采用分布式部署的方式把流量分流开,让每个服务器都承担一部分并发和流量。 +缓存:使用缓存来提高系统的性能,就好比用“拓宽河道”的方式抵抗高并发大流量的冲击。 +异步:在某些场景下,未处理完成之前,我们可以让请求先返回,在数据准备好之后再通知请求方,这样可以在单位时间内处理更多的请求。 + + +简单介绍了这三种方法之后,我再详细地带你了解一下,这样当你在设计高并发系统时就可以有考虑的方向了。当然了,这三种方法会细化出更多的内容,我会在后面的课程中深入讲解。 + +首先,我们先来了解第一种方法:Scale-out。 + +Scale-up vs Scale-out + +著名的“摩尔定律”是由 Intel 的创始人之一戈登·摩尔于 1965 年提出的。这个定律提到,集成电路上可容纳的晶体管的数量约每隔两年会增加一倍。 + +后来,Intel 首席执行官大卫·豪斯提出“18 个月”的说法,即预计 18 个月会将芯片的性能提升一倍,这个说法广为流传。 + +摩尔定律虽然描述的是芯片的发展速度,但我们可以延伸为整体的硬件性能,从 20 世纪后半叶开始,计算机硬件的性能是指数级演进的。 + +直到现在,摩尔定律依然生效,在半个世纪以来的 CPU 发展过程中,芯片厂商靠着在有限面积上做更小的晶体管的黑科技,大幅度地提升着芯片的性能。从第一代集成电路上只有十几个晶体管,到现在一个芯片上动辄几十亿晶体管的数量,摩尔定律指引着芯片厂商完成了技术上的飞跃。 + +但是有专家预测,摩尔定律可能在未来几年之内不再生效,原因是目前的芯片技术已经做到了 10nm 级别,在工艺上已经接近极限,再往上做,即使有新的技术突破,在成本上也难以被市场接受。后来,双核和多核技术的产生拯救了摩尔定律,这些技术的思路是将多个 CPU 核心压在一个芯片上,从而大大提升 CPU 的并行处理能力。 + +我们在高并发系统设计上也沿用了同样的思路,将类似追逐摩尔定律不断提升 CPU 性能的方案叫做 Scale-up(纵向扩展),把类似 CPU 多核心的方案叫做 Scale-out,这两种思路在实现方式上是完全不同的。 + + +Scale-up,通过购买性能更好的硬件来提升系统的并发处理能力,比方说目前系统 4 核 4G 每秒可以处理 200 次请求,那么如果要处理 400 次请求呢?很简单,我们把机器的硬件提升到 8 核 8G(硬件资源的提升可能不是线性的,这里仅为参考)。 +Scale-out,则是另外一个思路,它通过将多个低性能的机器组成一个分布式集群来共同抵御高并发流量的冲击。沿用刚刚的例子,我们可以使用两台 4 核 4G 的机器来处理那 400 次请求。 + + +那么什么时候选择 Scale-up,什么时候选择 Scale-out 呢?一般来讲,在我们系统设计初期会考虑使用 Scale-up 的方式,因为这种方案足够简单,所谓能用堆砌硬件解决的问题就用硬件来解决,但是当系统并发超过了单机的极限时,我们就要使用 Scale-out 的方式。 + +Scale-out 虽然能够突破单机的限制,但也会引入一些复杂问题。比如,如果某个节点出现故障如何保证整体可用性?当多个节点有状态需要同步时,如何保证状态信息在不同节点的一致性?如何做到使用方无感知的增加和删除节点?等等。其中每一个问题都涉及很多的知识点,我会在后面的课程中深入地讲解,这里暂时不展开了。 + +说完了 Scale-out,我们再来看看高并发系统设计的另一种方法:缓存。 + +使用缓存提升性能 + +Web 2.0 是缓存的时代,这一点毋庸置疑。缓存遍布在系统设计的每个角落,从操作系统到浏览器,从数据库到消息队列,任何略微复杂的服务和组件中,你都可以看到缓存的影子。我们使用缓存的主要作用是提升系统的访问性能,那么在高并发的场景下,就可以支撑更多用户的同时访问。 + +那么为什么缓存可以大幅度提升系统的性能呢?我们知道数据是放在持久化存储中的,一般的持久化存储都是使用磁盘作为存储介质的,而普通磁盘数据由机械手臂、磁头、转轴、盘片组成,盘片又分为磁道、柱面和扇区,盘片构造图我放在下面了。 + +盘片是存储介质,每个盘片被划分为多个同心圆,信息都被存储在同心圆之中,这些同心圆就是磁道。在磁盘工作时盘片是在高速旋转的,机械手臂驱动磁头沿着径向移动,在磁道上读取所需要的数据。我们把磁头寻找信息花费的时间叫做寻道时间。 + + + +普通磁盘的寻道时间是 10ms 左右,而相比于磁盘寻道花费的时间,CPU 执行指令和内存寻址的时间都在是 ns(纳秒)级别,从千兆网卡上读取数据的时间是在μs(微秒)级别。所以在整个计算机体系中,磁盘是最慢的一环,甚至比其它的组件要慢几个数量级。因此,我们通常使用以内存作为存储介质的缓存,以此提升性能。 + +当然,缓存的语义已经丰富了很多,我们可以将任何降低响应时间的中间存储都称为缓存。缓存的思想遍布很多设计领域,比如在操作系统中 CPU 有多级缓存,文件有 Page Cache 缓存,你应该有所了解。 + +异步处理 + +异步也是一种常见的高并发设计方法,我们在很多文章和演讲中都能听到这个名词,与之共同出现的还有它的反义词:同步。比如,分布式服务框架 Dubbo 中有同步方法调用和异步方法调用,IO 模型中有同步 IO 和异步 IO。 + +那么什么是同步,什么是异步呢?以方法调用为例,同步调用代表调用方要阻塞等待被调用方法中的逻辑执行完成。这种方式下,当被调用方法响应时间较长时,会造成调用方长久的阻塞,在高并发下会造成整体系统性能下降甚至发生雪崩。 + +异步调用恰恰相反,调用方不需要等待方法逻辑执行完成就可以返回执行其他的逻辑,在被调用方法执行完毕后再通过回调、事件通知等方式将结果反馈给调用方。 + +异步调用在大规模高并发系统中被大量使用,比如我们熟知的 12306 网站。当我们订票时,页面会显示系统正在排队,这个提示就代表着系统在异步处理我们的订票请求。在 12306 系统中查询余票、下单和更改余票状态都是比较耗时的操作,可能涉及多个内部系统的互相调用,如果是同步调用就会像 12306 刚刚上线时那样,高峰期永远不可能下单成功。 + +而采用异步的方式,后端处理时会把请求丢到消息队列中,同时快速响应用户,告诉用户我们正在排队处理,然后释放出资源来处理更多的请求。订票请求处理完之后,再通知用户订票成功或者失败。 + +处理逻辑后移到异步处理程序中,Web 服务的压力小了,资源占用的少了,自然就能接收更多的用户订票请求,系统承受高并发的能力也就提升了。 + + + +既然我们了解了这三种方法,那么是不是意味着在高并发系统设计中,开发一个系统时要把这些方法都用上呢?当然不是,系统的设计是不断演进的。 + +罗马不是一天建成的,系统的设计也是如此。不同量级的系统有不同的痛点,也就有不同的架构设计的侧重点。如果都按照百万、千万并发来设计系统,电商一律向淘宝看齐,IM 全都学习微信和 QQ,那么这些系统的命运一定是灭亡。 + +因为淘宝、微信的系统虽然能够解决同时百万、千万人同时在线的需求,但其内部的复杂程度也远非我们能够想象的。盲目地追从只能让我们的架构复杂不堪,最终难以维护。就拿从单体架构往服务化演进来说,淘宝也是在经历了多年的发展后,发现系统整体的扩展能力出现问题时,开始启动服务化改造项目的。 + +我之前也踩过一些坑,参与的一个创业项目在初始阶段就采用了服务化的架构,但由于当时人力有限,团队技术积累不足,因此在实际项目开发过程中,发现无法驾驭如此复杂的架构,也出现了问题难以定位、系统整体性能下降等多方面的问题,甚至连系统宕机了都很难追查到根本原因,最后不得不把服务做整合,回归到简单的单体架构中。 + +所以我建议一般系统的演进过程应该遵循下面的思路: + + +最简单的系统设计满足业务需求和流量现状,选择最熟悉的技术体系。 +随着流量的增加和业务的变化,修正架构中存在问题的点,如单点问题,横向扩展问题,性能无法满足需求的组件。在这个过程中,选择社区成熟的、团队熟悉的组件帮助我们解决问题,在社区没有合适解决方案的前提下才会自己造轮子。 +当对架构的小修小补无法满足需求时,考虑重构、重写等大的调整方式以解决现有的问题。 + + +以淘宝为例,当时在业务从 0 到 1 的阶段是通过购买的方式快速搭建了系统。而后,随着流量的增长,淘宝做了一系列的技术改造来提升高并发处理能力,比如数据库存储引擎从 MyISAM 迁移到 InnoDB,数据库做分库分表,增加缓存,启动中间件研发等。 + +当这些都无法满足时就考虑对整体架构做大规模重构,比如说著名的“五彩石”项目让淘宝的架构从单体演进为服务化架构。正是通过逐步的技术演进,淘宝才进化出如今承担过亿 QPS 的技术架构。 + +归根结底一句话:高并发系统的演进应该是循序渐进,以解决系统中存在的问题为目的和驱动力的。 + +课程小结 + +在今天的课程中,我带着你了解了高并发系统设计的三种通用方法:Scale-out、缓存和异步。这三种方法可以在做方案设计时灵活地运用,但它不是具体实施的方案,而是三种思想,在实际运用中会千变万化。 + +就拿 Scale-out 来说,数据库一主多从、分库分表、存储分片都是它的实际应用方案。而我们需要注意的是,在应对高并发大流量的时候,系统是可以通过增加机器来承担流量冲击的,至于要采用什么样的方案还是要具体问题具体分析。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/02架构分层:我们为什么一定要这么做?.md b/专栏/高并发系统设计40问/02架构分层:我们为什么一定要这么做?.md new file mode 100644 index 0000000..22bc3d5 --- /dev/null +++ b/专栏/高并发系统设计40问/02架构分层:我们为什么一定要这么做?.md @@ -0,0 +1,123 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 02 架构分层:我们为什么一定要这么做? + 在系统从 0 到 1 的阶段,为了让系统快速上线,我们通常是不考虑分层的。但是随着业务越来越复杂,大量的代码纠缠在一起,会出现逻辑不清晰、各模块相互依赖、代码扩展性差、改动一处就牵一发而动全身等问题。 + +这时,对系统进行分层就会被提上日程,那么我们要如何对架构进行分层?架构分层和高并发架构设计又有什么关系呢?本节课,我将带你寻找答案。 + +什么是分层架构 + +软件架构分层在软件工程中是一种常见的设计方式,它是将整体系统拆分成 N 个层次,每个层次有独立的职责,多个层次协同提供完整的功能。 + +我们在刚刚成为程序员的时候,会被“教育”说系统的设计要是“MVC”(Model-View-Controller)架构。它将整体的系统分成了 Model(模型),View(视图)和 Controller(控制器)三个层次,也就是将用户视图和业务处理隔离开,并且通过控制器连接起来,很好地实现了表现和逻辑的解耦,是一种标准的软件分层架构。 + + + +另外一种常见的分层方式是将整体架构分为表现层、逻辑层和数据访问层: + + +表现层,顾名思义嘛,就是展示数据结果和接受用户指令的,是最靠近用户的一层; +逻辑层里面有复杂业务的具体实现; +数据访问层则是主要处理和存储之间的交互。 + + +这是在架构上最简单的一种分层方式。其实,我们在不经意间已经按照三层架构来做系统分层设计了,比如在构建项目的时候,我们通常会建立三个目录:Web、Service 和 Dao,它们分别对应了表现层、逻辑层还有数据访问层。 + + + +除此之外,如果我们稍加留意,就可以发现很多的分层的例子。比如我们在大学中学到的 OSI 网络模型,它把整个网络分了七层,自下而上分别是物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。 + +工作中经常能用到 TCP/IP 协议,它把网络简化成了四层,即链路层、网络层、传输层和应用层。每一层各司其职又互相帮助,网络层负责端到端的寻址和建立连接,传输层负责端到端的数据传输等,同时呢相邻两层还会有数据的交互。这样可以隔离关注点,让不同的层专注做不同的事情。 + + + +Linux 文件系统也是分层设计的,从下图你可以清晰地看出文件系统的层次。在文件系统的最上层是虚拟文件系统(VFS),用来屏蔽不同的文件系统之间的差异,提供统一的系统调用接口。虚拟文件系统的下层是 Ext3、Ext4 等各种文件系统,再向下是为了屏蔽不同硬件设备的实现细节,我们抽象出来的单独的一层——通用块设备层,然后就是不同类型的磁盘了。 + +我们可以看到,某些层次负责的是对下层不同实现的抽象,从而对上层屏蔽实现细节。比方说 VFS 对上层(系统调用层)来说提供了统一的调用接口,同时对下层中不同的文件系统规约了实现模型,当新增一种文件系统实现的时候,只需要按照这种模型来设计,就可以无缝插入到 Linux 文件系统中。 + + + +那么,为什么这么多系统一定要做分层的设计呢?答案是分层设计存在一定的优势。 + +分层有什么好处 + +分层的设计可以简化系统设计,让不同的人专注做某一层次的事情。想象一下,如果你要设计一款网络程序却没有分层,该是一件多么痛苦的事情。 + +因为你必须是一个通晓网络的全才,要知道各种网络设备的接口是什么样的,以便可以将数据包发送给它。你还要关注数据传输的细节,并且需要处理类似网络拥塞,数据超时重传这样的复杂问题。当然了,你更需要关注数据如何在网络上安全传输,不会被别人窥探和篡改。 + +而有了分层的设计,你只需要专注设计应用层的程序就可以了,其他的,都可以交给下面几层来完成。 + +再有,分层之后可以做到很高的复用。比如,我们在设计系统 A 的时候,发现某一层具有一定的通用性,那么我们可以把它抽取独立出来,在设计系统 B 的时候使用起来,这样可以减少研发周期,提升研发的效率。 + +最后一点,分层架构可以让我们更容易做横向扩展。如果系统没有分层,当流量增加时我们需要针对整体系统来做扩展。但是,如果我们按照上面提到的三层架构将系统分层后,那么我们就可以针对具体的问题来做细致的扩展。 + +比如说,业务逻辑里面包含有比较复杂的计算,导致 CPU 成为性能的瓶颈,那这样就可以把逻辑层单独抽取出来独立部署,然后只对逻辑层来做扩展,这相比于针对整体系统扩展所付出的代价就要小的多了。 + +这一点也可以解释我们课程开始时提出的问题:架构分层究竟和高并发设计的关系是怎样的?在“[01 | 高并发系统:它的通用设计方法是什么?]”中我们了解到,横向扩展是高并发系统设计的常用方法之一,既然分层的架构可以为横向扩展提供便捷, 那么支撑高并发的系统一定是分层的系统。 + +如何来做系统分层 + +说了这么多分层的优点,那么当我们要做分层设计的时候,需要考虑哪些关键因素呢? + +在我看来,最主要的一点就是你需要理清楚每个层次的边界是什么。你也许会问:“如果按照三层架构来分层的话,每一层的边界不是很容易就界定吗?” + +没错,当业务逻辑简单时,层次之间的边界的确清晰,开发新的功能时也知道哪些代码要往哪儿写。但是当业务逻辑变得越来越复杂时,边界就会变得越来越模糊,给你举个例子。 + +任何一个系统中都有用户系统,最基本的接口是返回用户信息的接口,它调用逻辑层的 GetUser 方法,GetUser 方法又和 User DB 交互获取数据,就像下图左边展示的样子。 + +这时,产品提出一个需求,在 APP 中展示用户信息的时候,如果用户不存在,那么要自动给用户创建一个用户。同时,要做一个 HTML5 的页面,HTML5 页面要保留之前的逻辑,也就是不需要创建用户。这时逻辑层的边界就变得不清晰,表现层也承担了一部分的业务逻辑(将获取用户和创建用户接口编排起来)。 + + + +那我们要如何做呢?参照阿里发布的《阿里巴巴 Java 开发手册 v1.4.0(详尽版)》,我们可以将原先的三层架构细化成下面的样子: + + + +我来解释一下这个分层架构中的每一层的作用。 + + +终端显示层:各端模板渲染并执行显示的层。当前主要是 Velocity 渲染,JS 渲染, JSP 渲染,移动端展示等。 +开放接口层:将 Service 层方法封装成开放接口,同时进行网关安全控制和流量控制等。 +Web 层:主要是对访问控制进行转发,各类基本参数校验,或者不复用的业务简单处理等。 +Service 层:业务逻辑层。 +Manager 层:通用业务处理层。这一层主要有两个作用,其一,你可以将原先 Service 层的一些通用能力下沉到这一层,比如与缓存和存储交互策略,中间件的接入;其二,你也可以在这一层封装对第三方接口的调用,比如调用支付服务,调用审核服务等。 +DAO 层:数据访问层,与底层 MySQL、Oracle、Hbase 等进行数据交互。 +外部接口或第三方平台:包括其它部门 RPC 开放接口,基础平台,其它公司的 HTTP 接口。 + + +在这个分层架构中主要增加了 Manager 层,它与 Service 层的关系是:Manager 层提供原子的服务接口,Service 层负责依据业务逻辑来编排原子接口。 + +以上面的例子来说,Manager 层提供创建用户和获取用户信息的接口,而 Service 层负责将这两个接口组装起来。这样就把原先散布在表现层的业务逻辑都统一到了 Service 层,每一层的边界就非常清晰了。 + +除此之外,分层架构需要考虑的另一个因素,是层次之间一定是相邻层互相依赖,数据的流转也只能在相邻的两层之间流转。 + +我们还是以三层架构为例,数据从表示层进入之后一定要流转到逻辑层,做业务逻辑处理,然后流转到数据访问层来和数据库交互。那么你可能会问:“如果业务逻辑很简单的话可不可以从表示层直接到数据访问层,甚至直接读数据库呢?” + +其实从功能上是可以的,但是从长远的架构设计考虑,这样会造成层级调用的混乱,比方说如果表示层或者业务层可以直接操作数据库,那么一旦数据库地址发生变更,你就需要在多个层次做更改,这样就失去了分层的意义,并且对于后面的维护或者重构都会是灾难性的。 + +分层架构的不足 + +任何事物都不可能是尽善尽美的,分层架构虽有优势也会有缺陷,它最主要的一个缺陷就是增加了代码的复杂度。 + +这是显而易见的嘛,明明可以在接收到请求后就可以直接查询数据库获得结果,却偏偏要在中间插入多个层次,并且有可能每个层次只是简单地做数据的传递。有时增加一个小小的需求也需要更改所有层次上的代码,看起来增加了开发的成本,并且从调试上来看也增加了复杂度,原本如果直接访问数据库我只需要调试一个方法,现在我却要调试多个层次的多个方法。 + +另外一个可能的缺陷是,如果我们把每个层次独立部署,层次间通过网络来交互,那么多层的架构在性能上会有损耗。这也是为什么服务化架构性能要比单体架构略差的原因,也就是所谓的“多一跳”问题。 + +那我们是否要选择分层的架构呢?答案当然是肯定的。 + +你要知道,任何的方案架构都是有优势有缺陷的,天地尚且不全何况我们的架构呢?分层架构固然会增加系统复杂度,也可能会有性能的损耗,但是相比于它能带给我们的好处来说,这些都是可以接受的,或者可以通过其它的方案解决的。我们在做决策的时候切不可以偏概全,因噎废食。 + +课程小结 + +今天我带着你了解了分层架构的优势和不足,以及我们在实际工作中如何来对架构做分层。我想让你了解的是,分层架构是软件设计思想的外在体现,是一种实现方式。我们熟知的一些软件设计原则都在分层架构中有所体现。 + +比方说,单一职责原则规定每个类只有单一的功能,在这里可以引申为每一层拥有单一职责,且层与层之间边界清晰;迪米特法则原意是一个对象应当对其它对象有尽可能少的了解,在分层架构的体现是数据的交互不能跨层,只能在相邻层之间进行;而开闭原则要求软件对扩展开放,对修改关闭。它的含义其实就是将抽象层和实现层分离,抽象层是对实现层共有特征的归纳总结,不可以修改,但是具体的实现是可以无限扩展,随意替换的。 + +掌握这些设计思想会自然而然地明白分层架构设计的妙处,同时也能帮助我们做出更好的设计方案。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/03系统设计目标(一):如何提升系统性能?.md b/专栏/高并发系统设计40问/03系统设计目标(一):如何提升系统性能?.md new file mode 100644 index 0000000..4a745d0 --- /dev/null +++ b/专栏/高并发系统设计40问/03系统设计目标(一):如何提升系统性能?.md @@ -0,0 +1,147 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 03 系统设计目标(一):如何提升系统性能? + 提到互联网系统设计,你可能听到最多的词儿就是“三高”,也就是“高并发”“高性能”“高可用”,它们是互联网系统架构设计永恒的主题。在前两节课中,我带你了解了高并发系统设计的含义,意义以及分层设计原则,接下来,我想带你整体了解一下高并发系统设计的目标,然后在此基础上,进入我们今天的话题:如何提升系统的性能? + +高并发系统设计的三大目标:高性能、高可用、可扩展 + +高并发,是指运用设计手段让系统能够处理更多的用户并发请求,也就是承担更大的流量。它是一切架构设计的背景和前提,脱离了它去谈性能和可用性是没有意义的。很显然嘛,你在每秒一次请求和每秒一万次请求,两种不同的场景下,分别做到毫秒级响应时间和五个九(99.999%)的可用性,无论是设计难度还是方案的复杂度,都不是一个级别的。 + +而性能和可用性,是我们实现高并发系统设计必须考虑的因素。 + +性能反应了系统的使用体验,想象一下,同样承担每秒一万次请求的两个系统,一个响应时间是毫秒级,一个响应时间在秒级别,它们带给用户的体验肯定是不同的。 + +可用性则表示系统可以正常服务用户的时间。我们再类比一下,还是两个承担每秒一万次的系统,一个可以做到全年不停机、无故障,一个隔三差五宕机维护,如果你是用户,你会选择使用哪一个系统呢?答案不言而喻。 + +另一个耳熟能详的名词叫“可扩展性”,它同样是高并发系统设计需要考虑的因素。为什么呢?我来举一个具体的例子。 + +流量分为平时流量和峰值流量两种,峰值流量可能会是平时流量的几倍甚至几十倍,在应对峰值流量的时候,我们通常需要在架构和方案上做更多的准备。这就是淘宝会花费大半年的时间准备双十一,也是在面对“明星离婚”等热点事件时,看起来无懈可击的微博系统还是会出现服务不可用的原因。而易于扩展的系统能在短时间内迅速完成扩容,更加平稳地承担峰值流量。 + +高性能、高可用和可扩展,是我们在做高并发系统设计时追求的三个目标,我会用三节课的时间,带你了解在高并发大流量下如何设计高性能、高可用和易于扩展的系统。 + +了解完这些内容之后,我们正式进入今天的话题:如何提升系统的性能? + +性能优化原则 + +“天下武功,唯快不破”。性能是系统设计成功与否的关键,实现高性能也是对程序员个人能力的挑战。不过在了解实现高性能的方法之前,我们先明确一下性能优化的原则。 + +首先,性能优化一定不能盲目,一定是问题导向的。脱离了问题,盲目地提早优化会增加系统的复杂度,浪费开发人员的时间,也因为某些优化可能会对业务上有些折中的考虑,所以也会损伤业务。 + +其次,性能优化也遵循“八二原则”,即你可以用 20% 的精力解决 80% 的性能问题。所以我们在优化过程中一定要抓住主要矛盾,优先优化主要的性能瓶颈点。 + +再次,性能优化也要有数据支撑。在优化过程中,你要时刻了解你的优化让响应时间减少了多少,提升了多少的吞吐量。 + +最后,性能优化的过程是持续的。高并发的系统通常是业务逻辑相对复杂的系统,那么在这类系统中出现的性能问题通常也会有多方面的原因。因此,我们在做性能优化的时候要明确目标,比方说,支撑每秒 1 万次请求的吞吐量下响应时间在 10ms,那么我们就需要持续不断地寻找性能瓶颈,制定优化方案,直到达到目标为止。 + +在以上四个原则的指引下,掌握常见性能问题的排查方式和优化手段,就一定能让你在设计高并发系统时更加游刃有余。 + +性能的度量指标 + +性能优化的第三点原则中提到,对于性能我们需要有度量的标准,有了数据才能明确目前存在的性能问题,也能够用数据来评估性能优化的效果。所以明确性能的度量指标十分重要。 + +一般来说,度量性能的指标是系统接口的响应时间,但是单次的响应时间是没有意义的,你需要知道一段时间的性能情况是什么样的。所以,我们需要收集这段时间的响应时间数据,然后依据一些统计方法计算出特征值,这些特征值就能够代表这段时间的性能情况。我们常见的特征值有以下几类。 + + +平均值 + + +顾名思义,平均值是把这段时间所有请求的响应时间数据相加,再除以总请求数。平均值可以在一定程度上反应这段时间的性能,但它敏感度比较差,如果这段时间有少量慢请求时,在平均值上并不能如实的反应。 + +举个例子,假设我们在 30s 内有 10000 次请求,每次请求的响应时间都是 1ms,那么这段时间响应时间平均值也是 1ms。这时,当其中 100 次请求的响应时间变成了 100ms,那么整体的响应时间是 (100 * 100 + 9900 * 1) / 10000 = 1.99ms。你看,虽然从平均值上来看仅仅增加了不到 1ms,但是实际情况是有 1% 的请求(100/10000)的响应时间已经增加了 100 倍。所以,平均值对于度量性能来说只能作为一个参考。 + + +最大值 + + +这个更好理解,就是这段时间内所有请求响应时间最长的值,但它的问题又在于过于敏感了。 + +还拿上面的例子来说,如果 10000 次请求中只有一次请求的响应时间达到 100ms,那么这段时间请求的响应耗时的最大值就是 100ms,性能损耗为原先的百分之一,这种说法明显是不准确的。 + + +分位值 + + +分位值有很多种,比如 90 分位、95 分位、75 分位。以 90 分位为例,我们把这段时间请求的响应时间从小到大排序,假如一共有 100 个请求,那么排在第 90 位的响应时间就是 90 分位值。分位值排除了偶发极慢请求对于数据的影响,能够很好地反应这段时间的性能情况,分位值越大,对于慢请求的影响就越敏感。 + + + +在我来看,分位值是最适合作为时间段内,响应时间统计值来使用的,在实际工作中也应用最多。除此之外,平均值也可以作为一个参考值来使用。 + +我在上面提到,脱离了并发来谈性能是没有意义的,我们通常使用吞吐量或者同时在线用户数来度量并发和流量,使用吞吐量的情况会更多一些。但是你要知道,这两个指标是呈倒数关系的。 + +这很好理解,响应时间 1s 时,吞吐量是每秒 1 次,响应时间缩短到 10ms,那么吞吐量就上升到每秒 100 次。所以,一般我们度量性能时都会同时兼顾吞吐量和响应时间,比如我们设立性能优化的目标时通常会这样表述:在每秒 1 万次的请求量下,响应时间 99 分位值在 10ms 以下。 + +那么,响应时间究竟控制在多长时间比较合适呢?这个不能一概而论。 + +从用户使用体验的角度来看,200ms 是第一个分界点:接口的响应时间在 200ms 之内,用户是感觉不到延迟的,就像是瞬时发生的一样。而 1s 是另外一个分界点:接口的响应时间在 1s 之内时,虽然用户可以感受到一些延迟,但却是可以接受的,超过 1s 之后用户就会有明显等待的感觉,等待时间越长,用户的使用体验就越差。所以,健康系统的 99 分位值的响应时间通常需要控制在 200ms 之内,而不超过 1s 的请求占比要在 99.99% 以上。 + +现在你了解了性能的度量指标,那我们再来看一看,随着并发的增长我们实现高性能的思路是怎样的。 + +高并发下的性能优化 + +假如说,你现在有一个系统,这个系统中处理核心只有一个,执行的任务的响应时间都在 10ms,它的吞吐量是在每秒 100 次。那么我们如何来优化性能从而提高系统的并发能力呢?主要有两种思路:一种是提高系统的处理核心数,另一种是减少单次任务的响应时间。 + +1. 提高系统的处理核心数 + +提高系统的处理核心数就是增加系统的并行处理能力,这个思路是优化性能最简单的途径。拿上一个例子来说,你可以把系统的处理核心数增加为两个,并且增加一个进程,让这两个进程跑在不同的核心上。这样从理论上,你系统的吞吐量可以增加一倍。当然了,在这种情况下,吞吐量和响应时间就不是倒数关系了,而是:吞吐量 = 并发进程数 / 响应时间。 + +计算机领域的阿姆达尔定律(Amdahl’s law)是吉恩·阿姆达尔在 1967 年提出的。它描述了并发进程数与响应时间之间的关系,含义是在固定负载下,并行计算的加速比,也就是并行化之后效率提升情况,可以用下面公式来表示: + + +(Ws + Wp) / (Ws + Wp/s) + + +其中,Ws 表示任务中的串行计算量,Wp 表示任务中的并行计算量,s 表示并行进程数。从这个公式我们可以推导出另外一个公式: + + +1/(1-p+p/s) + + +其中,s 还是表示并行进程数,p 表示任务中并行部分的占比。当 p 为 1 时,也就是完全并行时,加速比与并行进程数相等;当 p 为 0 时,即完全串行时,加速比为 1,也就是说完全无加速;当 s 趋近于无穷大的时候,加速比就等于 1/(1-p),你可以看到它完全和 p 成正比。特别是,当 p 为 1 时,加速比趋近于无穷大。 + +以上公式的推导过程有些复杂,你只需要记住结论就好了。 + +我们似乎找到了解决问题的银弹,是不是无限制地增加处理核心数就能无限制地提升性能,从而提升系统处理高并发的能力呢?很遗憾,随着并发进程数的增加,并行的任务对于系统资源的争抢也会愈发严重。在某一个临界点上继续增加并发进程数,反而会造成系统性能的下降,这就是性能测试中的拐点模型。 + + + +从图中你可以发现,并发用户数处于轻压力区时,响应时间平稳,吞吐量和并发用户数线性相关。而当并发用户数处于重压力区时,系统资源利用率到达极限,吞吐量开始有下降的趋势,响应时间也会略有上升。这个时候,再对系统增加压力,系统就进入拐点区,处于超负荷状态,吞吐量下降,响应时间大幅度上升。 + +所以我们在评估系统性能时通常需要做压力测试,目的就是找到系统的“拐点”,从而知道系统的承载能力,也便于找到系统的瓶颈,持续优化系统性能。 + +说完了提升并行能力,我们再看看优化性能的另一种方式:减少单次任务响应时间。 + +2. 减少单次任务响应时间 + +想要减少任务的响应时间,首先要看你的系统是 CPU 密集型还是 IO 密集型的,因为不同类型的系统性能优化方式不尽相同。 + +CPU 密集型系统中,需要处理大量的 CPU 运算,那么选用更高效的算法或者减少运算次数就是这类系统重要的优化手段。比方说,如果系统的主要任务是计算 Hash 值,那么这时选用更高性能的 Hash 算法就可以大大提升系统的性能。发现这类问题的主要方式,是通过一些 Profile 工具来找到消耗 CPU 时间最多的方法或者模块,比如 Linux 的 perf、eBPF 等。 + +IO 密集型系统指的是系统的大部分操作是在等待 IO 完成,这里 IO 指的是磁盘 IO 和网络 IO。我们熟知的系统大部分都属于 IO 密集型,比如数据库系统、缓存系统、Web 系统。这类系统的性能瓶颈可能出在系统内部,也可能是依赖的其他系统,而发现这类性能瓶颈的手段主要有两类。 + +第一类是采用工具,Linux 的工具集很丰富,完全可以满足你的优化需要,比如网络协议栈、网卡、磁盘、文件系统、内存,等等。这些工具的用法很多,你可以在排查问题的过程中逐渐积累。除此之外呢,一些开发语言还有针对语言特性的分析工具,比如说 Java 语言就有其专属的内存分析工具。 + +另外一类手段就是可以通过监控来发现性能问题。在监控中我们可以对任务的每一个步骤做分时的统计,从而找到任务的哪一步消耗了更多的时间。这一部分在演进篇中会有专门的介绍,这里就不再展开了。 + +那么找到了系统的瓶颈点,我们要如何优化呢?优化方案会随着问题的不同而不同。比方说,如果是数据库访问慢,那么就要看是不是有锁表的情况、是不是有全表扫描、索引加得是否合适、是否有 JOIN 操作、需不需要加缓存,等等;如果是网络的问题,就要看网络的参数是否有优化的空间,抓包来看是否有大量的超时重传,网卡是否有大量丢包等。 + +总而言之,“兵来将挡水来土掩”,我们需要制定不同的性能优化方案来应对不同的性能问题。 + +课程小结 + +今天,我带你了解了性能的原则、度量指标,以及在高并发下优化性能的基本思路。性能优化是一个很大的话题,只用短短一讲是完全不够的,所以我会在后面的课程中详细介绍其中的某些方面,比方说我们如何用缓存优化系统的读取性能,如何使用消息队列优化系统的写入性能等等。 + +有时候你在遇到性能问题的时候会束手无策,从今天的课程中你可以得到一些启示,在这里我给你总结出几点: + + +数据优先,你做一个新的系统在上线之前一定要把性能监控系统做好; +掌握一些性能优化工具和方法,这就需要在工作中不断的积累; +计算机基础知识很重要,比如说网络知识、操作系统知识等等,掌握了基础知识才能让你在优化过程中抓住性能问题的关键,也能在性能优化过程中游刃有余。 + + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/04系统设计目标(二):系统怎样做到高可用?.md b/专栏/高并发系统设计40问/04系统设计目标(二):系统怎样做到高可用?.md new file mode 100644 index 0000000..04b315b --- /dev/null +++ b/专栏/高并发系统设计40问/04系统设计目标(二):系统怎样做到高可用?.md @@ -0,0 +1,145 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 04 系统设计目标(二):系统怎样做到高可用? + 你好,我是唐扬。 + +开课之后,有同学反馈说课程中偏理论知识的讲解比较多,希望看到实例。我一直关注这些声音,也感谢你提出的建议,在 04 讲的开篇,我想对此作出一些回应。 + +在课程设计时,我主要想用基础篇中的前五讲内容带你了解一些关于高并发系统设计的基本概念,期望能帮你建立一个整体的框架,这样方便在后面的演进篇和实战篇中对涉及的知识点做逐一的展开和延伸。比方说,本节课提到了降级,那我会在运维篇中以案例的方式详细介绍降级方案的种类以及适用的场景,之所以这么设计是期望通过前面少量的篇幅把课程先串起来,以点带面,逐步展开。 + +当然,不同的声音是我后续不断优化课程内容的动力,我会认真对待每一条建议,不断优化课程,与你一起努力、进步。 + +接下来,让我们正式进入课程。 + +本节课,我会继续带你了解高并发系统设计的第二个目标——高可用性。你需要在本节课对提升系统可用性的思路和方法有一个直观的了解,这样,当后续对点讲解这些内容时,你能马上反应过来,你的系统在遇到可用性的问题时,也能参考这些方法进行优化。 + +高可用性(High Availability,HA)是你在系统设计时经常会听到的一个名词,它指的是系统具备较高的无故障运行的能力。 + +我们在很多开源组件的文档中看到的 HA 方案就是提升组件可用性,让系统免于宕机无法服务的方案。比如,你知道 Hadoop 1.0 中的 NameNode 是单点的,一旦发生故障则整个集群就会不可用;而在 Hadoop2 中提出的 NameNode HA 方案就是同时启动两个 NameNode,一个处于 Active 状态,另一个处于 Standby 状态,两者共享存储,一旦 Active NameNode 发生故障,则可以将 Standby NameNode 切换成 Active 状态继续提供服务,这样就增强了 Hadoop 的持续无故障运行的能力,也就是提升了它的可用性。 + +通常来讲,一个高并发大流量的系统,系统出现故障比系统性能低更损伤用户的使用体验。想象一下,一个日活用户过百万的系统,一分钟的故障可能会影响到上千的用户。而且随着系统日活的增加,一分钟的故障时间影响到的用户数也随之增加,系统对于可用性的要求也会更高。所以今天,我就带你了解一下在高并发下,我们如何来保证系统的高可用性,以便给你的系统设计提供一些思路。 + +可用性的度量 + +可用性是一个抽象的概念,你需要知道要如何来度量它,与之相关的概念是:MTBF 和 MTTR。 + +MTBF(Mean Time Between Failure)是平均故障间隔的意思,代表两次故障的间隔时间,也就是系统正常运转的平均时间。这个时间越长,系统稳定性越高。 + +MTTR(Mean Time To Repair)表示故障的平均恢复时间,也可以理解为平均故障时间。这个值越小,故障对于用户的影响越小。 + +可用性与 MTBF 和 MTTR 的值息息相关,我们可以用下面的公式表示它们之间的关系: + + +Availability = MTBF / (MTBF + MTTR) + + +这个公式计算出的结果是一个比例,而这个比例代表着系统的可用性。一般来说,我们会使用几个九来描述系统的可用性。 + + + +其实通过这张图你可以发现,一个九和两个九的可用性是很容易达到的,只要没有蓝翔技校的铲车搞破坏,基本上可以通过人肉运维的方式实现。 + +三个九之后,系统的年故障时间从 3 天锐减到 8 小时。到了四个九之后,年故障时间缩减到 1 小时之内。在这个级别的可用性下,你可能需要建立完善的运维值班体系、故障处理流程和业务变更流程。你可能还需要在系统设计上有更多的考虑。比如,在开发中你要考虑,如果发生故障,是否不用人工介入就能自动恢复。当然了,在工具建设方面,你也需要多加完善,以便快速排查故障原因,让系统快速恢复。 + +到达五个九之后,故障就不能靠人力恢复了。想象一下,从故障发生到你接收报警,再到你打开电脑登录服务器处理问题,时间可能早就过了十分钟了。所以这个级别的可用性考察的是系统的容灾和自动恢复的能力,让机器来处理故障,才会让可用性指标提升一个档次。 + +一般来说,我们的核心业务系统的可用性,需要达到四个九,非核心系统的可用性最多容忍到三个九。在实际工作中,你可能听到过类似的说法,只是不同级别,不同业务场景的系统对于可用性要求是不一样的。 + +目前,你已经对可用性的评估指标有了一定程度的了解了,接下来,我们来看一看高可用的系统设计需要考虑哪些因素。 + +高可用系统设计的思路 + +一个成熟系统的可用性需要从系统设计和系统运维两方面来做保障,两者共同作用,缺一不可。那么如何从这两方面入手,解决系统高可用的问题呢? + +1. 系统设计 + +“Design for failure”是我们做高可用系统设计时秉持的第一原则。在承担百万 QPS 的高并发系统中,集群中机器的数量成百上千台,单机的故障是常态,几乎每一天都有发生故障的可能。 + +未雨绸缪才能决胜千里。我们在做系统设计的时候,要把发生故障作为一个重要的考虑点,预先考虑如何自动化地发现故障,发生故障之后要如何解决。当然了,除了要有未雨绸缪的思维之外,我们还需要掌握一些具体的优化方法,比如failover(故障转移)、超时控制以及降级和限流。 + +一般来说,发生 failover 的节点可能有两种情况: + +\1. 是在完全对等的节点之间做 failover。 +\2. 是在不对等的节点之间,即系统中存在主节点也存在备节点。 + +在对等节点之间做 failover 相对来说简单些。在这类系统中所有节点都承担读写流量,并且节点中不保存状态,每个节点都可以作为另一个节点的镜像。在这种情况下,如果访问某一个节点失败,那么简单地随机访问另一个节点就好了。 + +举个例子,Nginx 可以配置当某一个 Tomcat 出现大于 500 的请求的时候,重试请求另一个 Tomcat 节点,就像下面这样: + + + +针对不对等节点的 failover 机制会复杂很多。比方说我们有一个主节点,有多台备用节点,这些备用节点可以是热备(同样在线提供服务的备用节点),也可以是冷备(只作为备份使用),那么我们就需要在代码中控制如何检测主备机器是否故障,以及如何做主备切换。 + +使用最广泛的故障检测机制是“心跳”。你可以在客户端上定期地向主节点发送心跳包,也可以从备份节点上定期发送心跳包。当一段时间内未收到心跳包,就可以认为主节点已经发生故障,可以触发选主的操作。 + +选主的结果需要在多个备份节点上达成一致,所以会使用某一种分布式一致性算法,比方说 Paxos,Raft。 + +除了故障转移以外,对于系统间调用超时的控制也是高可用系统设计的一个重要考虑方面。 + +复杂的高并发系统通常会有很多的系统模块组成,同时也会依赖很多的组件和服务,比如说缓存组件,队列服务等等。它们之间的调用最怕的就是延迟而非失败,因为失败通常是瞬时的,可以通过重试的方式解决。而一旦调用某一个模块或者服务发生比较大的延迟,调用方就会阻塞在这次调用上,它已经占用的资源得不到释放。当存在大量这种阻塞请求时,调用方就会因为用尽资源而挂掉。 + +在系统开发的初期,超时控制通常不被重视,或者是没有方式来确定正确的超时时间。 + +我之前经历过一个项目,模块之间通过 RPC 框架来调用,超时时间是默认的 30 秒。平时系统运行得非常稳定,可是一旦遇到比较大的流量,RPC 服务端出现一定数量慢请求的时候,RPC 客户端线程就会大量阻塞在这些慢请求上长达 30 秒,造成 RPC 客户端用尽调用线程而挂掉。后面我们在故障复盘的时候发现这个问题后,调整了 RPC,数据库,缓存以及调用第三方服务的超时时间,这样在出现慢请求的时候可以触发超时,就不会造成整体系统雪崩。 + +既然要做超时控制,那么我们怎么来确定超时时间呢?这是一个比较困难的问题。 + +超时时间短了,会造成大量的超时错误,对用户体验产生影响;超时时间长了,又起不到作用。我建议你通过收集系统之间的调用日志,统计比如说 99% 的响应时间是怎样的,然后依据这个时间来指定超时时间。如果没有调用的日志,那么你只能按照经验值来指定超时时间。不过,无论你使用哪种方式,超时时间都不是一成不变的,需要在后面的系统维护过程中不断地修改。 + +超时控制实际上就是不让请求一直保持,而是在经过一定时间之后让请求失败,释放资源给接下来的请求使用。这对于用户来说是有损的,但是却是必要的,因为它牺牲了少量的请求却保证了整体系统的可用性。而我们还有另外两种有损的方案能保证系统的高可用,它们就是降级和限流。 + +降级是为了保证核心服务的稳定而牺牲非核心服务的做法。比方说我们发一条微博会先经过反垃圾服务检测,检测内容是否是广告,通过后才会完成诸如写数据库等逻辑。 + +反垃圾的检测是一个相对比较重的操作,因为涉及到非常多的策略匹配,在日常流量下虽然会比较耗时却还能正常响应。但是当并发较高的情况下,它就有可能成为瓶颈,而且它也不是发布微博的主体流程,所以我们可以暂时关闭反垃圾服务检测,这样就可以保证主体的流程更加稳定。 + +限流完全是另外一种思路,它通过对并发的请求进行限速来保护系统。 + +比如对于 Web 应用,我限制单机只能处理每秒 1000 次的请求,超过的部分直接返回错误给客户端。虽然这种做法损害了用户的使用体验,但是它是在极端并发下的无奈之举,是短暂的行为,因此是可以接受的。 + +实际上,无论是降级还是限流,在细节上还有很多可供探讨的地方,我会在后面的课程中,随着系统的不断演进深入地剖析,在基础篇里就不多说了。 + +2. 系统运维 + +在系统设计阶段为了保证系统的可用性可以采取上面的几种方法,那在系统运维的层面又能做哪些事情呢?其实,我们可以从灰度发布、故障演练两个方面来考虑如何提升系统的可用性。 + +你应该知道,在业务平稳运行过程中,系统是很少发生故障的,90% 的故障是发生在上线变更阶段的。比方说,你上了一个新的功能,由于设计方案的问题,数据库的慢请求数翻了一倍,导致系统请求被拖慢而产生故障。 + +如果没有变更,数据库怎么会无缘无故地产生那么多的慢请求呢?因此,为了提升系统的可用性,重视变更管理尤为重要。而除了提供必要回滚方案,以便在出现问题时快速回滚恢复之外,另一个主要的手段就是灰度发布。 + +灰度发布指的是系统的变更不是一次性地推到线上的,而是按照一定比例逐步推进的。一般情况下,灰度发布是以机器维度进行的。比方说,我们先在 10% 的机器上进行变更,同时观察 Dashboard 上的系统性能指标以及错误日志。如果运行了一段时间之后系统指标比较平稳并且没有出现大量的错误日志,那么再推动全量变更。 + +灰度发布给了开发和运维同学绝佳的机会,让他们能在线上流量上观察变更带来的影响,是保证系统高可用的重要关卡。 + +灰度发布是在系统正常运行条件下,保证系统高可用的运维手段,那么我们如何知道发生故障时系统的表现呢?这里就要依靠另外一个手段:故障演练。 + +故障演练指的是对系统进行一些破坏性的手段,观察在出现局部故障时,整体的系统表现是怎样的,从而发现系统中存在的,潜在的可用性问题。 + +一个复杂的高并发系统依赖了太多的组件,比方说磁盘,数据库,网卡等,这些组件随时随地都可能会发生故障,而一旦它们发生故障,会不会如蝴蝶效应一般造成整体服务不可用呢?我们并不知道,因此,故障演练尤为重要。 + +在我来看,故障演练和时下比较流行的“混沌工程”的思路如出一辙,作为混沌工程的鼻祖,Netfix 在 2010 年推出的“Chaos Monkey”工具就是故障演练绝佳的工具。它通过在线上系统上随机地关闭线上节点来模拟故障,让工程师可以了解,在出现此类故障时会有什么样的影响。 + +当然,这一切是以你的系统可以抵御一些异常情况为前提的。如果你的系统还没有做到这一点,那么我建议你另外搭建一套和线上部署结构一模一样的线下系统,然后在这套系统上做故障演练,从而避免对生产系统造成影响。 + +课程小结 + +本节课我带你了解了如何度量系统的可用性,以及在做高并发系统设计时如何来保证高可用。 + +说了这么多,你可以看到从开发和运维角度上来看,提升可用性的方法是不同的: + + +开发注重的是如何处理故障,关键词是冗余和取舍。冗余指的是有备用节点,集群来顶替出故障的服务,比如文中提到的故障转移,还有多活架构等等;取舍指的是丢卒保车,保障主体服务的安全。 +从运维角度来看则更偏保守,注重的是如何避免故障的发生,比如更关注变更管理以及如何做故障的演练。 + + +两者结合起来才能组成一套完善的高可用体系。 + +你还需要注意的是,提高系统的可用性有时候是以牺牲用户体验或者是牺牲系统性能为前提的,也需要大量人力来建设相应的系统,完善机制。所以我们要把握一个度,不该做过度的优化。就像我在文中提到的,核心系统四个九的可用性已经可以满足需求,就没有必要一味地追求五个九甚至六个九的可用性。 + +另外,一般的系统或者组件都是追求极致的性能的,那么有没有不追求性能,只追求极致的可用性的呢?答案是有的。比如配置下发的系统,它只需要在其它系统启动时提供一份配置即可,所以秒级返回也可,十秒钟也 OK,无非就是增加了其它系统的启动速度而已。但是,它对可用性的要求是极高的,甚至会到六个九,原因是配置可以获取的慢,但是不能获取不到。我给你举这个例子是想让你了解,可用性和性能有时候是需要做取舍的,但如何取舍就要视不同的系统而定,不能一概而论了。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/05系统设计目标(三):如何让系统易于扩展?.md b/专栏/高并发系统设计40问/05系统设计目标(三):如何让系统易于扩展?.md new file mode 100644 index 0000000..6f65c67 --- /dev/null +++ b/专栏/高并发系统设计40问/05系统设计目标(三):如何让系统易于扩展?.md @@ -0,0 +1,91 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 05 系统设计目标(三):如何让系统易于扩展? + 从架构设计上来说,高可扩展性是一个设计的指标,它表示可以通过增加机器的方式来线性提高系统的处理能力,从而承担更高的流量和并发。 + +你可能会问:“在架构设计之初,为什么不预先考虑好使用多少台机器,支持现有的并发呢?”这个问题我在“[03 | 系统设计目标(一):如何提升系统性能?]”一课中提到过,答案是峰值的流量不可控。 + +一般来说,基于成本考虑,在业务平稳期,我们会预留 30%~50% 的冗余以应对运营活动或者推广可能带来的峰值流量,但是当有一个突发事件发生时,流量可能瞬间提升到 2~3 倍甚至更高,我们还是以微博为例。 + +鹿晗和关晓彤互圈公布恋情,大家会到两个人的微博下面,或围观,或互动,微博的流量短时间内增长迅速,微博信息流也短暂出现无法刷出新的消息的情况。 + +那我们要如何应对突发的流量呢?架构的改造已经来不及了,最快的方式就是堆机器。不过我们需要保证,扩容了三倍的机器之后,相应的我们的系统也能支撑三倍的流量。有的人可能会产生疑问:“这不是显而易见的吗?很简单啊。”真的是这样吗?我们来看看做这件事儿难在哪儿。 + +为什么提升扩展性会很复杂 + +在上一讲中,我提到可以在单机系统中通过增加处理核心的方式,来增加系统的并行处理能力,但这个方式并不总生效。因为当并行的任务数较多时,系统会因为争抢资源而达到性能上的拐点,系统处理能力不升反降。 + +而对于由多台机器组成的集群系统来说也是如此。集群系统中,不同的系统分层上可能存在一些“瓶颈点”,这些瓶颈点制约着系统的横线扩展能力。这句话比较抽象,我举个例子你就明白了。 + +比方说,你系统的流量是每秒 1000 次请求,对数据库的请求量也是每秒 1000 次。如果流量增加 10 倍,虽然系统可以通过扩容正常服务,数据库却成了瓶颈。再比方说,单机网络带宽是 50Mbps,那么如果扩容到 30 台机器,前端负载均衡的带宽就超过了千兆带宽的限制,也会成为瓶颈点。那么,我们的系统中存在哪些服务会成为制约系统扩展的重要因素呢? + +其实,无状态的服务和组件更易于扩展,而像 MySQL 这种存储服务是有状态的,就比较难以扩展。因为向存储集群中增加或者减少机器时,会涉及大量数据的迁移,而一般传统的关系型数据库都不支持。这就是为什么提升系统扩展性会很复杂的主要原因。 + +除此之外,从例子中你可以看到,我们需要站在整体架构的角度,而不仅仅是业务服务器的角度来考虑系统的扩展性 。所以说,数据库、缓存、依赖的第三方、负载均衡、交换机带宽等等都是系统扩展时需要考虑的因素。我们要知道系统并发到了某一个量级之后,哪一个因素会成为我们的瓶颈点,从而针对性地进行扩展。 + +针对这些复杂的扩展性问题,我提炼了一些系统设计思路,供你了解。 + +高可扩展性的设计思路 + +拆分是提升系统扩展性最重要的一个思路,它会把庞杂的系统拆分成独立的,有单一职责的模块。相对于大系统来说,考虑一个一个小模块的扩展性当然会简单一些。将复杂的问题简单化,这就是我们的思路。 + +但对于不同类型的模块,我们在拆分上遵循的原则是不一样的。我给你举一个简单的例子,假如你要设计一个社区,那么社区会有几个模块呢?可能有 5 个模块。 + + +用户:负责维护社区用户信息,注册,登陆等; +关系:用户之间关注、好友、拉黑等关系的维护; +内容:社区发的内容,就像朋友圈或者微博的内容; +评论、赞:用户可能会有的两种常规互动操作; +搜索:用户的搜索,内容的搜索。 + + +而部署方式遵照最简单的三层部署架构,负载均衡负责请求的分发,应用服务器负责业务逻辑的处理,数据库负责数据的存储落地。这时,所有模块的业务代码都混合在一起了,数据也都存储在一个库里。 + + + +1. 存储层的扩展性 + +无论是存储的数据量,还是并发访问量,不同的业务模块之间的量级相差很大,比如说成熟社区中,关系的数据量是远远大于用户数据量的,但是用户数据的访问量却远比关系数据要大。所以假如存储目前的瓶颈点是容量,那么我们只需要针对关系模块的数据做拆分就好了,而不需要拆分用户模块的数据。所以存储拆分首先考虑的维度是业务维度。 + +拆分之后,这个简单的社区系统就有了用户库、内容库、评论库、点赞库和关系库。这么做还能隔离故障,某一个库“挂了”不会影响到其它的数据库。 + + + +按照业务拆分,在一定程度上提升了系统的扩展性,但系统运行时间长了之后,单一的业务数据库在容量和并发请求量上仍然会超过单机的限制。这时,我们就需要针对数据库做第二次拆分。 + +这次拆分是按照数据特征做水平的拆分,比如说我们可以给用户库增加两个节点,然后按照某些算法将用户的数据拆分到这三个库里面,具体的算法我会在后面讲述数据库分库分表时和你细说。 + +水平拆分之后,我们就可以让数据库突破单机的限制了。但这里要注意,我们不能随意地增加节点,因为一旦增加节点就需要手动地迁移数据,成本还是很高的。所以基于长远的考虑,我们最好一次性增加足够的节点以避免频繁地扩容。 + +当数据库按照业务和数据维度拆分之后,我们尽量不要使用事务。因为当一个事务中同时更新不同的数据库时,需要使用二阶段提交,来协调所有数据库要么全部更新成功,要么全部更新失败。这个协调的成本会随着资源的扩展不断升高,最终达到无法承受的程度。 + +说完了存储层的扩展性,我们来看看业务层是如何做到易于扩展的。 + +2. 业务层的扩展性 + +我们一般会从三个维度考虑业务层的拆分方案,它们分别是:业务纬度,重要性纬度和请求来源纬度。 + +首先,我们需要把相同业务的服务拆分成单独的业务池,比方说上面的社区系统中,我们可以按照业务的维度拆分成用户池、内容池、关系池、评论池、点赞池和搜索池。 + +每个业务依赖独自的数据库资源,不会依赖其它业务的数据库资源。这样当某一个业务的接口成为瓶颈时,我们只需要扩展业务的池子,以及确认上下游的依赖方就可以了,这样就大大减少了扩容的复杂度。 + + + +除此之外,我们还可以根据业务接口的重要程度,把业务分为核心池和非核心池。打个比方,就关系池而言,关注、取消关注接口相对重要一些,可以放在核心池里面;拉黑和取消拉黑的操作就相对不那么重要,可以放在非核心池里面。这样,我们可以优先保证核心池的性能,当整体流量上升时优先扩容核心池,降级部分非核心池的接口,从而保证整体系统的稳定性。 + + + +最后,你还可以根据接入客户端类型的不同做业务池的拆分。比如说,服务于客户端接口的业务可以定义为外网池,服务于小程序或者 HTML5 页面的业务可以定义为 H5 池,服务于内部其它部门的业务可以定义为内网池,等等。 + +课程小结 + +本节课我带你了解了提升系统扩展性的复杂度以及系统拆分的思路。拆分看起来比较简单,可是什么时候做拆分,如何做拆分还是有很多细节考虑的。 + +未做拆分的系统虽然可扩展性不强,但是却足够简单,无论是系统开发还是运行维护都不需要投入很大的精力。拆分之后,需求开发需要横跨多个系统多个小团队,排查问题也需要涉及多个系统,运行维护上,可能每个子系统都需要有专人来负责,对于团队是一个比较大的考验。这个考验是我们必须要经历的一个大坎,需要我们做好准备。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/06面试现场第一期:当问到组件实现原理时,面试官是在刁难你吗?.md b/专栏/高并发系统设计40问/06面试现场第一期:当问到组件实现原理时,面试官是在刁难你吗?.md new file mode 100644 index 0000000..6d4edca --- /dev/null +++ b/专栏/高并发系统设计40问/06面试现场第一期:当问到组件实现原理时,面试官是在刁难你吗?.md @@ -0,0 +1,11 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 06 面试现场第一期:当问到组件实现原理时,面试官是在刁难你吗? + + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/07池化技术:如何减少频繁创建数据库连接的性能损耗?.md b/专栏/高并发系统设计40问/07池化技术:如何减少频繁创建数据库连接的性能损耗?.md new file mode 100644 index 0000000..2c0671d --- /dev/null +++ b/专栏/高并发系统设计40问/07池化技术:如何减少频繁创建数据库连接的性能损耗?.md @@ -0,0 +1,138 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 07 池化技术:如何减少频繁创建数据库连接的性能损耗? + 在前面几节课程中,我从宏观的角度带你了解了高并发系统设计的基础知识,你已经知晓了,我们系统设计的目的是为了获得更好的性能、更高的可用性,以及更强的系统扩展能力。 + +那么从这一讲开始,我们正式进入演进篇,我会再从局部出发,带你逐一了解完成这些目标会使用到的一些方法,这些方法会针对性地解决高并发系统设计中出现的问题。比如,在 15 讲中我会提及布隆过滤器,这个组件就是为了解决存在大量缓存穿透的情况下,如何尽量提升缓存命中率的问题。 + +当然,单纯地讲解理论,讲解方案会比较枯燥,所以我将用一个虚拟的系统作为贯穿整个课程的主线,说明当这个系统到达某一个阶段时,我们会遇到什么问题,然后要采用什么样的方案应对,应对的过程中又涉及哪些技术点。通过这样的讲述方式,力求以案例引出问题,能够让你了解遇到不同问题时,解决思路是怎样的,当然,在这个过程中,我希望你能多加思考,然后将学到的知识活学活用到实际的项目中。 + +接下来,让我们正式进入课程。 + +来想象这样一个场景,一天,公司 CEO 把你叫到会议室,告诉你公司看到了一个新的商业机会,希望你能带领一名兄弟,迅速研发出一套面向某个垂直领域的电商系统。 + +在人手紧张,时间不足的情况下,为了能够完成任务,你毫不犹豫地采用了最简单的架构:前端一台 Web 服务器运行业务代码,后端一台数据库服务器存储业务数据。 + + + +这个架构图是我们每个人最熟悉的,最简单的架构原型,很多系统在一开始都是长这样的,只是随着业务复杂度的提高,架构做了叠加,然后看起来就越来越复杂了。 + +再说回我们的垂直电商系统,系统一开始上线之后,虽然用户量不大,但运行平稳,你很有成就感,不过 CEO 觉得用户量太少了,所以紧急调动运营同学做了一次全网的流量推广。 + +这一推广很快带来了一大波流量,但这时,系统的访问速度开始变慢。 + +分析程序的日志之后,你发现系统慢的原因出现在和数据库的交互上。因为你们数据库的调用方式是先获取数据库的连接,然后依靠这条连接从数据库中查询数据,最后关闭连接释放数据库资源。这种调用方式下,每次执行 SQL 都需要重新建立连接,所以你怀疑,是不是频繁地建立数据库连接耗费时间长导致了访问慢的问题。 + +那么为什么频繁创建连接会造成响应时间慢呢?来看一个实际的测试。 + +我用”tcpdump -i bond0 -nn -tttt port 4490”命令抓取了线上 MySQL 建立连接的网络包来做分析,从抓包结果来看,整个 MySQL 的连接过程可以分为两部分: + +第一部分是前三个数据包。第一个数据包是客户端向服务端发送的一个“SYN”包,第二个包是服务端回给客户端的“ACK”包以及一个“SYN”包,第三个包是客户端回给服务端的“ACK”包,熟悉 TCP 协议的同学可以看出这是一个 TCP 的三次握手过程。 + +第二部分是 MySQL 服务端校验客户端密码的过程。其中第一个包是服务端发给客户端要求认证的报文,第二和第三个包是客户端将加密后的密码发送给服务端的包,最后两个包是服务端回给客户端认证 OK 的报文。从图中,你可以看到整个连接过程大概消耗了 4ms(969012-964904)。 + + + +那么单条 SQL 执行时间是多少呢?我们统计了一段时间的 SQL 执行时间,发现 SQL 的平均执行时间大概是 1ms,也就是说相比于 SQL 的执行,MySQL 建立连接的过程是比较耗时的。这在请求量小的时候其实影响不大,因为无论是建立连接还是执行 SQL,耗时都是毫秒级别的。可是请求量上来之后,如果按照原来的方式建立一次连接只执行一条 SQL 的话,1s 只能执行 200 次数据库的查询,而数据库建立连接的时间占了其中 4/5。 + +那这时你要怎么做呢? + +一番谷歌搜索之后,你发现解决方案也很简单,只要使用连接池将数据库连接预先建立好,这样在使用的时候就不需要频繁地创建连接了。调整之后,你发现 1s 就可以执行 1000 次的数据库查询,查询性能大大的提升了。 + +用连接池预先建立数据库连接 + +虽然短时间解决了问题,不过你还是想彻底搞明白解决问题的核心原理,于是又开始补课。 + +其实,在开发过程中我们会用到很多的连接池,像是数据库连接池、HTTP 连接池、Redis 连接池等等。而连接池的管理是连接池设计的核心,我就以数据库连接池为例,来说明一下连接池管理的关键点。 + +数据库连接池有两个最重要的配置:最小连接数和最大连接数,它们控制着从连接池中获取连接的流程: + + +如果当前连接数小于最小连接数,则创建新的连接处理数据库请求; +如果连接池中有空闲连接则复用空闲连接; +如果空闲池中没有连接并且当前连接数小于最大连接数,则创建新的连接处理请求; +如果当前连接数已经大于等于最大连接数,则按照配置中设定的时间(C3P0 的连接池配置是 checkoutTimeout)等待旧的连接可用; +如果等待超过了这个设定时间则向用户抛出错误。 + + +这个流程你不用死记,非常简单。你可以停下来想想如果你是连接池的设计者你会怎么设计,有哪些关键点,这个设计思路在我们以后的架构设计中经常会用到。 + +为了方便你理解性记忆这个流程,我来举个例子。 + +假设你在机场里经营着一家按摩椅的小店,店里一共摆着 10 台按摩椅(类比最大连接数),为了节省成本(按摩椅费电),你平时会保持店里开着 4 台按摩椅(最小连接数),其他 6 台都关着。 + +有顾客来的时候,如果平时保持启动的 4 台按摩椅有空着的,你直接请他去空着的那台就好了。但如果顾客来的时候,4 台按摩椅都不空着,那你就会新启动一台,直到你的 10 台按摩椅都被用完。 + +那 10 台按摩椅都被用完之后怎么办呢?你会告诉用户,稍等一会儿,我承诺你 5 分钟(等待时间)之内必定能空出来,然后第 11 位用户就开始等着。这时,会有两个结果:如果 5 分钟之内有空出来的,那顾客直接去空出来的那台按摩椅就可以了,但如果用户等了 5 分钟都没空出来,那你就得赔礼道歉,让用户去其他店再看看。 + +对于数据库连接池,根据我的经验,一般在线上我建议最小连接数控制在 10 左右,最大连接数控制在 20~30 左右即可。 + +在这里,你需要注意池子中连接的维护问题,也就是我提到的按摩椅。有的按摩椅虽然开着,但有的时候会有故障,一般情况下,“按摩椅故障”的原因可能有以下几种: + +\1. 数据库的域名对应的 IP 发生了变更,池子的连接还是使用旧的 IP,当旧的 IP 下的数据库服务关闭后,再使用这个连接查询就会发生错误; + +2.MySQL 有个参数是“wait_timeout”,控制着当数据库连接闲置多长时间后,数据库会主动的关闭这条连接。这个机制对于数据库使用方是无感知的,所以当我们使用这个被关闭的连接时就会发生错误。 + +那么,作为按摩椅店老板,你怎么保证你启动着的按摩椅一定是可用的呢? + +\1. 启动一个线程来定期检测连接池中的连接是否可用,比如使用连接发送“select 1”的命令给数据库看是否会抛出异常,如果抛出异常则将这个连接从连接池中移除,并且尝试关闭。目前 C3P0 连接池可以采用这种方式来检测连接是否可用,也是我比较推荐的方式。 + +\2. 在获取到连接之后,先校验连接是否可用,如果可用才会执行 SQL 语句。比如 DBCP 连接池的 testOnBorrow 配置项,就是控制是否开启这个验证。这种方式在获取连接时会引入多余的开销,在线上系统中还是尽量不要开启,在测试服务上可以使用。 + +至此,你彻底搞清楚了连接池的工作原理。可是,当你刚想松一口气的时候,CEO 又提出了一个新的需求。你分析了一下这个需求,发现在一个非常重要的接口中,你需要访问 3 次数据库。根据经验判断,你觉得这里未来肯定会成为系统瓶颈。 + +进一步想,你觉得可以创建多个线程来并行处理与数据库之间的交互,这样速度就能快了。不过,因为有了上次数据库的教训,你想到在高并发阶段,频繁创建线程的开销也会很大,于是顺着之前的思路继续想,猜测到了线程池。 + +用线程池预先创建线程 + +果不其然,JDK 1.5 中引入的 ThreadPoolExecutor 就是一种线程池的实现,它有两个重要的参数:coreThreadCount 和 maxThreadCount,这两个参数控制着线程池的执行过程。它的执行原理类似上面我们说的按摩椅店的模式,我这里再给你描述下,以加深你的记忆: + + +如果线程池中的线程数少于 coreThreadCount 时,处理新的任务时会创建新的线程; +如果线程数大于 coreThreadCount 则把任务丢到一个队列里面,由当前空闲的线程执行; +当队列中的任务堆积满了的时候,则继续创建线程,直到达到 maxThreadCount; +当线程数达到 maxTheadCount 时还有新的任务提交,那么我们就不得不将它们丢弃了。 + + + + +这个任务处理流程看似简单,实际上有很多坑,你在使用的时候一定要注意。 + +首先, JDK 实现的这个线程池优先把任务放入队列暂存起来,而不是创建更多的线程,它比较适用于执行 CPU 密集型的任务,也就是需要执行大量 CPU 运算的任务。这是为什么呢?因为执行 CPU 密集型的任务时 CPU 比较繁忙,因此只需要创建和 CPU 核数相当的线程就好了,多了反而会造成线程上下文切换,降低任务执行效率。所以当当前线程数超过核心线程数时,线程池不会增加线程,而是放在队列里等待核心线程空闲下来。 + +但是,我们平时开发的 Web 系统通常都有大量的 IO 操作,比方说查询数据库、查询缓存等等。任务在执行 IO 操作的时候 CPU 就空闲了下来,这时如果增加执行任务的线程数而不是把任务暂存在队列中,就可以在单位时间内执行更多的任务,大大提高了任务执行的吞吐量。所以你看 Tomcat 使用的线程池就不是 JDK 原生的线程池,而是做了一些改造,当线程数超过 coreThreadCount 之后会优先创建线程,直到线程数到达 maxThreadCount,这样就比较适合于 Web 系统大量 IO 操作的场景了,你在实际运用过程中也可以参考借鉴。 + +其次,线程池中使用的队列的堆积量也是我们需要监控的重要指标,对于实时性要求比较高的任务来说,这个指标尤为关键。 + +我在实际项目中就曾经遇到过任务被丢给线程池之后,长时间都没有被执行的诡异问题。最初,我认为这是代码的 Bug 导致的,后来经过排查发现,是因为线程池的 coreThreadCount 和 maxThreadCount 设置的比较小,导致任务在线程池里面大量的堆积,在调大了这两个参数之后问题就解决了。跳出这个坑之后,我就把重要线程池的队列任务堆积量,作为一个重要的监控指标放到了系统监控大屏上。 + +最后,如果你使用线程池请一定记住不要使用无界队列(即没有设置固定大小的队列)。也许你会觉得使用了无界队列后,任务就永远不会被丢弃,只要任务对实时性要求不高,反正早晚有消费完的一天。但是,大量的任务堆积会占用大量的内存空间,一旦内存空间被占满就会频繁地触发 Full GC,造成服务不可用,我之前排查过的一次 GC 引起的宕机,起因就是系统中的一个线程池使用了无界队列。 + +理解了线程池的关键要点,你在系统里加上了这个特性,至此,系统稳定,你圆满完成了公司给你的研发任务。 + +这时,你回顾一下这两种技术,会发现它们都有一个共同点:它们所管理的对象,无论是连接还是线程,它们的创建过程都比较耗时,也比较消耗系统资源。所以,我们把它们放在一个池子里统一管理起来,以达到提升性能和资源复用的目的。 + +这是一种常见的软件设计思想,叫做池化技术,它的核心思想是空间换时间,期望使用预先创建好的对象来减少频繁创建对象的性能开销,同时还可以对对象进行统一的管理,降低了对象的使用的成本,总之是好处多多。 + +不过,池化技术也存在一些缺陷,比方说存储池子中的对象肯定需要消耗多余的内存,如果对象没有被频繁使用,就会造成内存上的浪费。再比方说,池子中的对象需要在系统启动的时候就预先创建完成,这在一定程度上增加了系统启动时间。 + +可这些缺陷相比池化技术的优势来说就比较微不足道了,只要我们确认要使用的对象在创建时确实比较耗时或者消耗资源,并且这些对象也确实会被频繁地创建和销毁,我们就可以使用池化技术来优化。 + +课程小结 + +本节课,我模拟了研发垂直电商系统最原始的场景,在遇到数据库查询性能下降的问题时,我们使用数据库连接池解决了频繁创建连接带来的性能问题,后面又使用线程池提升了并行查询数据库的性能。 + +其实,连接池和线程池你并不陌生,不过你可能对它们的原理和使用方式上还存在困惑或者误区,我在面试时,就发现有很多的同学对线程池的基本使用方式都不了解。借用这节课,我想再次强调的重点是: + + +池子的最大值和最小值的设置很重要,初期可以依据经验来设置,后面还是需要根据实际运行情况做调整。 +池子中的对象需要在使用之前预先初始化完成,这叫做池子的预热,比方说使用线程池时就需要预先初始化所有的核心线程。如果池子未经过预热可能会导致系统重启后产生比较多的慢请求。 +池化技术核心是一种空间换时间优化方法的实践,所以要关注空间占用情况,避免出现空间过度使用出现内存泄露或者频繁垃圾回收等问题。 + + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/08数据库优化方案(一):查询请求增加时,如何做主从分离?.md b/专栏/高并发系统设计40问/08数据库优化方案(一):查询请求增加时,如何做主从分离?.md new file mode 100644 index 0000000..0d95ecc --- /dev/null +++ b/专栏/高并发系统设计40问/08数据库优化方案(一):查询请求增加时,如何做主从分离?.md @@ -0,0 +1,118 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 08 数据库优化方案(一):查询请求增加时,如何做主从分离? + 你好,我是唐扬。 + +上节课,我们用池化技术解决了数据库连接复用的问题,这时,你的垂直电商系统虽然整体架构上没有变化,但是和数据库交互的过程有了变化,在你的 Web 工程和数据库之间增加了数据库连接池,减少了频繁创建连接的成本,从上节课的测试来看性能上可以提升 80%。现在的架构图如下所示: + + + +此时,你的数据库还是单机部署,依据一些云厂商的 Benchmark 的结果,在 4 核 8G 的机器上运 MySQL 5.7 时,大概可以支撑 500 的 TPS 和 10000 的 QPS。这时,运营负责人说正在准备双十一活动,并且公司层面会继续投入资金在全渠道进行推广,这无疑会引发查询量骤然增加的问题。那么今天,我们就一起来看看当查询请求增加时,应该如何做主从分离来解决问题。 + +主从读写分离 + +其实,大部分系统的访问模型是读多写少,读写请求量的差距可能达到几个数量级。 + +这很好理解,刷朋友圈的请求量肯定比发朋友圈的量大,淘宝上一个商品的浏览量也肯定远大于它的下单量。因此,我们优先考虑数据库如何抗住更高的查询请求,那么首先你需要把读写流量区分开,因为这样才方便针对读流量做单独的扩展,这就是我们所说的主从读写分离。 + +它其实是个流量分离的问题,就好比道路交通管制一样,一个四车道的大马路划出三个车道给领导外宾通过,另外一个车道给我们使用,优先保证领导先行,就是这个道理。 + +这个方法本身是一种常规的做法,即使在一个大的项目中,它也是一个应对数据库突发读流量的有效方法。 + +我目前的项目中就曾出现过前端流量突增导致从库负载过高的问题,DBA 兄弟会优先做一个从库扩容上去,这样对数据库的读流量就会落入到多个从库上,从库的负载就降了下来,然后研发同学再考虑使用什么样的方案将流量挡在数据库层之上。 + +主从读写的两个技术关键点 + +一般来说在主从读写分离机制中,我们将一个数据库的数据拷贝为一份或者多份,并且写入到其它的数据库服务器中,原始的数据库我们称为主库,主要负责数据的写入,拷贝的目标数据库称为从库,主要负责支持数据查询。可以看到,主从读写分离有两个技术上的关键点: + +\1. 一个是数据的拷贝,我们称为主从复制; +\2. 在主从分离的情况下,我们如何屏蔽主从分离带来的访问数据库方式的变化,让开发同学像是在使用单一数据库一样。 + +接下来,我们分别来看一看。 + +1. 主从复制 + +我先以 MySQL 为例介绍一下主从复制。 + +MySQL 的主从复制是依赖于 binlog 的,也就是记录 MySQL 上的所有变化并以二进制形式保存在磁盘上二进制日志文件。主从复制就是将 binlog 中的数据从主库传输到从库上,一般这个过程是异步的,即主库上的操作不会等待 binlog 同步的完成。 + +主从复制的过程是这样的:首先从库在连接到主节点时会创建一个 IO 线程,用以请求主库更新的 binlog,并且把接收到的 binlog 信息写入一个叫做 relay log 的日志文件中,而主库也会创建一个 log dump 线程来发送 binlog 给从库;同时,从库还会创建一个 SQL 线程读取 relay log 中的内容,并且在从库中做回放,最终实现主从的一致性。这是一种比较常见的主从复制方式。 + +在这个方案中,使用独立的 log dump 线程是一种异步的方式,可以避免对主库的主体更新流程产生影响,而从库在接收到信息后并不是写入从库的存储中,是写入一个 relay log,是避免写入从库实际存储会比较耗时,最终造成从库和主库延迟变长。 + + + +你会发现,基于性能的考虑,主库的写入流程并没有等待主从同步完成就会返回结果,那么在极端的情况下,比如说主库上 binlog 还没有来得及刷新到磁盘上就出现了磁盘损坏或者机器掉电,就会导致 binlog 的丢失,最终造成主从数据的不一致。不过,这种情况出现的概率很低,对于互联网的项目来说是可以容忍的。 + +做了主从复制之后,我们就可以在写入时只写主库,在读数据时只读从库,这样即使写请求会锁表或者锁记录,也不会影响到读请求的执行。同时呢,在读流量比较大的情况下,我们可以部署多个从库共同承担读流量,这就是所说的“一主多从”部署方式,在你的垂直电商项目中就可以通过这种方式来抵御较高的并发读流量。另外,从库也可以当成一个备库来使用,以避免主库故障导致数据丢失。 + +那么你可能会说,是不是我无限制地增加从库的数量就可以抵抗大量的并发呢?实际上并不是的。因为随着从库数量增加,从库连接上来的 IO 线程比较多,主库也需要创建同样多的 log dump 线程来处理复制的请求,对于主库资源消耗比较高,同时受限于主库的网络带宽,所以在实际使用中,一般一个主库最多挂 3~5 个从库。 + +当然,主从复制也有一些缺陷,除了带来了部署上的复杂度,还有就是会带来一定的主从同步的延迟,这种延迟有时候会对业务产生一定的影响,我举个例子你就明白了。 + +在发微博的过程中会有些同步的操作,像是更新数据库的操作,也有一些异步的操作,比如说将微博的信息同步给审核系统,所以我们在更新完主库之后,会将微博的 ID 写入消息队列,再由队列处理机依据 ID 在从库中获取微博信息再发送给审核系统。此时如果主从数据库存在延迟,会导致在从库中获取不到微博信息,整个流程会出现异常。 + + + +这个问题解决的思路有很多,核心思想就是尽量不去从库中查询信息,纯粹以上面的例子来说,我就有三种解决方案: + +第一种方案是数据的冗余。你可以在发送消息队列时不仅仅发送微博 ID,而是发送队列处理机需要的所有微博信息,借此避免从数据库中重新查询数据。 + +第二种方案是使用缓存。我可以在同步写数据库的同时,也把微博的数据写入到 Memcached 缓存里面,这样队列处理机在获取微博信息的时候会优先查询缓存,这样也可以保证数据的一致性。 + +最后一种方案是查询主库。我可以在队列处理机中不查询从库而改为查询主库。不过,这种方式使用起来要慎重,要明确查询的量级不会很大,是在主库的可承受范围之内,否则会对主库造成比较大的压力。 + +我会优先考虑第一种方案,因为这种方式足够简单,不过可能造成单条消息比较大,从而增加了消息发送的带宽和时间。 + +缓存的方案比较适合新增数据的场景,在更新数据的场景下,先更新缓存可能会造成数据的不一致,比方说两个线程同时更新数据,线程 A 把缓存中的数据更新为 1,此时另一个线程 B 把缓存中的数据更新为 2,然后线程 B 又更新数据库中的数据为 2,此时线程 A 更新数据库中的数据为 1,这样数据库中的值(1)和缓存中的值(2)就不一致了。 + +最后,若非万不得已的情况下,我不会使用第三种方案。原因是这种方案要提供一个查询主库的接口,在团队开发的过程中,你很难保证其他同学不会滥用这个方法,而一旦主库承担了大量的读请求导致崩溃,那么对于整体系统的影响是极大的。 + +所以对这三种方案来说,你要有所取舍,根据实际项目情况做好选择。 + +另外,主从同步的延迟,是我们排查问题时很容易忽略的一个问题。有时候我们遇到从数据库中获取不到信息的诡异问题时,会纠结于代码中是否有一些逻辑会把之前写入的内容删除,但是你又会发现,过了一段时间再去查询时又可以读到数据了,这基本上就是主从延迟在作怪。所以,一般我们会把从库落后的时间作为一个重点的数据库指标做监控和报警,正常的时间是在毫秒级别,一旦落后的时间达到了秒级别就需要告警了。 + +2. 如何访问数据库 + +我们已经使用主从复制的技术将数据复制到了多个节点,也实现了数据库读写的分离,这时,对于数据库的使用方式发生了变化。以前只需要使用一个数据库地址就好了,现在需要使用一个主库地址和多个从库地址,并且需要区分写入操作和查询操作,如果结合下一节课中要讲解的内容“分库分表”,复杂度会提升更多。为了降低实现的复杂度,业界涌现了很多数据库中间件来解决数据库的访问问题,这些中间件可以分为两类。 + +第一类以淘宝的 TDDL( Taobao Distributed Data Layer)为代表,以代码形式内嵌运行在应用程序内部。你可以把它看成是一种数据源的代理,它的配置管理着多个数据源,每个数据源对应一个数据库,可能是主库,可能是从库。当有一个数据库请求时,中间件将 SQL 语句发给某一个指定的数据源来处理,然后将处理结果返回。 + +这一类中间件的优点是简单易用,没有多余的部署成本,因为它是植入到应用程序内部,与应用程序一同运行的,所以比较适合运维能力较弱的小团队使用;缺点是缺乏多语言的支持,目前业界这一类的主流方案除了 TDDL,还有早期的网易 DDB,它们都是 Java 语言开发的,无法支持其他的语言。另外,版本升级也依赖使用方更新,比较困难。 + +另一类是单独部署的代理层方案,这一类方案代表比较多,如早期阿里巴巴开源的 Cobar,基于 Cobar 开发出来的 Mycat,360 开源的 Atlas,美团开源的基于 Atlas 开发的 DBProxy 等等。 + +这一类中间件部署在独立的服务器上,业务代码如同在使用单一数据库一样使用它,实际上它内部管理着很多的数据源,当有数据库请求时,它会对 SQL 语句做必要的改写,然后发往指定的数据源。 + +它一般使用标准的 MySQL 通信协议,所以可以很好地支持多语言。由于它是独立部署的,所以也比较方便进行维护升级,比较适合有一定运维能力的大中型团队使用。它的缺陷是所有的 SQL 语句都需要跨两次网络:从应用到代理层和从代理层到数据源,所以在性能上会有一些损耗。 + + + +这些中间件,对你而言,可能并不陌生,但是我想让你注意到是,在使用任何中间件的时候一定要保证对于中间件有足够深入的了解,否则一旦出了问题没法快速地解决就悲剧了。 + +我之前的一个项目中,一直使用自研的一个组件来实现分库分表,后来发现这套组件有一定几率会产生对数据库多余的连接,于是团队讨论后决定替换成 Sharding-JDBC。原本以为是一次简单的组件切换,结果上线后发现两个问题:一是因为使用姿势不对,会偶发地出现分库分表不生效导致扫描所有库表的情况,二是偶发地出现查询延时达到秒级别。由于缺少对于 Sharding-JDBC 足够的了解,这两个问题我们都没有很快解决,后来不得已只能切回原来的组件,在找到问题之后再进行切换。 + +课程小结 + +本节课,我带你了解了查询量增加时,我们如何通过主从分离和一主多从部署抵抗增加的数据库流量的,你除了掌握主从复制的技术之外,还需要了解主从分离会带来什么问题以及它们的解决办法。这里我想让你明确的要点主要有: + +\1. 主从读写分离以及部署一主多从可以解决突发的数据库读流量,是一种数据库横向扩展的方法; + +\2. 读写分离后,主从的延迟是一个关键的监控指标,可能会造成写入数据之后立刻读的时候读取不到的情况; + +\3. 业界有很多的方案可以屏蔽主从分离之后数据库访问的细节,让开发人员像是访问单一数据库一样,包括有像 TDDL、Sharding-JDBC 这样的嵌入应用内部的方案,也有像 Mycat 这样的独立部署的代理方案。 + +其实,我们可以把主从复制引申为存储节点之间互相复制存储数据的技术,它可以实现数据的冗余,以达到备份和提升横向扩展能力的作用。在使用主从复制这个技术点时,你一般会考虑两个问题: + +\1. 主从的一致性和写入性能的权衡,如果你要保证所有从节点都写入成功,那么写入性能一定会受影响;如果你只写入主节点就返回成功,那么从节点就有可能出现数据同步失败的情况,从而造成主从不一致,而在互联网的项目中,我们一般会优先考虑性能而不是数据的强一致性。 + +\2. 主从的延迟问题,很多诡异的读取不到数据的问题都可能会和它有关,如果你遇到这类问题不妨先看看主从延迟的数据。 + +我们采用的很多组件都会使用到这个技术,比如,Redis 也是通过主从复制实现读写分离;Elasticsearch 中存储的索引分片也可以被复制到多个节点中;写入到 HDFS 中文件也会被复制到多个 DataNode 中。只是不同的组件对于复制的一致性、延迟要求不同,采用的方案也不同。但是这种设计的思想是通用的,是你需要了解的,这样你在学习其他存储组件的时候就能够触类旁通了。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/09数据库优化方案(二):写入数据量增加时,如何实现分库分表?.md b/专栏/高并发系统设计40问/09数据库优化方案(二):写入数据量增加时,如何实现分库分表?.md new file mode 100644 index 0000000..d822484 --- /dev/null +++ b/专栏/高并发系统设计40问/09数据库优化方案(二):写入数据量增加时,如何实现分库分表?.md @@ -0,0 +1,93 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 09 数据库优化方案(二):写入数据量增加时,如何实现分库分表? + 你好,我是唐扬。 + +前一节课,我们学习了在高并发下数据库的一种优化方案:读写分离,它就是依靠主从复制的技术使得数据库实现了数据复制为多份,增强了抵抗大量并发读请求的能力,提升了数据库的查询性能的同时,也提升了数据的安全性,当某一个数据库节点,无论是主库还是从库发生故障时,我们还有其他的节点中存储着全量的数据,保证数据不会丢失。此时,你的电商系统的架构图变成了下面这样: + + + +这时,公司 CEO 突然传来一个好消息,运营推广持续带来了流量,你所设计的电商系统的订单量突破了五千万,订单数据都是单表存储的,你的压力倍增,因为无论是数据库的查询还是写入性能都在下降,数据库的磁盘空间也在报警。所以,你主动分析现阶段自己需要考虑的问题,并寻求高效的解决方式,以便系统能正常运转下去。你考虑的问题主要有以下几点: + +\1. 系统正在持续不断地的发展,注册的用户越来越多,产生的订单越来越多,数据库中存储的数据也越来越多,单个表的数据量超过了千万甚至到了亿级别。这时即使你使用了索引,索引占用的空间也随着数据量的增长而增大,数据库就无法缓存全量的索引信息,那么就需要从磁盘上读取索引数据,就会影响到查询的性能了。那么这时你要如何提升查询性能呢? + +\2. 数据量的增加也占据了磁盘的空间,数据库在备份和恢复的时间变长,你如何让数据库系统支持如此大的数据量呢? + +\3. 不同模块的数据,比如用户数据和用户关系数据,全都存储在一个主库中,一旦主库发生故障,所有的模块儿都会受到影响,那么如何做到不同模块的故障隔离呢? + +\4. 你已经知道了,在 4 核 8G 的云服务器上对 MySQL5.7 做 Benchmark,大概可以支撑 500TPS 和 10000QPS,你可以看到数据库对于写入性能要弱于数据查询的能力,那么随着系统写入请求量的增长,数据库系统如何来处理更高的并发写入请求呢? + +这些问题你可以归纳成,数据库的写入请求量大造成的性能和可用性方面的问题,要解决这些问题,你所采取的措施就是对数据进行分片,对数据进行分片,可以很好地分摊数据库的读写压力,也可以突破单机的存储瓶颈,而常见的一种方式是对数据库做“分库分表”。 + +分库分表是一个很常见的技术方案,你应该有所了解。那你会说了:“既然这个技术很普遍,而我又有所了解,那你为什么还要提及这个话题呢?”因为以我过往的经验来看,不少人会在“分库分表”这里踩坑,主要体现在: + +\1. 对如何使用正确的分库分表方式一知半解,没有明白使用场景和方法。比如,一些同学会在查询时不使用分区键; + +\2. 分库分表引入了一些问题后,没有找到合适的解决方案。比如,会在查询时使用大量连表查询等等。 + +本节课,我就带你解决这两个问题,从常人容易踩坑的地方,跳出来。 + +如何对数据库做垂直拆分 + +分库分表是一种常见的将数据分片的方式,它的基本思想是依照某一种策略将数据尽量平均的分配到多个数据库节点或者多个表中。 + +不同于主从复制时数据是全量地被拷贝到多个节点,分库分表后,每个节点只保存部分的数据,这样可以有效地减少单个数据库节点和单个数据表中存储的数据量,在解决了数据存储瓶颈的同时也能有效的提升数据查询的性能。同时,因为数据被分配到多个数据库节点上,那么数据的写入请求也从请求单一主库变成了请求多个数据分片节点,在一定程度上也会提升并发写入的性能。 + +比如,我之前做过一个直播项目,在这个项目中,需要存储用户在直播间中发的消息以及直播间中的系统消息,你知道这些消息量极大,有些比较火的直播间有上万条留言是很常见的事儿,日积月累下来就积攒了几亿的数据,查询的性能和存储空间都扛不住了。没办法,就只能加班加点重构,启动多个数据库来分摊写入压力和容量的压力,也需要将原来单库的数据迁移到新启动的数据库节点上,好在最后成功完成分库分表和数据迁移校验工作,不过也着实花费了不少的时间和精力。 + +数据库分库分表的方式有两种:一种是垂直拆分,另一种是水平拆分。这两种方式,在我看来,掌握拆分方式是关键,理解拆分原理是内核。所以你在学习时,最好可以结合自身业务来思考。 + +垂直拆分,顾名思义就是对数据库竖着拆分,也就是将数据库的表拆分到多个不同的数据库中。 + +垂直拆分的原则一般是按照业务类型来拆分,核心思想是专库专用,将业务耦合度比较高的表拆分到单独的库中。举个形象的例子就是在整理衣服的时候,将羽绒服、毛衣、T 恤分别放在不同的格子里。这样可以解决我在开篇提到的第三个问题:把不同的业务的数据分拆到不同的数据库节点上,这样一旦数据库发生故障时只会影响到某一个模块的功能,不会影响到整体功能,从而实现了数据层面的故障隔离。 + +我还是以微博系统为例来给你说明一下。 + +在微博系统中有和用户相关的表,有和内容相关的表,有和关系相关的表,这些表都存储在主库中。在拆分后,我们期望用户相关的表分拆到用户库中,内容相关的表分拆到内容库中,关系相关的表分拆到关系库中。 + + + +对数据库进行垂直拆分是一种偏常规的方式,这种方式其实你会比较常用,不过拆分之后,虽然可以暂时缓解存储容量的瓶颈,但并不是万事大吉,因为数据库垂直拆分后依然不能解决某一个业务模块的数据大量膨胀的问题,一旦你的系统遭遇某一个业务库的数据量暴增,在这个情况下,你还需要继续寻找可以弥补的方式。 + +比如微博关系量早已经过了千亿,单一的数据库或者数据表已经远远不能满足存储和查询的需求了,这个时候,你需要将数据拆分到多个数据库和数据表中,也就是对数据库和数据表做水平拆分了。 + +如何对数据库做水平拆分 + +和垂直拆分的关注点不同,垂直拆分的关注点在于业务相关性,而水平拆分指的是将单一数据表按照某一种规则拆分到多个数据库和多个数据表中,关注点在数据的特点。 + +拆分的规则有下面这两种: + +\1. 按照某一个字段的哈希值做拆分,这种拆分规则比较适用于实体表,比如说用户表,内容表,我们一般按照这些实体表的 ID 字段来拆分。比如说我们想把用户表拆分成 16 个库,64 张表,那么可以先对用户 ID 做哈希,哈希的目的是将 ID 尽量打散,然后再对 16 取余,这样就得到了分库后的索引值;对 64 取余,就得到了分表后的索引值。 + + + +\2. 另一种比较常用的是按照某一个字段的区间来拆分,比较常用的是时间字段。你知道在内容表里面有“创建时间”的字段,而我们也是按照时间来查看一个人发布的内容。我们可能会要看昨天的内容,也可能会看一个月前发布的内容,这时就可以按照创建时间的区间来分库分表,比如说可以把一个月的数据放入一张表中,这样在查询时就可以根据创建时间先定位数据存储在哪个表里面,再按照查询条件来查询。 + +一般来说,列表数据可以使用这种拆分方式,比如一个人一段时间的订单,一段时间发布的内容。但是这种方式可能会存在明显的热点,这很好理解嘛,你当然会更关注最近我买了什么,发了什么,所以查询的 QPS 也会更多一些,对性能有一定的影响。另外,使用这种拆分规则后,数据表要提前建立好,否则如果时间到了 2020 年元旦,DBA(Database Administrator,数据库管理员)却忘记了建表,那么 2020 年的数据就没有库表可写了,就会发生故障了。 + + + +数据库在分库分表之后,数据的访问方式也有了极大的改变,原先只需要根据查询条件到从库中查询数据即可,现在则需要先确认数据在哪一个库表中,再到那个库表中查询数据。这种复杂度也可以通过数据库中间件来解决,我们在上一节中已经有所讲解,这里就不再赘述了,不过,我想再次强调的是你需要对所使用数据库中间件的原理有足够的了解和足够强的运维上的把控能力。 + +不过,你要知道的是,分库分表虽然能够解决数据库扩展性的问题,但是它也给我们的使用带来了一些问题。 + +解决分库分表引入的问题 + +分库分表引入的一个最大的问题就是引入了分库分表键,也叫做分区键,也就是我们对数据库做分库分表所依据的字段。 + +从分库分表规则中你可以看到,无论是哈希拆分还是区间段的拆分,我们首先都需要选取一个数据库字段,这带来一个问题是:我们之后所有的查询都需要带上这个字段,才能找到数据所在的库和表,否则就只能向所有的数据库和数据表发送查询命令。如果像上面说的要拆分成 16 个库和 64 张表,那么一次数据的查询会变成 16*64=1024 次查询,查询的性能肯定是极差的。 + +当然,方法总比问题多,针对这个问题,我们也会有一些相应的解决思路。比如,在用户库中我们使用 ID 作为分区键,这时如果需要按照昵称来查询用户时,你可以按照昵称作为分区键再做一次拆分,但是这样会极大的增加存储成本,如果以后我们还需要按照注册时间来查询时要怎么办呢,再做一次拆分吗? + +所以最合适的思路是你要建立一个昵称和 ID 的映射表,在查询的时候要先通过昵称查询到 ID,再通过 ID 查询完整的数据,这个表也可以是分库分表的,也需要占用一定的存储空间,但是因为表中只有两个字段,所以相比重新做一次拆分还是会节省不少的空间的。 + +分库分表引入的另外一个问题是一些数据库的特性在实现时可能变得很困难。比如说多表的 join 在单库时是可以通过一个 SQL 语句完成的,但是拆分到多个数据库之后就无法跨库执行 SQL 了,不过好在我们对于 join 的需求不高,即使有也一般是把两个表的数据取出后在业务代码里面做筛选,复杂是有一些,不过是可以实现的。再比如说在未分库分表之前查询数据总数时只需要在 SQL 中执行 count() 即可,现在数据被分散到多个库表中,我们可能要考虑其他的方案,比方说将计数的数据单独存储在一张表中或者记录在 Redis 里面。 + +当然,虽然分库分表会对我们使用数据库带来一些不便,但是相比它所带来的扩展性和性能方面的提升,我们还是需要做的,因为,经历过分库分表后的系统,才能够突破单机的容量和请求量的瓶颈,就比如说,我在开篇提到的我们的电商系统,它正是经历了分库分表,才会解决订单表数据量过大带来的性能衰减和容量瓶颈。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/10发号器:如何保证分库分表后ID的全局唯一性?.md b/专栏/高并发系统设计40问/10发号器:如何保证分库分表后ID的全局唯一性?.md new file mode 100644 index 0000000..63d3209 --- /dev/null +++ b/专栏/高并发系统设计40问/10发号器:如何保证分库分表后ID的全局唯一性?.md @@ -0,0 +1,105 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 10 发号器:如何保证分库分表后ID的全局唯一性? + 你好,我是唐扬。 + +在前面两节课程中,我带你了解了分布式存储两个核心问题:数据冗余和数据分片,以及在传统关系型数据库中是如何解决的。当我们面临高并发的查询数据请求时,可以使用主从读写分离的方式,部署多个从库分摊读压力;当存储的数据量达到瓶颈时,我们可以将数据分片存储在多个节点上,降低单个存储节点的存储压力,此时我们的架构变成了下面这个样子: + + + +你可以看到,我们通过分库分表和主从读写分离的方式解决了数据库的扩展性问题,但是在 09 讲我也提到过,数据库在分库分表之后,我们在使用数据库时存在的许多限制,比方说查询的时候必须带着分区键;一些聚合类的查询(像是 count())性能较差,需要考虑使用计数器等其它的解决方案,其实分库分表还有一个问题我在[09 讲]中没有提到,就是主键的全局唯一性的问题。本节课,我将带你一起来了解,在分库分表后如何生成全局唯一的数据库主键。 + +不过,在探究这个问题之前,你需要对“使用什么字段作为主键”这个问题有所了解,这样才能为我们后续探究如何生成全局唯一的主键做好铺垫。 + +数据库的主键要如何选择? + +数据库中的每一条记录都需要有一个唯一的标识,依据数据库的第二范式,数据库中每一个表中都需要有一个唯一的主键,其他数据元素和主键一一对应。 + +那么关于主键的选择就成为一个关键点了,一般来讲,你有两种选择方式: + +\1. 使用业务字段作为主键,比如说对于用户表来说,可以使用手机号,email 或者身份证号作为主键。 + +\2. 使用生成的唯一 ID 作为主键。 + +不过对于大部分场景来说,第一种选择并不适用,比如像评论表你就很难找到一个业务字段作为主键,因为在评论表中,你很难找到一个字段唯一标识一条评论。而对于用户表来说,我们需要考虑的是作为主键的业务字段是否能够唯一标识一个人,一个人可以有多个 email 和手机号,一旦出现变更 email 或者手机号的情况,就需要变更所有引用的外键信息,所以使用 email 或者手机作为主键是不合适的。 + +身份证号码确实是用户的唯一标识,但是由于它的隐私属性,并不是一个用户系统的必须属性,你想想,你的系统如果没有要求做实名认证,那么肯定不会要求用户填写身份证号码的。并且已有的身份证号码是会变更的,比如在 1999 年时身份证号码就从 15 位变更为 18 位,但是主键一旦变更,以这个主键为外键的表也都要随之变更,这个工作量是巨大的。 + +因此,我更倾向于使用生成的 ID 作为数据库的主键。不单单是因为它的唯一性,更是因为一旦生成就不会变更,可以随意引用。 + +在单库单表的场景下,我们可以使用数据库的自增字段作为 ID,因为这样最简单,对于开发人员来说也是透明的。但是当数据库分库分表后,使用自增字段就无法保证 ID 的全局唯一性了。 + +想象一下,当我们分库分表之后,同一个逻辑表的数据被分布到多个库中,这时如果使用数据库自增字段作为主键,那么只能保证在这个库中是唯一的,无法保证全局的唯一性。那么假如你来设计用户系统的时候,使用自增 ID 作为用户 ID,就可能出现两个用户有两个相同的 ID,这是不可接受的,那么你要怎么做呢?我建议你搭建发号器服务来生成全局唯一的 ID。 + +基于 Snowflake 算法搭建发号器 + +从我历年所经历的项目中,我主要使用的是变种的 Snowflake 算法来生成业务需要的 ID 的,本讲的重点,也是运用它去解决 ID 全局唯一性的问题。搞懂这个算法,知道它是怎么实现的,就足够你应用它来设计一套分布式发号器了,不过你可能会说了:“那你提全局唯一性,怎么不提 UUID 呢?” + +没错,UUID(Universally Unique Identifier,通用唯一标识码)不依赖于任何第三方系统,所以在性能和可用性上都比较好,我一般会使用它生成 Request ID 来标记单次请求,但是如果用它来作为数据库主键,它会存在以下几点问题。 + +首先,生成的 ID 做好具有单调递增性,也就是有序的,而 UUID 不具备这个特点。为什么 ID 要是有序的呢?因为在系统设计时,ID 有可能成为排序的字段。我给你举个例子。 + +比如,你要实现一套评论的系统时,你一般会设计两个表,一张评论表,存储评论的详细信息,其中有 ID 字段,有评论的内容,还有评论人 ID,被评论内容的 ID 等等,以 ID 字段作为分区键;另一个是评论列表,存储着内容 ID 和评论 ID 的对应关系,以内容 ID 为分区键。 + +我们在获取内容的评论列表时,需要按照时间序倒序排列,因为 ID 是时间上有序的,所以我们就可以按照评论 ID 的倒序排列。而如果评论 ID 不是在时间上有序的话,我们就需要在评论列表中再存储一个多余的创建时间的列用作排序,假设内容 ID、评论 ID 和时间都是使用 8 字节存储,我们就要多出 50% 的存储空间存储时间字段,造成了存储空间上的浪费。 + +另一个原因在于 ID 有序也会提升数据的写入性能。 + +我们知道 MySQL InnoDB 存储引擎使用 B+ 树存储索引数据,而主键也是一种索引。索引数据在 B+ 树中是有序排列的,就像下面这张图一样,图中 2,10,26 都是记录的 ID,也是索引数据。 + + + +这时,当插入的下一条记录的 ID 是递增的时候,比如插入 30 时,数据库只需要把它追加到后面就好了。但是如果插入的数据是无序的,比如 ID 是 13,那么数据库就要查找 13 应该插入的位置,再挪动 13 后面的数据,这就造成了多余的数据移动的开销。 + + + +我们知道机械磁盘在完成随机的写时,需要先做“寻道”找到要写入的位置,也就是让磁头找到对应的磁道,这个过程是非常耗时的。而顺序写就不需要寻道,会大大提升索引的写入性能。 + +UUID 不能作为 ID 的另一个原因是它不具备业务含义,其实现实世界中使用的 ID 中都包含有一些有意义的数据,这些数据会出现在 ID 的固定的位置上。比如说我们使用的身份证的前六位是地区编号;7~14 位是身份证持有人的生日;不同城市电话号码的区号是不同的;你从手机号码的的前三位就可以看出这个手机号隶属于哪一个运营商。而如果生成的 ID 可以被反解,那么从反解出来的信息中我们可以对 ID 来做验证,我们可以从中知道这个 ID 的生成时间,从哪个机房的发号器中生成的,为哪个业务服务的,对于问题的排查有一定的帮助。 + +最后,UUID 是由 32 个 16 进制数字组成的字符串,如果作为数据库主键使用比较耗费空间。 + +你能看到,UUID 方案有很大的局限性,也是我不建议你用它的原因,而 twitter 提出的 Snowflake 算法完全可以弥补 UUID 存在的不足,因为它不仅算法简单易实现,也满足 ID 所需要的全局唯一性,单调递增性,还包含一定的业务上的意义。 + +Snowflake 的核心思想是将 64bit 的二进制数字分成若干部分,每一部分都存储有特定含义的数据,比如说时间戳、机器 ID、序列号等等,最终生成全局唯一的有序 ID。它的标准算法是这样的: + + + +从上面这张图中我们可以看到,41 位的时间戳大概可以支撑 pow(2,41)/1000/60/60/24/365 年,约等于 69 年,对于一个系统是足够了。 + +如果你的系统部署在多个机房,那么 10 位的机器 ID 可以继续划分为 2~3 位的 IDC 标示(可以支撑 4 个或者 8 个 IDC 机房)和 7~8 位的机器 ID(支持 128-256 台机器);12 位的序列号代表着每个节点每毫秒最多可以生成 4096 的 ID。 + +不同公司也会依据自身业务的特点对 Snowflake 算法做一些改造,比如说减少序列号的位数增加机器 ID 的位数以支持单 IDC 更多的机器,也可以在其中加入业务 ID 字段来区分不同的业务。比方说我现在使用的发号器的组成规则就是:1 位兼容位恒为 0 + 41 位时间信息 + 6 位 IDC 信息(支持 64 个 IDC)+ 6 位业务信息(支持 64 个业务)+ 10 位自增信息(每毫秒支持 1024 个号) + +我选择这个组成规则,主要是因为我在单机房只部署一个发号器的节点,并且使用 KeepAlive 保证可用性。业务信息指的是项目中哪个业务模块使用,比如用户模块生成的 ID,内容模块生成的 ID,把它加入进来,一是希望不同业务发出来的 ID 可以不同,二是因为在出现问题时可以反解 ID,知道是哪一个业务发出来的 ID。 + +那么了解了 Snowflake 算法的原理之后,我们如何把它工程化,来为业务生成全局唯一的 ID 呢?一般来说我们会有两种算法的实现方式: + +一种是嵌入到业务代码里,也就是分布在业务服务器中。这种方案的好处是业务代码在使用的时候不需要跨网络调用,性能上会好一些,但是就需要更多的机器 ID 位数来支持更多的业务服务器。另外,由于业务服务器的数量很多,我们很难保证机器 ID 的唯一性,所以就需要引入 ZooKeeper 等分布式一致性组件来保证每次机器重启时都能获得唯一的机器 ID。 + +另外一个部署方式是作为独立的服务部署,这也就是我们常说的发号器服务。业务在使用发号器的时候就需要多一次的网络调用,但是内网的调用对于性能的损耗有限,却可以减少机器 ID 的位数,如果发号器以主备方式部署,同时运行的只有一个发号器,那么机器 ID 可以省略,这样可以留更多的位数给最后的自增信息位。即使需要机器 ID,因为发号器部署实例数有限,那么就可以把机器 ID 写在发号器的配置文件里,这样即可以保证机器 ID 唯一性,也无需引入第三方组件了。微博和美图都是使用独立服务的方式来部署发号器的,性能上单实例单 CPU 可以达到两万每秒。 + +Snowflake 算法设计的非常简单且巧妙,性能上也足够高效,同时也能够生成具有全局唯一性、单调递增性和有业务含义的 ID,但是它也有一些缺点,其中最大的缺点就是它依赖于系统的时间戳,一旦系统时间不准,就有可能生成重复的 ID。所以如果我们发现系统时钟不准,就可以让发号器暂时拒绝发号,直到时钟准确为止。 + +另外,如果请求发号器的 QPS 不高,比如说发号器每毫秒只发一个 ID,就会造成生成 ID 的末位永远是 1,那么在分库分表时如果使用 ID 作为分区键就会造成库表分配的不均匀。这一点,也是我在实际项目中踩过的坑,而解决办法主要有两个: + +\1. 时间戳不记录毫秒而是记录秒,这样在一个时间区间里可以多发出几个号,避免出现分库分表时数据分配不均。 + +\2. 生成的序列号的起始号可以做一下随机,这一秒是 21,下一秒是 30,这样就会尽量的均衡了。 + +我在开头提到,自己的实际项目中采用的是变种的 Snowflake 算法,也就是说对 Snowflake 算法进行了一定的改造,从上面的内容中你可以看出,这些改造:一是要让算法中的 ID 生成规则符合自己业务的特点;二是为了解决诸如时间回拨等问题。 + +其实,大厂除了采取 Snowflake 算法之外,还会选用一些其他的方案,比如滴滴和美团都有提出基于数据库生成 ID 的方案。这些方法根植于公司的业务,同样能解决分布式环境下 ID 全局唯一性的问题。对你而言,可以多角度了解不同的方法,这样能够寻找到更适合自己业务目前场景的解决方案,不过我想说的是,方案不在多,而在精,方案没有最好,只有最适合,真正弄懂方法背后的原理,并将它落地,才是你最佳的选择。 + +课程小结 + +本节课,我结合自己的项目经历带你了解了如何使用 Snowflake 算法解决分库分表后数据库 ID 的全局唯一的问题,在这个问题中,又延伸性地带你了解了生成的 ID 需要满足单调递增性,以及要具有一定业务含义的特性。当然,我们重点的内容是讲解如何将 Snowflake 算法落地,以及在落地过程中遇到了哪些坑,带你去解决它。 + +Snowflake 的算法并不复杂,你在使用的时候可以不考虑独立部署的问题,先想清楚按照自身的业务场景,需要如何设计 Snowflake 算法中的每一部分占的二进制位数。比如你的业务会部署几个 IDC,应用服务器要部署多少台机器,每秒钟发号个数的要求是多少等等,然后在业务代码中实现一个简单的版本先使用,等到应用服务器数量达到一定规模,再考虑独立部署的问题就可以了。这样可以避免多维护一套发号器服务,减少了运维上的复杂度。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/11NoSQL:在高并发场景下,数据库和NoSQL如何做到互补?.md b/专栏/高并发系统设计40问/11NoSQL:在高并发场景下,数据库和NoSQL如何做到互补?.md new file mode 100644 index 0000000..d2e64a2 --- /dev/null +++ b/专栏/高并发系统设计40问/11NoSQL:在高并发场景下,数据库和NoSQL如何做到互补?.md @@ -0,0 +1,140 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 11 NoSQL:在高并发场景下,数据库和NoSQL如何做到互补? + 你好,我是唐扬。 + +前几节课,我带你了解了在你的垂直电商项目中,如何将传统的关系型数据库改造成分布式存储服务,以抵抗高并发和大流量的冲击。 + +对于存储服务来说,我们一般会从两个方面对它做改造: + +\1. 提升它的读写性能,尤其是读性能,因为我们面对的多是一些读多写少的产品。比方说,你离不开的微信朋友圈、微博和淘宝,都是查询 QPS 远远大于写入 QPS。 + +\2. 增强它在存储上的扩展能力,从而应对大数据量的存储需求。 + +我之前带你学习的读写分离和分库分表就是从这两方面出发,改造传统的关系型数据库的,但仍有一些问题无法解决。 + +比如,在微博项目中关系的数据量达到了千亿,那么即使分隔成 1024 个库表,每张表的数据量也达到了亿级别,并且关系的数据量还在以极快的速度增加,即使你分隔成再多的库表,数据量也会很快增加到瓶颈。这个问题用传统数据库很难根本解决,因为它在扩展性方面是很弱的,这时,就可以利用 NoSQL,因为它有着天生分布式的能力,能够提供优秀的读写性能,可以很好地补充传统关系型数据库的短板。那么它是如何做到的呢? + +这节课,我就还是以你的垂直电商系统为例,带你掌握如何用 NoSQL 数据库和关系型数据库互补,共同承担高并发和大流量的冲击。 + +首先,我们先来了解一下 NoSQL 数据库。 + +NoSQL,No SQL? + +NoSQL 想必你很熟悉,它指的是不同于传统的关系型数据库的其他数据库系统的统称,它不使用 SQL 作为查询语言,提供优秀的横向扩展能力和读写性能,非常契合互联网项目高并发大数据的特点。所以一些大厂,比如小米、微博、陌陌都很倾向使用它来作为高并发大容量的数据存储服务。 + +NoSQL 数据库发展到现在,十几年间,出现了多种类型,我来给你举几个例子: + + +Redis、LevelDB 这样的 KV 存储。这类存储相比于传统的数据库的优势是极高的读写性能,一般对性能有比较高的要求的场景会使用。 +Hbase、Cassandra 这样的列式存储数据库。这种数据库的特点是数据不像传统数据库以行为单位来存储,而是以列来存储,适用于一些离线数据统计的场景。 +像 MongoDB、CouchDB 这样的文档型数据库。这种数据库的特点是 Schema Free(模式自由),数据表中的字段可以任意扩展,比如说电商系统中的商品有非常多的字段,并且不同品类的商品的字段也都不尽相同,使用关系型数据库就需要不断增加字段支持,而用文档型数据库就简单很多了。 + + +在 NoSQL 数据库刚刚被应用时,它被认为是可以替代关系型数据库的银弹,在我看来,也许因为以下几个方面的原因: + + +弥补了传统数据库在性能方面的不足; +数据库变更方便,不需要更改原先的数据结构; +适合互联网项目常见的大数据量的场景; + + +不过,这种看法是个误区,因为慢慢地我们发现在业务开发的场景下还是需要利用 SQL 语句的强大的查询功能以及传统数据库事务和灵活的索引等功能,NoSQL 只能作为一些场景的补充。 + +那么接下来,我就带你了解NoSQL 数据库是如何做到与关系数据库互补的。了解这部分内容,你可以在实际项目中更好地使用 NoSQL 数据库补充传统数据库的不足。 + +首先,我们来关注一下数据库的写入性能。 + +使用 NoSQL 提升写入性能 + +数据库系统大多使用的是传统的机械磁盘,对于机械磁盘的访问方式有两种:一种是随机 IO;另一种是顺序 IO。随机 IO 就需要花费时间做昂贵的磁盘寻道,一般来说,它的读写效率要比顺序 IO 小两到三个数量级,所以我们想要提升写入的性能就要尽量减少随机 IO。 + +以 MySQL 的 InnoDB 存储引擎来说,更新 binlog、redolog、undolog 都是在做顺序 IO,而更新 datafile 和索引文件则是在做随机 IO,而为了减少随机 IO 的发生,关系数据库已经做了很多的优化,比如说写入时先写入内存,然后批量刷新到磁盘上,但是随机 IO 还是会发生。 + +索引在 InnoDB 引擎中是以 B+ 树([上一节课]提到了 B+ 树,你可以回顾一下)方式来组织的,而 MySQL 主键是聚簇索引(一种索引类型,数据与索引数据放在一起),既然数据和索引数据放在一起,那么在数据插入或者更新的时候,我们需要找到要插入的位置,再把数据写到特定的位置上,这就产生了随机的 IO。而且一旦发生了页分裂,就不可避免会做数据的移动,也会极大地损耗写入性能。 + +NoSQL 数据库是怎么解决这个问题的呢? + +它们有多种的解决方式,这里我给你讲一种最常见的方案,就是很多 NoSQL 数据库都在使用的基于 LSM 树的存储引擎,这种算法使用最多,所以在这里着重剖析一下。 + +LSM 树(Log-Structured Merge Tree)牺牲了一定的读性能来换取写入数据的高性能,Hbase、Cassandra、LevelDB 都是用这种算法作为存储的引擎。 + +它的思想很简单,数据首先会写入到一个叫做 MemTable 的内存结构中,在 MemTable 中数据是按照写入的 Key 来排序的。为了防止 MemTable 里面的数据因为机器掉电或者重启而丢失,一般会通过写 Write Ahead Log 的方式将数据备份在磁盘上。 + +MemTable 在累积到一定规模时,它会被刷新生成一个新的文件,我们把这个文件叫做 SSTable(Sorted String Table)。当 SSTable 达到一定数量时,我们会将这些 SSTable 合并,减少文件的数量,因为 SSTable 都是有序的,所以合并的速度也很快。 + +当从 LSM 树里面读数据时,我们首先从 MemTable 中查找数据,如果数据没有找到,再从 SSTable 中查找数据。因为存储的数据都是有序的,所以查找的效率是很高的,只是因为数据被拆分成多个 SSTable,所以读取的效率会低于 B+ 树索引。 + + + +和 LSM 树类似的算法有很多,比如说 TokuDB 使用的名为 Fractal tree 的索引结构,它们的核心思想就是将随机 IO 变成顺序的 IO,从而提升写入的性能。 + +在后面的缓存篇中,我也将给你着重介绍我们是如何使用 KV 型 NoSQL 存储来提升读性能的。所以你看,NoSQL 数据库补充关系型数据库的第一种方式就是提升读写性能。 + +场景补充 + +除了可以提升性能之外,NoSQL 数据库还可以在某些场景下作为传统关系型数据库的补充,来看一个具体的例子。 + +假设某一天,CEO 找到你并且告诉你,他正在为你的垂直电商项目规划搜索的功能,需要支持按照商品的名称模糊搜索到对应的商品,希望你尽快调研出解决方案。 + +一开始,你认为这非常的简单,不就是在数据库里面执行一条类似:“select * from product where name like ‘%***%’”的语句吗?可是在实际执行的过程中,却发现了问题。 + +你发现这类语句并不是都能使用到索引,只有后模糊匹配的语句才能使用索引。比如语句“select * from product where name like ‘% 电冰箱’”就没有使用到字段“name”上的索引,而“select * from product where name like ‘索尼 %’”就使用了“name”上的索引。而一旦没有使用索引就会扫描全表的数据,在性能上是无法接受的。 + +于是你在谷歌上搜索了一下解决方案,发现大家都在使用开源组件 Elasticsearch 来支持搜索的请求,它本身是基于“倒排索引”来实现的,那么什么是倒排索引呢? + +倒排索引是指将记录中的某些列做分词,然后形成的分词与记录 ID 之间的映射关系。比如说,你的垂直电商项目里面有以下记录: + + + +那么,我们将商品名称做简单的分词,然后建立起分词和商品 ID 的对应关系,就像下面展示的这样: + + + +这样,如果用户搜索电冰箱,就可以给他展示商品 ID 为 1 和 3 的两件商品了。 + +而 Elasticsearch 作为一种常见的 NoSQL 数据库,就以倒排索引作为核心技术原理,为你提供了分布式的全文搜索服务,这在传统的关系型数据库中使用 SQL 语句是很难实现的。所以你看,NoSQL 可以在某些业务场景下代替传统数据库提供数据存储服务。 + +提升扩展性 + +另外,在扩展性方面,很多 NoSQL 数据库也有着先天的优势。还是以你的垂直电商系统为例,你已经为你的电商系统增加了评论系统,开始你的评估比较乐观,觉得电商系统的评论量级不会增长很快,所以就为它分了 8 个库,每个库拆分成 16 张表。 + +但是评论系统上线之后,存储量级增长的异常迅猛,你不得不将数据库拆分成更多的库表,而数据也要重新迁移到新的库表中,过程非常痛苦,而且数据迁移的过程也非常容易出错。 + +这时,你考虑是否可以考虑使用 NoSQL 数据库来彻底解决扩展性的问题,经过调研你发现它们在设计之初就考虑到了分布式和大数据存储的场景,比如像 MongoDB 就有三个扩展性方面的特性。 + + +其一是 Replica,也叫做副本集,你可以理解为主从分离,也就是通过将数据拷贝成多份来保证当主挂掉后数据不会丢失。同时呢,Replica 还可以分担读请求。Replica 中有主节点来承担写请求,并且把对数据变动记录到 oplog 里(类似于 binlog);从节点接收到 oplog 后就会修改自身的数据以保持和主节点的一致。一旦主节点挂掉,MongoDB 会从从节点中选取一个节点成为主节点,可以继续提供写数据服务。 +其二是 Shard,也叫做分片,你可以理解为分库分表,即将数据按照某种规则拆分成多份,存储在不同的机器上。MongoDB 的 Sharding 特性一般需要三个角色来支持,一个是 Shard Server,它是实际存储数据的节点,是一个独立的 Mongod 进程;二是 Config Server,也是一组 Mongod 进程,主要存储一些元信息,比如说哪些分片存储了哪些数据等;最后是 Route Server,它不实际存储数据,仅仅作为路由使用,它从 Config Server 中获取元信息后,将请求路由到正确的 Shard Server 中。 + + + + + +其三是负载均衡,就是当 MongoDB 发现 Shard 之间数据分布不均匀,会启动 Balancer 进程对数据做重新的分配,最终让不同 Shard Server 的数据可以尽量的均衡。当我们的 Shard Server 存储空间不足需要扩容时,数据会自动被移动到新的 Shard Server 上,减少了数据迁移和验证的成本。 + + +你可以看到,NoSQL 数据库中内置的扩展性方面的特性可以让我们不再需要对数据库做分库分表和主从分离,也是对传统数据库一个良好的补充。 + +你可能会觉得,NoSQL 已经成熟到可以代替关系型数据库了,但是就目前来看,NoSQL 只能作为传统关系型数据库的补充而存在,弥补关系型数据库在性能、扩展性和某些场景下的不足,所以你在使用或者选择时要结合自身的场景灵活地运用。 + +课程小结 + +本节课我带你了解了 NoSQL 数据库在性能、扩展性上的优势,以及它的一些特殊功能特性,主要有以下几点: + +\1. 在性能方面,NoSQL 数据库使用一些算法将对磁盘的随机写转换成顺序写,提升了写的性能; + +\2. 在某些场景下,比如全文搜索功能,关系型数据库并不能高效地支持,需要 NoSQL 数据库的支持; + +\3. 在扩展性方面,NoSQL 数据库天生支持分布式,支持数据冗余和数据分片的特性。 + +这些都让它成为传统关系型数据库的良好的补充,你需要了解的是,NoSQL 可供选型的种类很多,每一个组件都有各自的特点。你在做选型的时候需要对它的实现原理有比较深入的了解,最好在运维方面对它有一定的熟悉,这样在出现问题时才能及时找到解决方案。否则,盲目跟从地上了一个新的 NoSQL 数据库,最终可能导致会出了故障无法解决,反而成为整体系统的拖累。 + +我在之前的项目中曾经使用 Elasticsearch 作为持久存储,支撑社区的 feed 流功能,初期开发的时候确实很爽,你可以针对 feed 中的任何字段做灵活高效地查询,业务功能迭代迅速,代码也简单易懂。可是到了后期流量上来之后,由于缺少对于 Elasticsearch 成熟的运维能力,造成故障频出,尤其到了高峰期就会出现节点不可用的问题,而由于业务上的巨大压力又无法分出人力和精力对 Elasticsearch 深入的学习和了解,最后不得不做大的改造切回熟悉的 MySQL。所以,对于开源组件的使用,不能只停留在只会“hello world”的阶段,而应该对它有足够的运维上的把控能力。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/12缓存:数据库成为瓶颈后,动态数据的查询要如何加速?.md b/专栏/高并发系统设计40问/12缓存:数据库成为瓶颈后,动态数据的查询要如何加速?.md new file mode 100644 index 0000000..930e0ba --- /dev/null +++ b/专栏/高并发系统设计40问/12缓存:数据库成为瓶颈后,动态数据的查询要如何加速?.md @@ -0,0 +1,136 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 12 缓存:数据库成为瓶颈后,动态数据的查询要如何加速? + 你好,我是唐扬。 + +通过前面数据库篇的学习,你已经了解了在高并发大流量下,数据库层的演进过程以及库表设计上的考虑点。你的垂直电商系统在完成了对数据库的主从分离和分库分表之后,已经可以支撑十几万 DAU 了,整体系统的架构也变成了下面这样: + + + +从整体上看,数据库分了主库和从库,数据也被切分到多个数据库节点上。但随着并发的增加,存储数据量的增多,数据库的磁盘 IO 逐渐成了系统的瓶颈,我们需要一种访问更快的组件来降低请求响应时间,提升整体系统性能。这时我们就会使用缓存。那么什么是缓存,我们又该如何将它的优势最大化呢? + +本节课是缓存篇的总纲,我将从缓存定义、缓存分类和缓存优势劣势三个方面全方位带你掌握缓存的设计思想和理念,再用剩下 4 节课的时间,带你针对性地掌握使用缓存的正确姿势,以便让你在实际工作中能够更好地使用缓存提升整体系统的性能。 + +接下来,让我们进入今天的课程吧! + +什么是缓存 + +缓存,是一种存储数据的组件,它的作用是让对数据的请求更快地返回。 + +我们经常会把缓存放在内存中来存储, 所以有人就把内存和缓存画上了等号,这完全是外行人的见解。作为业内人士,你要知道在某些场景下我们可能还会使用 SSD 作为冷数据的缓存。比如说 360 开源的 Pika 就是使用 SSD 存储数据解决 Redis 的容量瓶颈的。 + +实际上,凡是位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为缓存。那么说到这儿我们就需要知道常见硬件组件的延时情况是什么样的了,这样在做方案的时候可以对延迟有更直观的印象。幸运的是,业内已经有人帮我们总结出这些数据了,我将这些数据整理了一下,你可以看一下。 + + + +从这些数据中,你可以看到,做一次内存寻址大概需要 100ns,而做一次磁盘的查找则需要 10ms。如果我们将做一次内存寻址的时间类比为一个课间,那么做一次磁盘查找相当于度过了大学的一个学期。可见,我们使用内存作为缓存的存储介质相比于以磁盘作为主要存储介质的数据库来说,性能上会提高多个数量级,同时也能够支撑更高的并发量。所以,内存是最常见的一种缓存数据的介质。 + +缓存作为一种常见的空间换时间的性能优化手段,在很多地方都有应用,我们先来看几个例子,相信你一定不会陌生。 + +1. 缓存案例 + +Linux 内存管理是通过一个叫做 MMU(Memory Management Unit)的硬件,来实现从虚拟地址到物理地址的转换的,但是如果每次转换都要做这么复杂计算的话,无疑会造成性能的损耗,所以我们会借助一个叫做 TLB(Translation Lookaside Buffer)的组件来缓存最近转换过的虚拟地址,和物理地址的映射。TLB 就是一种缓存组件,缓存复杂运算的结果,就好比你做一碗色香味俱全的面条可能比较复杂,那么我们把做好的面条油炸处理一下做成方便面,你做方便面的话就简单多了,也快速多了。这个缓存组件比较底层,这里你只需要了解一下就可以了。 + +在大部分的笔记本,桌面电脑和服务器上都会有一个或者多个 TLB 组件,在不经意间帮助我们加快地址转换的速度。 + +再想一下你平时经常刷的抖音。平台上的短视频实际上是使用内置的网络播放器来完成的。网络播放器接收的是数据流,将数据下载下来之后经过分离音视频流,解码等流程后输出到外设设备上播放。 + +如果我们在打开一个视频的时候才开始下载数据的话,无疑会增加视频的打开速度(我们叫首播时间),并且播放过程中会有卡顿。所以我们的播放器中通常会设计一些缓存的组件,在未打开视频时缓存一部分视频数据,比如我们打开抖音,服务端可能一次会返回三个视频信息,我们在播放第一个视频的时候,播放器已经帮我们缓存了第二、三个视频的部分数据,这样在看第二个视频的时候就可以给用户“秒开”的感觉。 + +除此之外,我们熟知的 HTTP 协议也是有缓存机制的。当我们第一次请求静态的资源时,比如一张图片,服务端除了返回图片信息,在响应头里面还有一个“Etag”的字段。浏览器会缓存图片信息以及这个字段的值。当下一次再请求这个图片的时候,浏览器发起的请求头里面会有一个“If-None-Match”的字段,并且把缓存的“Etag”的值写进去发给服务端。服务端比对图片信息是否有变化,如果没有,则返回浏览器一个 304 的状态码,浏览器会继续使用缓存的图片信息。通过这种缓存协商的方式,可以减少网络传输的数据大小,从而提升页面展示的性能。 + + + +2. 缓存与缓冲区 + +讲了这么多缓存案例,想必你对缓存已经有了一个直观并且形象的了解了。除了缓存,我们在日常开发过程中还会经常听见一个相似的名词——缓冲区,那么,什么是缓冲区呢?缓冲和缓存只有一字之差,它们有什么区别呢? + +我们知道,缓存可以提高低速设备的访问速度,或者减少复杂耗时的计算带来的性能问题。理论上说,我们可以通过缓存解决所有关于“慢”的问题,比如从磁盘随机读取数据慢,从数据库查询数据慢,只是不同的场景消耗的存储成本不同。 + +缓冲区则是一块临时存储数据的区域,这些数据后面会被传输到其他设备上。缓冲区更像“消息队列篇”中即将提到的消息队列,用以弥补高速设备和低速设备通信时的速度差。比如,我们将数据写入磁盘时并不是直接刷盘,而是写到一块缓冲区里面,内核会标识这个缓冲区为脏。当经过一定时间或者脏缓冲区比例到达一定阈值时,由单独的线程把脏块刷新到硬盘上。这样避免了每次写数据都要刷盘带来的性能问题。 + + + +以上就是缓冲区和缓存的区别,从这个区别来看,上面提到的 TLB 的命名是有问题的,它应该是缓存而不是缓冲区。 + +现在你已经了解了缓存的含义,那么我们经常使用的缓存都有哪些?我们又该如何使用缓存,将它的优势最大化呢? + +缓存分类 + +在我们日常开发中,常见的缓存主要就是静态缓存、分布式缓存和热点本地缓存这三种。 + +静态缓存在 Web 1.0 时期是非常著名的,它一般通过生成 Velocity 模板或者静态 HTML 文件来实现静态缓存,在 Nginx 上部署静态缓存可以减少对于后台应用服务器的压力。例如,我们在做一些内容管理系统的时候,后台会录入很多的文章,前台在网站上展示文章内容,就像新浪,网易这种门户网站一样。 + +当然,我们也可以把文章录入到数据库里面,然后前端展示的时候穿透查询数据库来获取数据,但是这样会对数据库造成很大的压力。即使我们使用分布式缓存来挡读请求,但是对于像日均 PV 几十亿的大型门户网站来说,基于成本考虑仍然是不划算的。 + +所以我们的解决思路是每篇文章在录入的时候渲染成静态页面,放置在所有的前端 Nginx 或者 Squid 等 Web 服务器上,这样用户在访问的时候会优先访问 Web 服务器上的静态页面,在对旧的文章执行一定的清理策略后,依然可以保证 99% 以上的缓存命中率。 + +这种缓存只能针对静态数据来缓存,对于动态请求就无能为力了。那么我们如何针对动态请求做缓存呢?这时你就需要分布式缓存了。 + +分布式缓存的大名可谓是如雷贯耳了,我们平时耳熟能详的 Memcached、Redis 就是分布式缓存的典型例子。它们性能强劲,通过一些分布式的方案组成集群可以突破单机的限制。所以在整体架构中,分布式缓存承担着非常重要的角色(接下来的课程我会专门针对分布式缓存,带你了解分布式缓存的使用技巧以及高可用的方案,让你能在工作中对分布式缓存运用自如)。 + +对于静态的资源的缓存你可以选择静态缓存,对于动态的请求你可以选择分布式缓存,那么什么时候要考虑热点本地缓存呢? + +答案是当我们遇到极端的热点数据查询的时候。热点本地缓存主要部署在应用服务器的代码中,用于阻挡热点查询对于分布式缓存节点或者数据库的压力。 + +比如某一位明星在微博上有了热点话题,“吃瓜群众”会到他 (她) 的微博首页围观,这就会引发这个用户信息的热点查询。这些查询通常会命中某一个缓存节点或者某一个数据库分区,短时间内会形成极高的热点查询。 + +那么我们会在代码中使用一些本地缓存方案,如 HashMap,Guava Cache 或者是 Ehcache 等,它们和应用程序部署在同一个进程中,优势是不需要跨网络调度,速度极快,所以可以来阻挡短时间内的热点查询。来看个例子。 + +比方说你的垂直电商系统的首页有一些推荐的商品,这些商品信息是由编辑在后台录入和变更。你分析编辑录入新的商品或者变更某个商品的信息后,在页面的展示是允许有一些延迟的,比如说 30 秒的延迟,并且首页请求量最大,即使使用分布式缓存也很难抗住,所以你决定使用 Guava Cache 来将所有的推荐商品的信息缓存起来,并且设置每隔 30 秒重新从数据库中加载最新的所有商品。 + +首先,我们初始化 Guava 的 Loading Cache: + +CacheBuilder> cacheBuilder = CacheBuilder.newBuilder().maximumSize(maxSize).recordStats(); // 设置缓存最大值 +cacheBuilder = cacheBuilder.refreshAfterWrite(30, TimeUnit.Seconds); // 设置刷新间隔 + +LoadingCache> cache = cacheBuilder.build(new CacheLoader>() { + @Override + public List load(String k) throws Exception { + return productService.loadAll(); // 获取所有商品 + } +}); + + +这样,你在获取所有商品信息的时候可以调用 Loading Cache 的 get 方法,就可以优先从本地缓存中获取商品信息,如果本地缓存不存在,会使用 CacheLoader 中的逻辑从数据库中加载所有的商品。 + +由于本地缓存是部署在应用服务器中,而我们应用服务器通常会部署多台,当数据更新时,我们不能确定哪台服务器本地中了缓存,更新或者删除所有服务器的缓存不是一个好的选择,所以我们通常会等待缓存过期。因此,这种缓存的有效期很短,通常为分钟或者秒级别,以避免返回前端脏数据。 + +缓存的不足 + +通过了解上面的内容,你不难发现,缓存的主要作用是提升访问速度,从而能够抗住更高的并发。那么,缓存是不是能够解决一切问题?显然不是。事物都是具有两面性的,缓存也不例外,我们要了解它的优势的同时也需要了解它有哪些不足,从而扬长避短,将它的作用发挥到最大。 + +首先,缓存比较适合于读多写少的业务场景,并且数据最好带有一定的热点属性。这是因为缓存毕竟会受限于存储介质不可能缓存所有数据,那么当数据有热点属性的时候才能保证一定的缓存命中率。比如说类似微博、朋友圈这种 20% 的内容会占到 80% 的流量。所以,一旦当业务场景读少写多时或者没有明显热点时,比如在搜索的场景下,每个人搜索的词都会不同,没有明显的热点,那么这时缓存的作用就不明显了。 + +其次,缓存会给整体系统带来复杂度,并且会有数据不一致的风险。当更新数据库成功,更新缓存失败的场景下,缓存中就会存在脏数据。对于这种场景,我们可以考虑使用较短的过期时间或者手动清理的方式来解决。 + +再次,之前提到缓存通常使用内存作为存储介质,但是内存并不是无限的。因此,我们在使用缓存的时候要做数据存储量级的评估,对于可预见的需要消耗极大存储成本的数据,要慎用缓存方案。同时,缓存一定要设置过期时间,这样可以保证缓存中的会是热点数据。 + +最后,缓存会给运维也带来一定的成本,运维需要对缓存组件有一定的了解,在排查问题的时候也多了一个组件需要考虑在内。 + +虽然有这么多的不足,但是缓存对于性能的提升是毋庸置疑的,我们在做架构设计的时候也需要把它考虑在内,只是在做具体方案的时候需要对缓存的设计有更细致的思考,才能最大化的发挥缓存的优势。 + +课程小结 + +这节课我带你了解了缓存的定义,常见缓存的分类以及缓存的不足。我想跟你强调的重点有以下几点: + + +缓存可以有多层,比如上面提到的静态缓存处在负载均衡层,分布式缓存处在应用层和数据库层之间,本地缓存处在应用层。我们需要将请求尽量挡在上层,因为越往下层,对于并发的承受能力越差; +缓存命中率是我们对于缓存最重要的一个监控项,越是热点的数据,缓存的命中率就越高。 + + +你还需要理解的是,缓存不仅仅是一种组件的名字,更是一种设计思想,你可以认为任何能够加速读请求的组件和设计方案都是缓存思想的体现。而这种加速通常是通过两种方式来实现: + + +使用更快的介质,比方说课程中提到的内存; +缓存复杂运算的结果,比方说前面 TLB 的例子就是缓存地址转换的结果。 + + +那么,当你在实际工作中碰到“慢”的问题时,缓存就是你第一时间需要考虑的。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/13缓存的使用姿势(一):如何选择缓存的读写策略?.md b/专栏/高并发系统设计40问/13缓存的使用姿势(一):如何选择缓存的读写策略?.md new file mode 100644 index 0000000..13701cf --- /dev/null +++ b/专栏/高并发系统设计40问/13缓存的使用姿势(一):如何选择缓存的读写策略?.md @@ -0,0 +1,127 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 13 缓存的使用姿势(一):如何选择缓存的读写策略? + 上节课,我带你了解了缓存的定义、分类以及不足,你现在应该对缓存有了初步的认知。从今天开始,我将带你了解一下使用缓存的正确姿势,比如缓存的读写策略是什么样的,如何做到缓存的高可用以及如何应对缓存穿透。通过了解这些内容,你会对缓存的使用有深刻的认识,这样在实际工作中就可以在缓存使用上游刃有余了。 + +今天,我们先讲讲缓存的读写策略。你可能觉得缓存的读写很简单,只需要优先读缓存,缓存不命中就从数据库查询,查询到了就回种缓存。实际上,针对不同的业务场景,缓存的读写策略也是不同的。 + +而我们在选择策略时也需要考虑诸多的因素,比如说,缓存中是否有可能被写入脏数据,策略的读写性能如何,是否存在缓存命中率下降的情况等等。接下来,我就以标准的“缓存 + 数据库”的场景为例,带你剖析经典的缓存读写策略以及它们适用的场景。这样一来,你就可以在日常的工作中根据不同的场景选择不同的读写策略。 + +Cache Aside(旁路缓存)策略 + +我们来考虑一种最简单的业务场景,比方说在你的电商系统中有一个用户表,表中只有 ID 和年龄两个字段,缓存中我们以 ID 为 Key 存储用户的年龄信息。那么当我们要把 ID 为 1 的用户的年龄从 19 变更为 20,要如何做呢? + +你可能会产生这样的思路:先更新数据库中 ID 为 1 的记录,再更新缓存中 Key 为 1 的数据。 + + + +这个思路会造成缓存和数据库中的数据不一致。比如,A 请求将数据库中 ID 为 1 的用户年龄从 19 变更为 20,与此同时,请求 B 也开始更新 ID 为 1 的用户数据,它把数据库中记录的年龄变更为 21,然后变更缓存中的用户年龄为 21。紧接着,A 请求开始更新缓存数据,它会把缓存中的年龄变更为 20。此时,数据库中用户年龄是 21,而缓存中的用户年龄却是 20。 + + + +为什么产生这个问题呢?因为变更数据库和变更缓存是两个独立的操作,而我们并没有对操作做任何的并发控制。那么当两个线程并发更新它们的时候,就会因为写入顺序的不同造成数据的不一致。 + +另外,直接更新缓存还存在另外一个问题就是丢失更新。还是以我们的电商系统为例,假如电商系统中的账户表有三个字段:ID、户名和金额,这个时候缓存中存储的就不只是金额信息,而是完整的账户信息了。当更新缓存中账户金额时,你需要从缓存中查询完整的账户数据,把金额变更后再写入到缓存中。 + +这个过程中也会有并发的问题,比如说原有金额是 20,A 请求从缓存中读到数据,并且把金额加 1,变更成 21,在未写入缓存之前又有请求 B 也读到缓存的数据后把金额也加 1,也变更成 21,两个请求同时把金额写回缓存,这时缓存里面的金额是 21,但是我们实际上预期是金额数加 2,这也是一个比较大的问题。 + +那我们要如何解决这个问题呢?其实,我们可以在更新数据时不更新缓存,而是删除缓存中的数据,在读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。 + + + +这个策略就是我们使用缓存最常见的策略,Cache Aside 策略(也叫旁路缓存策略),这个策略数据以数据库中的数据为准,缓存中的数据是按需加载的。它可以分为读策略和写策略,其中读策略的步骤是: + + +从缓存中读取数据; +如果缓存命中,则直接返回数据; +如果缓存不命中,则从数据库中查询数据; +查询到数据后,将数据写入到缓存中,并且返回给用户。 + + +写策略的步骤是: + + +更新数据库中的记录; +删除缓存记录。 + + +你也许会问了,在写策略中,能否先删除缓存,后更新数据库呢?答案是不行的,因为这样也有可能出现缓存数据不一致的问题,我以用户表的场景为例解释一下。 + +假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21,这就造成了缓存和数据库的不一致。 + + + +那么像 Cache Aside 策略这样先更新数据库,后删除缓存就没有问题了吗?其实在理论上还是有缺陷的。假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中,造成缓存和数据库数据不一致。 + + + +不过这种问题出现的几率并不高,原因是缓存的写入通常远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且清空了缓存,请求 A 才更新完缓存的情况。而一旦请求 A 早于请求 B 清空缓存之前更新了缓存,那么接下来的请求就会因为缓存为空而从数据库中重新加载数据,所以不会出现这种不一致的情况。 + +Cache Aside 策略是我们日常开发中最经常使用的缓存策略,不过我们在使用时也要学会依情况而变。比如说当新注册一个用户,按照这个更新策略,你要写数据库,然后清理缓存(当然缓存中没有数据给你清理)。可当我注册用户后立即读取用户信息,并且数据库主从分离时,会出现因为主从延迟所以读不到用户信息的情况。 + +而解决这个问题的办法恰恰是在插入新数据到数据库之后写入缓存,这样后续的读请求就会从缓存中读到数据了。并且因为是新注册的用户,所以不会出现并发更新用户信息的情况。 + +Cache Aside 存在的最大的问题是当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。如果你的业务对缓存命中率有严格的要求,那么可以考虑两种解决方案: + +\1. 一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响; + +\2. 另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快地过期,对业务的影响也是可以接受。 + +当然了,除了这个策略,在计算机领域还有其他几种经典的缓存策略,它们也有各自适用的使用场景。 + +Read/Write Through(读穿 / 写穿)策略 + +这个策略的核心原则是用户只与缓存打交道,由缓存和数据库通信,写入或者读取数据。这就好比你在汇报工作的时候只对你的直接上级汇报,再由你的直接上级汇报给他的上级,你是不能越级汇报的。 + +Write Through 的策略是这样的:先查询要写入的数据在缓存中是否已经存在,如果已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,如果缓存中数据不存在,我们把这种情况叫做“Write Miss(写失效)”。 + +一般来说,我们可以选择两种“Write Miss”方式:一个是“Write Allocate(按写分配)”,做法是写入缓存相应位置,再由缓存组件同步更新到数据库中;另一个是“No-write allocate(不按写分配)”,做法是不写入缓存中,而是直接更新到数据库中。 + +在 Write Through 策略中,我们一般选择“No-write allocate”方式,原因是无论采用哪种“Write Miss”方式,我们都需要同步将数据更新到数据库中,而“No-write allocate”方式相比“Write Allocate”还减少了一次缓存的写入,能够提升写入的性能。 + +Read Through 策略就简单一些,它的步骤是这样的:先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库中同步加载数据。 + +下面是 Read Through/Write Through 策略的示意图: + + + +Read Through/Write Through 策略的特点是由缓存节点而非用户来和数据库打交道,在我们开发过程中相比 Cache Aside 策略要少见一些,原因是我们经常使用的分布式缓存组件,无论是 Memcached 还是 Redis 都不提供写入数据库,或者自动加载数据库中的数据的功能。而我们在使用本地缓存的时候可以考虑使用这种策略,比如说在上一节中提到的本地缓存 Guava Cache 中的 Loading Cache 就有 Read Through 策略的影子。 + +我们看到 Write Through 策略中写数据库是同步的,这对于性能来说会有比较大的影响,因为相比于写缓存,同步写数据库的延迟就要高很多了。那么我们可否异步地更新数据库?这就是我们接下来要提到的“Write Back”策略。 + +Write Back(写回)策略 + +这个策略的核心思想是在写入数据时只写入缓存,并且把缓存块儿标记为“脏”的。而脏块儿只有被再次使用时才会将其中的数据写入到后端存储中。 + +需要注意的是,在“Write Miss”的情况下,我们采用的是“Write Allocate”的方式,也就是在写入后端存储的同时要写入缓存,这样我们在之后的写请求中都只需要更新缓存即可,而无需更新后端存储了,我将 Write back 策略的示意图放在了下面: + + + +如果使用 Write Back 策略的话,读的策略也有一些变化了。我们在读取缓存时如果发现缓存命中则直接返回缓存数据。如果缓存不命中则寻找一个可用的缓存块儿,如果这个缓存块儿是“脏”的,就把缓存块儿中之前的数据写入到后端存储中,并且从后端存储加载数据到缓存块儿,如果不是脏的,则由缓存组件将后端存储中的数据加载到缓存中,最后我们将缓存设置为不是脏的,返回数据就好了。 + + + +发现了吗?其实这种策略不能被应用到我们常用的数据库和缓存的场景中,它是计算机体系结构中的设计,比如我们在向磁盘中写数据时采用的就是这种策略。无论是操作系统层面的 Page Cache,还是日志的异步刷盘,亦或是消息队列中消息的异步写入磁盘,大多采用了这种策略。因为这个策略在性能上的优势毋庸置疑,它避免了直接写磁盘造成的随机写问题,毕竟写内存和写磁盘的随机 I/O 的延迟相差了几个数量级呢。 + +但因为缓存一般使用内存,而内存是非持久化的,所以一旦缓存机器掉电,就会造成原本缓存中的脏块儿数据丢失。所以你会发现系统在掉电之后,之前写入的文件会有部分丢失,就是因为 Page Cache 还没有来得及刷盘造成的。 + +当然,你依然可以在一些场景下使用这个策略,在使用时,我想给你的落地建议是:你在向低速设备写入数据的时候,可以在内存里先暂存一段时间的数据,甚至做一些统计汇总,然后定时地刷新到低速设备上。比如说,你在统计你的接口响应时间的时候,需要将每次请求的响应时间打印到日志中,然后监控系统收集日志后再做统计。但是如果每次请求都打印日志无疑会增加磁盘 I/O,那么不如把一段时间的响应时间暂存起来,经过简单的统计平均耗时,每个耗时区间的请求数量等等,然后定时地,批量地打印到日志中。 + +课程小结 + +本节课,我主要带你了解了缓存使用的几种策略,以及每种策略适用的使用场景是怎样的。我想让你掌握的重点是: + +1.Cache Aside 是我们在使用分布式缓存时最常用的策略,你可以在实际工作中直接拿来使用。 + +2.Read/Write Through 和 Write Back 策略需要缓存组件的支持,所以比较适合你在实现本地缓存组件的时候使用; + +3.Write Back 策略是计算机体系结构中的策略,不过写入策略中的只写缓存,异步写入后端存储的策略倒是有很多的应用场景。 + +而且,你还需要了解,我们今天提到的策略都是标准的使用姿势,在实际开发过程中需要结合实际的业务特点灵活使用甚至加以改造。这些业务特点包括但不仅限于:整体的数据量级情况,访问的读写比例的情况,对于数据的不一致时间的容忍度,对于缓存命中率的要求等等。理论结合实践,具体情况具体分析,你才能得到更好的解决方案。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/14缓存的使用姿势(二):缓存如何做到高可用?.md b/专栏/高并发系统设计40问/14缓存的使用姿势(二):缓存如何做到高可用?.md new file mode 100644 index 0000000..14f581f --- /dev/null +++ b/专栏/高并发系统设计40问/14缓存的使用姿势(二):缓存如何做到高可用?.md @@ -0,0 +1,147 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 14 缓存的使用姿势(二):缓存如何做到高可用? + 你好,我是唐扬。 + +前面几节课,我带你了解了缓存的原理、分类以及常用缓存的使用技巧。我们开始用缓存承担大部分的读压力,从而缓解数据库的查询压力,在提升性能的同时保证系统的稳定性。这时,你的电商系统整体的架构演变成下图的样子: + + + +我们在 Web 层和数据库层之间增加了缓存层,请求会首先查询缓存,只有当缓存中没有需要的数据时才会查询数据库。 + +在这里,你需要关注缓存命中率这个指标(缓存命中率 = 命中缓存的请求数 / 总请求数)。一般来说,在你的电商系统中,核心缓存的命中率需要维持在 99% 甚至是 99.9%,哪怕下降 1%,系统都会遭受毁灭性的打击。 + +这绝不是危言耸听,我们来计算一下。假设系统的 QPS 是 10000/s,每次调用会访问 10 次缓存或者数据库中的数据,那么当缓存命中率仅仅减少 1%,数据库每秒就会增加 10000 * 10 * 1% = 1000 次请求。而一般来说我们单个 MySQL 节点的读请求量峰值就在 1500/s 左右,增加的这 1000 次请求很可能会给数据库造成极大的冲击。 + +命中率仅仅下降 1% 造成的影响就如此可怕,更不要说缓存节点故障了。而图中单点部署的缓存节点就成了整体系统中最大的隐患,那我们要如何来解决这个问题,提升缓存的可用性呢? + +我们可以通过部署多个节点,同时设计一些方案让这些节点互为备份。这样,当某个节点故障时,它的备份节点可以顶替它继续提供服务。而这些方案就是我们本节课的重点:分布式缓存的高可用方案。 + +在我的项目中,我主要选择的方案有客户端方案、中间代理层方案和服务端方案三大类: + + +客户端方案就是在客户端配置多个缓存的节点,通过缓存写入和读取算法策略来实现分布式,从而提高缓存的可用性。 +中间代理层方案是在应用代码和缓存节点之间增加代理层,客户端所有的写入和读取的请求都通过代理层,而代理层中会内置高可用策略,帮助提升缓存系统的高可用。 +服务端方案就是 Redis 2.4 版本后提出的 Redis Sentinel 方案。 + + +掌握这些方案可以帮助你,抵御部分缓存节点故障导致的,缓存命中率下降的影响,增强你的系统的鲁棒性。 + +客户端方案 + +在客户端方案中,你需要关注缓存的写和读两个方面: + + +写入数据时,需要把被写入缓存的数据分散到多个节点中,即进行数据分片; +读数据时,可以利用多组的缓存来做容错,提升缓存系统的可用性。关于读数据,这里可以使用主从和多副本两种策略,两种策略是为了解决不同的问题而提出的。 + + +下面我就带你一起详细地看一下到底要怎么做。 + +1. 缓存数据如何分片 + +单一的缓存节点受到机器内存、网卡带宽和单节点请求量的限制,不能承担比较高的并发,因此我们考虑将数据分片,依照分片算法将数据打散到多个不同的节点上,每个节点上存储部分数据。 + +这样在某个节点故障的情况下,其他节点也可以提供服务,保证了一定的可用性。这就好比不要把鸡蛋放在同一个篮子里,这样一旦一个篮子掉在地上,摔碎了,别的篮子里还有没摔碎的鸡蛋,不至于一个不剩。 + +一般来讲,分片算法常见的就是 Hash 分片算法和一致性 Hash 分片算法两种。 + +Hash 分片的算法就是对缓存的 Key 做哈希计算,然后对总的缓存节点个数取余。你可以这么理解: + +比如说,我们部署了三个缓存节点组成一个缓存的集群,当有新的数据要写入时,我们先对这个缓存的 Key 做比如 crc32 等 Hash 算法生成 Hash 值,然后对 Hash 值模 3,得出的结果就是要存入缓存节点的序号。 + + + +这个算法最大的优点就是简单易理解,缺点是当增加或者减少缓存节点时,缓存总的节点个数变化造成计算出来的节点发生变化,从而造成缓存失效不可用。所以我建议你,如果采用这种方法,最好建立在你对于这组缓存命中率下降不敏感,比如下面还有另外一层缓存来兜底的情况下。 + +当然了,用一致性 Hash 算法可以很好地解决增加和删减节点时,命中率下降的问题。在这个算法中,我们将整个 Hash 值空间组织成一个虚拟的圆环,然后将缓存节点的 IP 地址或者主机名做 Hash 取值后,放置在这个圆环上。当我们需要确定某一个 Key 需要存取到哪个节点上的时候,先对这个 Key 做同样的 Hash 取值,确定在环上的位置,然后按照顺时针方向在环上“行走”,遇到的第一个缓存节点就是要访问的节点。比方说下面这张图里面,Key 1 和 Key 2 会落入到 Node 1 中,Key 3、Key 4 会落入到 Node 2 中,Key 5 落入到 Node 3 中,Key 6 落入到 Node 4 中。 + + + +这时如果在 Node 1 和 Node 2 之间增加一个 Node 5,你可以看到原本命中 Node 2 的 Key 3 现在命中到 Node 5,而其它的 Key 都没有变化;同样的道理,如果我们把 Node 3 从集群中移除,那么只会影响到 Key 5 。所以你看,在增加和删除节点时,只有少量的 Key 会“漂移”到其它节点上,而大部分的 Key 命中的节点还是会保持不变,从而可以保证命中率不会大幅下降。 + + + +不过,事物总有两面性。虽然这个算法对命中率的影响比较小,但它还是存在问题: + + +缓存节点在圆环上分布不平均,会造成部分缓存节点的压力较大;当某个节点故障时,这个节点所要承担的所有访问都会被顺移到另一个节点上,会对后面这个节点造成压力。 +一致性 Hash 算法的脏数据问题。 + + +极端情况下,比如一个有三个节点 A、B、C 承担整体的访问,每个节点的访问量平均,A 故障后,B 将承担双倍的压力(A 和 B 的全部请求),当 B 承担不了流量 Crash 后,C 也将因为要承担原先三倍的流量而 Crash,这就造成了整体缓存系统的雪崩。 + +说到这儿,你可能觉得很可怕,但也不要太担心,我们程序员就是要能够创造性地解决各种问题,所以你可以在一致性 Hash 算法中引入虚拟节点的概念。 + +它将一个缓存节点计算多个 Hash 值分散到圆环的不同位置,这样既实现了数据的平均,而且当某一个节点故障或者退出的时候,它原先承担的 Key 将以更加平均的方式分配到其他节点上,从而避免雪崩的发生。 + +其次,就是一致性 Hash 算法的脏数据问题。为什么会产生脏数据呢?比方说,在集群中有两个节点 A 和 B,客户端初始写入一个 Key 为 k,值为 3 的缓存数据到 Cache A 中。这时如果要更新 k 的值为 4,但是缓存 A 恰好和客户端连接出现了问题,那这次写入请求会写入到 Cache B 中。接下来缓存 A 和客户端的连接恢复,当客户端要获取 k 的值时,就会获取到存在 Cache A 中的脏数据 3,而不是 Cache B 中的 4。 + +所以,在使用一致性 Hash 算法时一定要设置缓存的过期时间,这样当发生漂移时,之前存储的脏数据可能已经过期,就可以减少存在脏数据的几率。 + + + +很显然,数据分片最大的优势就是缓解缓存节点的存储和访问压力,但同时它也让缓存的使用更加复杂。在 MultiGet(批量获取)场景下,单个节点的访问量并没有减少,同时节点数太多会造成缓存访问的 SLA(即“服务等级协议”,SLA 代表了网站服务可用性)得不到很好的保证,因为根据木桶原则,SLA 取决于最慢、最坏的节点的情况,节点数过多也会增加出问题的概率,因此我推荐 4 到 6 个节点为佳。 + +2.Memcached 的主从机制 + +Redis 本身支持主从的部署方式,但是 Memcached 并不支持,所以我们今天主要来了解一下 Memcached 的主从机制是如何在客户端实现的。 + +在之前的项目中,我就遇到了单个主节点故障导致数据穿透的问题,这时我为每一组 Master 配置一组 Slave,更新数据时主从同步更新。读取时,优先从 Slave 中读数据,如果读取不到数据就穿透到 Master 读取,并且将数据回种到 Slave 中以保持 Slave 数据的热度。 + +主从机制最大的优点就是当某一个 Slave 宕机时,还会有 Master 作为兜底,不会有大量请求穿透到数据库的情况发生,提升了缓存系统的高可用性。 + + + +3. 多副本 + +其实,主从方式已经能够解决大部分场景的问题,但是对于极端流量的场景下,一组 Slave 通常来说并不能完全承担所有流量,Slave 网卡带宽可能成为瓶颈。 + +为了解决这个问题,我们考虑在 Master/Slave 之前增加一层副本层,整体架构是这样的: + + + +在这个方案中,当客户端发起查询请求时,请求首先会先从多个副本组中选取一个副本组发起查询,如果查询失败,就继续查询 Master/Slave,并且将查询的结果回种到所有副本组中,避免副本组中脏数据的存在。 + +基于成本的考虑,每一个副本组容量比 Master 和 Slave 要小,因此它只存储了更加热的数据。在这套架构中,Master 和 Slave 的请求量会大大减少,为了保证它们存储数据的热度,在实践中我们会把 Master 和 Slave 作为一组副本组使用。 + +中间代理层方案 + +虽然客户端方案已经能解决大部分的问题,但是只能在单一语言系统之间复用。例如微博使用 Java 语言实现了这么一套逻辑,我使用 PHP 就难以复用,需要重新写一套,很麻烦。而中间代理层的方案就可以解决这个问题。你可以将客户端解决方案的经验移植到代理层中,通过通用的协议(如 Redis 协议)来实现在其他语言中的复用。 + +如果你来自研缓存代理层,你就可以将客户端方案中的高可用逻辑封装在代理层代码里面,这样用户在使用你的代理层的时候就不需要关心缓存的高可用是如何做的,只需要依赖你的代理层就好了。 + +除此以外,业界也有很多中间代理层方案,比如 Facebook 的Mcrouter,Twitter 的Twemproxy,豌豆荚的Codis。它们的原理基本上可以由一张图来概括: + + + +看这张图你有什么发现吗? 所有缓存的读写请求都是经过代理层完成的。代理层是无状态的,主要负责读写请求的路由功能,并且在其中内置了一些高可用的逻辑,不同的开源中间代理层方案中使用的高可用策略各有不同。比如在 Twemproxy 中,Proxy 保证在某一个 Redis 节点挂掉之后会把它从集群中移除,后续的请求将由其他节点来完成;而 Codis 的实现略复杂,它提供了一个叫 Codis Ha 的工具来实现自动从节点提主节点,在 3.2 版本之后换做了 Redis Sentinel 方式,从而实现 Redis 节点的高可用。 + +服务端方案 + +Redis 在 2.4 版本中提出了 Redis Sentinel 模式来解决主从 Redis 部署时的高可用问题,它可以在主节点挂了以后自动将从节点提升为主节点,保证整体集群的可用性,整体的架构如下图所示: + + + +Redis Sentinel 也是集群部署的,这样可以避免 Sentinel 节点挂掉造成无法自动故障恢复的问题,每一个 Sentinel 节点都是无状态的。在 Sentinel 中会配置 Master 的地址,Sentinel 会时刻监控 Master 的状态,当发现 Master 在配置的时间间隔内无响应,就认为 Master 已经挂了,Sentinel 会从从节点中选取一个提升为主节点,并且把所有其他的从节点作为新主的从节点。Sentinel 集群内部在仲裁的时候,会根据配置的值来决定当有几个 Sentinel 节点认为主挂掉可以做主从切换的操作,也就是集群内部需要对缓存节点的状态达成一致才行。 + +Redis Sentinel 不属于代理层模式,因为对于缓存的写入和读取请求不会经过 Sentinel 节点。Sentinel 节点在架构上和主从是平级的,是作为管理者存在的,所以可以认为是在服务端提供的一种高可用方案。 + +课程小结 + +这就是今天分享的全部内容了,我们一起来回顾一下重点: + +分布式缓存的高可用方案主要有三种,首先是客户端方案,一般也称为 Smart Client。我们通过制定一些数据分片和数据读写的策略,可以实现缓存高可用。这种方案的好处是性能没有损耗,缺点是客户端逻辑复杂且在多语言环境下不能复用。 + +其次,中间代理方案在客户端和缓存节点之间增加了中间层,在性能上会有一些损耗,在代理层会有一些内置的高可用方案,比如 Codis 会使用 Codis Ha 或者 Sentinel。 + +最后,服务端方案依赖于组件的实现,Memcached 就只支持单机版没有分布式和 HA 的方案,而 Redis 在 2.4 版本提供了 Sentinel 方案可以自动进行主从切换。服务端方案会在运维上增加一些复杂度。 + +总体而言,分布式缓存的三种方案各有所长,有些团队可能在开发过程中已经积累了 Smart Client 上的一些经验;而有些团队在 Redis 运维上经验丰富,就可以推进 Sentinel 方案;有些团队在存储研发方面有些积累,就可以推进中间代理层方案,甚至可以自研适合自己业务场景的代理层组件,具体的选择还是要看团队的实际情况而定。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/15缓存的使用姿势(三):缓存穿透了怎么办?.md b/专栏/高并发系统设计40问/15缓存的使用姿势(三):缓存穿透了怎么办?.md new file mode 100644 index 0000000..eeec372 --- /dev/null +++ b/专栏/高并发系统设计40问/15缓存的使用姿势(三):缓存穿透了怎么办?.md @@ -0,0 +1,149 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 15 缓存的使用姿势(三):缓存穿透了怎么办? + 你好,我是唐扬。 + +我用三节课的时间,带你深入了解了缓存,你应该知道,对于缓存来说,命中率是它的生命线。 + +在低缓存命中率的系统中,大量查询商品信息的请求会穿透缓存到数据库,因为数据库对于并发的承受能力是比较脆弱的。一旦数据库承受不了用户大量刷新商品页面、定向搜索衣服信息,就会导致查询变慢,导致大量的请求阻塞在数据库查询上,造成应用服务器的连接和线程资源被占满,最终导致你的电商系统崩溃。 + +一般来说,我们的核心缓存的命中率要保持在 99% 以上,非核心缓存的命中率也要尽量保证在 90%,如果低于这个标准,那么你可能就需要优化缓存的使用方式了。 + +既然缓存的穿透会带来如此大的影响,那么我们该如何减少它的发生呢?本节课,我就带你全面探知,面对缓存穿透时,我们到底有哪些应对措施。不过在此之前,你需要了解“到底什么是缓存穿透”,只有这样,才能更好地考虑如何设计方案解决它。 + +什么是缓存穿透 + +缓存穿透其实是指从缓存中没有查到数据,而不得不从后端系统(比如数据库)中查询的情况。你可以把数据库比喻为手机,它是经受不了太多的划痕和磕碰的,所以你需要给它贴个膜再套个保护壳,就能对手机起到一定的保护作用了。 + +不过,少量的缓存穿透不可避免,对系统也是没有损害的,主要有几点原因: + + +一方面,互联网系统通常会面临极大数据量的考验,而缓存系统在容量上是有限的,不可能存储系统所有的数据,那么在查询未缓存数据的时候就会发生缓存穿透。 +另一方面,互联网系统的数据访问模型一般会遵从“80/20 原则”。“80/20 原则”又称为帕累托法则,是意大利经济学家帕累托提出的一个经济学的理论。它是指在一组事物中,最重要的事物通常只占 20%,而剩余的 80% 的事物确实不重要的。把它应用到数据访问的领域,就是我们会经常访问 20% 的热点数据,而另外的 80% 的数据则不会被经常访问。比如你买了很多衣服,很多书,但是其实经常穿的,经常看的,可能也就是其中很小的一部分。 + + +既然缓存的容量有限,并且大部分的访问只会请求 20% 的热点数据,那么理论上说,我们只需要在有限的缓存空间里存储 20% 的热点数据就可以有效地保护脆弱的后端系统了,也就可以放弃缓存另外 80% 的非热点数据了。所以,这种少量的缓存穿透是不可避免的,但是对系统是没有损害的。 + +那么什么样的缓存穿透对系统有害呢?答案是大量的穿透请求超过了后端系统的承受范围,造成了后端系统的崩溃。如果把少量的请求比作毛毛细雨,那么一旦变成倾盆大雨,引发洪水,冲倒房屋,肯定就不行了。 + +产生这种大量穿透请求的场景有很多,接下来,我就带你解析这几种场景以及相应的解决方案。 + +缓存穿透的解决方案 + +先来考虑这样一种场景:在你的电商系统的用户表中,我们需要通过用户 ID 查询用户的信息,缓存的读写策略采用 Cache Aside 策略。 + +那么,如果要读取一个用户表中未注册的用户,会发生什么情况呢?按照这个策略,我们会先读缓存,再穿透读数据库。由于用户并不存在,所以缓存和数据库中都没有查询到数据,因此也就不会向缓存中回种数据(也就是向缓存中设置值的意思),这样当再次请求这个用户数据的时候还是会再次穿透到数据库。在这种场景下,缓存并不能有效地阻挡请求穿透到数据库上,它的作用就微乎其微了。 + +那如何解决缓存穿透呢?一般来说我们会有两种解决方案:回种空值以及使用布隆过滤器。 + +我们先来看看第一种方案。 + +回种空值 + +回顾上面提到的场景,你会发现最大的问题在于数据库中并不存在用户的数据,这就造成无论查询多少次,数据库中永远都不会存在这个用户的数据,穿透永远都会发生。 + +类似的场景还有一些:比如由于代码的 bug 导致查询数据库的时候抛出了异常,这样可以认为从数据库查询出来的数据为空,同样不会回种缓存。 + +那么,当我们从数据库中查询到空值或者发生异常时,我们可以向缓存中回种一个空值。但是因为空值并不是准确的业务数据,并且会占用缓存的空间,所以我们会给这个空值加一个比较短的过期时间,让空值在短时间之内能够快速过期淘汰。下面是这个流程的伪代码: + +Object nullValue = new Object(); +try { + Object valueFromDB = getFromDB(uid); // 从数据库中查询数据 + if (valueFromDB == null) { + cache.set(uid, nullValue, 10); // 如果从数据库中查询到空值,就把空值写入缓存,设置较短的超时时间 + } else { + cache.set(uid, valueFromDB, 1000); + } +} catch(Exception e) { + cache.set(uid, nullValue, 10); +} + + +回种空值虽然能够阻挡大量穿透的请求,但如果有大量获取未注册用户信息的请求,缓存内就会有有大量的空值缓存,也就会浪费缓存的存储空间,如果缓存空间被占满了,还会剔除掉一些已经被缓存的用户信息反而会造成缓存命中率的下降。 + +所以这个方案,我建议你在使用的时候应该评估一下缓存容量是否能够支撑。如果需要大量的缓存节点来支持,那么就无法通过通过回种空值的方式来解决,这时你可以考虑使用布隆过滤器。 + +使用布隆过滤器 + +1970 年布隆提出了一种布隆过滤器的算法,用来判断一个元素是否在一个集合中。这种算法由一个二进制数组和一个 Hash 算法组成。它的基本思路如下: + +我们把集合中的每一个值按照提供的 Hash 算法算出对应的 Hash 值,然后将 Hash 值对数组长度取模后得到需要计入数组的索引值,并且将数组这个位置的值从 0 改成 1。在判断一个元素是否存在于这个集合中时,你只需要将这个元素按照相同的算法计算出索引值,如果这个位置的值为 1 就认为这个元素在集合中,否则则认为不在集合中。 + +下图是布隆过滤器示意图,我来带你分析一下图内的信息。 + + + +A、B、C 等元素组成了一个集合,元素 D 计算出的 Hash 值所对应的的数组中值是 1,所以可以认为 D 也在集合中。而 F 在数组中的值是 0,所以 F 不在数组中。 + +那么我们如何使用布隆过滤器来解决缓存穿透的问题呢? + +还是以存储用户信息的表为例进行讲解。首先,我们初始化一个很大的数组,比方说长度为 20 亿的数组,接下来我们选择一个 Hash 算法,然后我们将目前现有的所有用户的 ID 计算出 Hash 值并且映射到这个大数组中,映射位置的值设置为 1,其它值设置为 0。 + +新注册的用户除了需要写入到数据库中之外,它也需要依照同样的算法更新布隆过滤器的数组中,相应位置的值。那么当我们需要查询某一个用户的信息时,我们首先查询这个 ID 在布隆过滤器中是否存在,如果不存在就直接返回空值,而不需要继续查询数据库和缓存,这样就可以极大地减少异常查询带来的缓存穿透。 + + + +布隆过滤器拥有极高的性能,无论是写入操作还是读取操作,时间复杂度都是 O(1),是常量值。在空间上,相对于其他数据结构它也有很大的优势,比如,20 亿的数组需要 2000000000/8/1024/1024 = 238M 的空间,而如果使用数组来存储,假设每个用户 ID 占用 4 个字节的空间,那么存储 20 亿用户需要 2000000000 * 4 / 1024 / 1024 = 7600M 的空间,是布隆过滤器的 32 倍。 + +不过,任何事物都有两面性,布隆过滤器也不例外,它主要有两个缺陷: + +\1. 它在判断元素是否在集合中时是有一定错误几率的,比如它会把不是集合中的元素判断为处在集合中; + +\2. 不支持删除元素。 + +关于第一个缺陷,主要是 Hash 算法的问题。因为布隆过滤器是由一个二进制数组和一个 Hash 算法组成的,Hash 算法存在着一定的碰撞几率。Hash 碰撞的含义是不同的输入值经过 Hash 运算后得到了相同的 Hash 结果。 + +本来,Hash 的含义是不同的输入,依据不同的算法映射成独一无二的固定长度的值,也就是我输入字符串“1”,根据 CRC32 算法,值是 2212294583。但是现实中 Hash 算法的输入值是无限的,输出值的值空间却是固定的,比如 16 位的 Hash 值的值空间是 65535,那么它的碰撞几率就是 1/65535,即如果输入值的个数超过 65535 就一定会发生碰撞。 + +那么你可能会问为什么不映射成更长的 Hash 值呢? + +因为更长的 Hash 值会带来更高的存储成本和计算成本。即使使用 32 位的 Hash 算法,它的值空间长度是 2 的 32 次幂减一,约等于 42 亿,用来映射 20 亿的用户数据,碰撞几率依然有接近 50%。 + +Hash 的碰撞就造成了两个用户 ID ,A 和 B 会计算出相同的 Hash 值,那么如果 A 是注册的用户,它的 Hash 值对应的数组中的值是 1,那么 B 即使不是注册用户,它在数组中的位置和 A 是相同的,对应的值也是 1,这就产生了误判。 + +布隆过滤器的误判有一个特点,就是它只会出现“false positive”的情况。这是什么意思呢?当布隆过滤器判断元素在集合中时,这个元素可能不在集合中。但是一旦布隆过滤器判断这个元素不在集合中时,它一定不在集合中。这一点非常适合解决缓存穿透的问题。为什么呢? + +你想,如果布隆过滤器会将集合中的元素判定为不在集合中,那么我们就不确定,被布隆过滤器判定为不在集合中的元素,是不是在集合中。假设在刚才的场景中,如果有大量查询未注册的用户信息的请求存在,那么这些请求到达布隆过滤器之后,即使布隆过滤器判断为不是注册用户,那么我们也不确定它是不是真的不是注册用户,那么就还是需要去数据库和缓存中查询,这就使布隆过滤器失去了价值。 + +所以你看,布隆过滤器虽然存在误判的情况,但是还是会减少缓存穿透的情况发生,只是我们需要尽量减少误判的几率,这样布隆过滤器的判断正确的几率更高,对缓存的穿透也更少。一个解决方案是: + +使用多个 Hash 算法为元素计算出多个 Hash 值,只有所有 Hash 值对应的数组中的值都为 1 时,才会认为这个元素在集合中。 + +布隆过滤器不支持删除元素的缺陷也和 Hash 碰撞有关。给你举一个例子,假如两个元素 A 和 B 都是集合中的元素,它们有相同的 Hash 值,它们就会映射到数组的同一个位置。这时我们删除了 A,数组中对应位置的值也从 1 变成 0,那么在判断 B 的时候发现值是 0,也会判断 B 是不在集合中的元素,就会得到错误的结论。 + +那么我是怎么解决这个问题的呢?我会让数组中不再只有 0 和 1 两个值,而是存储一个计数。比如如果 A 和 B 同时命中了一个数组的索引,那么这个位置的值就是 2,如果 A 被删除了就把这个值从 2 改为 1。这个方案中的数组不再存储 bit 位,而是存储数值,也就会增加空间的消耗。所以,你要依据业务场景来选择是否能够使用布隆过滤器,比如像是注册用户的场景下,因为用户删除的情况基本不存在,所以还是可以使用布隆过滤器来解决缓存穿透的问题的。 + +讲了这么多,关于布隆过滤器的使用上,我也给你几个建议: + +\1. 选择多个 Hash 函数计算多个 Hash 值,这样可以减少误判的几率; + +\2. 布隆过滤器会消耗一定的内存空间,所以在使用时需要评估你的业务场景下需要多大的内存,存储的成本是否可以接受。 + +总的来说,回种空值和布隆过滤器是解决缓存穿透问题的两种最主要的解决方案,但是它们也有各自的适用场景,并不能解决所有问题。比方说当有一个极热点的缓存项,它一旦失效会有大量请求穿透到数据库,这会对数据库造成瞬时极大的压力,我们把这个场景叫做“dog-pile effect”(狗桩效应), + +这是典型的缓存并发穿透的问题,那么,我们如何来解决这个问题呢?解决狗桩效应的思路是尽量地减少缓存穿透后的并发,方案也比较简单: + +\1. 在代码中,控制在某一个热点缓存项失效之后启动一个后台线程,穿透到数据库,将数据加载到缓存中,在缓存未加载之前,所有访问这个缓存的请求都不再穿透而直接返回。 + +\2. 通过在 Memcached 或者 Redis 中设置分布式锁,只有获取到锁的请求才能够穿透到数据库。 + +分布式锁的方式也比较简单,比方说 ID 为 1 的用户是一个热点用户,当他的用户信息缓存失效后,我们需要从数据库中重新加载数据时,先向 Memcached 中写入一个 Key 为”lock.1”的缓存项,然后去数据库里面加载数据,当数据加载完成后再把这个 Key 删掉。这时,如果另外一个线程也要请求这个用户的数据,它发现缓存中有 Key 为“lock.1”的缓存,就认为目前已经有线程在加载数据库中的值到缓存中了,它就可以重新去缓存中查询数据,不再穿透数据库了。 + +课程小结 + +本节课,我带你了解了一些解决缓存穿透的方案,你可以在发现自己的缓存系统命中率下降时,从中得到一些借鉴的思路。我想让你明确的重点是: + +\1. 回种空值是一种最常见的解决思路,实现起来也最简单,如果评估空值缓存占据的缓存空间可以接受,那么可以优先使用这种方案; + +\2. 布隆过滤器会引入一个新的组件,也会引入一些开发上的复杂度和运维上的成本。所以只有在存在海量查询数据库中,不存在数据的请求时才会使用,在使用时也要关注布隆过滤器对内存空间的消耗; + +\3. 对于极热点缓存数据穿透造成的“狗桩效应”,可以通过设置分布式锁或者后台线程定时加载的方式来解决。 + +除此之外,你还需要了解的是,数据库是一个脆弱的资源,它无论是在扩展性、性能还是承担并发的能力上,相比缓存都处于绝对的劣势,所以我们解决缓存穿透问题的核心目标在于减少对于数据库的并发请求。了解了这个核心的思想,也许你还会在日常工作中找到其他更好的解决缓存穿透问题的方案。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/16CDN:静态资源如何加速?.md b/专栏/高并发系统设计40问/16CDN:静态资源如何加速?.md new file mode 100644 index 0000000..22fa6e2 --- /dev/null +++ b/专栏/高并发系统设计40问/16CDN:静态资源如何加速?.md @@ -0,0 +1,123 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 16 CDN:静态资源如何加速? + 你好,我是唐扬。 + +前面几节课,我带你了解了缓存的定义以及常用缓存的使用姿势,现在,你应该对包括本地缓存、分布式缓存等缓存组件的适用场景和使用技巧有了一定了解了。结合在14 讲中我提到的客户端高可用方案,你会将单个缓存节点扩展为高可用的缓存集群,现在,你的电商系统架构演变成了下面这样: + + + +在这个架构中我们使用分布式缓存对动态请求数据的读取做了加速,但是在我们的系统中存在着大量的静态资源请求: + +\1. 对于移动 APP 来说,这些静态资源主要是图片、视频和流媒体信息。 + +\2. 对于 Web 网站来说,则包括了 JavaScript 文件,CSS 文件,静态 HTML 文件等等。 + +具体到你的电商系统来说,商品的图片,介绍商品使用方法的视频等等静态资源,现在都放在了 Nginx 等 Web 服务器上,它们的读请求量极大,并且对访问速度的要求很高,并且占据了很高的带宽,这时会出现访问速度慢,带宽被占满影响动态请求的问题,那么你就需要考虑如何针对这些静态资源进行读加速了。 + +静态资源加速的考虑点 + +你可能会问:“我们是否也可以使用分布式缓存来解决这个问题呢?”答案是否定的。一般来说,图片和视频的大小会在几兆到几百兆之间不等,如果我们的应用服务器和分布式缓存都部署在北京的机房里,这时一个杭州的用户要访问缓存中的一个视频,那这个视频文件就需要从北京传输到杭州,期间会经过多个公网骨干网络,延迟很高,会让用户感觉视频打开很慢,严重影响到用户的使用体验。 + +所以,静态资源访问的关键点是就近访问,即北京用户访问北京的数据,杭州用户访问杭州的数据,这样才可以达到性能的最优。 + +你可能会说:“那我们在杭州也自建一个机房,让用户访问杭州机房的数据就好了呀。”可用户遍布在全国各地,有些应用可能还有国外的用户,我们不可能在每个地域都自建机房,这样成本太高了。 + +另外,单个视频和图片等静态资源很大,并且访问量又极高,如果使用业务服务器和分布式缓存来承担这些流量,无论是对于内网还是外网的带宽都会是很大的考验。 + +所以我们考虑在业务服务器的上层,增加一层特殊的缓存,用来承担绝大部分对于静态资源的访问,这一层特殊缓存的节点需要遍布在全国各地,这样可以让用户选择最近的节点访问。缓存的命中率也需要一定的保证,尽量减少访问资源存储源站的请求数量(回源请求)。这一层缓存就是我们这节课的重点:CDN。 + +CDN 的关键技术 + +CDN(Content Delivery Network/Content Distribution Network,内容分发网络)。简单来说,CDN 就是将静态的资源分发到,位于多个地理位置机房中的服务器上,因此它能很好地解决数据就近访问的问题,也就加快了静态资源的访问速度。 + +在大中型公司里面,CDN 的应用非常的普遍,大公司为了提供更稳定的 CDN 服务会选择自建 CDN,而大部分公司基于成本的考虑还是会选择专业的 CDN 厂商,网宿、阿里云、腾讯云、蓝汛等等,其中网宿和蓝汛是老牌的 CDN 厂商,阿里云和腾讯云是云厂商提供的服务,如果你的服务部署在云上可以选择相应云厂商的 CDN 服务,这些 CDN 厂商都是现今行业内比较主流的。 + +对于 CDN 来说,你可能已经从运维的口中听说过,并且也了解了它的作用。但是当让你来配置 CDN 或者是排查 CDN 方面的问题时,你就有可能因为不了解它的原理而束手无策了。 + +所以,我先来带你了解一下,要搭建一个 CDN 系统需要考虑哪两点: + +\1. 如何将用户的请求映射到 CDN 节点上; + +\2. 如何根据用户的地理位置信息选择到比较近的节点。 + +下面我就带你具体了解一下 CDN 系统是如何实现加速用户对于静态资源的请求的。 + +1. 如何让用户的请求到达 CDN 节点 + +首先,我们考虑一下如何让用户的请求到达 CDN 节点,你可能会觉得,这很简单啊,只需要告诉用户 CDN 节点的 IP 地址,然后请求这个 IP 地址上面部署的 CDN 服务就可以了啊。但是这样会有一个问题:就是我们使用的是第三方厂商的 CDN 服务,CDN 厂商会给我们一个 CDN 的节点 IP,比如说这个 IP 地址是“111.202.34.130”,那么我们的电商系统中的图片的地址很可能是这样的:“http://111.202.34.130⁄1.jpg”, 这个地址是要存储在数据库中的。 + +那么如果这个节点 IP 发生了变更怎么办?或者我们如果更改了 CDN 厂商怎么办?是不是要修改所有的商品的 url 域名呢?这就是一个比较大的工作量了。所以,我们要做的事情是将第三方厂商提供的 IP 隐藏起来,给到用户的最好是一个本公司域名的子域名。 + +那么如何做到这一点呢?这就需要依靠 DNS 来帮我们解决域名映射的问题了。 + +DNS(Domain Name System,域名系统)实际上就是一个存储域名和 IP 地址对应关系的分布式数据库。而域名解析的结果一般有两种,一种叫做“A 记录”,返回的是域名对应的 IP 地址;另一种是“CNAME 记录”,返回的是另一个域名,也就是说当前域名的解析要跳转到另一个域名的解析上,实际上 www.baidu.com 域名的解析结果就是一个 CNAME 记录,域名的解析被跳转到 www.a.shifen.com 上了,我们正是利用 CNAME 记录来解决域名映射问题的,具体是怎么解决的呢?我给你举个例子。 + +比如你的公司的一级域名叫做 example.com,那么你可以给你的图片服务的域名定义为“img.example.com”,然后将这个域名的解析结果的 CNAME 配置到 CDN 提供的域名上,比如 uclound 可能会提供一个域名是“80f21f91.cdn.ucloud.com.cn”这个域名。这样你的电商系统使用的图片地址可以是“http://img.example.com/1.jpg”。 + +用户在请求这个地址时,DNS 服务器会将域名解析到 80f21f91.cdn.ucloud.com.cn 域名上,然后再将这个域名解析为 CDN 的节点 IP,这样就可以得到 CDN 上面的资源数据了。 + +不过,这里面有一个问题:因为域名解析过程是分级的,每一级有专门的域名服务器承担解析的职责,所以,域名的解析过程有可能需要跨越公网做多次 DNS 查询,在性能上是比较差的。 + + + +从“ 域名分级解析示意图”中你可以看出 DNS 分为很多种,有根 DNS,顶级 DNS 等等。除此之外还有两种 DNS 需要特别留意:一种是 Local DNS,它是由你的运营商提供的 DNS,一般域名解析的第一站会到这里;另一种是权威 DNS,它的含义是自身数据库中存储了这个域名对应关系的 DNS。 + +下面我以 www.baidu.com 这个域名为例给你简单介绍一下域名解析的过程: + +一开始,域名解析请求先会检查本机的 hosts 文件,查看是否有 www.baidu.com 对应的 IP; + +如果没有的话,就请求 Local DNS 是否有域名解析结果的缓存,如果有就返回,标识是从非权威 DNS 返回的结果; + +如果没有,就开始 DNS 的迭代查询。先请求根 DNS,根 DNS 返回顶级 DNS(.com)的地址;再请求.com 顶级 DNS,得到 baidu.com 的域名服务器地址;再从 baidu.com 的域名服务器中查询到 www.baidu.com 对应的 IP 地址,返回这个 IP 地址的同时,标记这个结果是来自于权威 DNS 的结果,同时写入 Local DNS 的解析结果缓存,这样下一次的解析同一个域名就不需要做 DNS 的迭代查询了。 + +经过了向多个 DNS 服务器做查询之后,整个 DNS 的解析的时间有可能会到秒级别,那么我们如何来解决这个性能问题呢? + +一个解决的思路是:在 APP 启动时,对需要解析的域名做预先解析,然后把解析的结果缓存到本地的一个 LRU 缓存里面。这样当我们要使用这个域名的时候,只需要从缓存中直接拿到所需要的 IP 地址就好了,如果缓存中不存在才会走整个 DNS 查询的过程。同时,为了避免 DNS 解析结果的变更造成缓存内数据失效,我们可以启动一个定时器,定期地更新缓存中的数据。 + +我曾经测试过这种方式,对于 HTTP 请求的响应时间的提升是很明显的,原先 DNS 解析时间经常会超过 1s,使用这种方式后,DNS 解析时间可以控制在 200ms 之内,整个 HTTP 请求的过程也可以减少大概 80ms~100ms。 + + + +这里总结一下,将用户的请求映射到 CDN 服务器上,是使用 CDN 时需要解决的一个核心的问题,而 CNAME 记录在 DNS 解析过程中可以充当一个中间代理层的角色,可以把将用户最初使用的域名代理到正确的 IP 地址上。图片: + + + +现在,剩下的一个问题就是如何找到更近的 CDN 节点了,而 GSLB 承担了这个职责。 + +2. 如何找到离用户最近的 CDN 节点 + +GSLB(Global Server Load Balance,全局负载均衡), 它的含义是对于部署在不同地域的服务器之间做负载均衡,下面可能管理了很多的本地负载均衡组件。它有两方面的作用: + +一方面,它是一种负载均衡服务器,负载均衡,顾名思义嘛,指的是让流量平均分配使得下面管理的服务器的负载更平均; + +另一方面,它还需要保证流量流经的服务器与流量源头在地缘上是比较接近的。 + +GSLB 可以通过多种策略,来保证返回的 CDN 节点和用户尽量保证在同一地缘区域,比如说可以将用户的 IP 地址按照地理位置划分为若干的区域,然后将 CDN 节点对应到一个区域上,然后根据用户所在区域来返回合适的节点;也可以通过发送数据包测量 RTT 的方式来决定返回哪一个节点。不过,这些原理不是本节课重点内容,你了解一下就可以了,我不做详细的介绍。 + +有了 GSLB 之后,节点的解析过程变成了下图中的样子: + + + +当然,是否能够从 CDN 节点上获取到资源还取决于 CDN 的同步延时。一般,我们会通过 CDN 厂商的接口将静态的资源写入到某一个 CDN 节点上,再由 CDN 内部的同步机制将资源分散同步到每个 CDN 节点,即使 CDN 内部网络经过了优化,这个同步的过程是有延时的,一旦我们无法从选定的 CDN 节点上获取到数据,我们就不得不从源站获取数据,而用户网络到源站的网络可能会跨越多个主干网,这样不仅性能上有损耗,也会消耗源站的带宽,带来更高的研发成本。所以,我们在使用 CDN 的时候需要关注 CDN 的命中率和源站的带宽情况。 + +课程小结 + +本节课,我主要带你了解了 CDN 对静态资源进行加速的原理和使用的核心技术,这里你需要了解的重点有以下几点: + +1.DNS 技术是 CDN 实现中使用的核心技术,可以将用户的请求映射到 CDN 节点上; + +2.DNS 解析结果需要做本地缓存,降低 DNS 解析过程的响应时间; + +3.GSLB 可以给用户返回一个离着他更近的节点,加快静态资源的访问速度。 + +作为一个服务端开发人员,你可能会忽略 CDN 的重要性,对于偶尔出现的 CDN 问题嗤之以鼻,觉得这个不是我们应该关心的内容,这种想法是错的。 + +CDN 是我们系统的门面,其缓存的静态数据,如图片和视频数据的请求量很可能是接口请求数据的几倍甚至更高,一旦发生故障,对于整体系统的影响是巨大的。另外 CDN 的带宽历来是我们研发成本的大头,尤其是目前处于小视频和直播风口上,大量的小视频和直播研发团队都在绞尽脑汁地减少 CDN 的成本。由此看出,CDN 是我们整体系统至关重要的组成部分,而它作为一种特殊的缓存,其命中率和可用性也是我们服务端开发人员需要重点关注的指标。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/17消息队列:秒杀时如何处理每秒上万次的下单请求?.md b/专栏/高并发系统设计40问/17消息队列:秒杀时如何处理每秒上万次的下单请求?.md new file mode 100644 index 0000000..9b6ffd5 --- /dev/null +++ b/专栏/高并发系统设计40问/17消息队列:秒杀时如何处理每秒上万次的下单请求?.md @@ -0,0 +1,115 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 17 消息队列:秒杀时如何处理每秒上万次的下单请求? + 你好,我是唐扬。 + +在课程一开始,我就带你了解了高并发系统设计的三个目标:性能、可用性和可扩展性,而在提升系统性能方面,我们一直关注的是系统的查询性能。也用了很多的篇幅去讲解数据库的分布式改造,各类缓存的原理和使用技巧。究其原因在于,我们遇到的大部分场景都是读多写少,尤其是在一个系统的初级阶段。 + +比如说,一个社区的系统初期一定是只有少量的种子用户在生产内容,而大部分的用户都在“围观”别人在说什么。此时,整体的流量比较小,而写流量可能只占整体流量的百分之一,那么即使整体的 QPS 到了 10000 次 / 秒,写请求也只是到了每秒 100 次,如果要对写请求做性能优化,它的性价比确实不太高。 + +但是,随着业务的发展,你可能会遇到一些存在高并发写请求的场景,其中秒杀抢购就是最典型的场景。假设你的商城策划了一期秒杀活动,活动在第五天的 00:00 开始,仅限前 200 名,那么秒杀即将开始时,后台会显示用户正在疯狂地刷新 APP 或者浏览器来保证自己能够尽量早的看到商品。 + +这时,你面对的依旧是读请求过高,那么应对的措施有哪些呢? + +因为用户查询的是少量的商品数据,属于查询的热点数据,你可以采用缓存策略,将请求尽量挡在上层的缓存中,能被静态化的数据,比如说商城里的图片和视频数据,尽量做到静态化,这样就可以命中 CDN 节点缓存,减少 Web 服务器的查询量和带宽负担。Web 服务器比如 Nginx 可以直接访问分布式缓存节点,这样可以避免请求到达 Tomcat 等业务服务器。 + +当然,你可以加上一些限流的策略,比如,对于短时间之内来自某一个用户、某一个 IP 或者某一台设备的重复请求做丢弃处理。 + +通过这几种方式,你发现自己可以将请求尽量挡在数据库之外了。 + +稍微缓解了读请求之后,00:00 分秒杀活动准时开始,用户瞬间向电商系统请求生成订单,扣减库存,用户的这些写操作都是不经过缓存直达数据库的。1 秒钟之内,有 1 万个数据库连接同时达到,系统的数据库濒临崩溃,寻找能够应对如此高并发的写请求方案迫在眉睫。这时你想到了消息队列。 + +我所理解的消息队列 + +关于消息队列是什么,你可能有所了解了,所以有关它的概念讲解,就不是本节课的重点,这里只聊聊我自己对消息队列的看法。在我历年的工作经历中,我一直把消息队列看作暂时存储数据的一个容器,认为消息队列是一个平衡低速系统和高速系统处理任务时间差的工具,我给你举个形象的例子。 + +比方说,古代的臣子经常去朝见皇上陈述一些国家大事,等着皇上拍板做决策。但是大臣很多,如果同时去找皇上,你说一句我说一句,皇上肯定会崩溃。后来变成臣子到了午门之后要原地等着皇上将他们一个一个地召见进大殿商议国事,这样就可以缓解皇上处理事情的压力了。你可以把午门看作一个暂时容纳臣子的容器,也就是我们所说的消息队列。 + +其实,你在一些组件中都会看到消息队列的影子: + +在 Java 线程池中我们就会使用一个队列来暂时存储提交的任务,等待有空闲的线程处理这些任务; + +操作系统中,中断的下半部分也会使用工作队列来实现延后执行; + +我们在实现一个 RPC 框架时,也会将从网络上接收到的请求写到队列里,再启动若干个工作线程来处理。 + +…… + +总之,队列是在系统设计时一种常见的组件。 + +那么我们如何用消息队列解决秒杀场景下的问题呢?接下来,我们来结合具体的例子来看看消息队列在秒杀场景下起到的作用。 + +削去秒杀场景下的峰值写流量 + +刚才提到,在秒杀场景下,短时间之内数据库的写流量会很高,那么依照我们以前的思路应该对数据做分库分表。如果已经做了分库分表,那么就需要扩展更多的数据库来应对更高的写流量。但是无论是分库分表,还是扩充更多的数据库,都会比较复杂,原因是你需要将数据库中的数据做迁移,这个时间就要按天甚至按周来计算了。 + +而在秒杀场景下,高并发的写请求并不是持续的,也不是经常发生的,而只有在秒杀活动开始后的几秒或者十几秒时间内才会存在。为了应对这十几秒的瞬间写高峰,就要花费几天甚至几周的时间来扩容数据库,再在秒杀之后花费几天的时间来做缩容,这无疑是得不偿失的。 + +所以,我们的思路是:将秒杀请求暂存在消息队列中,然后业务服务器会响应用户“秒杀结果正在计算中”,释放了系统资源之后再处理其它用户的请求。 + +我们会在后台启动若干个队列处理程序,消费消息队列中的消息,再执行校验库存、下单等逻辑。因为只有有限个队列处理线程在执行,所以落入后端数据库上的并发请求是有限的。而请求是可以在消息队列中被短暂地堆积,当库存被消耗完之后,消息队列中堆积的请求就可以被丢弃了。 + + + +这就是消息队列在秒杀系统中最主要的作用:削峰填谷,也就是说它可以削平短暂的流量高峰,虽说堆积会造成请求被短暂延迟处理,但是只要我们时刻监控消息队列中的堆积长度,在堆积量超过一定量时,增加队列处理机数量,来提升消息的处理能力就好了,而且秒杀的用户对于短暂延迟知晓秒杀的结果,也是有一定容忍度的。 + +这里需要注意一下,我所说的是“短暂”延迟,如果长时间没有给用户公示秒杀结果,那么用户可能就会怀疑你的秒杀活动有猫腻了。所以,在使用消息队列应对流量峰值时,需要对队列处理的时间、前端写入流量的大小,数据库处理能力做好评估,然后根据不同的量级来决定部署多少台队列处理程序。 + +比如你的秒杀商品有 1000 件,处理一次购买请求的时间是 500ms,那么总共就需要 500s 的时间。这时,你部署 10 个队列处理程序,那么秒杀请求的处理时间就是 50s,也就是说用户需要等待 50s 才可以看到秒杀的结果,这是可以接受的。这时会并发 10 个请求到达数据库,并不会对数据库造成很大的压力。 + +通过异步处理简化秒杀请求中的业务流程 + +其实,在大量的写请求“攻击”你的电商系统的时候,消息队列除了发挥主要的削峰填谷的作用之外,还可以实现异步处理来简化秒杀请求中的业务流程,提升系统的性能。 + +你想,在刚才提到的秒杀场景下,我们在处理购买请求时,需要 500ms。这时,你分析了一下整个的购买流程,发现这里面会有主要的业务逻辑,也会有次要的业务逻辑:比如说,主要的流程是生成订单、扣减库存;次要的流程可能是我们在下单购买成功之后会给用户发放优惠券,会增加用户的积分。 + +假如发放优惠券的耗时是 50ms,增加用户积分的耗时也是 50ms,那么如果我们将发放优惠券、增加积分的操作放在另外一个队列处理机中执行,那么整个流程就缩短到了 400ms,性能提升了 20%,处理这 1000 件商品的时间就变成了 400s。如果我们还是希望能在 50s 之内看到秒杀结果的话,只需要部署 8 个队列程序就好了。 + +经过将一些业务流程异步处理之后,我们的秒杀系统部署结构也会有所改变: + + + +解耦实现秒杀系统模块之间松耦合 + +除了异步处理和削峰填谷以外,消息队列在秒杀系统中起到的另一个作用是解耦合。 + +比如数据团队对你说,在秒杀活动之后想要统计活动的数据,借此来分析活动商品的受欢迎程度、购买者人群的特点以及用户对于秒杀互动的满意程度等等指标。而我们需要将大量的数据发送给数据团队,那么要怎么做呢? + +一个思路是:可以使用 HTTP 或者 RPC 的方式来同步地调用,也就是数据团队这边提供一个接口,我们实时将秒杀的数据推送给它,但是这样调用会有两个问题: + +整体系统的耦合性比较强,当数据团队的接口发生故障时,会影响到秒杀系统的可用性。 + +当数据系统需要新的字段,就要变更接口的参数,那么秒杀系统也要随着一起变更。 + +这时,我们可以考虑使用消息队列降低业务系统和数据系统的直接耦合度。 + +秒杀系统产生一条购买数据后,我们可以先把全部数据发送给消息队列,然后数据团队再订阅这个消息队列的话题,这样它们就可以接收到数据,然后再做过滤和处理了。 + +秒杀系统在这样解耦合之后,数据系统的故障就不会影响到秒杀系统了,同时,当数据系统需要新的字段时,只需要解析消息队列中的消息,拿到需要的数据就好了。 + + + +异步处理、解耦合和削峰填谷是消息队列在秒杀系统设计中起到的主要作用,其中,异步处理可以简化业务流程中的步骤,提升系统性能;削峰填谷可以削去到达秒杀系统的峰值流量,让业务逻辑的处理更加缓和;解耦合可以将秒杀系统和数据系统解耦开,这样两个系统的任何变更都不会影响到另一个系统, + +如果你的系统想要提升写入性能,实现系统的低耦合,想要抵挡高并发的写流量,那么你就可以考虑使用消息队列来完成。 + +课程小结 + +本节课,我结合自己的实际经验,主要带你了解了,消息队列在高并发系统设计中起到的作用,以及一些注意事项,你需要了解的重点如下: + +削峰填谷是消息队列最主要的作用,但是会造成请求处理的延迟。 + +异步处理是提升系统性能的神器,但是你需要分清同步流程和异步流程的边界,同时消息存在着丢失的风险,我们需要考虑如何确保消息一定到达。 + +解耦合可以提升你的整体系统的鲁棒性。 + +当然,你要知道,在使用消息队列之后虽然可以解决现有的问题,但是系统的复杂度也会上升。比如上面提到的业务流程中,同步流程和异步流程的边界在哪里?消息是否会丢失,是否会重复?请求的延迟如何能够减少?消息接收的顺序是否会影响到业务流程的正常执行?如果消息处理流程失败了之后是否需要补发?这些问题都是我们需要考虑的。我会利用接下来的两节课,针对最主要的两个问题来讲讲解决思路:一个是如何处理消息的丢失和重复,另一个是如何减少消息的延迟。 + +引入了消息队列的同时也会引入了新的问题,需要新的方案来解决,这就是系统设计的挑战,也是系统设计独有的魅力,而我们也会在这些挑战中不断提升技术能力和系统设计能力。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/18消息投递:如何保证消息仅仅被消费一次?.md b/专栏/高并发系统设计40问/18消息投递:如何保证消息仅仅被消费一次?.md new file mode 100644 index 0000000..43a5839 --- /dev/null +++ b/专栏/高并发系统设计40问/18消息投递:如何保证消息仅仅被消费一次?.md @@ -0,0 +1,161 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 18 消息投递:如何保证消息仅仅被消费一次? + 你好,我是唐扬。 + +经过上一节课,我们在电商系统中增加了消息队列,用它来对峰值写流量做削峰填谷,对次要的业务逻辑做异步处理,对不同的系统模块做解耦合。因为业务逻辑从同步代码中移除了,所以,我们也要有相应的队列处理程序来处理消息、执行业务逻辑,这时,你的系统架构变成了下面的样子: + + + +这是一个简化版的架构图,实际上,随着业务逻辑越来越复杂,会引入更多的外部系统和服务来解决业务上的问题。比如说,我们会引入 Elasticsearch 来解决商品和店铺搜索的问题,也会引入审核系统,来对售卖的商品、用户的评论做自动的和人工的审核,你会越来越多地使用消息队列与外部系统解耦合,以及提升系统性能。 + +比如说,你的电商系统需要上一个新的红包功能:用户在购买一定数量的商品之后,由你的系统给用户发一个现金的红包,鼓励用户消费。由于发放红包的过程不应该在购买商品的主流程之内,所以你考虑使用消息队列来异步处理。这时,你发现了一个问题:如果消息在投递的过程中发生丢失,那么用户就会因为没有得到红包而投诉。相反,如果消息在投递的过程中出现了重复,那么你的系统就会因为发送两个红包而损失。 + +那么我们如何保证,产生的消息一定会被消费到,并且只被消费一次呢?这个问题虽然听起来很浅显,很好理解,但是实际上却藏着很多玄机,本节课我就带你深入探讨。 + +消息为什么会丢失 + +如果要保证消息只被消费一次,首先就要保证消息不会丢失。那么消息从被写入到消息队列,到被消费者消费完成,这个链路上会有哪些地方存在丢失消息的可能呢?其实,主要存在三个场景: + +消息从生产者写入到消息队列的过程。 + +消息在消息队列中的存储场景。 + +消息被消费者消费的过程。 + + + +接下来,我就针对每一个场景,详细地剖析一下,这样你可以针对不同的场景选择合适的,减少消息丢失的解决方案。 + +1. 在消息生产的过程中丢失消息 + +在这个环节中主要有两种情况。 + +首先,消息的生产者一般是我们的业务服务器,消息队列是独立部署在单独的服务器上的。两者之间的网络虽然是内网,但是也会存在抖动的可能,而一旦发生抖动,消息就有可能因为网络的错误而丢失。 + +针对这种情况,我建议你采用的方案是消息重传:也就是当你发现发送超时后你就将消息重新发一次,但是你也不能无限制地重传消息。一般来说,如果不是消息队列发生故障,或者是到消息队列的网络断开了,重试 2~3 次就可以了。 + +不过,这种方案可能会造成消息的重复,从而导致在消费的时候会重复消费同样的消息。比方说,消息生产时由于消息队列处理慢或者网络的抖动,导致虽然最终写入消息队列成功,但在生产端却超时了,生产者重传这条消息就会形成重复的消息,那么针对上面的例子,直观显示在你面前的就会是你收到了两个现金红包。 + +那么消息发送到了消息队列之后是否就万无一失了呢?当然不是,在消息队列中消息仍然有丢失的风险。 + +2. 在消息队列中丢失消息 + +拿 Kafka 举例,消息在 Kafka 中是存储在本地磁盘上的,而为了减少消息存储时对磁盘的随机 I/O,我们一般会将消息先写入到操作系统的 Page Cache 中,然后再找合适的时机刷新到磁盘上。 + +比如,Kafka 可以配置当达到某一时间间隔,或者累积一定的消息数量的时候再刷盘,也就是所说的异步刷盘。 + +来看一个形象的比喻:假如你经营一个图书馆,读者每还一本书你都要去把图书归位,不仅工作量大而且效率低下,但是如果你可以选择每隔 3 小时,或者图书达到一定数量的时候再把图书归位,这样可以把同一类型的书一起归位,节省了查找图书位置的时间,这样就可以提高效率了。 + +不过,如果发生机器掉电或者机器异常重启,那么 Page Cache 中还没有来得及刷盘的消息就会丢失了。那么怎么解决呢? + +你可能会把刷盘的间隔设置很短,或者设置累积一条消息就就刷盘,但这样频繁刷盘会对性能有比较大的影响,而且从经验来看,出现机器宕机或者掉电的几率也不高,所以我不建议你这样做。 + + + +如果你的电商系统对消息丢失的容忍度很低,那么你可以考虑以集群方式部署 Kafka 服务,通过部署多个副本备份数据,保证消息尽量不丢失。 + +那么它是怎么实现的呢? + +Kafka 集群中有一个 Leader 负责消息的写入和消费,可以有多个 Follower 负责数据的备份。Follower 中有一个特殊的集合叫做 ISR(in-sync replicas),当 Leader 故障时,新选举出来的 Leader 会从 ISR 中选择,默认 Leader 的数据会异步地复制给 Follower,这样在 Leader 发生掉电或者宕机时,Kafka 会从 Follower 中消费消息,减少消息丢失的可能。 + +由于默认消息是异步地从 Leader 复制到 Follower 的,所以一旦 Leader 宕机,那些还没有来得及复制到 Follower 的消息还是会丢失。为了解决这个问题,Kafka 为生产者提供一个选项叫做“acks”,当这个选项被设置为“all”时,生产者发送的每一条消息除了发给 Leader 外还会发给所有的 ISR,并且必须得到 Leader 和所有 ISR 的确认后才被认为发送成功。这样,只有 Leader 和所有的 ISR 都挂了,消息才会丢失。 + + + +从上面这张图来看,当设置“acks=all”时,需要同步执行 1,3,4 三个步骤,对于消息生产的性能来说也是有比较大的影响的,所以你在实际应用中需要仔细地权衡考量。我给你的建议是: + +\1. 如果你需要确保消息一条都不能丢失,那么建议不要开启消息队列的同步刷盘,而是需要使用集群的方式来解决,可以配置当所有 ISR Follower 都接收到消息才返回成功。 + +\2. 如果对消息的丢失有一定的容忍度,那么建议不部署集群,即使以集群方式部署,也建议配置只发送给一个 Follower 就可以返回成功了。 + +\3. 我们的业务系统一般对于消息的丢失有一定的容忍度,比如说以上面的红包系统为例,如果红包消息丢失了,我们只要后续给没有发送红包的用户补发红包就好了。 + +3. 在消费的过程中存在消息丢失的可能 + +我还是以 Kafka 为例来说明。一个消费者消费消息的进度是记录在消息队列集群中的,而消费的过程分为三步:接收消息、处理消息、更新消费进度。 + +这里面接收消息和处理消息的过程都可能会发生异常或者失败,比如说,消息接收时网络发生抖动,导致消息并没有被正确的接收到;处理消息时可能发生一些业务的异常导致处理流程未执行完成,这时如果更新消费进度,那么这条失败的消息就永远不会被处理了,也可以认为是丢失了。 + +所以,在这里你需要注意的是,一定要等到消息接收和处理完成后才能更新消费进度,但是这也会造成消息重复的问题,比方说某一条消息在处理之后,消费者恰好宕机了,那么因为没有更新消费进度,所以当这个消费者重启之后,还会重复地消费这条消息。 + +如何保证消息只被消费一次 + +从上面的分析中,你能发现,为了避免消息丢失,我们需要付出两方面的代价:一方面是性能的损耗;一方面可能造成消息重复消费。 + +性能的损耗我们还可以接受,因为一般业务系统只有在写请求时才会有发送消息队列的操作,而一般系统的写请求的量级并不高,但是消息一旦被重复消费,就会造成业务逻辑处理的错误。那么我们要如何避免消息的重复呢? + +想要完全的避免消息重复的发生是很难做到的,因为网络的抖动、机器的宕机和处理的异常都是比较难以避免的,在工业上并没有成熟的方法,因此我们会把要求放宽,只要保证即使消费到了重复的消息,从消费的最终结果来看和只消费一次是等同的就好了,也就是保证在消息的生产和消费的过程是“幂等”的。 + +1. 什么是幂等 + +幂等是一个数学上的概念,它的含义是多次执行同一个操作和执行一次操作,最终得到的结果是相同的,说起来可能有些抽象,我给你举个例子: + +比如,男生和女生吵架,女生抓住一个点不放,传递“你不在乎我了吗?”(生产消息)的信息。那么当多次埋怨“你不在乎我了吗?”的时候(多次生产相同消息),她不知道的是,男生的耳朵(消息处理)会自动把 N 多次的信息屏蔽,就像只听到一次一样,这就是幂等性。 + +如果我们消费一条消息的时候,要给现有的库存数量减 1,那么如果消费两条相同的消息就会给库存数量减 2,这就不是幂等的。而如果消费一条消息后,处理逻辑是将库存的数量设置为 0,或者是如果当前库存数量是 10 时则减 1,这样在消费多条消息时,所得到的结果就是相同的,这就是幂等的。 + +说白了,你可以这么理解“幂等”:一件事儿无论做多少次都和做一次产生的结果是一样的,那么这件事儿就具有幂等性。 + +2. 在生产、消费过程中增加消息幂等性的保证 + +消息在生产和消费的过程中都可能会产生重复,所以你要做的是,在生产过程和消费过程中增加消息幂等性的保证,这样就可以认为从“最终结果上来看”,消息实际上是只被消费了一次的。 + +在消息生产过程中,在 Kafka0.11 版本和 Pulsar 中都支持“producer idempotency”的特性,翻译过来就是生产过程的幂等性,这种特性保证消息虽然可能在生产端产生重复,但是最终在消息队列存储时只会存储一份。 + +它的做法是给每一个生产者一个唯一的 ID,并且为生产的每一条消息赋予一个唯一 ID,消息队列的服务端会存储 < 生产者 ID,最后一条消息 ID> 的映射。当某一个生产者产生新的消息时,消息队列服务端会比对消息 ID 是否与存储的最后一条 ID 一致,如果一致,就认为是重复的消息,服务端会自动丢弃。 + + + +而在消费端,幂等性的保证会稍微复杂一些,你可以从通用层和业务层两个层面来考虑。 + +在通用层面,你可以在消息被生产的时候,使用发号器给它生成一个全局唯一的消息 ID,消息被处理之后,把这个 ID 存储在数据库中,在处理下一条消息之前,先从数据库里面查询这个全局 ID 是否被消费过,如果被消费过就放弃消费。 + +你可以看到,无论是生产端的幂等性保证方式,还是消费端通用的幂等性保证方式,它们的共同特点都是为每一个消息生成一个唯一的 ID,然后在使用这个消息的时候,先比对这个 ID 是否已经存在,如果存在,则认为消息已经被使用过。所以这种方式是一种标准的实现幂等的方式,你在项目之中可以拿来直接使用,它在逻辑上的伪代码就像下面这样: + +boolean isIDExisted = selectByID(ID); // 判断 ID 是否存在 + +if(isIDExisted) { + + return; // 存在则直接返回 + +} else { + + process(message); // 不存在,则处理消息 + + saveID(ID); // 存储 ID + +} + + +不过这样会有一个问题:如果消息在处理之后,还没有来得及写入数据库,消费者宕机了重启之后发现数据库中并没有这条消息,还是会重复执行两次消费逻辑,这时你就需要引入事务机制,保证消息处理和写入数据库必须同时成功或者同时失败,但是这样消息处理的成本就更高了,所以,如果对于消息重复没有特别严格的要求,可以直接使用这种通用的方案,而不考虑引入事务。 + +在业务层面怎么处理呢?这里有很多种处理方式,其中有一种是增加乐观锁的方式。比如,你的消息处理程序需要给一个人的账号加钱,那么你可以通过乐观锁的方式来解决。 + +具体的操作方式是这样的:你给每个人的账号数据中增加一个版本号的字段,在生产消息时先查询这个账户的版本号,并且将版本号连同消息一起发送给消息队列。消费端在拿到消息和版本号后,在执行更新账户金额 SQL 的时候带上版本号,类似于执行: + +update user set amount = amount + 20, version=version+1 where userId=1 and version=1; + + +你看,我们在更新数据时给数据加了乐观锁,这样在消费第一条消息时,version 值为 1,SQL 可以执行成功,并且同时把 version 值改为了 2;在执行第二条相同的消息时,由于 version 值不再是 1,所以这条 SQL 不能执行成功,也就保证了消息的幂等性。 + +课程小结 + +本节课,我主要带你了解了在消息队列中,消息可能会发生丢失的场景,和我们的应对方法,以及在消息重复的场景下,你要如何保证,尽量不影响消息最终的处理结果。我想强调的重点是: + +消息的丢失可以通过生产端的重试、消息队列配置集群模式,以及消费端合理处理消费进度三个方式来解决。 + +为了解决消息的丢失通常会造成性能上的问题以及消息的重复问题。 + +通过保证消息处理的幂等性可以解决消息的重复问题。 + +虽然我讲了很多应对消息丢失的方法,但并不是说消息丢失一定不能被接受,毕竟你可以看到,在允许消息丢失的情况下,消息队列的性能更好,方案实现的复杂度也最低。比如像是日志处理的场景,日志存在的意义在于排查系统的问题,而系统出现问题的几率不高,偶发的丢失几条日志是可以接受的。 + +所以方案设计看场景,这是一切设计的原则,你不能把所有的消息队列都配置成防止消息丢失的方式,也不能要求所有的业务处理逻辑都要支持幂等性,这样会给开发和运维带来额外的负担。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/19消息队列:如何降低消息队列系统中消息的延迟?.md b/专栏/高并发系统设计40问/19消息队列:如何降低消息队列系统中消息的延迟?.md new file mode 100644 index 0000000..30576bf --- /dev/null +++ b/专栏/高并发系统设计40问/19消息队列:如何降低消息队列系统中消息的延迟?.md @@ -0,0 +1,148 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 19 消息队列:如何降低消息队列系统中消息的延迟? + 你好,我是唐扬。 + +学完前面两节课之后,相信你对在垂直电商项目中,如何使用消息队列应对秒杀时的峰值流量已经有所了解。当然了,你也应该知道要如何做,才能保证消息不会丢失,尽量避免消息重复带来的影响。那么我想让你思考一下:除了这些内容,你在使用消息队列时还需要关注哪些点呢? + +先来看一个场景:在你的垂直电商项目中,你会在用户下单支付之后,向消息队列里面发送一条消息,队列处理程序消费了消息后,会增加用户的积分,或者给用户发送优惠券。那么用户在下单之后,等待几分钟或者十几分钟拿到积分和优惠券是可以接受的,但是一旦消息队列出现大量堆积,用户消费完成后几小时还拿到优惠券,那就会有用户投诉了。 + +这时,你要关注的就是消息队列中,消息的延迟了,这其实是消费性能的问题,那么你要如何提升消费性能,保证更短的消息延迟呢?在我看来,你首先需要掌握如何来监控消息的延迟,因为有了数据之后,你才可以知道目前的延迟数据是否满足要求,也可以评估优化之后的效果。然后,你要掌握使用消息队列的正确姿势,以及关注消息队列本身是如何保证消息尽快被存储和投递的。 + +接下来,我们先来看看第一点:如何监控消息延迟。 + +如何监控消息延迟 + +在我看来,监控消息的延迟有两种方式: + +使用消息队列提供的工具,通过监控消息的堆积来完成; + +通过生成监控消息的方式来监控消息的延迟情况。 + +接下来,我带你实际了解一下。 + +假设在开篇的场景之下,电商系统中的消息队列已经堆积了大量的消息,那么你要想监控消息的堆积情况,首先需要从原理上了解,在消息队列中消费者的消费进度是多少,因为这样才方便计算当前的消费延迟是多少。比方说,生产者向队列中一共生产了 1000 条消息,某一个消费者消费进度是 900 条,那么这个消费者的消费延迟就是 100 条消息。 + +在 Kafka 中,消费者的消费进度在不同的版本上是不同的。 + +在 Kafka0.9 之前的版本中,消费进度是存储在 ZooKeeper 中的,消费者在消费消息的时候,先要从 ZooKeeper 中获取最新的消费进度,再从这个进度的基础上消费后面的消息。 + +在 Kafka0.9 版本之后,消费进度被迁入到 Kakfa 的一个专门的 topic 叫“__consumer_offsets”里面。所以,如果你了解 kafka 的原理,你可以依据不同的版本,从不同的位置,获取到这个消费进度的信息。 + +当然,作为一个成熟的组件,Kafka 也提供了一些工具来获取这个消费进度的信息,帮助你实现自己的监控,这个工具主要有两个: + +首先,Kafka 提供了工具叫做“kafka-consumer-groups.sh”(它在 Kafka 安装包的 bin 目录下)。 + +为了帮助你理解,我简单地搭建了一个 Kafka 节点,并且写入和消费了一些信息,然后我来使用命令看看消息累积情况,具体的命令如下: + +./bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group test-consumer-group + + +结果如下: + + + +图中的前两列是队列的基本信息,包括话题名和分区名; + +第三列是当前消费者的消费进度; + +第四列是当前生产消息的总数; + +第五列就是消费消息的堆积数(也就是第四列与第三列的差值)。 + +通过这个命令你可以很方便地了解消费者的消费情况。 + +其次,第二个工具是 JMX。 + +Kafka 通过 JMX 暴露了消息堆积的数据,我在本地启动了一个 console consumer,然后使用 jconsole 连接这个 consumer,你就可以看到这个 consumer 的堆积数据了(就是下图中红框里的数据)。这些数据你可以写代码来获取,这样也可以方便地输出到监控系统中,我比较推荐这种方式。 + + + +除了使用消息队列提供的工具以外,你还可以通过生成监控消息的方式,来监控消息的延迟。具体怎么做呢? + +你先定义一种特殊的消息,然后启动一个监控程序,将这个消息定时地循环写入到消息队列中,消息的内容可以是生成消息的时间戳,并且也会作为队列的消费者消费数据。业务处理程序消费到这个消息时直接丢弃掉,而监控程序在消费到这个消息时,就可以和这个消息的生成时间做比较,如果时间差达到某一个阈值就可以向我们报警。 + + + +这两种方式都可以监控消息的消费延迟情况,而从我的经验出来,我比较推荐两种方式结合来使用。比如在我的实际项目中,我会优先在监控程序中获取 JMX 中的队列堆积数据,做到 dashboard 报表中,同时也会启动探测进程,确认消息的延迟情况是怎样的。 + +在我看来,消息的堆积是对于消息队列的基础监控,这是你无论如何都要做的。但是,了解了消息的堆积情况,并不能很直观地了解消息消费的延迟,你也只能利用经验来确定堆积的消息量到了多少才会影响到用户的体验;而第二种方式对于消费延迟的监控则更加直观,而且从时间的维度来做监控也比较容易确定报警阈值。 + +了解了消息延迟的监控方式之后,我们再来看看如何提升消息的写入和消费性能,这样才会让异步的消息得到尽快的处理。 + +减少消息延迟的正确姿势 + +想要减少消息的处理延迟,我们需要在消费端和消息队列两个层面来完成。 + +在消费端,我们的目标是提升消费者的消息处理能力,你能做的是: + +优化消费代码提升性能; + +增加消费者的数量(这个方式比较简单)。 + +不过,第二种方式会受限于消息队列的实现。比如说,如果消息队列使用的是 Kafka 就无法通过增加消费者数量的方式,来提升消息处理能力。 + +因为在 Kafka 中,一个 Topic(话题)可以配置多个 Partition(分区),数据会被平均或者按照生产者指定的方式,写入到多个分区中,那么在消费的时候,Kafka 约定一个分区只能被一个消费者消费,为什么要这么设计呢?在我看来,如果有多个 consumer(消费者)可以消费一个分区的数据,那么在操作这个消费进度的时候就需要加锁,可能会对性能有一定的影响。 + +所以说,话题的分区数量决定了消费的并行度,增加多余的消费者也是没有用处的,那么你可以通过增加分区来提高消费者的处理能力。 + + + +那么,如何在不增加分区的前提下提升消费能力呢? + +既然不能增加 consumer,那么你可以在一个 consumer 中提升处理消息的并行度,所以可以考虑使用多线程的方式来增加处理能力:你可以预先创建一个或者多个线程池,在接收到消息之后,把消息丢到线程池中来异步地处理,这样,原本串行的消费消息的流程就变成了并行的消费,可以提高消息消费的吞吐量,在并行处理的前提下,我们就可以在一次和消息队列的交互中多拉取几条数据,然后分配给多个线程来处理。 + + + +另外,你在消费队列中数据的时候还需要注意消费线程空转的问题。 + +我是最初在测试自己写的一个消息中间件的时候发现的。当时,我发现运行消费客户端的进程会偶发地出现 CPU 跑满的情况,于是打印了 JVM 线程堆栈,找到了那个跑满 CPU 的线程。这个时候才发现,原来是消息队列中,有一段时间没有新的消息,于是消费客户端拉取不到新的消息就会不间断地轮询拉取消息,这个线程就把 CPU 跑满了。 + +所以,你在写消费客户端的时候要考虑这种场景,拉取不到消息可以等待一段时间再来拉取,等待的时间不宜过长,否则会增加消息的延迟。我一般建议固定的 10ms~100ms,也可以按照一定步长递增,比如第一次拉取不到消息等待 10ms,第二次 20ms,最长可以到 100ms,直到拉取到消息再回到 10ms。 + +说完了消费端的做法之后,再来说说消息队列本身在读取性能优化方面做了哪些事情。 + +我曾经也做过一个消息中间件,在最初设计中间件的时候,我主要从两方面考虑读取性能问题: + +消息的存储; + +零拷贝技术。 + +针对第一点,我最初在设计的时候为了实现简单,使用了普通的数据库来存储消息,但是受限于数据库的性能瓶颈,读取 QPS 只能到 2000,后面我重构了存储模块,使用本地磁盘作为存储介质。Page Cache 的存在就可以提升消息的读取速度,即使要读取磁盘中的数据,由于消息的读取是顺序的,并且不需要跨网络读取数据,所以读取消息的 QPS 提升了一个数量级。 + +另外一个优化点是零拷贝技术,说是零拷贝,其实,我们不可能消灭数据的拷贝,只是尽量减少拷贝的次数。在读取消息队列的数据的时候,其实就是把磁盘中的数据通过网络发送给消费客户端,在实现上会有四次数据拷贝的步骤: + +\1. 数据从磁盘拷贝到内核缓冲区; + +\2. 系统调用将内核缓存区的数据拷贝到用户缓冲区; + +\3. 用户缓冲区的数据被写入到 Socket 缓冲区中; + +\4. 操作系统再将 Socket 缓冲区的数据拷贝到网卡的缓冲区中。 + + + +操作系统提供了 Sendfile 函数,可以减少数据被拷贝的次数。使用了 Sendfile 之后,在内核缓冲区的数据不会被拷贝到用户缓冲区,而是直接被拷贝到 Socket 缓冲区,节省了一次拷贝的过程,提升了消息发送的性能。高级语言中对于 Sendfile 函数有封装,比如说在 Java 里面的 java.nio.channels.FileChannel 类就提供了 transferTo 方法提供了 Sendfile 的功能。 + + + +课程小结 + +本节课我带你了解了,如何提升消息队列的性能来降低消息消费的延迟,这里我想让你明确的重点是: + +我们可以使用消息队列提供的工具,或者通过发送监控消息的方式,来监控消息的延迟情况; + +横向扩展消费者是提升消费处理能力的重要方式; + +选择高性能的数据存储方式,配合零拷贝技术,可以提升消息的消费性能。 + +其实,队列是一种常用的组件,只要涉及到队列,任务的堆积就是一个不可忽视的问题,我遇到过的很多故障都是源于此。 + +比如说,前一段时间处理的一个故障,前期只是因为数据库性能衰减有少量的慢请求,结果这些慢请求占满了 Tomcat 线程池,导致整体服务的不可用。如果我们能对 Tomcat 线程池的任务堆积情况有实时地监控,或者说对线程池有一些保护策略,比方说线程全部使用之后丢弃请求,也许就会避免故障的发生。在此,我希望你在实际的工作中能够引以为戒,只要有队列就要监控它的堆积情况,把问题消灭在萌芽之中。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/20面试现场第二期:当问到项目经历时,面试官究竟想要了解什么?.md b/专栏/高并发系统设计40问/20面试现场第二期:当问到项目经历时,面试官究竟想要了解什么?.md new file mode 100644 index 0000000..48642be --- /dev/null +++ b/专栏/高并发系统设计40问/20面试现场第二期:当问到项目经历时,面试官究竟想要了解什么?.md @@ -0,0 +1,21 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 20 面试现场第二期:当问到项目经历时,面试官究竟想要了解什么? + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/21系统架构:每秒1万次请求的系统要做服务化拆分吗?.md b/专栏/高并发系统设计40问/21系统架构:每秒1万次请求的系统要做服务化拆分吗?.md new file mode 100644 index 0000000..1480bd5 --- /dev/null +++ b/专栏/高并发系统设计40问/21系统架构:每秒1万次请求的系统要做服务化拆分吗?.md @@ -0,0 +1,115 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 21 系统架构:每秒1万次请求的系统要做服务化拆分吗? + 你好,我是唐扬。 + +通过前面几个篇章的内容,你已经从数据库、缓存和消息队列的角度对自己的垂直电商系统在性能、可用性和扩展性上做了优化。 + +现在,你的系统运行稳定,好评不断,每天高峰期的流量,已经达到了 10000/s 请求,DAU 也涨到了几十万。CEO 非常高兴,打算继续完善产品功能,以便进行新一轮的运营推广,争取在下个双十一可以将 DAU 冲击过百万。这时,你开始考虑,怎么通过技术上的优化改造,来支撑更高的并发流量,比如支撑过百万的 DAU。 + +于是,你重新审视了自己的系统架构,分析系统中有哪些可以优化的点。 + + + +目前来看,工程的部署方式还是采用一体化架构,也就是说所有的功能模块,比方说电商系统中的订单模块、用户模块、支付模块、物流模块等等,都被打包到一个大的 Web 工程中,然后部署在应用服务器上。 + +你隐约觉得这样的部署方式可能存在问题,于是,你 Google 了一下,发现当系统发展到一定阶段,都要做微服务化的拆分,你也看到淘宝的“五彩石”项目,对于淘宝整体架构的扩展性,带来的巨大影响。这一切让你心驰神往。 + +但是有一个问题一直萦绕在你的心里:究竟是什么促使我们将一体化架构,拆分成微服务化架构?是不是说系统的整体 QPS 到了 1 万,或者到了 2 万,就一定要做微服务化拆分呢? + +一体化架构的痛点 + +先来回想一下,你当初为什么选用了一体化架构。 + +在电商项目刚刚启动的时候,你只是希望能够尽量快地将项目搭建起来,方便将产品更早地投放市场,快速完成验证。 + +在系统开发的初期,这种架构确实给你的开发运维,带来了很大的便捷,主要体现在: + +开发简单直接,代码和项目集中式管理; + +只需要维护一个工程,节省维护系统运行的人力成本; + +排查问题的时候,只需要排查这个应用进程就可以了,目标性强。 + +但随着功能越来越复杂,开发团队规模越来越大,你慢慢感受到了一体化架构的一些缺陷,这主要体现在以下几个方面。 + +首先,在技术层面上,数据库连接数可能成为系统的瓶颈。 + +在第 7 讲中我提到,数据库的连接是比较重的一类资源,不仅连接过程比较耗时,而且连接 MySQL 的客户端数量有限制,最多可以设置为 16384(在实际的项目中,可以依据实际业务来调整)。 + +这个数字看着很大,但是因为你的系统是按照一体化架构部署的,在部署结构上没有分层,应用服务器直接连接数据库,那么当前端请求量增加,部署的应用服务器扩容,数据库的连接数也会大增,给你举个例子。 + +我之前维护的一个系统中,数据库的最大连接数设置为 8000,应用服务器部署在虚拟机上,数量大概是 50 个,每个服务器会和数据库建立 30 个连接,但是数据库的连接数,却远远大于 30 * 50 = 1500。 + +因为你不仅要支撑来自客户端的外网流量,还要部署单独的应用服务,支撑来自其它部门的内网调用,也要部署队列处理机,处理来自消息队列的消息,这些服务也都是与数据库直接连接的,林林总总加起来,在高峰期的时候,数据库的连接数要接近 3400。 + +所以,一旦遇到一些大的运营推广活动,服务器就要扩容,数据库连接数也随之增加,基本上就会处在最大连接数的边缘。这就像一颗定时炸弹,随时都会影响服务的稳定。 + +第二点,一体化架构增加了研发的成本,抑制了研发效率的提升。 + +《人月神话》中曾经提到:一个团队内部沟通成本,和人员数量 n 有关,约等于 n(n-1)/2,也就是说随着团队人员的增加,沟通的成本呈指数级增长,一个 100 人的团队,需要沟通的渠道大概是 100(100-1)/2 = 4950。那么为了减少沟通成本,我们一般会把团队拆分成若干个小团队,每个小团队 5~7 人,负责一部分功能模块的开发和维护。 + +比方说,你的垂直电商系统团队就会被拆分为用户组、订单组、支付组、商品组等等。当如此多的小团队共同维护一套代码,和一个系统时,在配合时就会出现问题。 + +不同的团队之间沟通少,假如一个团队需要一个发送短信的功能,那么有的研发同学会认为最快的方式,不是询问其他团队是否有现成的,而是自己写一套,但是这种想法是不合适的,这样一来就会造成功能服务的重复开发。 + +由于代码部署在一起,每个人都向同一个代码库提交代码,代码冲突无法避免;同时,功能之间耦合严重,可能你只是更改了很小的逻辑,却导致其它功能不可用,从而在测试时需要对整体功能回归,延长了交付时间。 + +模块之间互相依赖,一个小团队中的成员犯了一个错误,就可能会影响到,其它团队维护的服务,对于整体系统稳定性影响很大。 + +第三点,一体化架构对于系统的运维也会有很大的影响。 + +想象一下,在项目初期,你的代码可能只有几千行,构建一次只需要一分钟,那么你可以很敏捷灵活地频繁上线变更修复问题。但是当你的系统扩充到几十万行,甚至上百万行代码的时候,一次构建的过程,包括编译、单元测试、打包和上传到正式环境,花费的时间可能达到十几分钟,并且,任何小的修改,都需要构建整个项目,上线变更的过程非常不灵活。 + +而我说的这些问题,都可以通过微服务化拆分来解决。 + +如何使用微服务化解决这些痛点 + +之前,我在做一个社区业务的时候,开始采用的架构也是一体化的架构,数据库已经做了垂直分库,分出了用户库、内容库和互动库,并且已经将工程拆分了业务池,拆分成了用户池、内容池和互动池。 + +当前端的请求量越来越大时,我们发现,无论哪个业务池子,用户模块都是请求量最大的模块儿,用户库也是请求量最大的数据库。这很好理解,无论是内容还是互动,都会查询用户库获取用户数据,所以,即使我们做了业务池的拆分,但实际上,每一个业务池子都需要连接用户库,并且请求量都很大,这就造成了用户库的连接数比其它都要多一些,容易成为系统的瓶颈。 + + + +那么我们怎么解决这个问题呢? + +其实,可以把与用户相关的逻辑,部署成一个单独的服务,其它无论是用户池、内容池还是互动池,都连接这个服务来获取和更改用户信息,那么也就是说,只有这个服务可以连接用户库,其它的业务池都不直连用户库获取数据。 + +由于这个服务只处理和用户相关的逻辑,所以,不需要部署太多的实例就可以承担流量,这样就可以有效地控制用户库的连接数,提升了系统的可扩展性。那么如此一来,我们也可以将内容和互动相关的逻辑,都独立出来,形成内容服务和互动服务,这样,我们就通过按照业务做横向拆分的方式,解决了数据库层面的扩展性问题。 + + + +再比如,我们在做社区业务的时候,会有多个模块需要使用地理位置服务,将 IP 信息或者经纬度信息,转换为城市信息。比如,推荐内容的时候,可以结合用户的城市信息,做附近内容的推荐;展示内容信息的时候,也需要展示城市信息等等。 + +那么,如果每一个模块都实现这么一套逻辑就会导致代码不够重用。因此,我们可以把将 IP 信息或者经纬度信息,转换为城市信息,包装成单独的服务供其它模块调用,也就是,我们可以将与业务无关的公用服务抽取出来,下沉成单独的服务。 + +按照以上两种拆分方式将系统拆分之后,每一个服务的功能内聚,维护人员职责明确,增加了新的功能只需要测试自己的服务就可以了,而一旦服务出了问题,也可以通过服务熔断、降级的方式减少对于其他服务的影响(我会在第 34 讲中系统地讲解)。 + +另外,由于每个服务都只是原有系统的子集,代码行数相比原有系统要小很多,构建速度上也会有比较大的提升。 + +当然,微服务化之后,原有单一系统被拆分成多个子服务,无论在开发,还是运维上都会引入额外的问题,那么这些问题是什么? 我们将如何解决呢?下一节课,我会带你来了解。 + +课程小结 + +本节课,我主要带你了解了,实际业务中会基于什么样的考虑,对系统做微服务化拆分,其实,系统的 QPS 并不是决定性的因素。影响的因素,我归纳为以下几点: + +系统中,使用的资源出现扩展性问题,尤其是数据库的连接数出现瓶颈; + +大团队共同维护一套代码,带来研发效率的降低,和研发成本的提升; + +系统部署成本越来越高。 + +从中你应该有所感悟:在架构演进的初期和中期,性能、可用性、可扩展性是我们追求的主要目标,高性能和高可用给用户带来更好的使用体验,扩展性可以方便我们支撑更大量级的并发。但是当系统做的越来越大,团队成员越来越多,我们就不得不考虑成本了。 + +这里面的“成本”有着复杂的含义,它不仅代表购买服务器的费用,还包括研发团队,内部的开发成本,沟通成本以及运维成本等等,甚至有些时候,成本会成为架构设计中的决定性因素。 + +比方说,你做一个直播系统,在架构设计时除了要关注起播速度,还需要关注 CDN 成本;再比如作为团队 Leader,你在日常开发中除了要推进正常的功能需求开发,也要考虑完善工具链建设,提高工程师的研发效率,降低研发成本。 + +这很好理解,如果在一个 100 个人的团队中,你的工具为每个人每天节省了 10 分钟,那么加起来就是接近 17 小时,差不多增加了 2 个人工作时间。而正是基于提升扩展性和降低成本的考虑,我们最终走上了微服务化的道路。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/22微服务架构:微服务化后,系统架构要如何改造?.md b/专栏/高并发系统设计40问/22微服务架构:微服务化后,系统架构要如何改造?.md new file mode 100644 index 0000000..54dfb01 --- /dev/null +++ b/专栏/高并发系统设计40问/22微服务架构:微服务化后,系统架构要如何改造?.md @@ -0,0 +1,105 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 22 微服务架构:微服务化后,系统架构要如何改造? + 你好,我是唐扬。 + +上一节课,我带你了解了,单体架构向微服务化架构演进的原因,你应该了解到,当系统依赖资源的扩展性出现问题,或者是一体化架构带来的研发成本、部署成本变得难以接受时,我们会考虑对整体系统,做微服务化拆分。 + +微服务化之后,垂直电商系统的架构会将变成下面这样: + + + +在这个架构中,我们将用户、订单和商品相关的逻辑,抽取成服务独立的部署,原本的 Web 工程和队列处理程序,将不再直接依赖缓存和数据库,而是通过调用服务接口,查询存储中的信息。 + +有了构思和期望之后,为了将服务化拆分尽快落地,你们决定抽调主力研发同学,共同制定拆分计划。但是细致讨论后发现,虽然对服务拆分有了大致的方向,可还是有很多疑问,比如: + +服务拆分时要遵循哪些原则? + +服务的边界如何确定?服务的粒度是怎样呢? + +在服务化之后,会遇到哪些问题呢?我们又将如何来解决? + +当然,你也许想知道,微服务拆分的具体操作过程和步骤是怎样的,但是这部分内容涉及的知识点比较多,不太可能在一次课程中,把全部内容涵盖到。而且《DDD 实战课》中,已经侧重讲解了微服务化拆分的具体过程,你可以借鉴。 + +上面这三点内容,会影响服务化拆分的效果,但在实际的项目中,经常被大部分人忽略,所以是我们本节课的重点内容。而我希望你能把本节课的内容和自身的业务结合起来体会,思考业务服务化拆分的方式和方法。 + +微服务拆分的原则 + +之前,你维护的一体化架构,就像是一个大的蜘蛛网,不同功能模块,错综复杂地交织在一起,方法之间调用关系非常的复杂,导致你修复了一个 Bug,可能会引起另外多个 Bug,整体的维护成本非常高。同时,数据库较弱的扩展性,也限制了服务的扩展能力 + +出于上述考虑,你要对架构做拆分。但拆分并不像听上去那么简单,这其实就是将整体工程,重构甚至重写的过程。你需要将代码,拆分到若干个子工程里面,再将这些子工程,通过一些通信方式组装起来,这对架构是很大的调整,需要跨多个团队协调完成。 + +所以在开始拆分之前,你需要明确几个拆分的原则,否则就会事倍功半,甚至对整体项目产生不利的影响。 + +原则一,做到单一服务内部功能的高内聚,和低耦合。也就是说,每个服务只完成自己职责之内的任务,对于不是自己职责的功能,交给其它服务来完成。说起来你可能觉得理所当然,对这一点不屑一顾,但很多人在实际开发中,经常会出现一些问题。 + +比如,我之前的项目中, 有用户服务和内容服务,用户信息中有“是否为认证用户”字段。组内有个同学在内容服务里有这么一段逻辑:如果用户认证字段等于 1,代表是认证用户,那么就把内容权重提升。问题是,判断用户是否为认证用户的逻辑,应该内聚在用户服务内部,而不应该由内容服务判断,否则认证的逻辑一旦变更,内容服务也需要一同跟着变更,这就不满足高内聚、低耦合的要求了。所幸,我们在 Review 代码时,及时发现了这个问题,并在服务上线之前修复了它。 + +原则二,你需要关注服务拆分的粒度,先粗略拆分,再逐渐细化。在服务拆分的初期,你其实很难确定,服务究竟要拆分成什么样。但是,从“微服务”这几个字来看,服务的粒度貌似应该足够小,甚至有“一方法一服务”的说法。不过,服务多了也会带来问题,像是服务个数的增加会增加运维的成本。再比如,原本一次请求只需要调用进程内的多个方法,现在则需要跨网络调用多个 RPC 服务,在性能上肯定会有所下降。 + +所以我推荐的做法是:拆分初期可以把服务粒度拆的粗一些,后面随着团队对于业务和微服务理解的加深,再考虑把服务粒度细化。比如说,对于一个社区系统来说,你可以先把和用户关系相关的业务逻辑,都拆分到用户关系服务中,之后,再把比如黑名单的逻辑独立成黑名单服务。 + +原则三,拆分的过程,要尽量避免影响产品的日常功能迭代,也就是说,要一边做产品功能迭代,一边完成服务化拆分。 + +还是拿我之前维护的一个项目举例。我曾经在竞品对手快速发展的时期做了服务的拆分,拆分的方式是停掉所有业务开发,全盘推翻重构,结果错失了产品发展的最佳机会,最终败给了竞争对手。因此,我们的拆分只能在现有一体化系统的基础上,不断剥离业务独立部署,剥离的顺序,你可以参考以下几点: + +\1. 优先剥离比较独立的边界服务(比如短信服务、地理位置服务),从非核心的服务出发,减少拆分对现有业务的影响,也给团队一个练习、试错的机会; + +\2. 当两个服务存在依赖关系时,优先拆分被依赖的服务。比方说,内容服务依赖于用户服务获取用户的基本信息,那么如果先把内容服务拆分出来,内容服务就会依赖于一体化架构中的用户模块,这样还是无法保证内容服务的快速部署能力。 + +所以正确的做法是,你要理清服务之间的调用关系,比如说,内容服务会依赖用户服务获取用户信息,互动服务会依赖内容服务,所以要按照先用户服务,再内容服务,最后互动服务的顺序来进行拆分。 + +原则四,服务接口的定义要具备可扩展性。服务拆分之后,由于服务是以独立进程的方式部署,所以服务之间通信,就不再是进程内部的方法调用,而是跨进程的网络通信了。在这种通信模型下需要注意,服务接口的定义要具备可扩展性,否则在服务变更时,会造成意想不到的错误。 + +在之前的项目中,某一个微服务的接口有三个参数,在一次业务需求开发中,组内的一个同学将这个接口的参数调整为了四个,接口被调用的地方也做了修改,结果上线这个服务后,却不断报错,无奈只能回滚。 + +想必你明白了,这是因为这个接口先上线后,参数变更成了四个,但是调用方还未变更,还是在调用三个参数的接口,那就肯定会报错了。所以,服务接口的参数类型最好是封装类,这样如果增加参数,就不必变更接口的签名,而只需要在类中添加字段即就可以了。 + +微服务化带来的问题和解决思路 + +那么,依据这些原则,将系统做微服务拆分之后,是不是就可以一劳永逸,解决所有问题了呢?当然不是。 + +微服务化只是一种架构手段,有效拆分后,可以帮助实现服务的敏捷开发和部署。但是,由于将原本一体化架构的应用,拆分成了,多个通过网络通信的分布式服务,为了在分布式环境下,协调多个服务正常运行,就必然引入一定的复杂度,这些复杂度主要体现在以下几个方面: + +\1. 服务接口的调用,不再是同一进程内的方法调用,而是跨进程的网络调用,这会增加接口响应时间的增加。此时,我们就要选择高效的服务调用框架,同时,接口调用方需要知道服务部署在哪些机器的哪个端口上,这些信息需要存储在一个分布式一致性的存储中,于是就需要引入服务注册中心,这一点,是我在 24 讲会提到的内容。不过在这里我想强调的是,注册中心管理的是服务完整的生命周期,包括对于服务存活状态的检测。 + +\2. 多个服务之间有着错综复杂的依赖关系。一个服务会依赖多个其它服务,也会被多个服务所依赖,那么一旦被依赖的服务的性能出现问题,产生大量的慢请求,就会导致依赖服务的工作线程池中的线程被占满,那么依赖的服务也会出现性能问题。接下来,问题就会沿着依赖网,逐步向上蔓延,直到整个系统出现故障为止。 + +为了避免这种情况的发生,我们需要引入服务治理体系,针对出问题的服务,采用熔断、降级、限流、超时控制的方法,使得问题被限制在单一服务中,保护服务网络中的其它服务不受影响。 + +\3. 服务拆分到多个进程后,一条请求的调用链路上,涉及多个服务,那么一旦这个请求的响应时间增长,或者是出现错误,我们就很难知道,是哪一个服务出现的问题。另外,整体系统一旦出现故障,很可能外在的表现是所有服务在同一时间都出现了问题,你在问题定位时,很难确认哪一个服务是源头,这就需要引入分布式追踪工具,以及更细致的服务端监控报表。 + +我在 25 讲和 30 讲会详细的剖析这个内容,在这里我想强调的是,监控报表关注的是,依赖服务和资源的宏观性能表现;分布式追踪关注的是,单一慢请求中的性能瓶颈分析,两者需要结合起来帮助你来排查问题。 + +以上这些微服务化后,在开发方面引入的问题,就是接下来,“分布式服务篇”和“维护篇”的主要讨论内容。 + +总的来说,微服务化是一个很大的话题,在微服务开发和维护时,你也许会在很短时间就把微服务拆分完成,但是你可能会花相当长的时间来完善服务治理体系。接下来的内容,会涉及一些常用微服务中间件的原理,和使用方式,你可以使用以下的方式更好地理解后面的内容: + +快速完成中间件的部署运行,建立对它感性的认识; + +阅读它的文档中,基本原理和架构设计部分; + +必要时,阅读它的源码,加深对它的理解,这样可以帮助你在维护你的微服务时,排查中间件引起的故障和解决性能问题。 + +课程小结 + +本节课,为了能够指导你更好地进行服务化的拆分,我带你了解了,微服务化拆分的原则,内容比较清晰。在这里,我想延伸一些内容: + +1.“康威定律”提到,设计系统的组织,其产生的设计等同于组织间的沟通结构。通俗一点说,就是你的团队组织结构是什么样的,你的架构就会长成什么样。 + +如果你的团队分为服务端开发团队,DBA 团队,运维团队,测试团队,那么你的架构就是一体化的,所有的团队共同为一个大系统负责,团队内成员众多,沟通成本就会很高;而如果你想实现微服务化的架构,那么你的团队也要按照业务边界拆分,每一个模块由一个自治的小团队负责,这个小团队里面有开发、测试、运维和 DBA,这样沟通就只发生在这个小团队内部,沟通的成本就会明显降低。 + +\2. 微服务化的一个目标是减少研发的成本,其中也包括沟通的成本,所以小团队内部成员不宜过多。 + +按照亚马逊 CEO,贝佐斯的“两个披萨”的理论,如果两个披萨不够你的团队吃,那么你的团队就太大了,需要拆分,所以一个小团队包括开发、运维、测试以 6~8 个人为最佳; + +\3. 如果你的团队人数不多,还没有做好微服务化的准备,而你又感觉到研发和部署的成本确实比较高,那么一个折中的方案是,你可以优先做工程的拆分。 + +比如说,如果你使用的是 Java 语言,你可以依据业务的边界,将代码拆分到不同的子工程中,然后子工程之间以 jar 包的方式依赖,这样每个子工程代码量减少,可以减少打包时间;并且子工程代码内部,可以做到高内聚低耦合,一定程度上减少研发的成本,也不失为一个不错的保守策略。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/23RPC框架:10万QPS下如何实现毫秒级的服务调用?.md b/专栏/高并发系统设计40问/23RPC框架:10万QPS下如何实现毫秒级的服务调用?.md new file mode 100644 index 0000000..9b412c2 --- /dev/null +++ b/专栏/高并发系统设计40问/23RPC框架:10万QPS下如何实现毫秒级的服务调用?.md @@ -0,0 +1,171 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 23 RPC框架:10万QPS下如何实现毫秒级的服务调用? + 你好,我是唐扬。 + +在21 讲和22 讲中,你的团队已经决定对垂直电商系统做服务化拆分,以便解决扩展性和研发成本高的问题。与此同时,你们在不断学习的过程中还发现,系统做了服务化拆分之后,会引入一些新的问题,这些问题我在上节课提到过,归纳起来主要是两点: + +服务拆分单独部署后,引入的服务跨网络通信的问题; + +在拆分成多个小服务之后,服务如何治理的问题。 + +如果想要解决这两方面问题,你需要了解,微服务化所需要的中间件的基本原理,和使用技巧,那么本节课,我会带你掌握,解决第一点问题的核心组件:RPC 框架。 + +来思考这样一个场景:你的垂直电商系统的 QPS 已经达到了每秒 2 万次,在做了服务化拆分之后,由于我们把业务逻辑,都拆分到了单独部署的服务中,那么假设你在完成一次完整的请求时,需要调用 4~5 次服务,计算下来,RPC 服务需要承载大概每秒 10 万次的请求。那么,你该如何设计 RPC 框架,来承载如此大的请求量呢?你要做的是: + +选择合适的网络模型,有针对性地调整网络参数,以优化网络传输性能; + +选择合适的序列化方式,以提升封包、解包的性能。 + +接下来,我从原理出发,让你对于 RPC 有一个理性的认识,这样你在设计 RPC 框架时,就可以清晰地知道自己的设计目标是什么了。 + +你所知道的 RPC + +说到 RPC(Remote Procedure Call,远程过程调用),你不会陌生,它指的是通过网络,调用另一台计算机上部署服务的技术。 + +而 RPC 框架就封装了网络调用的细节,让你像调用本地服务一样,调用远程部署的服务。你也许觉得只有像 Dubbo、Grpc、Thrift 这些新兴的框架才算是 RPC 框架,其实严格来说,你很早之前就接触到与 RPC 相关的技术了。 + +比如,Java 原生就有一套远程调用框架叫做 RMI(Remote Method Invocation), 它可以让 Java 程序通过网络,调用另一台机器上的 Java 对象的方法。它是一种远程调用的方法,也是 J2EE 时代大名鼎鼎的 EJB 的实现基础。 + +时至今日,你仍然可以通过 Spring 的“RmiServiceExporter”将 Spring 管理的 bean 暴露成一个 RMI 的服务,从而继续使用 RMI 来实现跨进程的方法调用。之所以 RMI 没有像 Dubbo,Grpc 一样大火,是因为它存在着一些缺陷: + +RMI 使用专为 Java 远程对象定制的协议 JRMP(Java Remote Messaging Protocol)进行通信,这限制了它的通信双方,只能是 Java 语言的程序,无法实现跨语言通信; + +RMI 使用 Java 原生的对象序列化方式,生成的字节数组空间较大,效率很差。 + +另一个你可能听过的技术是 Web Service,它也可以认为是 RPC 的一种实现方式。它的优势是,使用 HTTP+SOAP 协议,保证了调用可以跨语言,跨平台。只要你支持 HTTP 协议,可以解析 XML,那么就能够使用 Web Service。在我来看,它由于使用 XML 封装数据,数据包大,性能还是比较差。 + +借上面几个例子,我主要是想告诉你,RPC 并不是互联网时代的产物,也不是服务化之后才衍生出来的技术,而是一种规范,只要是封装了网络调用的细节,能够实现远程调用其他服务,就可以算作是一种 RPC 技术了。 + +那么你的垂直电商项目在使用 RPC 框架之后,会产生什么变化呢? + +在我来看,在性能上的变化是不可忽视的,我给你举个例子。 比方说,你的电商系统中,商品详情页面需要商品数据、评论数据还有店铺数据,如果在一体化的架构中,你只需要从商品库,评论库和店铺库获取数据就可以了,不考虑缓存的情况下有三次网络请求。 + +但是,如果独立出商品服务、评论服务和店铺服务之后,那么就需要分别调用这三个服务,而这三个服务又会分别调用各自的数据库,这就是六次网络请求。如果你服务拆分的更细粒度,那么多出的网络调用就会越多,请求的延迟就会更长,而这就是你为了提升系统的扩展性,在性能上所付出的代价。 + + + +那么,我们要如果优化 RPC 的性能,从而尽量减少网络调用,对于性能的影响呢?在这里,你首先需要了解一次 RPC 的调用都经过了哪些步骤,因为这样,你才可以针对这些步骤中可能存在的性能瓶颈点提出优化方案。步骤如下: + +在一次 RPC 调用过程中,客户端首先会将调用的类名、方法名、参数名、参数值等信息,序列化成二进制流; + +然后客户端将二进制流,通过网络发送给服务端; + +服务端接收到二进制流之后,将它反序列化,得到需要调用的类名、方法名、参数名和参数值,再通过动态代理的方式,调用对应的方法得到返回值; + +服务端将返回值序列化,再通过网络发送给客户端; + +客户端对结果反序列化之后,就可以得到调用的结果了。 + +过程图如下: + + + +从这张图中你可以看到,有网络传输的过程,也有将请求序列化和反序列化的过程, 所以,如果要提升 RPC 框架的性能,需要从网络传输和序列化两方面来优化。 + +如何提升网络传输性能 + +在网络传输优化中,你首要做的,是选择一种高性能的 I/O 模型。所谓 I/O 模型,就是我们处理 I/O 的方式。而一般单次 I/O 请求会分为两个阶段,每个阶段对于 I/O 的处理方式是不同的。 + +首先,I/O 会经历一个等待资源的阶段,比方说,等待网络传输数据可用,在这个过程中我们对 I/O 会有两种处理方式: + +阻塞。指的是在数据不可用时,I/O 请求一直阻塞,直到数据返回; + +非阻塞。指的是数据不可用时,I/O 请求立即返回,直到被通知资源可用为止。 + +然后是使用资源的阶段,比如说从网络上接收到数据,并且拷贝到应用程序的缓冲区里面。在这个阶段我们也会有两种处理方式: + +同步处理。指的是 I/O 请求在读取或者写入数据时会阻塞,直到读取或者写入数据完成; + +异步处理。指的是 I/O 请求在读取或者写入数据时立即返回,当操作系统处理完成 I/O 请求,并且将数据拷贝到用户提供的缓冲区后,再通知应用 I/O 请求执行完成。 + +将这两个阶段的四种处理方式,做一些排列组合,再做一些补充,就得到了我们常见的五种 I/O 模型: + +同步阻塞 I/O + +同步非阻塞 I/O + +同步多路 I/O 复用 + +信号驱动 I/O + +异步 I/O + +这五种 I/O 模型,你需要理解它们的区别和特点,不过在理解上你可能会有些难度,所以我来做个比喻,方便你理解。 + +我们来把 I/O 过程比喻成烧水倒水的过程,等待资源(就是烧水的过程),使用资源(就是倒水的过程): + +如果你站在炤台边上一直等着(等待资源)水烧开,然后倒水(使用资源),那么就是同步阻塞 I/O; + +如果你偷点儿懒,在烧水的时候躺在沙发上看会儿电视(不再时时刻刻等待资源),但是还是要时不时的去看看水开了没有,一旦水开了,马上去倒水(使用资源),那么这就是同步非阻塞 I/O; + +如果你想要洗澡,需要同时烧好多壶水,那你就在看电视的间隙去看看哪壶水开了(等待多个资源),哪一壶开了就先倒哪一壶,这样就加快了烧水的速度,这就是同步多路 I/O 复用; + +不过你发现自己总是跑厨房去看水开了没,太累了,于是你考虑给你的水壶加一个报警器(信号),只要水开了就马上去倒水,这就是信号驱动 I/O; + +最后一种就高级了,你发明了一个智能水壶,在水烧好后自动就可以把水倒好,这就是异步 I/O。 + +这五种 I/O 模型中最被广泛使用的是多路 I/O 复用,Linux 系统中的 select、epoll 等系统调用都是支持多路 I/O 复用模型的,Java 中的高性能网络框架 Netty 默认也是使用这种模型。所以,我们可以选择它。 + +那么,选择好了一种高性能的 I/O 模型,是不是就能实现,数据在网络上的高效传输呢?其实并没有那么简单,网络性能的调优涉及很多方面,其中不可忽视的一项就是网络参数的调优,接下来,我带你了解其中一个典型例子。当然,你可以结合网络基础知识,以及成熟 RPC 框架(比如 Dubbo)的源码来深入了解,网络参数调优的方方面面。 + +在之前的项目中,我的团队曾经写过一个简单的 RPC 通信框架。在进行测试的时候发现,远程调用一个空业务逻辑的方法时,平均响应时间居然可以到几十毫秒,这明显不符合我们的预期,在我们看来,运行一个空的方法,应该在 1 毫秒之内可以返回。于是,我先在测试的时候使用 tcpdump 抓了包,发现一次请求的 Ack 包竟然要经过 40ms 才返回。在网上 google 了一下原因,发现原因和一个叫做 tcp_nodelay 的参数有关。这个参数是什么作用呢? + +tcp 协议的包头有 20 字节,ip 协议的包头也有 20 字节,如果仅仅传输 1 字节的数据,在网络上传输的就有 20 + 20 + 1 = 41 字节,其中真正有用的数据只有 1 个字节,这对效率和带宽是极大的浪费。所以在 1984 年的时候,John Nagle 提出了以他的名字命名的 Nagle`s 算法,他期望: + +如果是连续的小数据包,大小没有一个 MSS(Maximum Segment + +Size,最大分段大小),并且还没有收到之前发送的数据包的 Ack 信息,那么这些小数据包就会在发送端暂存起来,直到小数据包累积到一个 MSS,或者收到一个 Ack 为止。 + +这原本是为了减少不必要的网络传输,但是如果接收端开启了 DelayedACK(延迟 ACK 的发送,这样可以合并多个 ACK,提升网络传输效率),那就会发生,发送端发送第一个数据包后,接收端没有返回 ACK,这时发送端发送了第二个数据包,因为 Nagle`s 算法的存在,并且第一个发送包的 ACK 还没有返回,所以第二个包会暂存起来。而 DelayedACK 的超时时间,默认是 40ms,所以一旦到了 40ms,接收端回给发送端 ACK,那么发送端才会发送第二个包,这样就增加了延迟。 + +解决的方式非常简单:只要在 socket 上开启 tcp_nodelay 就好了,这个参数关闭了 Nagle`s 算法,这样发送端就不需要等到上一个发送包的 ACK 返回,直接发送新的数据包就好了。这对于强网络交互的场景来说非常的适用,基本上,如果你要自己实现一套网络框架,tcp_nodelay 这个参数最好是要开启的。 + +选择合适的序列化方式 + +在对网络数据传输完成调优之后,另外一个需要关注的点就是,数据的序列化和反序列化。通常所说的序列化,是将传输对象转换成二进制串的过程,而反序列化则是相反的动作,是将二进制串转换成对象的过程。 + +从上面的 RPC 调用过程中你可以看到,一次 RPC 调用需要经历两次数据序列化的过程,和两次数据反序列化的过程,可见它们对于 RPC 的性能影响是很大的,那么我们在选择序列化方式的时候需要考虑哪些因素呢? + +首先需要考虑的肯定是性能嘛,性能包括时间上的开销和空间上的开销,时间上的开销就是序列化和反序列化的速度,这是显而易见需要重点考虑的,而空间上的开销则是序列化后的二进制串的大小,过大的二进制串也会占据传输带宽,影响传输效率。 + +除去性能之外,我们需要考虑的是它是否可以跨语言,跨平台,这一点也非常重要,因为一般的公司的技术体系都不是单一的,使用的语言也不是单一的,那么如果你的 RPC 框架中传输的数据只能被一种语言解析,那么这无疑限制了框架的使用。 + +另外,扩展性也是一个需要考虑的重点问题。你想想,如果对象增加了一个字段就会造成传输协议的不兼容,导致服务调用失败,这会是多么可怕的事情。 + +综合上面的几个考虑点,在我看来,我们的序列化备选方案主要有以下几种: + +首先是大家熟知的 JSON,它起源于 JavaScript,是一种最广泛使用的序列化协议,它的优势简单易用,人言可读,同时在性能上相比 XML 有比较大的优势。 + +另外的 Thrift 和 Protobuf 都是需要引入 IDL(Interface description language)的,也就是需要按照约定的语法写一个 IDL 文件,然后通过特定的编译器将它转换成各语言对应的代码,从而实现跨语言的特点。 + +Thrift 是 Facebook 开源的高性能的序列化协议,也是一个轻量级的 RPC 框架;Protobuf 是谷歌开源的序列化协议。它们的共同特点是,无论在空间上还是时间上都有着很高的性能,缺点就是由于 IDL 存在带来一些使用上的不方便。 + +那么,你要如何选择这几种序列化协议呢?这里我给你几点建议: + +如果对于性能要求不高,在传输数据占用带宽不大的场景下,可以使用 JSON 作为序列化协议; + +如果对于性能要求比较高,那么使用 Thrift 或者 Protobuf 都可以。而 Thrift 提供了配套的 RPC 框架,所以想要一体化的解决方案,你可以优先考虑 Thrift; + +在一些存储的场景下,比如说你的缓存中存储的数据占用空间较大,那么你可以考虑使用 Protobuf 替换 JSON,作为存储数据的序列化方式。 + +课程小结 + +为了优化 RPC 框架的性能,本节课,我带你了解了网络 I/O 模型和序列化方式的选择,它们是实现高并发 RPC 框架的要素,总结起来有三个要点: + +\1. 选择高性能的 I/O 模型,这里我推荐使用同步多路 I/O 复用模型; + +\2. 调试网络参数,这里面有一些经验值的推荐。比如将 tcp_nodelay 设置为 true,也有一些参数需要在运行中来调试,比如接受缓冲区和发送缓冲区的大小,客户端连接请求缓冲队列的大小(back log)等等; + +\3. 序列化协议依据具体业务来选择。如果对性能要求不高,可以选择 JSON,否则可以从 Thrift 和 Protobuf 中选择其一。 + +在学习本节课的过程中,我建议你阅读一下,成熟的 RPC 框架的源代码。比如,阿里开源的 Dubbo,微博的 Motan 等等,理解它们的实现原理和细节,这样你会更有信心维护好你的微服务系统;同时,你也可以从优秀的代码中,学习到代码设计的技巧,比如说 Dubbo 对于 RPC 的抽象,SPI 扩展点的设计,这样可以有助你提升代码能力。 + +当然了,本节课我不仅仅想让你了解 RPC 框架实现的一些原理,更想让你了解在做网络编程时,需要考虑哪些关键点,这样你在设计此类型的系统时,就会有一些考虑的方向和思路了。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/24注册中心:分布式系统如何寻址?.md b/专栏/高并发系统设计40问/24注册中心:分布式系统如何寻址?.md new file mode 100644 index 0000000..6c355d6 --- /dev/null +++ b/专栏/高并发系统设计40问/24注册中心:分布式系统如何寻址?.md @@ -0,0 +1,141 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 24 注册中心:分布式系统如何寻址? + 你好,我是唐扬。 + +上一节课,我带你了解了 RPC 框架实现中的一些关键的点,你通过 RPC 框架,能够解决服务之间,跨网络通信的问题,这就完成了微服务化改造的基础。 + +但是在服务拆分之后,你需要维护更多的细粒度的服务,而你需要面对的第一个问题就是,如何让 RPC 客户端知道服务端部署的地址,这就是我们今天要讲到的,服务注册与发现的问题。 + +你所知道的服务发现 + +服务注册和发现不是一个新的概念,你在之前的实际项目中也一定了解过,只是你可能没怎么注意罢了。比如说,你知道 Nginx 是一个反向代理组件,那么 Nginx 需要知道,应用服务器的地址是什么,这样才能够将流量透传到应用服务器上,这就是服务发现的过程。 + +那么 Nginx 是怎么实现的呢?它是把应用服务器的地址配置在了文件中。 + +这固然是一种解决的思路,实际上,我在早期的项目中也是这么做的。那时,项目刚刚做了服务化拆分,RPC 服务端的地址,就是配置在了客户端的代码中,不过,这样做之后出现了几个问题: + +首先在紧急扩容的时候,就需要修改客户端配置后,重启所有的客户端进程,操作时间比较长; + +其次,一旦某一个服务器出现故障时,也需要修改所有客户端配置后重启,无法快速修复,更无法做到自动恢复; + +最后,RPC 服务端上线无法做到提前摘除流量,这样在重启服务端的时候,客户端发往被重启服务端的请求还没有返回,会造成慢请求甚至请求失败。 + +因此,我们考虑使用注册中心来解决这些问题。 + +目前业界有很多可供你来选择的注册中心组件,比如说老派的 ZooKeeper,Kubernetes 使用的 ETCD,阿里的微服务注册中心 Nacos,Spring Cloud 的 Eureka 等等。 + +这些注册中心的基本功能有两点: + +其一是提供了服务地址的存储; + +其二是当存储内容发生变化时,可以将变更的内容推送给客户端。 + +第二个功能是我们使用注册中心的主要原因。因为无论是,当我们需要紧急扩容,还是在服务器发生故障时,需要快速摘除节点,都不用重启服务器就可以实现了。使用了注册中心组件之后,RPC 的通信过程就变成了下面这个样子: + + + +从图中,你可以看到一个完整的,服务注册和发现的过程: + +客户端会与注册中心建立连接,并且告诉注册中心,它对哪一组服务感兴趣; + +服务端向注册中心注册服务后,注册中心会将最新的服务注册信息通知给客户端; + +客户端拿到服务端的地址之后就可以向服务端发起调用请求了。 + +从这个过程中可以看出,有了注册中心之后,服务节点的增加和减少对于客户端就是透明的。这样,除了可以实现不重启客户端,就能动态地变更服务节点以外,还可以实现优雅关闭的功能。 + +优雅关闭是你在系统研发过程中,必须要考虑的问题。因为如果暴力地停止服务,那么已经发送给服务端的请求,来不及处理服务就被杀掉了,就会造成这部分请求失败,服务就会有波动。所以,服务在退出的时候,都需要先停掉流量,再停止服务,这样服务的关闭才会更平滑,比如说,消息队列处理器就是要将所有,已经从消息队列中读出的消息,处理完之后才能退出。 + +对于 RPC 服务来说,我们可以先将 RPC 服务从注册中心的服务列表中删除掉,然后观察 RPC 服务端没有流量之后,再将服务端停掉。有了优雅关闭之后,RPC 服务端再重启的时候,就会减少对客户端的影响。 + +在这个过程中,服务的上线和下线是由服务端主动向注册中心注册、和取消注册来实现的,这在正常的流程中是没有问题的。可是,如果某一个服务端意外故障,比如说机器掉电,网络不通等情况,服务端就没有办法向注册中心通信,将自己从服务列表中删除,那么客户端也就不会得到通知,它就会继续向一个故障的服务端发起请求,也就会有错误发生了。那这种情况如何来避免呢?其实,这种情况是一个服务状态管理的问题。 + +服务状态管理如何来做 + +针对上面我提到的问题,我们一般会有两种解决思路。 + +第一种思路是主动探测,方法是这样的: + +你的 RPC 服务要打开一个端口,然后由注册中心每隔一段时间(比如 30 秒)探测这些端口是否可用,如果可用就认为服务仍然是正常的,否则就可以认为服务不可用,那么注册中心就可以把服务从列表里面删除了。 + + + +微博早期的注册中心就是采用这种方式,但是后面出现的两个问题,让我们不得不对它做改造。 + +第一个问题是:所有的 RPC 服务端都需要,开放一个统一的端口给注册中心探测,那时候还没有容器化,一台物理机上会混合部署很多的服务,你需要开放的端口很可能已经被占用,这样会造成 RPC 服务启动失败。 + +还有一个问题是:如果 RPC 服务端部署的实例比较多,那么每次探测的成本也会比较高,探测的时间也比较长,这样当一个服务不可用时,可能会有一段时间的延迟,才会被注册中心探测到。 + +因此,我们后面把它改造成了心跳模式。 + +这也是大部分注册中心提供的,检测连接上来的 RPC 服务端是否存活的方式,比如 Eureka、ZooKeeper,在我来看,这种心跳机制可以这样实现: + +注册中心为每一个连接上来的 RPC 服务节点,记录最近续约的时间,RPC 服务节点在启动注册到注册中心后,就按照一定的时间间隔(比如 30 秒),向注册中心发送心跳包。注册中心在接受到心跳包之后,会更新这个节点的最近续约时间。然后,注册中心会启动一个定时器,定期检测当前时间和节点,最近续约时间的差值,如果达到一个阈值(比如说 90 秒),那么认为这个服务节点不可用。 + + + +在实际的使用中,心跳机制相比主动探测的机制,适用范围更广,如果你的服务也需要检测是否存活,那么也可以考虑使用心跳机制来检测。 + +接着说回来,有了心跳机制之后,注册中心就可以管理注册的服务节点的状态了,也让你的注册中心成为了整体服务最重要的组件,因为一旦它出现问题或者代码出现 Bug,那么很可能会导致整个集群的故障,给你举一个真实的案例。 + +在我之前的一个项目中,工程是以“混合云”的方式部署的,也就是一部分节点部署在自建机房中,一部分节点部署在云服务器上,每一个机房都部署了自研的一套注册中心,每套注册中心中都保存了全部节点的数据。 + +这套自研的注册中心使用 Redis 作为最终的存储,而在自建机房和云服务器上的注册中心,共用同一套 Redis 存储资源。由于“混合云”还处在测试阶段,所以,所有的流量还都在自建机房,自建机房和云服务器之前的专线带宽还比较小,部署结构如下: + + + +在测试的过程中,系统运行稳定,但是某一天早上五点,我突然发现,所有的服务节点都被摘除了,客户端因为拿不到服务端的节点地址列表全部调用失败,整体服务宕机。经过排查我发现,云服务器上部署的注册中心,竟然将所有的服务节点全部删除了!进一步排查之后,原来是自研注册中心出现了 Bug。 + +在正常的情况下,无论是自建机房,还是云服务器上的服务节点,都会向各自机房的注册中心注册地址信息,并且发送心跳。而这些地址信息,以及服务的最近续约时间,都是存储在 Redis 主库中,各自机房的注册中心,会读各自机房的从库来获取最近续约时间,从而判断服务节点是否有效。 + +Redis 的主从同步数据是通过专线来传输的,出现故障之前,专线带宽被占满,导致主从同步延迟。这样一来,云上部署的 Redis 从库中存储的最近续约时间,就没有得到及时更新,随着主从同步延迟越发严重,最终,云上部署的注册中心发现了,当前时间与最近续约时间的差值,超过了摘除的阈值,所以将所有的节点摘除,从而导致了故障。 + +有了这次惨痛的教训,我们给注册中心增加了保护的策略:如果摘除的节点占到了服务集群节点数的 40%,就停止摘除服务节点,并且给服务的开发同学和,运维同学报警处理(这个阈值百分比可以调整,保证了一定的灵活性)。 + +据我所知,Eureka 也采用了类似的策略,来避免服务节点被过度摘除,导致服务集群不足以承担流量的问题。如果你使用的是 ZooKeeper 或者 ETCD 这种无保护策略的分布式一致性组件,那你可以考虑在客户端,实现保护策略的逻辑,比如说当摘除的节点超过一定比例时,你在 RPC 客户端就不再处理变更通知,你可以依据自己的实际情况来实现。 + +除此之外,在实际项目中,我们还发现注册中心另一个重要的问题就是“通知风暴”。你想一想,变更一个服务的一个节点,会产生多少条推送消息?假如你的服务有 100 个调用者,有 100 个节点,那么变更一个节点会推送 100 * 100 = 10000 个节点的数据。那么如果多个服务集群同时上线或者发生波动时,注册中心推送的消息就会更多,会严重占用机器的带宽资源,这就是我所说的“通知风暴”。那么怎么解决这个问题呢?你可以从以下几个方面来思考: + +首先,要控制一组注册中心管理的服务集群的规模,具体限制多少没有统一的标准,你需要结合你的业务以及注册中心的选型来考虑,主要考察的指标就是注册中心服务器的峰值带宽; + +其次,你也可以通过扩容注册中心节点的方式来解决; + +再次,你可以规范一下对于注册中心的使用方式,如果只是变更某一个节点,那么只需要通知这个节点的变更信息即可; + +最后,如果是自建的注册中心,你也可以在其中加入一些保护策略,比如说如果通知的消息量达到某一个阈值就停止变更通知。 + +其实,服务的注册和发现,归根结底是服务治理中的一环,服务治理(service governance),其实更直白的翻译应该是服务的管理,也就是解决多个服务节点,组成集群的时候,产生的一些复杂的问题。为了帮助你理解,我来做个简单的比喻。 + +你可以把集群看作是一个微型的城市,把道路看做是组成集群的服务,把行走在道路上的车当做是流量,那么服务治理就是对于整个城市道路的管理。 + +如果你新建了一条街道(相当于启动了一个新的服务节点),那么就要通知所有的车辆(流量)有新的道路可以走了;你关闭了一条街道,你也要通知所有车辆不要从这条路走了,这就是服务的注册和发现。 + +我们在道路上安装监控,监视每条道路的流量情况,这就是服务的监控。 + +道路一旦出现拥堵或者道路需要维修,那么就需要暂时封闭这条道路,由城市来统一调度车辆,走不堵的道路,这就是熔断以及引流。 + +道路之间纵横交错四通八达,一旦在某条道路上出现拥堵,但是又发现这条道路从头堵到尾,说明事故并不是发生在这条道路上,那么就需要从整体链路上来排查事故究竟处在哪个位置,这就是分布式追踪。 + +不同道路上的车辆有多有少,那么就需要有一个警察来疏导,在某一个时间走哪一条路会比较快,这就是负载均衡。 + +而这些问题,我会在后面的课程中针对性地讲解。 + +课程小结 + +本节课,我带你了解了在微服务架构中,注册中心是如何实现服务的注册和发现的,以及在实现中遇到的一些坑,除此之外,我还带你了解了服务治理的含义,以及后续我们会讲到的一些技术点。在这节课中,我想让你明确的重点如下: + +注册中心可以让我们动态地,变更 RPC 服务的节点信息,对于动态扩缩容,故障快速恢复,以及服务的优雅关闭都有重要的意义; + +心跳机制是一种常见的探测服务状态的方式,你在实际的项目中也可以考虑使用; + +我们需要对注册中心中管理的节点提供一些保护策略,避免节点被过度摘除导致的服务不可用。 + +你看,注册中心虽然是一种简单易懂的分布式组件,但是它在整体架构中的位置至关重要,不容忽视。同时,在它的设计方案中,也蕴含了一些系统设计的技巧,比如上,面提到的服务状态检测的方式,还有上面提到的优雅关闭的方式,了解注册中心的原理,会给你之后的研发工作提供一些思路。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/25分布式Trace:横跨几十个分布式组件的慢请求要如何排查?.md b/专栏/高并发系统设计40问/25分布式Trace:横跨几十个分布式组件的慢请求要如何排查?.md new file mode 100644 index 0000000..88cc001 --- /dev/null +++ b/专栏/高并发系统设计40问/25分布式Trace:横跨几十个分布式组件的慢请求要如何排查?.md @@ -0,0 +1,226 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 25 分布式Trace:横跨几十个分布式组件的慢请求要如何排查? + 你好,我是唐扬。 + +经过前面几节课的学习,你的垂直电商系统在引入 RPC 框架,和注册中心之后已经完成基本的服务化拆分了,系统架构也有了改变: + + + +现在,你的系统运行平稳,老板很高兴,你也安心了很多。而且你认为,在经过了服务化拆分之后,服务的可扩展性增强了很多,可以通过横向扩展服务节点的方式,进行平滑地扩容了,对于应对峰值流量也更有信心了。 + +但是这时出现了问题:你通过监控发现,系统的核心下单接口在晚高峰的时候,会有少量的慢请求,用户也投诉在 APP 上下单时,等待的时间比较长。而下单的过程可能会调用多个 RPC 服务,或者使用多个资源,一时之间,你很难快速判断,究竟是哪个服务或者资源出了问题,从而导致整体流程变慢,于是,你和你的团队开始想办法如何排查这个问题。 + +一体化架构中的慢请求排查如何做 + +因为在分布式环境下,请求要在多个服务之间调用,所以对于慢请求问题的排查会更困难,我们不妨从简单的入手,先看看在一体化架构中,是如何排查这个慢请求的问题的。 + +最简单的思路是:打印下单操作的每一个步骤的耗时情况,然后通过比较这些耗时的数据,找到延迟最高的一步,然后再来看看这个步骤要如何的优化。如果有必要的话,你还需要针对步骤中的子步骤,再增加日志来继续排查,简单的代码就像下面这样: + +long start = System.currentTimeMillis(); + +processA(); + +Logs.info("process A cost " + (System.currentTimeMillis() - start));// 打印 A 步骤的耗时 + +start = System.currentTimeMillis(); + +processB(); + +Logs.info("process B cost " + (System.currentTimeMillis() - start));// 打印 B 步骤的耗时 + +start = System.currentTimeMillis(); + +processC(); + +Logs.info("process C cost " + (System.currentTimeMillis() - start));// 打印 C 步骤的耗时 + + +这是最简单的实现方式,打印出日志后,我们可以登录到机器上,搜索关键词来查看每个步骤的耗时情况。 + +虽然这个方式比较简单,但你可能很快就会遇到问题:由于同时会有多个下单请求并行处理,所以,这些下单请求的每个步骤的耗时日志,是相互穿插打印的。你无法知道这些日志,哪些是来自于同一个请求,也就不能很直观地看到,某一次请求耗时最多的步骤是哪一步了。那么,你要如何把单次请求,每个步骤的耗时情况串起来呢? + +一个简单的思路是:给同一个请求的每一行日志,增加一个相同的标记。这样,只要拿到这个标记就可以查询到这个请求链路上,所有步骤的耗时了,我们把这个标记叫做 requestId,我们可以在程序的入口处生成一个 requestId,然后把它放在线程的上下文中,这样就可以在需要时,随时从线程上下文中获取到 requestId 了。简单的代码实现就像下面这样: + +String requestId = UUID.randomUUID().toString(); + +ThreadLocal tl = new ThreadLocal(){ + + @Override + + protected String initialValue() { + + return requestId; + + } + +}; //requestId 存储在线程上下文中 + +long start = System.currentTimeMillis(); + +processA(); + +Logs.info("rid : " + tl.get() + ", process A cost " + (System.currentTimeMillis() - start)); // 日志中增加 requestId + +start = System.currentTimeMillis(); + +processB(); + +Logs.info("rid : " + tl.get() + ", process B cost " + (System.currentTimeMillis() - start)); + +start = System.currentTimeMillis(); + +processC(); + +Logs.info("rid : " + tl.get() + ", process C cost " + (System.currentTimeMillis() - start)); + + +有了 requestId,你就可以清晰地了解一个调用链路上的耗时分布情况了。 + +于是,你给你的代码增加了大量的日志,来排查下单操作缓慢的问题。很快, 你发现是某一个数据库查询慢了才导致了下单缓慢,然后你优化了数据库索引,问题最终得到了解决。 + +正当你要松一口气的时候,问题接踵而至:又有用户反馈某些商品业务打开缓慢;商城首页打开缓慢。你开始焦头烂额地给代码中增加耗时日志,而这时你意识到,每次排查一个接口就需要增加日志、重启服务,这并不是一个好的办法,于是你开始思考解决的方案。 + +其实,从我的经验出发来说,一个接口响应时间慢,一般是出在跨网络的调用上,比如说请求数据库、缓存或者依赖的第三方服务。所以,我们只需要针对这些调用的客户端类,做切面编程,通过插入一些代码打印它们的耗时就好了。 + +说到切面编程(AOP)你应该并不陌生,它是面向对象编程的一种延伸,可以在不修改源代码的前提下,给应用程序添加功能,比如说鉴权,打印日志等等。如果你对切面编程的概念理解的还不透彻,那我给你做个比喻,帮你理解一下。 + +这就像开发人员在向代码仓库提交代码后,他需要对代码编译、构建、执行单元测试用例,以保证提交的代码是没有问题的。但是,如果每个人提交了代码都做这么多事儿,无疑会对开发同学造成比较大的负担,那么你可以配置一个持续集成的流程,在提交代码之后,自动帮你完成这些操作,这个持续集成的流程就可以认为是一个切面。 + +一般来说,切面编程的实现分为两类: + +一类是静态代理,典型的代表是 AspectJ,它的特点是在编译期做切面代码注入; + +另一类是动态代理,典型的代表是 Spring AOP,它的特点是在运行期做切面代码注入。 + +这两者有什么差别呢?以 Java 为例,源代码 Java 文件先被 Java 编译器,编译成 Class 文件,然后 Java 虚拟机将 Class 装载进来之后,进行必要的验证和初始化后就可以运行了。 + +静态代理是在编译期插入代码,增加了编译的时间,给你的直观感觉就是启动的时间变长了,但是一旦在编译期插入代码完毕之后,在运行期就基本对于性能没有影响。 + +而动态代理不会去修改生成的 Class 文件,而是会在运行期生成一个代理对象,这个代理对象对源对象做了字节码增强,来完成切面所要执行的操作。由于在运行期需要生成代理对象,所以动态代理的性能要比静态代理要差。 + +我们做切面的原因,是想生成一些调试的日志,所以我们期望尽量减少对于原先接口性能的影响。因此,我推荐采用静态代理的方式,实现切面编程。 + +如果你的系统中需要增加切面,来做一些校验、限流或者日志打印的工作,我也建议你考虑使用静态代理的方式,使用 AspectJ 做切面的简单代码实现就像下面这样: + +@Aspect + +public class Tracer { + + @Around(value = "execution(public methodsig)", argNames = "pjp") //execution 内替换要做切面的方法签名 + + public Object trace(ProceedingJoinPoint pjp) throws Throwable { + + TraceContext traceCtx = TraceContext.get(); // 获取追踪上下文,上下文的初始化可以在程序入口处 + + String requestId = reqCtx.getRequestId(); // 获取 requestId + + String sig = pjp.getSignature().toShortString(); // 获取方法签名 + + boolean isSuccessful = false; + + String errorMsg = ""; + + Object result = null; + + long start = System.currentTimeMillis(); + + try { + + result = pjp.proceed(); + + isSuccessful = true; + + return result; + + } catch (Throwable t) { + + isSuccessful = false; + + errorMsg = t.getMessage(); + + return result; + + } finally { + + long elapseTime = System.currentTimeMillis() - start; + + Logs.info("rid : " + requestId + ", start time: " + start + ", elapseTime: " + elapseTime + ", sig: " + sig + ", isSuccessful: " + isSuccessful + ", errorMsg: " + errorMsg ); + + } + + } + +} + + +这样,你就在你的系统的每个接口中,打印出了所有访问数据库、缓存、外部接口的耗时情况,一次请求可能要打印十几条日志,如果你的电商系统的 QPS 是 10000 的话,就是每秒钟会产生十几万条日志,对于磁盘 I/O 的负载是巨大的,那么这时,你就要考虑如何减少日志的数量。 + +你可以考虑对请求做采样,采样的方式也简单,比如你想采样 10% 的日志,那么你可以只打印“requestId%10==0”的请求。 + +有了这些日志之后,当给你一个 requestId 的时候,你发现自己并不能确定这个请求到了哪一台服务器上,所以你不得不登陆所有的服务器,去搜索这个 requestId 才能定位请求。这样无疑会增加问题排查的时间。 + +你可以考虑的解决思路是:把日志不打印到本地文件中,而是发送到消息队列里,再由消息处理程序写入到集中存储中,比如 Elasticsearch。这样,你在排查问题的时候,只需要拿着 requestId 到 Elasticsearch 中查找相关的记录就好了。在加入消息队列和 Elasticsearch 之后,我们这个排查程序的架构图也会有所改变: + + + +我来总结一下,为了排查单次请求响应时间长的原因,我们主要做了哪些事情: + +\1. 在记录打点日志时,我们使用 requestId 将日志串起来,这样方便比较一次请求中的多个步骤的耗时情况; + +\2. 我们使用静态代理的方式做切面编程,避免在业务代码中,加入大量打印耗时的日志的代码,减少了对于代码的侵入性,同时编译期的代码注入可以减少; + +\3. 我们增加了日志采样率,避免全量日志的打印; + +\4. 最后为了避免在排查问题时,需要到多台服务器上搜索日志,我们使用消息队列,将日志集中起来放在了 Elasticsearch 中。 + +如何来做分布式 Trace + +你可能会问:题目既然是“分布式 Trace:横跨几十个分布式组件的慢请求要如何排查?”,那么我为什么要花费大量的篇幅,来说明在一体化架构中如何排查问题呢?这是因为在分布式环境下,你基本上也是依据上面,我提到的这几点来构建分布式追踪的中间件的。 + +在一体化架构中,单次请求的所有的耗时日志,都被记录在一台服务器上,而在微服务的场景下,单次请求可能跨越多个 RPC 服务,这就造成了,单次的请求的日志会分布在多个服务器上。 + +当然,你也可以通过 requestId 将多个服务器上的日志串起来,但是仅仅依靠 requestId 很难表达清楚服务之间的调用关系,所以从日志中,就无法了解服务之间是谁在调用谁。因此,我们采用 traceId + spanId 这两个数据维度来记录服务之间的调用关系(这里 traceId 就是 requestId),也就是使用 traceId 串起单次请求,用 spanId 记录每一次 RPC 调用。说起来可能比较抽象,我给你举一个具体的例子。 + +比如,你的请求从用户端过来,先到达 A 服务,A 服务会分别调用 B 和 C 服务,B 服务又会调用 D 和 E 服务。 + + + +我来给你讲讲图中的内容: + +用户到 A 服务之后会初始化一个 traceId 为 100,spanId 为 1; + +A 服务调用 B 服务时,traceId 不变,而 spanId 用 1.1 标识,代表上一级的 spanId 是 1,这一级的调用次序是 1; + +A 调用 C 服务时,traceId 依然不变,spanId 则变为了 1.2,代表上一级的 spanId 还是 1,而调用次序则变成了 2,以此类推。 + +通过这种方式,我们可以在日志中,清晰地看出服务的调用关系是如何的,方便在后续计算中调整日志顺序,打印出完整的调用链路。 + +那么 spanId 是何时生成的,又是如何传递的呢?这部分内容可以算作一个延伸点,能够帮你了解分布式 trace 中间件的实现原理。 + +首先,A 服务在发起 RPC 请求服务 B 前,先从线程上下文中获取当前的 traceId 和 spanId,然后,依据上面的逻辑生成本次 RPC 调用的 spanId,再将 spanId 和 traceId 序列化后,装配到请求体中,发送给服务方 B。 + +服务方 B 获取请求后,从请求体中反序列化出 spanId 和 traceId,同时设置到线程上下文中,以便给下次 RPC 调用使用。在服务 B 调用完成返回响应前,计算出服务 B 的执行时间发送给消息队列。 + +当然,在服务 B 中,你依然可以使用切面编程的方式,得到所有调用的数据库、缓存、HTTP 服务的响应时间,只是在发送给消息队列的时候,要加上当前线程上下文中的 spanId 和 traceId。 + +这样,无论是数据库等资源的响应时间,还是 RPC 服务的响应时间就都汇总到了消息队列中,在经过一些处理之后,最终被写入到 Elasticsearch 中以便给开发和运维同学查询使用。 + +而在这里,你大概率会遇到的问题还是性能的问题,也就是因为引入了分布式追踪中间件,导致对于磁盘 I/O 和网络 I/O 的影响,而我给你的“避坑”指南就是:如果你是自研的分布式 trace 中间件,那么一定要提供一个开关,方便在线上随时将日志打印关闭;如果使用开源的组件,可以开始设置一个较低的日志采样率,观察系统性能情况再调整到一个合适的数值。 + +课程小结 + +本节课我带你了解了在一体化架构和服务化架构中,你要如何排查单次慢请求中,究竟哪一个步骤是瓶颈,这里你需要了解的主要有以下几个重点: + +服务的追踪的需求主要有两点,一点对代码要无侵入,你可以使用切面编程的方式来解决;另一点是性能上要低损耗,我建议你采用静态代理和日志采样的方式,来尽量减少追踪日志对于系统性能的影响; + +无论是单体系统还是服务化架构,无论是服务追踪还是业务问题排查,你都需要在日志中增加 requestId,这样可以将你的日志串起来,给你呈现一个完整的问题场景。如果 requestId 可以在客户端上生成,在请求业务接口的时候传递给服务端,那么就可以把客户端的日志体系也整合进来,对于问题的排查帮助更大。 + +其实,分布式追踪系统不是一项新的技术,而是若干项已有技术的整合,在实现上并不复杂,却能够帮助你实现跨进程调用链展示、服务依赖分析,在性能优化和问题排查方面提供数据上的支持。所以,在微服务化过程中,它是一个必选项,无论是采用 Zipkin,Jaeger 这样的开源解决方案,还是团队内自研,你都应该在微服务化完成之前,尽快让它发挥应有的价值。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/26负载均衡:怎样提升系统的横向扩展能力?.md b/专栏/高并发系统设计40问/26负载均衡:怎样提升系统的横向扩展能力?.md new file mode 100644 index 0000000..609573d --- /dev/null +++ b/专栏/高并发系统设计40问/26负载均衡:怎样提升系统的横向扩展能力?.md @@ -0,0 +1,149 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 26 负载均衡:怎样提升系统的横向扩展能力? + 你好,我是唐扬。 + +在基础篇中,我提到了高并发系统设计的三个通用方法:缓存、异步和横向扩展,到目前为止,你接触到了缓存的使用姿势,也了解了,如何使用消息队列异步处理业务逻辑,那么本节课,我将带你了解一下,如何提升系统的横向扩展能力。 + +在之前的课程中,我也提到过提升系统横向扩展能力的一些案例。比如,08 讲提到,可以通过部署多个从库的方式,来提升数据库的扩展能力,从而提升数据库的查询性能,那么就需要借助组件,将查询数据库的请求,按照一些既定的策略分配到多个从库上,这是负载均衡服务器所起的作用,而我们一般使用 DNS 服务器来承担这个角色。 + +不过在实际的工作中,你经常使用的负载均衡的组件应该算是 Nginx,它的作用是承接前端的 HTTP 请求,然后将它们按照多种策略,分发给后端的多个业务服务器上。这样,我们可以随时通过扩容业务服务器的方式,来抵挡突发的流量高峰。与 DNS 不同的是,Nginx 可以在域名和请求 URL 地址的层面做更细致的流量分配,也提供更复杂的负载均衡策略。 + +你可能会想到,在微服务架构中,我们也会启动多个服务节点,来承接从用户端到应用服务器的请求,自然会需要一个负载均衡服务器,作为流量的入口,实现流量的分发。那么在微服务架构中,如何使用负载均衡服务器呢? + +在回答这些问题之前,我先带你了解一下,常见的负载均衡服务器都有哪几类,因为这样,你就可以依据不同类型负载均衡服务器的特点做选择了。 + +负载均衡服务器的种类 + +负载均衡的含义是:将负载(访问的请求)“均衡”地分配到多个处理节点上。这样可以减少单个处理节点的请求量,提升整体系统的性能。 + +同时,负载均衡服务器作为流量入口,可以对请求方屏蔽服务节点的部署细节,实现对于业务方无感知的扩容。它就像交通警察,不断地疏散交通,将汽车引入合适的道路上。 + +而在我看来,负载均衡服务大体上可以分为两大类:一类是代理类的负载均衡服务;另一类是客户端负载均衡服务。 + +代理类的负载均衡服务,以单独的服务方式部署,所有的请求都要先经过负载均衡服务,在负载均衡服务中,选出一个合适的服务节点后,再由负载均衡服务,调用这个服务节点来实现流量的分发。 + + + +由于这类服务需要承担全量的请求,所以对于性能的要求极高。代理类的负载均衡服务有很多开源实现,比较著名的有 LVS,Nginx 等等。LVS 在 OSI 网络模型中的第四层,传输层工作,所以 LVS 又可以称为四层负载;而 Nginx 运行在 OSI 网络模型中的第七层,应用层,所以又可以称它为七层负载(你可以回顾一下02 讲的内容)。 + +在项目的架构中,我们一般会同时部署 LVS 和 Nginx 来做 HTTP 应用服务的负载均衡。也就是说,在入口处部署 LVS,将流量分发到多个 Nginx 服务器上,再由 Nginx 服务器分发到应用服务器上,为什么这么做呢? + +主要和 LVS 和 Nginx 的特点有关,LVS 是在网络栈的四层做请求包的转发,请求包转发之后,由客户端和后端服务直接建立连接,后续的响应包不会再经过 LVS 服务器,所以相比 Nginx,性能会更高,也能够承担更高的并发。 + +可 LVS 缺陷是工作在四层,而请求的 URL 是七层的概念,不能针对 URL 做更细致地请求分发,而且 LVS 也没有提供探测后端服务是否存活的机制;而 Nginx 虽然比 LVS 的性能差很多,但也可以承担每秒几万次的请求,并且它在配置上更加灵活,还可以感知后端服务是否出现问题。 + +因此,LVS 适合在入口处,承担大流量的请求分发,而 Nginx 要部署在业务服务器之前做更细维度的请求分发。我给你的建议是,如果你的 QPS 在十万以内,那么可以考虑不引入 LVS 而直接使用 Nginx 作为唯一的负载均衡服务器,这样少维护一个组件,也会减少系统的维护成本。 + +不过这两个负载均衡服务适用于普通的 Web 服务,对于微服务架构来说,它们是不合适的。因为微服务架构中的服务节点存储在注册中心里,使用 LVS 就很难和注册中心交互,获取全量的服务节点列表。另外,一般微服务架构中,使用的是 RPC 协议而不是 HTTP 协议,所以 Nginx 也不能满足要求。 + +所以,我们会使用另一类的负载均衡服务,客户端负载均衡服务,也就是把负载均衡的服务内嵌在 RPC 客户端中。 + +它一般和客户端应用,部署在一个进程中,提供多种选择节点的策略,最终为客户端应用提供一个最佳的,可用的服务端节点。这类服务一般会结合注册中心来使用,注册中心提供服务节点的完整列表,客户端拿到列表之后使用负载均衡服务的策略选取一个合适的节点,然后将请求发到这个节点上。 + + + +了解负载均衡服务的分类,是你学习负载均衡服务的第一步,接下来,你需要掌握负载均衡策略,这样一来,你在实际工作中,配置负载均衡服务的时候,可以对原理有更深刻的了解。 + +常见的负载均衡策略有哪些 + +负载均衡策略从大体上来看可以分为两类: + +一类是静态策略,也就是说负载均衡服务器在选择服务节点时,不会参考后端服务的实际运行的状态。 + +一类是动态策略,也就是说负载均衡服务器会依据后端服务的一些负载特性,来决定要选择哪一个服务节点。 + +常见的静态策略有几种,其中使用最广泛的是轮询的策略(RoundRobin,RR),这种策略会记录上次请求后端服务的地址或者序号,然后在请求时,按照服务列表的顺序,请求下一个后端服务节点。伪代码如下: + +AtomicInteger lastCounter = getLastCounter();// 获取上次请求的服务节点的序号 + +List serverList = getServerList(); // 获取服务列表 + +int currentIndex = lastCounter.addAndGet(); // 增加序列号 + +if(currentIndex >= serverList.size()) { + + currentIndex = 0; + +} + +setLastCounter(currentIndex); + +return serverList.get(currentIndex); + + +它其实是一种通用的策略,基本上,大部分的负载均衡服务器都支持。轮询的策略可以做到将请求尽量平均地分配到所有服务节点上,但是,它没有考虑服务节点的具体配置情况。比如,你有三个服务节点,其中一个服务节点的配置是 8 核 8G,另外两个节点的配置是 4 核 4G,那么如果使用轮询的方式来平均分配请求的话,8 核 8G 的节点分到的请求数量和 4 核 4G 的一样多,就不能发挥性能上的优势了 + +所以,我们考虑给节点加上权重值,比如给 8 核 8G 的机器配置权重为 2,那么就会给它分配双倍的流量,这种策略就是带有权重的轮询策略。 + +除了这两种策略之外,目前开源的负载均衡服务还提供了很多静态策略: + +Nginx 提供了 ip_hash 和 url_hash 算法; + +LVS 提供了按照请求的源地址,和目的地址做 hash 的策略; + +Dubbo 也提供了随机选取策略,以及一致性 hash 的策略。 + +但是在我看来,轮询和带有权重的轮询策略,能够将请求尽量平均地分配到后端服务节点上,也就能够做到对于负载的均衡分配,在没有更好的动态策略之前,应该优先使用这两种策略,比如 Nginx 就会优先使用轮询的策略。 + +而目前开源的负载均衡服务中,也会提供一些动态策略,我强调一下它们的原理。 + +在负载均衡服务器上会收集对后端服务的调用信息,比如从负载均衡端到后端服务的活跃连接数,或者是调用的响应时间,然后从中选择连接数最少的服务,或者响应时间最短的后端服务。我举几个具体的例子: + +Dubbo 提供的 LeastAcive 策略,就是优先选择活跃连接数最少的服务; + +Spring Cloud 全家桶中的 Ribbon 提供了 WeightedResponseTimeRule 是使用响应时间,给每个服务节点计算一个权重,然后依据这个权重,来给调用方分配服务节点。 + +这些策略的思考点是从调用方的角度出发,选择负载最小、资源最空闲的服务来调用,以期望能得到更高的服务调用性能,也就能最大化地使用服务器的空闲资源,请求也会响应地更迅速,所以,我建议你,在实际开发中,优先考虑使用动态的策略。 + +到目前为止,你已经可以根据上面的分析,选择适合自己的负载均衡策略,并选择一个最优的服务节点,那么问题来了:你怎么保证选择出来的这个节点,一定是一个可以正常服务的节点呢?如果你采用的是轮询的策略,选择出来的,是一个故障节点又要怎么办呢?所以,为了降低请求被分配到一个故障节点的几率,有些负载均衡服务器,还提供了对服务节点的故障检测功能。 + +如何检测节点是否故障 + +24 讲中,我带你了解到,在微服务化架构中,服务节点会定期地向注册中心发送心跳包,这样注册中心就能够知晓服务节点是否故障,也就可以确认传递给负载均衡服务的节点,一定是可用的。 + +但对于 Nginx 来说,我们要如何保证配置的服务节点是可用的呢? + +这就要感谢淘宝开源的 Nginx 模块nginx_upstream_check_module了,这个模块可以让 Nginx 定期地探测后端服务的一个指定的接口,然后根据返回的状态码,来判断服务是否还存活。当探测不存活的次数达到一定阈值时,就自动将这个后端服务从负载均衡服务器中摘除。它的配置样例如下: + +upstream server { + + server 192.168.1.1:8080; + + server 192.168.1.2:8080; + + check interval=3000 rise=2 fall=5 timeout=1000 type=http default_down=true;// 检测间隔为 3 秒,检测超时时间是 1 秒,使用 http 协议。如果连续失败次数达到 5 次就认为服务不可用;如果连续连续成功次数达到 2 次,则认为服务可用。后端服务刚启动时状态是不可用的 + + check_http_send "GET /health_check HTTP/1.0\r\n\r\n"; // 检测 URL + + check_http_expect_alive http_2xx; // 检测返回状态码为 200 时认为检测成功 + +} + + +Nginx 按照上面的方式配置之后,你的业务服务器也要实现一个“/health_check”的接口,在这个接口中返回的 HTTP 状态码,这个返回的状态码可以存储在配置中心中,这样在变更状态码时,就不需要重启服务了(配置中心在第 33 节课中会讲到)。 + +节点检测的功能,还能够帮助我们实现 Web 服务的优雅关闭。在 24 讲中介绍注册中心时,我曾经提到,服务的优雅关闭需要先切除流量再关闭服务,使用了注册中心之后,就可以先从注册中心中摘除节点,再重启服务,以便达到优雅关闭的目的。那么 Web 服务要如何实现优雅关闭呢?接下来,我来给你了解一下,有了节点检测功能之后,服务是如何启动和关闭的。 + +在服务刚刚启动时,可以初始化默认的 HTTP 状态码是 500,这样 Nginx 就不会很快将这个服务节点标记为可用,也就可以等待服务中,依赖的资源初始化完成,避免服务初始启动时的波动。 + +在完全初始化之后,再将 HTTP 状态码变更为 200,Nginx 经过两次探测后,就会标记服务为可用。在服务关闭时,也应该先将 HTTP 状态码变更为 500,等待 Nginx 探测将服务标记为不可用后,前端的流量也就不会继续发往这个服务节点。在等待服务正在处理的请求全部处理完毕之后,再对服务做重启,可以避免直接重启导致正在处理的请求失败的问题。这是启动和关闭线上 Web 服务时的标准姿势,你可以在项目中参考使用。 + +课程小结 + +本节课,我带你了解了与负载均衡服务相关的一些知识点,以及在实际工作中的运用技巧。我想强调几个重点: + +网站负载均衡服务的部 署,是以 LVS 承接入口流量,在应用服务器之前,部署 Nginx 做细化的流量分发,和故障节点检测。当然,如果你的网站的并发不高,也可以考虑不引入 LVS。 + +负载均衡的策略可以优先选择动态策略,保证请求发送到性能最优的节点上;如果没有合适的动态策略,那么可以选择轮询的策略,让请求平均分配到所有的服务节点上。 + +Nginx 可以引入 nginx_upstream_check_module,对后端服务做定期的存活检测,后端的服务节点在重启时,也要秉承着“先切流量后重启”的原则,尽量减少节点重启对于整体系统的影响。 + +你可能会认为,像 Nginx、LVS 应该是运维所关心的组件,作为开发人员不用操心维护。不过通过今天的学习你应该可以看到:负载均衡服务是提升系统扩展性,和性能的重要组件,在高并发系统设计中,它发挥的作用是无法替代的。理解它的原理,掌握使用它的正确姿势,应该是每一个后端开发同学的必修课。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/27API网关:系统的门面要如何做呢?.md b/专栏/高并发系统设计40问/27API网关:系统的门面要如何做呢?.md new file mode 100644 index 0000000..2af6c6b --- /dev/null +++ b/专栏/高并发系统设计40问/27API网关:系统的门面要如何做呢?.md @@ -0,0 +1,127 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 27 API网关:系统的门面要如何做呢? + 你好,我是唐扬。 + +到目前为止,你的垂直电商系统在经过微服务化拆分之后,已经运行了一段时间了,系统的扩展性得到了很大的提升,也能够比较平稳地度过高峰期的流量了。 + +不过最近你发现,随着自己的电商网站知名度越来越高,系统迎来了一些“不速之客”,在凌晨的时候,系统中的搜索商品和用户接口的调用量,会有激剧的上升,持续一段时间之后又回归正常。 + +这些搜索请求有一个共同特征是,来自固定的几台设备。当你在搜索服务上加一个针对设备 ID 的限流功能之后,凌晨的高峰搜索请求不见了。但是不久之后,用户服务也出现了大量爬取用户信息的请求,商品接口出现了大量爬取商品信息的请求。你不得不在这两个服务上也增加一样的限流策略。 + +但是这样会有一个问题:不同的三个服务上使用同一种策略,在代码上会有冗余,无法做到重用,如果其他服务上也出现类似的问题,还要通过拷贝代码来实现,肯定是不行的。 + +不过作为 Java 程序员,你很容易想到:将限流的功能独立成一个单独的 jar 包,给这三个服务来引用。不过你忽略了一种情况,那就是你的电商团队使用的除了 Java,还有 PHP 和 Golang 等多种语言。 + +用多种语言开发的服务是没有办法使用 jar 包,来实现限流功能的,这时你需要引入 API 网关。 + +API 网关起到的作用(904) + +API 网关(API Gateway)不是一个开源组件,而是一种架构模式,它是将一些服务共有的功能整合在一起,独立部署为单独的一层,用来解决一些服务治理的问题。你可以把它看作系统的边界,它可以对出入系统的流量做统一的管控。 + +在我看来,API 网关可以分为两类:一类叫做入口网关,一类叫做出口网关。 + +入口网关是我们经常使用的网关种类,它部署在负载均衡服务器和应用服务器之间,主要有几方面的作用。 + +它提供客户端一个统一的接入地址,API 网关可以将用户的请求动态路由到不同的业务服务上,并且做一些必要的协议转换工作。在你的系统中,你部署的微服务对外暴露的协议可能不同:有些提供的是 HTTP 服务;有些已经完成 RPC 改造,对外暴露 RPC 服务;有些遗留系统可能还暴露的是 Web Service 服务。API 网关可以对客户端屏蔽这些服务的部署地址,以及协议的细节,给客户端的调用带来很大的便捷。 + +另一方面,在 API 网关中,我们可以植入一些服务治理的策略,比如服务的熔断、降级,流量控制和分流等等(关于服务降级和流量控制的细节,我会在后面的课程中具体讲解,在这里,你只要知道它们可以在 API 网关中实现就可以了)。 + +再有,客户端的认证和授权的实现,也可以放在 API 网关中。你要知道,不同类型的客户端使用的认证方式是不同的。在我之前项目中,手机 APP 使用 Oauth 协议认证,HTML5 端和 Web 端使用 Cookie 认证,内部服务使用自研的 Token 认证方式。这些认证方式在 API 网关上,可以得到统一处理,应用服务不需要了解认证的细节。 + +另外,API 网关还可以做一些与黑白名单相关的事情,比如针对设备 ID、用户 IP、用户 ID 等维度的黑白名单。 + +\5. 最后,在 API 网关中也可以做一些日志记录的事情,比如记录 HTTP 请求的访问日志,我在25 讲中讲述分布式追踪系统时,提到的标记一次请求的 requestId,也可以在网关中来生成。 + + + +出口网关就没有这么丰富的功能和作用了。我们在系统开发中,会依赖很多外部的第三方系统,比如典型的例子:第三方账户登录、使用第三方工具支付等等。我们可以在应用服务器和第三方系统之间,部署出口网关,在出口网关中,对调用外部的 API 做统一的认证、授权,审计以及访问控制。 + + + +我花一定的篇幅去讲 API 网关起到的作用,主要是想让你了解,API 网关可以解决什么样的实际问题,这样,当你在面对这些问题时,你就会有解决的思路,不会手足无措了。 + +API 网关要如何实现 + +了解 API 网关的作用之后,所以接下来,我们来看看 API 网关在实现中需要关注哪些点,以及常见的开源 API 网关有哪些,这样,你在实际工作中,无论是考虑自研 API 网关还是使用开源的实现都会比较自如了。 + +在实现一个 API 网关时,你首先要考虑的是它的性能。这很好理解,API 入口网关承担从客户端的所有流量。假如业务服务处理时间是 10ms,而 API 网关的耗时在 1ms,那么相当于每个接口的响应时间都要增加 10%,这对于性能的影响无疑是巨大的。而提升 API 网关性能的关键还是在 I/O 模型上(我在23 讲中详细讲到过),这里只是举一个例子来说明 I/O 模型对于性能的影响。 + +Netfix 开源的 API 网关 Zuul,在 1.0 版本的时候使用的是同步阻塞 I/O 模型,整体系统其实就是一个 servlet,在接收到用户的请求,然后执行在网关中配置的认证、协议转换等逻辑之后,调用后端的服务获取数据返回给用户。 + +而在 Zuul2.0 中,Netfix 团队将 servlet 改造成了一个 netty server(netty 服务),采用 I/O 多路复用的模型处理接入的 I/O 请求,并且将之前同步阻塞调用后端服务的方式,改造成使用 netty client(netty 客户端)非阻塞调用的方式。改造之后,Netfix 团队经过测试发现性能提升了 20% 左右。 + +除此之外,API 网关中执行的动作有些是可以预先定义好的,比如黑白名单的设置,接口动态路由;有些则是需要业务方依据自身业务来定义。所以,API 网关的设计要注意扩展性,也就是你可以随时在网关的执行链路上,增加一些逻辑,也可以随时下掉一些逻辑(也就是所谓的热插拔)。 + +所以一般来说,我们可以把每一个操作定义为一个 filter(过滤器),然后使用“责任链模式”将这些 filter 串起来。责任链可以动态地组织这些 filter,解耦 filter 之间的关系,无论是增加还是减少 filter,都不会对其他的 filter 有任何的影响。 + +Zuul 就是采用责任链模式,Zuul1 中将 filter 定义为三类:pre routing filter(路由前过滤器)、routing filter(路由过滤器)和 after routing filter(路由后过滤器)。每一个 filter 定义了执行的顺序,在 filter 注册时,会按照顺序插入到 filter chain(过滤器链)中。这样 Zuul 在接收到请求时,就会按照顺序依次执行插入到 filter chain 中的 filter 了。 + + + +另外还需要注意的一点是,为了提升网关对于请求的并行处理能力,我们一般会使用线程池来并行的执行请求。不过,这就带来一个问题:如果商品服务出现问题,造成响应缓慢,那么调用商品服务的线程就会被阻塞无法释放,久而久之,线程池中的线程就会被商品服务所占据,那么其他服务也会受到级联的影响。因此,我们需要针对不同的服务做线程隔离,或者保护。在我看来有两种思路: + +如果你后端的服务拆分得不多,可以针对不同的服务,采用不同的线程池,这样商品服务的故障就不会影响到支付服务和用户服务了; + +在线程池内部可以针对不同的服务,甚至不同的接口做线程的保护。比如说,线程池的最大线程数是 1000,那么可以给每个服务设置一个最多可以使用的配额。 + +一般来说,服务的执行时间应该在毫秒级别,线程被使用后会很快被释放,回到线程池给后续请求使用,同时处于执行中的线程数量不会很多,对服务或者接口设置线程的配额,不会影响到正常的执行。可是一旦发生故障,某个接口或者服务的响应时间变长,造成线程数暴涨,但是因为有配额的限制,也就不会影响到其他的接口或者服务了。 + +你在实际应用中也可以将这两种方式结合,比如说针对不同的服务使用不同的线程池,在线程池内部针对不同的接口设置配额。 + +以上就是实现 API 网关的一些关键的点,你如果要自研 API 网关服务的话可以参考借鉴。另外 API 网关也有很多开源的实现,目前使用比较广泛的有以下几个: + +Kong是在 Nginx 中运行的 Lua 程序。得益于 Nginx 的性能优势,Kong 相比于其它的开源 API 网关来说,性能方面是最好的。由于大中型公司对于 Nginx 运维能力都比较强,所以选择 Kong 作为 API 网关,无论是在性能还是在运维的把控力上,都是比较好的选择; + +Zuul是 Spring Cloud 全家桶中的成员,如果你已经使用了 Spring Cloud 中的其他组件,那么也可以考虑使用 Zuul 和它们无缝集成。不过,Zuul1 因为采用同步阻塞模型,所以在性能上并不是很高效,而 Zuul2 推出时间不长,难免会有坑。但是 Zuul 的代码简单易懂,可以很好的把控,并且你的系统的量级很可能达不到 Netfix 这样的级别,所以对于 Java 技术栈的团队,使用 Zuul 也是一个不错的选择; + +Tyk是一种 Go 语言实现的轻量级 API 网关,有着丰富的插件资源,对于 Go 语言栈的团队来说,也是一种不错的选择。 + +那么你要考虑的是,这些开源项目适不适合作为 AIP 网关供自己使用。而接下来,我以电商系统为例,带你将 API 网关引入我们的系统之中。 + +如何在你的系统中引入 API 网关呢? + +目前为止,我们的电商系统已经经过了服务化改造,在服务层和客户端之间有一层薄薄的 Web 层,这个 Web 层做的事情主要有两方面: + +一方面是对服务层接口数据的聚合。比如,商品详情页的接口,可能会聚合服务层中,获取商品信息、用户信息、店铺信息以及用户评论等多个服务接口的数据; + +另一方面 Web 层还需要将 HTTP 请求转换为 RPC 请求,并且对前端的流量做一些限制,对于某些请求添加设备 ID 的黑名单等等。 + +因此,我们在做改造的时候,可以先将 API 网关从 Web 层中独立出来,将协议转换、限流、黑白名单等事情,挪到 API 网关中来处理,形成独立的入口网关层; + +而针对服务接口数据聚合的操作,一般有两种解决思路: + +再独立出一组网关专门做服务聚合、超时控制方面的事情,我们一般把前一种网关叫做流量网关,后一种可以叫做业务网关; + +抽取独立的服务层,专门做接口聚合的操作。这样服务层就大概分为原子服务层和聚合服务层。 + +我认为,接口数据聚合是业务操作,与其放在通用的网关层来实现,不如放在更贴近业务的服务层来实现,所以,我更倾向于第二种方案。 + + + +同时,我们可以在系统和第三方支付服务,以及登陆服务之间部署出口网关服务。原先,你会在拆分出来的支付服务中,完成对于第三方支付接口所需要数据的加密、签名等操作,再调用第三方支付接口,完成支付请求。现在,你把对数据的加密、签名的操作放在出口网关中,这样一来,支付服务只需要调用出口网关的统一支付接口就可以了。 + +在引入了 API 网关之后,我们的系统架构就变成了下面这样: + + + +课程小结 + +本节课我带你了解了 API 网关在系统中的作用,在实现中的一些关键的点,以及如何将 API 网关引入你的系统,我想强调的重点如下: + +API 网关分为入口网关和出口网关两类,入口网关作用很多,可以隔离客户端和微服务,从中提供协议转换、安全策略、认证、限流、熔断等功能。出口网关主要是为调用第三方服务提供统一的出口,在其中可以对调用外部的 API 做统一的认证、授权,审计以及访问控制; + +API 网关的实现重点在于性能和扩展性,你可以使用多路 I/O 复用模型和线程池并发处理,来提升网关性能,使用责任链模式来提升网关的扩展性; + +API 网关中的线程池,可以针对不同的接口或者服务做隔离和保护,这样可以提升网关的可用性; + +API 网关可以替代原本系统中的 Web 层,将 Web 层中的协议转换、认证、限流等功能挪入到 API 网关中,将服务聚合的逻辑下沉到服务层。 + +API 网关可以为 API 的调用提供便捷,也可以为将一些服务治理的功能独立出来,达到复用的目的,虽然在性能上可能会有一些损耗,但是一般来说,使用成熟的开源 API 网关组件,这些损耗都是可以接受的。所以,当你的微服务系统越来越复杂时,你可以考虑使用 API 网关作为整体系统的门面。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/28多机房部署:跨地域的分布式系统如何做?.md b/专栏/高并发系统设计40问/28多机房部署:跨地域的分布式系统如何做?.md new file mode 100644 index 0000000..b3db428 --- /dev/null +++ b/专栏/高并发系统设计40问/28多机房部署:跨地域的分布式系统如何做?.md @@ -0,0 +1,111 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 28 多机房部署:跨地域的分布式系统如何做? + 你好,我是唐扬。 + +来想象这样一个场景:你的垂直电商系统部署的 IDC 机房,在某一天发布了公告说,机房会在第二天凌晨做一次网络设备的割接,在割接过程中会不定时出现瞬间,或短时间网络中断。 + +机房网络的中断,肯定会对业务造成不利的影响,即使割接的时间在凌晨(业务的低峰期),作为技术负责人的你,也要尽量思考方案来规避隔离的影响。然而不幸的是,在现有的技术架构下,电商业务全都部署在一个 IDC 机房中,你并没有好的解决办法。 + +而 IDC 机房的可用性问题是整个系统的阿喀琉斯之踵,一旦 IDC 机房像一些大厂一样,出现很严重的问题,就会对整体服务的可用性造成严重的影响。比如: + +2016 年 7 月,北京联通整顿旗下 40 多个 IDC 机房中,不规范的接入情况,大批不合规接入均被断网,这一举动致使脉脉当时使用的蓝汛机房受到影响,脉脉宕机长达 15 个小时,著名的 A 站甚至宕机超过 48 个小时,损失可想而知。 + +而目前,单一机房部署的架构特点,决定了你的系统可用性受制于机房的可用性,也就是机房掌控了系统的生命线。所以,你开始思考,如何通过架构的改造,来进一步提升系统的可用性。在网上搜索解决方案和学习一些大厂的经验后,你发现“多机房部署”可以解决这个问题。 + +多机房部署的难点是什么 + +多机房部署的含义是:在不同的 IDC 机房中,部署多套服务,这些服务共享同一份业务数据,并且都可以承接来自用户的流量。 + +这样,当其中某一个机房出现网络故障、火灾,甚至整个城市发生地震、洪水等大的不可抗的灾难时,你可以随时将用户的流量切换到其它地域的机房中,从而保证系统可以不间断地持续运行。这种架构听起来非常美好,但是在实现上却是非常复杂和困难的,那么它复杂在哪儿呢? + +假如我们有两个机房 A 和 B 都部署了应用服务,数据库的主库和从库部署在 A 机房,那么机房 B 的应用如何访问到数据呢?有两种思路。 + +一个思路是直接跨机房读取 A 机房的从库: + + + +另一个思路是在机房 B 部署一个从库,跨机房同步主库的数据,然后机房 B 的应用就可以读取这个从库的数据了: + + + +无论是哪一种思路,都涉及到跨机房的数据传输,这就对机房之间延迟情况有比较高的要求了。而机房之间的延迟,和机房之间的距离息息相关,你可以记住几个数字: + +\1. 北京同地双机房之间的专线延迟一般在 1ms~3ms。 + +这个延迟会造成怎样的影响呢?要知道,我们的接口响应时间需要控制在 200ms 之内,而一个接口可能会调用几次第三方 HTTP 服务,或者 RPC 服务。如果这些服务部署在异地机房,那么接口响应时间就会增加几毫秒,是可以接受的。 + +一次接口可能会涉及几次的数据库写入,那么如果数据库主库在异地机房,那么接口的响应时间也会因为写入异地机房的主库,增加几毫秒到十几毫秒,也是可以接受的。 + +但是,接口读取缓存和数据库的数量,可能会达到十几次甚至几十次,那么这就会增加几十毫秒甚至上百毫秒的延迟,就不能接受了。 + +\2. 国内异地双机房之间的专线延迟会在 50ms 之内。 + +具体的延迟数据依据距离的不同而不同。比如,北京到天津的专线延迟,会在 10ms 之内;而北京到上海的延迟就会提高到接近 30ms;如果想要在北京和广州部署双机房,那么延迟就会到达 50ms 了。在这个延迟数据下,要想保证接口的响应时间在 200ms 之内,就要尽量减少跨机房的服务调用,更要避免跨机房的数据库和缓存操作了。 + +\3. 如果你的业务是国际化的服务,需要部署跨国的双机房,那么机房之间的延迟就更高了,依据各大云厂商的数据来看,比如,从国内想要访问部署在美国西海岸的服务,这个延迟会在 100ms~200ms 左右。在这个延迟下,就要避免数据跨机房同步调用,而只做异步的数据同步。 + +如果你正在考虑多机房部署的架构,那么这些数字都是至关重要的基础数据,你需要牢牢记住,避免出现跨机房访问数据造成性能衰减问题。 + +机房之间的数据延迟,在客观上是存在的,你没有办法改变,你可以做的,就是尽量避免数据延迟对于接口响应时间的影响。那么在数据延迟下,你要如何设计多机房部署的方案呢? + +逐步迭代多机房部署方案 + +1. 同城双活 + +制定多机房部署的方案不是一蹴而就的,而是不断迭代发展的。我在上面提到,同城机房之间的延时在 1ms~3ms 左右,对于跨机房调用的容忍度比较高,所以,这种同城双活的方案复杂度会比较低。 + +但是,它只能做到机房级别的容灾,无法做到城市级别的容灾。不过,相比于城市发生地震、洪水等自然灾害来说,机房网络故障、掉电出现的概率要大的多。所以,如果你的系统不需要考虑城市级别的容灾,一般做到同城双活就足够了。那么,同城双活的方案要如何设计呢? + +假设这样的场景:你在北京有 A 和 B 两个机房,A 是联通的机房,B 是电信的机房,机房之间以专线连接,方案设计时,核心思想是,尽量避免跨机房的调用。具体方案如下: + +首先,数据库的主库可以部署在一个机房中,比如部署在 A 机房中,那么 A 和 B 机房数据都会被写入到 A 机房中。然后,在 A、B 两个机房中各部署一个从库,通过主从复制的方式,从主库中同步数据,这样双机房的查询请求可以查询本机房的从库。一旦 A 机房发生故障,可以通过主从切换的方式,将 B 机房的从库提升为主库,达到容灾的目的。 + +缓存也可以部署在两个机房中,查询请求也读取本机房的缓存,如果缓存中数据不存在,就穿透到本机房的从库中,加载数据。数据的更新可以更新双机房中的数据,保证数据的一致性。 + +不同机房的 RPC 服务会向注册中心,注册不同的服务组,而不同机房的 RPC 客户端,也就是 Web 服务,只订阅同机房的 RPC 服务组,这样就可以实现 RPC 调用尽量发生在本机房内,避免跨机房的 RPC 调用。 + + + +你的系统肯定会依赖公司内的其他服务,比如审核,搜索等服务,如果这些服务也是双机房部署的,那么也需要尽量保证只调用本机房的服务,降低调用的延迟。 + +使用了同城双活架构之后,可以实现机房级别的容灾,服务的部署也能够突破单一机房的限制,但是,还是会存在跨机房写数据的问题,不过鉴于写数据的请求量不高,所以在性能上是可以容忍的。 + +2. 异地多活 + +上面这个方案,足够应对你目前的需要,但是,你的业务是不断发展的,如果有朝一日,你的电商系统的流量达到了京东或者淘宝的级别,那么你就要考虑,即使机房所在的城市发生重大的自然灾害,也要保证系统的可用性。而这时,你需要采用异地多活的方案(据我所知,阿里和饿了么采用的都是异地多活的方案)。 + +在考虑异地多活方案时,你首先要考虑异地机房的部署位置。它部署的不能太近,否则发生自然灾害时,很可能会波及。所以,如果你的主机房在北京,那么异地机房就尽量不要建设在天津,而是可以选择上海、广州这样距离较远的位置。但这就会造成更高的数据传输延迟,同城双活中,使用的跨机房写数据库的方案,就不合适了。 + +所以,在数据写入时,你要保证只写本机房的数据存储服务,再采取数据同步的方案,将数据同步到异地机房中。一般来说,数据同步的方案有两种: + +一种基于存储系统的主从复制,比如 MySQL 和 Redis。也就是在一个机房部署主库,在异地机房部署从库,两者同步主从复制, 实现数据的同步。 + +另一种是基于消息队列的方式。一个机房产生写入请求后,会写一条消息到消息队列,另一个机房的应用消费这条消息后,再执行业务处理逻辑,写入到存储服务中。 + +我建议你,采用两种同步相结合的方式,比如,你可以基于消息的方式,同步缓存的数据、HBase 数据等。然后基于存储,主从复制同步 MySQL、Redis 等数据。 + +无论是采取哪种方案,数据从一个机房,传输到另一个机房都会有延迟,所以,你需要尽量保证用户在读取自己的数据时,读取数据主库所在的机房。为了达到这一点,你需要对用户做分片,让一个用户每次的读写都尽量在同一个机房中。同时,在数据读取和服务调用时,也要尽量调用本机房的服务。这里有一个场景:假如在电商系统中,用户 A 要查看所有订单的信息,而这些订单中,店铺的信息和卖家的信息很可能是存储在异地机房中,那么你应该优先保证服务调用,和数据读取在本机房中进行,即使读取的是跨机房从库的数据,会有一些延迟,也是可以接受的。 + + + +课程小结 + +本节课,为了提升系统的可用性和稳定性,我带你探讨了多机房部署的难点,以及同城双机房和异地多活的部署架构,在这里,我想强调几个重点: + +不同机房的数据传输延迟,是造成多机房部署困难的主要原因,你需要知道,同城多机房的延迟一般在 1ms~3ms,异地机房的延迟在 50ms 以下,而跨国机房的延迟在 200ms 以下。 + +同城多机房方案可以允许有跨机房数据写入的发生,但是数据的读取,和服务的调用应该尽量保证在同一个机房中。 + +异地多活方案则应该避免跨机房同步的数据写入和读取,而是采取异步的方式,将数据从一个机房同步到另一个机房。 + +多机房部署是一个业务发展到一定规模,对于机房容灾有需求时,才会考虑的方案,能不做则尽量不要做。一旦你的团队决定做多机房部署,那么同城双活已经能够满足你的需求了,这个方案相比异地多活要简单很多。而在业界,很少有公司,能够搭建一套真正的异步多活架构,这是因为这套架构在实现时过于复杂,所以,轻易不要尝试。 + +总之,架构需要依据系统的量级和对可用性、性能、扩展性的要求,不断演进和调整,盲目地追求架构的“先进性”只能造成方案的复杂,增加运维成本,从而给你的系统维护带来不便。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/29ServiceMesh:如何屏蔽服务化系统的服务治理细节?.md b/专栏/高并发系统设计40问/29ServiceMesh:如何屏蔽服务化系统的服务治理细节?.md new file mode 100644 index 0000000..72cdac0 --- /dev/null +++ b/专栏/高并发系统设计40问/29ServiceMesh:如何屏蔽服务化系统的服务治理细节?.md @@ -0,0 +1,160 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 29 Service Mesh:如何屏蔽服务化系统的服务治理细节? + 你好,我是唐扬。 + +在分布式服务篇的前几节课程中,我带你了解了在微服务化过程中,要使用哪些中间件解决服务之间通信和服务治理的问题,其中就包括: + +用 RPC 框架解决服务通信的问题; + +用注册中心解决服务注册,和发现的问题; + +使用分布式 Trace 中间件,排查跨服务调用慢请求; + +使用负载均衡服务器,解决服务扩展性的问题; + +在 API 网关中植入服务熔断、降级和流控等服务治理的策略。 + +经历了这几环之后,你的垂直电商系统基本上,已经完成了微服务化拆分的改造。不过,目前来看,你的系统使用的语言还是以 Java 为主,之前提到的服务治理的策略,和服务之间通信协议也是使用 Java 语言来实现的。 + +那么这会存在一个问题:一旦你的团队中,有若干个小团队开始尝试使用 Go 或者 PHP,来开发新的微服务,那么在微服务化过程中,一定会受到挑战。 + +跨语言体系带来的挑战 + +其实,一个公司的不同团队,使用不同的开发语言是比较常见的。比如,微博的主要开发语言是 Java 和 PHP,近几年也有一些使用 Go 开发的系统。而使用不同的语言开发出来的微服务,在相互调用时会存在两方面的挑战: + +一方面,服务之间的通信协议上,要对多语言友好,要想实现跨语言调用,关键点是选择合适的序列化方式。我给你举一个例子。 + +比如,你用 Java 开发一个 RPC 服务,使用的是 Java 原生的序列化方式,这种序列化方式对于其它语言并不友好,那么,你使用其它语言,调用这个 RPC 服务时,就很难解析序列化之后的二进制流。所以,我建议你,在选择序列化协议时,考虑序列化协议是否对多语言友好,比如,你可以选择 Protobuf、Thrift,这样一来,跨语言服务调用的问题,就可以很容易地解决了。 + +另一方面,使用新语言开发的微服务,无法使用之前积累的,服务治理的策略。比如说,RPC 客户端在使用注册中心,订阅服务的时候,为了避免每次 RPC 调用都要与注册中心交互,一般会在 RPC 客户端,缓存节点的数据。如果注册中心中的服务节点发生了变更,那么 RPC 客户端的节点缓存会得到通知,并且变更缓存数据。 + +而且,为了减少注册中心的访问压力,在 RPC 客户端上,我们一般会考虑使用多级缓存(内存缓存和文件缓存)来保证节点缓存的可用性。而这些策略在开始的时候,都是使用 Java 语言来实现的,并且封装在注册中心客户端里,提供给 RPC 客户端使用。如果更换了新的语言,这些逻辑就都要使用新的语言实现一套。 + +除此之外,负载均衡、熔断降级、流量控制、打印分布式追踪日志等等,这些服务治理的策略都需要重新实现,而使用其它语言重新实现这些策略无疑会带来巨大的工作量,也是中间件研发中,一个很大的痛点。 + +那么,你要如何屏蔽服务化架构中,服务治理的细节,或者说,如何让服务治理的策略在多语言之间复用呢? + +可以考虑将服务治理的细节,从 RPC 客户端中拆分出来,形成一个代理层单独部署。这个代理层可以使用单一的语言实现,所有的流量都经过代理层,来使用其中的服务治理策略。这是一种“关注点分离”的实现方式,也是 Service Mesh 的核心思想。 + +Service Mesh 是如何工作的 + +1. 什么是 Service Mesh + +Service Mesh 主要处理服务之间的通信,它的主要实现形式就是在应用程序同主机上部署一个代理程序,一般来讲,我们将这个代理程序称为“Sidecar(边车)”,服务之间的通信也从之前的,客户端和服务端直连,变成了下面这种形式: + + + +在这种形式下,RPC 客户端将数据包先发送给,与自身同主机部署的 Sidecar,在 Sidecar 中经过服务发现、负载均衡、服务路由、流量控制之后,再将数据发往指定服务节点的 Sidecar,在服务节点的 Sidecar 中,经过记录访问日志、记录分布式追踪日志、限流之后,再将数据发送给 RPC 服务端。 + +这种方式,可以把业务代码和服务治理的策略隔离开,将服务治理策略下沉,让它成为独立的基础模块。这样一来,不仅可以实现跨语言,服务治理策略的复用,还能对这些 Sidecar 做统一的管理。 + +目前,业界提及最多的 Service Mesh 方案当属istio, 它的玩法是这样的: + + + +它将组件分为数据平面和控制平面,数据平面就是我提到的 Sidecar(Istio 使用Envoy作为 Sidecar 的实现)。控制平面主要负责服务治理策略的执行,在 Istio 中,主要分为 Mixer、Pilot 和 Istio-auth 三部分。 + +你可以先不了解每一部分的作用,只知道它们共同构成了服务治理体系就可以了。 + +然而,在 Istio 中,每次请求都需要经过控制平面,也就是说,每次请求都需要跨网络的调用 Mixer,这会极大地影响性能。 + +因此,国内大厂开源出来的 Service Mesh 方案中,一般只借鉴 Istio 的数据平面和控制平面的思路,然后将服务治理策略做到了 Sidecar 中,控制平面只负责策略的下发,这样就不需要每次请求都经过控制平面,性能上会改善很多。 + +2. 如何将流量转发到 Sidecar 中 + +在 Service Mesh 的实现中,一个主要的问题,是如何尽量无感知地引入 Sidecar 作为网络代理,也就是说,无论是数据流入还是数据流出时,都要将数据包重定向到 Sidecar 的端口上。实现思路一般有两个: + +第一种,使用 iptables 的方式来实现流量透明的转发,而 Istio 就默认了,使用 iptables 来实现数据包的转发。为了能更清晰的说明流量转发的原理,我们先简单地回顾一下什么是 iptables。 + +Iptables 是 Linux 内核中,防火墙软件 Netfilter 的管理工具,它位于用户空间,可以控制 Netfilter,实现地址转换的功能。在 iptables 中默认有五条链,你可以把这五条链,当作数据包流转过程中的五个步骤,依次为 PREROUTING,INPUT,FORWARD,OUTPUT 和 POSTROUTING。数据包传输的大体流程如下: + + + +从图中可以看到,数据包以 PREROUTING 链作为入口,当数据包目的地为本机时,它们也都会流经到 OUTPUT 链。所以,我们可以在这两个链上,增加一些规则,将数据包重定向。我以 Istio 为例,带你看看如何使用 iptables 实现流量转发。 + +在 Istio 中,有一个叫做“istio-iptables.sh”的脚本,这个脚本在 Sidecar 被初始化的时候执行,主要是设置一些 iptables 规则。 + +我摘录了一些关键点来说明一下: + +// 流出流量处理 + +iptables -t nat -N ISTIO_REDIRECT // 增加 ISTIO_REDIRECT 链处理流出流量 + +iptables -t nat -A ISTIO_REDIRECT -p tcp -j REDIRECT --to-port "${PROXY_PORT}" // 重定向流量到 Sidecar 的端口上 + +iptables -t nat -N ISTIO_OUTPUT // 增加 ISTIO_OUTPUT 链处理流出流量 + +iptables -t nat -A OUTPUT -p tcp -j ISTIO_OUTPUT// 将 OUTPUT 链的流量重定向到 ISTIO_OUTPUT 链上 + +for uid in ${PROXY_UID}; do + + iptables -t nat -A ISTIO_OUTPUT -m owner --uid-owner "${uid}" -j RETURN //Sidecar 本身的流量不转发 + +done + +for gid in ${PROXY_GID}; do + + iptables -t nat -A ISTIO_OUTPUT -m owner --gid-owner "${gid}" -j RETURN //Sidecar 本身的流量不转发 + +done + +iptables -t nat -A ISTIO_OUTPUT -j ISTIO_REDIRECT // 将 ISTIO_OUTPUT 链的流量转发到 ISTIO_REDIRECT + +// 流入流量处理 + +iptables -t nat -N ISTIO_IN_REDIRECT // 增加 ISTIO_IN_REDIRECT 链处理流入流量 + +iptables -t nat -A ISTIO_IN_REDIRECT -p tcp -j REDIRECT --to-port "${PROXY_PORT}" // 将流入流量重定向到 Sidecar 端口 + +iptables -t ${table} -N ISTIO_INBOUND // 增加 ISTIO_INBOUND 链处理流入流量 + +iptables -t ${table} -A PREROUTING -p tcp -j ISTIO_INBOUND // 将 PREROUTING 的流量重定向到 ISTIO_INBOUND 链 + +iptables -t nat -A ISTIO_INBOUND -p tcp --dport "${port}" -j ISTIO_IN_REDIRECT // 将 ISTIO_INBOUND 链上指定目的端口的流量重定向到 ISTIO_IN_REDIRECT 链 + + +假设服务的节点部署在 9080 端口上,Sidecar 开发的端口是 15001,那么流入流量的流向如下: + + + +流出流量的流量图如下: + + + +Iptables 方式的优势在于,对于业务完全透明,业务甚至不知道有 Sidecar 存在,这样会减少业务接入的时间。不过,它也有缺陷,那就是它是在高并发下,性能上会有损耗,因此国内大厂采用了另外一种方式:轻量级客户端。 + +在这种方式下,RPC 客户端会通过配置的方式,知道 Sidecar 的部署端口,然后通过一个轻量级客户端,将调用服务的请求发送给 Sidecar,Sidecar 在转发请求之前,先执行一些服务治理的策略,比如说,从注册中心中,查询到服务节点信息并且缓存起来,然后从服务节点中,使用某种负载均衡的策略选出一个节点等等。 + +请求被发送到服务端的 Sidecar 上后,然后在服务端记录访问日志,和分布式追踪日志,再把请求转发到真正的服务节点上。当然,服务节点在启动时,会委托服务端 Sidecar,向注册中心注册节点,Sidecar 也就知道了真正服务节点部署的端口是多少。整个请求过程如图所示: + + + +当然,除了 iptables 和轻量级客户端两种方式外,目前在探索的方案还有Cilium,这个方案可以从 Socket 层面实现请求的转发,也就可以避免 iptables 方式在性能上的损耗。在这几种方案中,我建议你使用轻量级客户端的方式,这样虽然会有一些改造成本,但是却在实现上最简单,可以快速的让 Service Mesh 在你的项目中落地。 + +当然,无论采用哪种方式,你都可以实现将 Sidecar 部署到,客户端和服务端的调用链路上,让它代理进出流量,这样,你就可以使用运行在 Sidecar 中的服务治理的策略了。至于这些策略我在前面的课程中都带你了解过(你可以回顾 23 至 26 讲的课程),这里就不再赘述了。 + +与此同时,我也建议你了解目前业界一些开源的 Service Mesh 框架,这样在选择方案时可以多一些选择。目前在开源领域比较成熟的 Service Mesh 框架有下面几个,你可以通过阅读它们的文档来深入了解,作为本节课的引申阅读。 + +Istio 这个框架在业界最为著名,它提出了数据平面和控制平面的概念,是 Service Mesh 的先驱,缺陷就是刚才提到的 Mixer 的性能问题。 + +Linkerd 是第一代的 Service Mesh,使用 Scala 语言编写,其劣势就是内存的占用。 + +SOFAMesh 是蚂蚁金服开源的 Service Mesh 组件,在蚂蚁金服已经有大规模落地的经验。 + +课程小结 + +本节课,为了解决跨语言场景下,服务治理策略的复用问题,我带你了解了什么是 Service Mesh 以及如何在实际项目中落地,你需要的重点内容如下: + +1.Service Mesh 分为数据平面和控制平面。数据平面主要负责数据的传输;控制平面用来控制服务治理策略的植入。出于性能的考虑,一般会把服务治理策略植入到数据平面中,控制平面负责服务治理策略数据的下发。 + +2.Sidecar 的植入方式目前主要有两种实现方式,一种是使用 iptables 实现流量的劫持;另一种是通过轻量级客户端来实现流量转发。 + +目前,在一些大厂中,比如微博、蚂蚁金服,Service Mesh 已经开始在实际项目中大量的落地实践,而我建议你持续关注这项技术。它本身是一种将业务与通信基础设施分离的技术,如果你的业务上遇到多语言环境下,服务治理的困境,如果你的遗留服务,需要快速植入服务治理策略,如果你想要将你在服务治理方面积累的经验,快速地与其他团队共享,那么 Service Mesh 就是你的一个不错的选择。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/30给系统加上眼睛:服务端监控要怎么做?.md b/专栏/高并发系统设计40问/30给系统加上眼睛:服务端监控要怎么做?.md new file mode 100644 index 0000000..1f3278b --- /dev/null +++ b/专栏/高并发系统设计40问/30给系统加上眼睛:服务端监控要怎么做?.md @@ -0,0 +1,128 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 30 给系统加上眼睛:服务端监控要怎么做? + 你好,我是唐扬。 + +在一个项目的生命周期里,运行维护占据着很大的比重,在重要性上,它几乎与项目研发并驾齐驱。而在系统运维过程中,能够及时地发现问题并解决问题,是每一个团队的本职工作。所以,你的垂直电商系统在搭建之初,运维团队肯定完成了对于机器 CPU、内存、磁盘、网络等基础监控,期望能在出现问题时,及时地发现并且处理。你本以为万事大吉,却没想到系统在运行过程中,频频得到用户的投诉,原因是: + +使用的数据库主从延迟变长,导致业务功能上出现了问题; + +接口的响应时间变长,用户反馈商品页面出现空白页; + +系统中出现大量错误,影响了用户的正常使用。 + +这些问题,你本应该及时发现并处理的。但现实是,你只能被动地在问题被用户反馈后,手忙脚乱地修复。这时,你的团队才意识到,要想快速地发现和定位业务系统中出现的问题,必须搭建一套完善的服务端监控体系。正所谓“道路千万条,监控第一条,监控不到位,领导两行泪”。不过,在搭建的过程中,你的团队又陷入了困境: + +首先,监控的指标要如何选择呢? + +采集这些指标可以有哪些方法和途径呢? + +指标采集到之后又要如何处理和展示呢? + +这些问题,一环扣一环,关乎着系统的稳定性和可用性,而本节课,我就带你解决这些问题,搭建一套服务端监控体系。 + +监控指标如何选择 + +你在搭建监控系统时,所面临的第一个问题就是,选择什么样的监控指标,也就是监控什么。有些同学在给一个新的系统,设定监控指标的时候,会比较迷茫,不知道从哪方面入手。其实,有一些成熟的理论和套路,你可以直接拿来使用。比如,谷歌针对分布式系统监控的经验总结,四个黄金信号(Four Golden Signals)。它指的是,在服务层面一般需要监控四个指标,分别是延迟,通信量、错误和饱和度。 + +延迟指的是请求的响应时间。比如,接口的响应时间、访问数据库和缓存的响应时间。 + +通信量可以理解为吞吐量,也就是单位时间内,请求量的大小。比如,访问第三方服务的请求量,访问消息队列的请求量。 + +错误表示当前系统发生的错误数量。这里需要注意的是, 我们需要监控的错误既有显示的,比如在监控 Web 服务时,出现 4 * * 和 5 * * 的响应码;也有隐示的,比如,Web 服务虽然返回的响应码是 200,但是却发生了一些和业务相关的错误(出现了数组越界的异常或者空指针异常等),这些都是错误的范畴。 + +饱和度指的是服务或者资源到达上限的程度(也可以说是服务或者资源的利用率),比如说 CPU 的使用率,内存使用率,磁盘使用率,缓存数据库的连接数等等。 + +这四个黄金信号提供了通用的监控指标,除此之外,你还可以借鉴 RED 指标体系。这个体系,是四个黄金信号中衍生出来的,其中,R 代表请求量(Request rate),E 代表错误(Error),D 代表响应时间(Duration),少了饱和度的指标。你可以把它当作一种简化版的通用监控指标体系。 + +当然,一些组件或者服务还有独特的指标,这些指标也是需要你特殊关注的。比如,课程中提到的数据库主从延迟数据、消息队列的堆积情况、缓存的命中率等等。我把高并发系统中常见组件的监控指标,整理成了一张表格,其中没有包含诸如 CPU、内存、网络、磁盘等基础监控指标,只是业务上监控指标,主要方便你在实际工作中参考使用。 + + + +选择好了监控指标之后,你接下来要考虑的,是如何从组件或者服务中,采集到这些指标,也就是指标数据采集的问题。 + +如何采集数据指标 + +说到监控指标的采集,我们一般会依据采集数据源的不同,选用不同的采集方式,总结起来,大概有以下几种类型: + +首先,Agent 是一种比较常见的,采集数据指标的方式。 + +我们通过在数据源的服务器上,部署自研或者开源的 Agent,来收集收据,发送给监控系统,实现数据的采集。在采集数据源上的信息时,Agent 会依据数据源上,提供的一些接口获取数据,我给你举两个典型的例子。 + +比如,你要从 Memcached 服务器上,获取它的性能数据,那么,你就可以在 Agent 中,连接这个 Memcached 服务器,并且发送一个 stats 命令,获取服务器的统计信息。然后,你就可以从返回的信息中,挑选重要的监控指标,发送给监控服务器,形成 Memcached 服务的监控报表。你也可以从这些统计信息中,看出当前 Memcached 服务器,是否存在潜在的问题。下面是我推荐的,一些重要的状态项,你可以参考使用。 + + STAT cmd_get 201809037423 // 计算查询的 QPS + + STAT cmd_set 16174920166 // 计算写入的 QPS + + STAT get_hits 175226700643 // 用来计算命中率,命中率 = get_hits/cmd_get + + STAT curr_connections 1416 // 当前连接数 + + STAT bytes 3738857307 // 当前内存占用量 + + STAT evictions 11008640149 // 当前被 memcached 服务器剔除的 item 数 + +量,如果这个数量过大 (比如例子中的这个数值),那么代表当前 Memcached 容量不足或者 Memcached Slab Class 分配有问题 + + +另外,如果你是 Java 的开发者,那么一般使用 Java 语言开发的中间件,或者组件,都可以通过 JMX 获取统计或者监控信息。比如,在19 讲中,我提到可以使用 JMX,监控 Kafka 队列的堆积数,再比如,你也可以通过 JMX 监控 JVM 内存信息和 GC 相关的信息。 + +另一种很重要的数据获取方式,是在代码中埋点。 + +这个方式与 Agent 的不同之处在于,Agent 主要收集的是组件服务端的信息,而埋点则是从客户端的角度,来描述所使用的组件,和服务的性能和可用性。那么埋点的方式怎么选择呢? + +你可以使用25 讲分布式 Trace 组件中,提到的面向切面编程的方式;也可以在资源客户端中,直接计算调用资源或者服务的耗时、调用量、慢请求数,并且发送给监控服务器。 + +这里你需要注意一点,由于调用缓存、数据库的请求量会比较高,一般会单机也会达到每秒万次,如果不经过任何优化,把每次请求耗时都发送给监控服务器,那么,监控服务器会不堪重负。所以,我们一般会在埋点时,先做一些汇总。比如,每隔 10 秒汇总这 10 秒内,对同一个资源的请求量总和、响应时间分位值、错误数等,然后发送给监控服务器。这样,就可以大大减少发往监控服务器的请求量了。 + +最后,日志也是你监控数据的重要来源之一。 + +你所熟知的 Tomcat 和 Nginx 的访问日志,都是重要的监控日志。你可以通过开源的日志采集工具,将这些日志中的数据发送给监控服务器。目前,常用的日志采集工具有很多,比如,Apache Flume、Fluentd和Filebeat,你可以选择一种熟悉的使用。比如在我的项目中,我会倾向于使用 Filebeat 来收集监控日志数据。 + +监控数据的处理和存储 + +在采集到监控数据之后,你就可以对它们进行处理和存储了,在此之前,我们一般会先用消息队列来承接数据,主要的作用是削峰填谷,防止写入过多的监控数据,让监控服务产生影响。 + +与此同时,我们一般会部署两个队列处理程序,来消费消息队列中的数据。 + +一个处理程序接收到数据后,把数据写入到 Elasticsearch,然后通过 Kibana 展示数据,这份数据主要是用来做原始数据的查询; + +另一个处理程序是一些流式处理的中间件,比如,Spark、Storm。它们从消息队列里,接收数据后会做一些处理,这些处理包括: + +- 解析数据格式,尤其是日志格式。从里面提取诸如请求量、响应时间、请求 URL 等数据; + +- 对数据做一些聚合运算。 比如,针对 Tomcat 访问日志,可以计算同一个 URL 一段时间之内的请求量、响应时间分位值、非 200 请求量的大小等等。 + +- 将数据存储在时间序列数据库中。这类数据库的特点是,可以对带有时间标签的数据,做更有效的存储,而我们的监控数据恰恰带有时间标签,并且按照时间递增,非常适合存储在时间序列数据库中。目前业界比较常用的时序数据库有 InfluxDB、OpenTSDB、Graphite,各大厂的选择均有不同,你可以选择一种熟悉的来使用。 + +- 最后, 你就可以通过 Grafana 来连接时序数据库,将监控数据绘制成报表,呈现给开发和运维的同学了。 + + + +至此,你和你的团队,也就完成了垂直电商系统,服务端监控系统搭建的全过程。这里我想再多说一点,我们从不同的数据源中采集了很多的指标,最终在监控系统中一般会形成以下几个报表,你在实际的工作中可以参考借鉴: + +1. 访问趋势报表。这类报表接入的是 Web 服务器,和应用服务器的访问日志,展示了服务整体的访问量、响应时间情况、错误数量、带宽等信息。它主要反映的是,服务的整体运行情况,帮助你来发现问题。 + +2. 性能报表。 这类报表对接的是资源和依赖服务的埋点数据,展示了被埋点资源的访问量和响应时间情况。它反映了资源的整体运行情况,当你从访问趋势报表发现问题后,可以先从性能报表中,找到究竟是哪一个资源或者服务出现了问题。 + +3. 资源报表。 这类报表主要对接的是,使用 Agent 采集的,资源的运行情况数据。当你从性能报表中,发现某一个资源出现了问题,那么就可以进一步从这个报表中,发现资源究竟出现了什么问题,是连接数异常增高,还是缓存命中率下降。这样可以进一步帮你分析问题的根源,找到解决问题的方案。 + +课程小结 + +本节课,我带你了解了,服务端监控搭建的过程,在这里,你需要了解以下几个重点: + +耗时、请求量和错误数是三种最通用的监控指标,不同的组件还有一些特殊的监控指标,你在搭建自己的监控系统的时候可以直接使用; + +Agent、埋点和日志是三种最常见的数据采集方式; + +访问趋势报表用来展示服务的整体运行情况,性能报表用来分析资源或者依赖的服务是否出现问题,资源报表用来追查资源问题的根本原因。这三个报表共同构成了你的服务端监控体系。 + +总之,监控系统是你发现问题,排查问题的重要工具,你应该重视它,并且投入足够的精力来不断地完善它。只有这样,才能不断地提高对系统运维的掌控力,降低故障发生的风险。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/31应用性能管理:用户的使用体验应该如何监控?.md b/专栏/高并发系统设计40问/31应用性能管理:用户的使用体验应该如何监控?.md new file mode 100644 index 0000000..b31d78e --- /dev/null +++ b/专栏/高并发系统设计40问/31应用性能管理:用户的使用体验应该如何监控?.md @@ -0,0 +1,185 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 31 应用性能管理:用户的使用体验应该如何监控? + 你好,我是唐扬。 + +上一节课中,我带你了解了服务端监控搭建的过程。有了监控报表之后,你的团队在维护垂直电商系统时,就可以更早地发现问题,也有直观的工具辅助你们分析排查问题了。 + +不过,你很快发现,有一些问题,服务端的监控报表无法排查,甚至无法感知。比如,有用户反馈创建订单失败,但是从服务端的报表来看,并没有什么明显的性能波动,从存储在 Elasticsearch 里的原始日志中,甚至没有找到这次创建订单的请求。这有可能是客户端有 Bug,或者网络抖动导致创建订单的请求并没有发送到服务端。 + +再比如,有些用户会反馈,使用长城宽带打开商品详情页面特别慢,甚至出现 DNS 解析失败的情况。那么,当我们遇到这类问题时,要如何排查和优化呢? + +这里面涉及一个概念叫应用性能管理(Application Performance Management,简称 APM),它的含义是:对应用各个层面做全方位的监测,期望及时发现可能存在的问题,并加以解决,从而提升系统的性能和可用性。 + +你是不是觉得和之前讲到的服务端监控很相似?其实,服务端监控的核心关注点是后端服务的性能和可用性,而应用性能管理的核心关注点是终端用户的使用体验,也就是你需要衡量,从客户端请求发出开始,到响应数据返回到客户端为止,这个端到端的整体链路上的性能情况。 + +如果你能做到这一点,那么无论是订单创建问题的排查,还是长城宽带用户页面打开缓慢的问题,都可以通过这套监控来发现和排查。那么,如何搭建这么一套端到端的监控体系呢? + +如何搭建 APM 系统 + +与搭建服务端监控系统类似,在搭建端到端的,应用性能管理系统时,我们也可以从数据的采集、存储和展示几个方面来思考。 + +首先,在数据采集方面,我们可以采用类似 Agent 的方式,在客户端上植入 SDK,由 SDK 负责采集信息,并且经过采样之后,通过一个固定的接口,定期发送给服务端。这个固定接口和服务端,我们可以称为 APM 通道服务。 + +虽然客户端需要监控的指标很多,比如监控网络情况,监控客户端卡顿情况、垃圾收集数据等等,但我们可以定义一种通用的数据采集格式。 + +比如,在我之前的公司里,采集的数据包含下面几个部分,SDK 将这几部分数据转换成 JSON 格式后,就可以发送给 APM 通道服务了。这几部分数据格式,你可以在搭建自己的 APM 系统时,直接拿来参考。 + +系统部分:包括数据协议的版本号,以及下面提到的消息头、端消息体、业务消息体的长度; + +消息头:主要包括应用的标识(appkey),消息生成的时间戳,消息的签名以及消息体加密的秘钥; + +端消息体:主要存储客户端的一些相关信息,主要有客户端版本号、SDK 版本号、IDFA、IDFV、IMEI、机器型号、渠道号、运营商、网络类型、操作系统类型、国家、地区、经纬度等等。由于这些信息有些比较敏感,所以我们一般会对信息加密; + +业务消息体:也就是真正要采集的数据,这些数据也需要加密。 + +加密的方法是这样的:我们首先会分配给这个应用,一对 RSA 公钥和私钥,然后 SDK 在启动的时候,先请求一个策略服务,获取 RSA 公钥。在加密时,客户端会随机生成一个对称加密的秘钥 Key,端消息体和业务消息体,都会使用这个秘钥来加密。那么数据发到 APM 通道服务后,要如何解密呢? + +客户端会使用 RSA 的公钥对秘钥加密,存储在消息头里面(也就是上面提到的,消息体加密秘钥),然后 APM 通道服务使用 RSA 秘钥,解密得到秘钥,就可以解密得到端消息体和业务消息体的内容了。 + +最后,我们把消息头、端消息体、业务消息体还有消息头中的时间戳组装起来,用 MD5 生成摘要后,存储在消息头中(也就是消息的签名)。这样,APM 通道服务在接收到消息后,可以使用同样的算法生成摘要,并且与发送过来的摘要比对,以防止消息被篡改。 + +数据被采集到 APM 通道服务之后,我们先对 JSON 消息做解析,得到具体的数据,然后发送到消息队列里面。从消息队列里面消费到数据之后,会写一份数据到 Elasticsearch 中,作为原始数据保存起来,再写一份到统计平台,以形成客户端的报表。 + + + +有了这套 APM 通道服务,我们就可以将从客户端上采集到的信息,通过统一的方式上报到服务端做集中处理。这样一来,你就可以收集到客户端上的性能数据和业务数据,能够及时地发现问题了。 + +那么问题来了:虽然你搭建了客户端监控系统,但是在我们电商系统客户端中可以收集到用户网络数据,卡顿数据等等,你是要把这些信息都监控到位,还是有所侧重呢?要知道,监控的信息不明确,会给问题排查带来不便,而这就是我们接下来探究的问题,也就是你到底需要监控用户的哪些信息。 + +需要监控用户的哪些信息 + +在我看来,搭建端到端的监控体系的首要目标,是解决如何监控客户端网络的问题,这是因为我们遇到的客户端问题,大部分的原因还是出在客户端网络上。 + +在中国复杂的网络环境下,大的运营商各行其是,各自为政,在不同的地区的链路质量各有不同,而小的运营商又鱼龙混杂,服务质量得不到保障。我给你说一个典型的问题。 + +之前在讲解 DNS 时,我曾经提到在做 DNS 解析的时候,为了缩短查询的链路,首先会查询运营商的 Local DNS,但是 Local DNS 这个东西很不靠谱,有些小的运营商为了节省流量,他会把一些域名解析到内容缓存服务器上,甚至会解析到广告或者钓鱼网站上去,这就是域名劫持。也有一些运营商它比较懒,自己不去解析域名,而是把解析请求,转发到别的运营商上,这就导致权威 DNS 收到请求的来源 IP 的运营商,是不正确的。这样一来,解析的 IP 和请求源,会来自不同的运营商,形成跨网的流量,导致 DNS 解析时间过长。这些需要我们进行实时地监控,以尽快地发现问题,反馈给运营商来解决。 + +那么,我们如何采集网络数据呢?一般来说,我们会用埋点的方式,将网络请求的每一个步骤耗时情况、是否发生错误,都打印下来,我以安卓系统为例,解释一下是如何做的。 + +安卓一般会使用 OkHttpClient 来请求接口数据,而 OkHttpClient 提供了 EventListner 接口,可以让调用者接收到网络请求事件,比如,开始解析 DNS 事件,解析 DNS 结束的事件等等。那么你就可以埋点计算出,一次网络请求的各个阶段的耗时情况。我写了一段具体的示例代码,计算了一次请求的 DNS 解析时间,你可以拿去参考。 + +public class HttpEventListener extends EventListener { + + final static AtomicLong nextCallId = new AtomicLong(1L); + + private final long callId; + + private long dnsStartTime; + + private HttpUrl url ; + + public HttpEventListener(HttpUrl url) { + + this.callId = nextCallId.getAndIncrement(); // 初始化唯一标识这次请求的 ID + + this.url = url; + + } + + + + @Override + + public void dnsStart(Call call, String domainName) { + + super.dnsStart(call, domainName); + + this.dnsStartTime = System.nanoTime(); // 记录 dns 开始时间 + + } + + @Override + + public void dnsEnd(Call call, String domainName, List inetAddressList) { + + super.dnsEnd(call, domainName, inetAddressList); + + System.out.println("url: " + url.host() + ", Dns time: " + (System.nanoTime() - dnsStartTime)); // 计算 dns 解析的时间 + + } + +} + + +有了这个 EventListner,你就可以在初始化 HttpClient 的时候把它注入进去,代码如下: + +OkHttpClient.Builder builder = new OkHttpClient.Builder() + + .eventListenerFactory(new Factory() { + + @Override + + public EventListener create(Call call) { + + return new HttpEventListener(call.request().url()); + + } + + }); + + +这样,我们可以得出一次请求过程中,经历的一系列过程的时间,其中主要包括下面几项。 + +等待时间:异步调用时,请求会首先缓存在本地的队列里面,由专门的 I/O 线程负责,那么在 I/O 线程真正处理请求之前,会有一个等待的时间。 + +DNS 时间:域名解析时间。 + +握手时间:TCP 三次握手的时间。 + +SSL 时间:如果服务是 HTTPS 服务,那么就会有一个 SSL 认证的时间。 + +发送时间:请求包被发送出去的时间。 + +首包时间:服务端处理后,客户端收到第一个响应包的时间。 + +包接收时间:我们接收到所有数据的时间。 + + + +有了这些数据之后,我们可以通过上面提到的 APM 通道服务,发送给服务端,这样服务端和客户端的同学,就可以从 Elasticsearch 中,查询到原始的数据,也可以对数据做一些聚合处理、统计分析之后,形成客户端请求监控的报表。这样,我们就可以有针对性地对 HTTP 请求的某一个过程做优化了。 + +而对于用户网络的监控,可以给你带来三方面的价值。 + +首先,这种用户网络监控的所有监控数据均来自客户端,是用户访问数据实时上报,因此能够准确、真实、实时地反应用户操作体验。 + +再者,它是我们性能优化的指向标,当做业务架构改造、服务性能优化、网络优化等任何优化行为时,可以反馈用户性能数据,引导业务正向优化接口性能、可用性等指标。 + +最后,它也能帮助我们监控 CDN 链路质量。之前的 CDN 的监控,严重依赖 CDN 厂商,这有一个问题是:CDN 无法从端上获取到全链路的监控数据,有些时候,客户端到 CDN 的链路出了问题,CDN 厂商是感知不到的,而客户端监控弥补了这方面的缺陷,并且可以通过告警机制督促 CDN 及时优化调整问题线路。 + +除了上报网络数据之外,我们还可以上报一些异常事件的数据,比如你的垂直电商系统可能会遇到以下一些异常情况。 + +登录失败 + +下单失败 + +浏览商品信息失败 + +评论列表加载不出来 + +无法评分留言 + +… + +你在业务逻辑的代码中,都可以检测到这些异常数据,当然,也可以通过 APM 通道服务,上传到服务端,这样方便服务端和客户端的同学一起来排查问题,也能给你的版本灰度提供数据的支持。 + +总的来说,如果说搭建的系统是骨架,那么具体监控的数据就是灵魂,因为数据是监控的主题内容,系统只是呈现数据的载体。所以,你需要在系统运维的过程中不断完善对数据的收集,这也是对你的监控系统不断升级完善的过程。 + +课程小结 + +以上就是本节课的全部内容了。本节课,我主要带你了解了,如何搭建一个端到端的 APM 监控系统,你需要了解的重点是: + +\1. 从客户端采集到的数据可以用通用的消息格式,上传到 APM 服务端,服务端将数据存入到 Elasticsearch 中,以提供原始日志的查询,也可以依据这些数据形成客户端的监控报表; + +\2. 用户网络数据是我们排查客户端,和服务端交互过程的重要数据,你可以通过代码的植入,来获取到这些数据; + +\3. 无论是网络数据,还是异常数据,亦或是卡顿、崩溃、流量、耗电量等数据,你都可以通过把它们封装成 APM 消息格式,上传到 APM 服务端,这些用户在客户端上留下的踪迹可以帮助你更好地优化用户的使用体验。 + +总而言之,监测和优化用户的使用体验是应用性能管理的最终目标。然而,服务端的开发人员往往会陷入一个误区,认为我们将服务端的监控做好,保证接口性能和可用性足够好就好了。事实上,接口的响应时间只是我们监控系统中很小的一部分,搭建一套端到端的全链路的监控体系,才是你的监控系统的最终形态。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/32压力测试:怎样设计全链路压力测试平台?.md b/专栏/高并发系统设计40问/32压力测试:怎样设计全链路压力测试平台?.md new file mode 100644 index 0000000..f5482e9 --- /dev/null +++ b/专栏/高并发系统设计40问/32压力测试:怎样设计全链路压力测试平台?.md @@ -0,0 +1,125 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 32 压力测试:怎样设计全链路压力测试平台? + 你好,我是唐扬。 + +经过两节课的学习,我们已经搭建了服务端和客户端的监控,通过监控的报表和一些报警规则的设置,你可以实时地跟踪和解决垂直电商系统中出现的问题了。不过,你不能掉以轻心,因为监控只能发现目前系统中已经存在的问题,对于未来可能发生的性能问题是无能为力的。 + +一旦你的系统流量有大的增长,比如类似“双十一”的流量,那么你在面临性能问题时就可能会手足无措。为了解决后顾之忧,你需要了解在流量增长若干倍的时候,系统的哪些组件或者服务会成为整体系统的瓶颈点,这时你就需要做一次全链路的压力测试。 + +那么,什么是压力测试呢?要如何来做全链路的压测呢?这两个问题就是本节课重点讲解的内容。 + +什么是压力测试 + +压力测试(简称为压测)这个名词儿,你在业界的分享中一定听过很多次,当然了,你也可能在项目的研发过程中做过压力测试,所以,对于你来说,压力测试并不陌生。 + +不过,我想让你回想一下,自己是怎么做压力测试的?是不是像很多同学一样:先搭建一套与正式环境功能相同的测试环境,并且导入或者生成一批测试数据,然后在另一台服务器,启动多个线程并发地调用需要压测的接口(接口的参数一般也会设置成相同的,比如,想要压测获取商品信息的接口,那么压测时会使用同一个商品 ID)。最后,通过统计访问日志,或者查看测试环境的监控系统,来记录最终压测 QPS 是多少之后,直接交差? + +这么做压力测试其实是不正确的,错误之处主要有以下几点: + +\1. 首先,做压力测试时,最好使用线上的数据和线上的环境,因为,你无法确定自己搭建的测试环境与正式环境的差异,是否会影响到压力测试的结果; + +\2. 其次,压力测试时不能使用模拟的请求,而是要使用线上的流量。你可以通过拷贝流量的方式,把线上流量拷贝一份到压力测试环境。因为模拟流量的访问模型,和线上流量相差很大,会对压力测试的结果产生比较大的影响。 + +比如,你在获取商品信息的时候,线上的流量会获取不同商品的数据,这些商品的数据有的命中了缓存,有的没有命中缓存。如果使用同一个商品 ID 来做压力测试,那么只有第一次请求没有命中缓存,而在请求之后会将数据库中的数据回种到缓存,后续的请求就一定会命中缓存了,这种压力测试的数据就不具备参考性了。 + +\3. 不要从一台服务器发起流量,这样很容易达到这台服务器性能瓶颈,从而导致压力测试的 QPS 上不去,最终影响压力测试的结果。而且,为了尽量真实地模拟用户请求,我们倾向于把流量产生的机器,放在离用户更近的位置,比如放在 CDN 节点上。如果没有这个条件,那么可以放在不同的机房中,这样可以尽量保证压力测试结果的真实性。 + +之所以有很多同学出现这个问题,主要是对压力测试的概念没有完全理解,以为只要是使用多个线程并发的请求服务接口,就算是对接口进行压力测试了。 + +那么究竟什么是压力测试呢?压力测试指的是,在高并发大流量下,进行的测试,测试人员可以通过观察系统在峰值负载下的表现,从而找到系统中存在的性能隐患。 + +与监控一样,压力测试是一种常见的,发现系统中存在问题的方式,也是保障系统可用性和稳定性的重要手段。而在压力测试的过程中,我们不能只针对某一个核心模块来做压测,而需要将接入层、所有后端服务、数据库、缓存、消息队列、中间件以及依赖的第三方服务系统及其资源,都纳入压力测试的目标之中。因为,一旦用户的访问行为增加,包含上述组件服务的整个链路都会受到不确定的大流量的冲击,因此,它们都需要依赖压力测试来发现可能存在的性能瓶颈,这种针对整个调用链路执行的压力测试也称为“全链路压测”。 + +由于在互联网项目中,功能迭代的速度很快,系统的复杂性也变得越来越高,新增加的功能和代码很可能会成为新的性能瓶颈点。也许半年前做压力测试时,单台机器可以承担每秒 1000 次请求,现在很可能就承担每秒 800 次请求了。所以,压力测试应该作为系统稳定性保障的常规手段,周期性地进行。 + +但是,通常做一次全链路压力测试,需要联合 DBA、运维、依赖服务方、中间件架构等多个团队,一起协调进行,无论是人力成本还是沟通协调的成本都比较高。同时,在压力测试的过程中,如果没有很好的监控机制,那么还会对线上系统造成不利的影响。为了解决这些问题,我们需要搭建一套自动化的全链路压测平台,来降低成本和风险。 + +如何搭建全链路压测平台 + +搭建全链路压测平台,主要有两个关键点。 + +一点是流量的隔离。由于压力测试是在正式环境进行,所以需要区分压力测试流量和正式流量,这样可以针对压力测试的流量做单独的处理。 + +另一点是风险的控制。也就是,尽量避免压力测试对于正常访问用户的影响,因此,一般来说全链路压测平台需要包含以下几个模块: + +流量构造和产生模块; + +压测数据隔离模块; + +系统健康度检查和压测流量干预模块。 + +整体压测平台的架构图可以是下面这样的: + + + +为了让你能够更清晰地了解每一个模块是如何实现的,方便你来设计适合自身业务的全链路压测平台,我会对压测平台的每一个模块做更细致地介绍。先来看看压力测试的流量是如何产生的。 + +压测数据的产生 + +一般来说,我们系统的入口流量是来自于客户端的 HTTP 请求,所以,我们会考虑在系统高峰期时,将这些入口流量拷贝一份,在经过一些流量清洗的工作之后(比如过滤一些无效的请求),将数据存储在像是 HBase、MongoDB 这些 NoSQL 存储组件,或者亚马逊 S3 这些云存储服务中,我们称之为流量数据工厂。 + +这样,当我们要压测的时候,就可以从这个工厂中获取数据,将数据切分多份后下发到多个压测节点上了,在这里,我想强调几个,你需要特殊注意的点。 + +首先,我们可以使用多种方式来实现流量的拷贝。最简单的一种方式:直接拷贝负载均衡服务器的访问日志,数据就以文本的方式写入到流量数据工厂中,但是这样产生的数据在发起压测时,需要自己写解析的脚本来解析访问日志,会增加压测时候的成本,不太建议使用。 + +另一种方式:通过一些开源的工具来实现流量的拷贝。这里,我推荐一款轻型的流量拷贝工具GoReplay,它可以劫持本机某一个端口的流量,将它们记录在文件中,传送到流量数据工厂中。在压测时,你也可以使用这个工具进行加速的流量回放,这样就可以实现对正式环境的压力测试了。 + +其次,如上文中提到的,我们在下发压测流量时,需要保证下发流量的节点与用户更加接近,起码不能和服务部署节点在同一个机房中,这样可以尽量保证压测数据的真实性。 + +另外,我们还需要对压测流量染色,也就是增加压测标记。在实际项目中,我会在 HTTP 的请求头中增加一个标记项,比如说叫做 is stress test,在流量拷贝之后,批量在请求中增加这个标记项,再写入到数据流量工厂中。 + +数据如何隔离 + +将压测流量拷贝下来的同时,我们也需要考虑对系统做改造,以实现压测流量和正式流量的隔离,这样一来就会尽量避免压测对线上系统的影响,一般来说,我们需要做两方面的事情。 + +一方面,针对读取数据的请求(一般称之为下行流量),我们会针对某些不能压测的服务或者组件,做 Mock 或者特殊的处理。举个例子。 + +在业务开发中,我们一般会依据请求,记录用户的行为,比如,用户请求了某个商品的页面,我们会记录这个商品多了一次浏览的行为,这些行为数据会写入一份单独的大数据日志中,再传输给数据分析部门,形成业务报表给到产品或者老板做业务的分析决策。 + +在压测的时候,肯定会增加这些行为数据,比如原本一天商品页面的浏览行为是一亿次,而压测之后变成了十亿次,这样就会对业务报表产生影响,影响后续的产品方向的决策。因此,我们对于这些压测产生的用户行为做特殊处理,不再记录到大数据日志中。 + +再比如,我们系统会依赖一些推荐服务,推荐一些你可能感兴趣的商品,但是这些数据的展示有一个特点就是,展示过的商品就不再会被推荐出来。如果你的压测流量经过这些推荐服务,大量的商品就会被压测流量请求到,线上的用户就不会再看到这些商品了,也就会影响推荐的效果。 + +所以,我们需要 Mock 这些推荐服务,让不带有压测标记的请求经过推荐服务,而让带有压测标记的请求经过 Mock 服务。搭建 Mock 服务,你需要注意一点:这些 Mock 服务最好部署在真实服务所在的机房,这样可以尽量模拟真实的服务部署结构,提高压测结果的真实性。 + +另一方面,针对写入数据的请求(一般称之为上行流量),我们会把压测流量产生的数据,写入到影子库,也就是和线上数据存储,完全隔离的一份存储系统中。针对不同的存储类型,我们会使用不同的影子库的搭建方式: + +如果数据存储在 MySQL 中,我们可以在同一个 MySQL 实例,不同的 Schema 中创建一套和线上相同的库表结构,并且把线上的数据也导入进来。 + +而如果数据是放在 Redis 中,我们对压测流量产生的数据,增加一个统一的前缀,存储在同一份存储中。 + +还有一些数据会存储在 Elasticsearch 中,针对这部分数据,我们可以放在另外一个单独的索引表中。 + +通过对下行流量的特殊处理以及对上行流量增加影子库的方式,我们就可以实现压测流量的隔离了。 + +压力测试如何实施 + +在拷贝了线上流量和完成了对线上系统的改造之后,我们就可以进行压力测试的实施了。在此之前,一般会设立一个压力测试的目标,比如说,整体系统的 QPS 需要达到每秒 20 万。 + +不过,在压测时,不会一下子把请求量增加到每秒 20 万次,而是按照一定的步长(比如每次压测增加一万 QPS),逐渐地增加流量。在增加一次流量之后,让系统稳定运行一段时间,观察系统在性能上的表现。如果发现依赖的服务或者组件出现了瓶颈,可以先减少压测流量,比如,回退到上一次压测的 QPS,保证服务的稳定,再针对此服务或者组件进行扩容,然后再继续增加流量压测。 + +为了能够减少压力测试过程中,人力投入成本,可以开发一个流量监控的组件,在这个组件中,预先设定一些性能阈值。比如,容器的 CPU 使用率的阈值可以设定为 60%~70%;系统的平均响应时间的上限可以设定为 1 秒;系统慢请求的比例设置为 1% 等等。 + +当系统性能达到这个阈值之后,流量监控组件可以及时发现,并且通知压测流量下发组件减少压测流量,并且发送报警给到开发和运维的同学,开发和运维同学就迅速地排查性能瓶颈,在解决问题或者扩容之后再继续执行压测。 + +业界关于全链路压测平台的探索有很多,一些大厂比如阿里、京东、美团和微博都有了适合自身业务的全链路压测平台。在我看来,这些压测平台万变不离其宗,都无非是经过流量拷贝、流量染色隔离、打压、监控熔断等步骤,与本课程中介绍的核心思想都是相通的。因此,你在考虑自研适合自己项目的全链路压测平台时,也可以遵循这个成熟的套路。 + +课程小结 + +本节课,我带你了解了做压力测试常见的误区,以及自动化的全链路压测平台的搭建过程,这里你需要了解的重点是: + +压力测试是一种发现系统性能隐患的重要手段,所以应该尽量使用正式的环境和数据; + +对压测的流量需要增加标记,这样就可以通过 Mock 第三方依赖服务和影子库的方式来实现压测数据和正式数据的隔离; + +压测时,应该实时地对系统性能指标做监控和告警,及时地对出现瓶颈的资源或者服务扩容,避免对正式环境产生影响。 + +这套全链路的压力测试系统对于我们来说有三方面的价值:其一,它可以帮助我们发现系统中可能出现的性能瓶颈,方便我们提前准备预案来应对;其次,它也可以为我们做容量评估,提供数据上的支撑;最后,我们也可以在压测的时候做预案演练,因为压测一般会安排在流量的低峰期进行,这样我们可以降级一些服务来验证预案效果,并且可以尽量减少对线上用户的影响。所以,随着你的系统流量的快速增长,你也需要及时考虑搭建这么一套全链路压测平台,来保证你的系统的稳定性。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/33配置管理:成千上万的配置项要如何管理?.md b/专栏/高并发系统设计40问/33配置管理:成千上万的配置项要如何管理?.md new file mode 100644 index 0000000..66e5ac2 --- /dev/null +++ b/专栏/高并发系统设计40问/33配置管理:成千上万的配置项要如何管理?.md @@ -0,0 +1,121 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 33 配置管理:成千上万的配置项要如何管理? + 你好,我是唐扬。 + +相信在实际工作中,提及性能优化你会想到代码优化,但是实际上有些性能优化可能只需要调整一些配置参数就可以搞定了,为什么这么说呢?我给你举几个例子: + +你可以调整配置的超时时间,让请求快速失败,防止系统的雪崩,提升系统的可用性; + +你还可以调整 HTTP 客户端连接池的大小,来提升调用第三方 HTTP 服务的并行处理能力,从而提升系统的性能。 + +你可以认为,配置是管理你系统的工具,在你的垂直电商系统中,一定会有非常多的配置项,比如数据库的地址、请求 HTTP 服务的域名、本地内存最大缓存数量等等。 + +那么,你要如何对这些配置项做管理呢?管理的过程中要注意哪些事情呢? + +如何对配置进行管理呢? + +配置管理由来已久,比如在 Linux 系统中就提供了大量的配置项,你可以依据自身业务的实际情况,动态地对系统功能做调整。比如,你可以修改 dirty_writeback_centisecs 参数的数值,就可以调整 Page Cache 中脏数据刷新到磁盘上的频率;你也可以通过修改 tcp_max_syn_backlog 参数置的值,来调整未建立连接队列的长度。修改这些参数既可以通过修改配置文件并且重启服务器来配置生效,也可以通过 sysctl 命令来动态地调整,让配置即时生效。 + +那么你在开发应用的时候,都有哪些管理配置的方式呢?主要有两种: + +一种是通过配置文件来管理; + +另一种是使用配置中心来管理。 + +以电商系统为例,你和你的团队在刚开始研发垂直电商系统时,为了加快产品的研发速度,大概率不会注意配置管理的问题,会自然而然地把配置项和代码写在一起。但是随着配置项越来越多,为了更好地对配置项进行管理,同时避免修改配置项后还要对代码做重新的编译,你选择把配置项独立成单独的文件(文件可以是 properties 格式、xml 格式或 yaml 格式)。不过,这些文件还是会和工程一起打包部署,只是更改配置后不需要对代码重新编译了。 + +随后,你很快发现了一个问题:虽然把配置拆分了出来,但是由于配置还是和代码打包在一起,如果要更改一个配置,还是需要重新打包,这样无疑会增加打包的时间。于是,你考虑把配置写到单独的目录中,这样,修改配置就不需要再重新打包了(不过,由于配置并不能够实时地生效,所以想让配置生效,还是需要重启服务)。 + +我们一般使用的基础组件,比如 Tomcat,Nginx,都是采用上面这种配置文件的方式来管理配置项的,而在 Linux 系统中,我提到的 tcp_max_syn_backlog 就可以配置在 /etc/sysctl.conf 中。 + +这里,我需要强调一点,我们通常会把配置文件存储的目录,标准化为特定的目录。比如,都配置成 /data/confs 目录,然后把配置项使用 Git 等代码仓库管理起来。这样,在增加新的机器时,在机器初始化脚本中,只需要创建这个目录,再从 Git 中拉取配置就可以了,是一个标准化的过程,这样可以避免在启动应用时忘记部署配置文件。 + +另外,如果你的服务是多机房部署的,那么不同机房的配置项中,有可能有相同的,或者有不同的。那么,你需要将相同的配置项放置在一个目录中给多个机房公用,再将不同的配置项,放置在以机房名为名称的目录中。在读取配置的时候应该优先读取机房的配置,再读取公共配置,这样可以减少配置文件中的配置项的数量。 + +我给你列了一个典型目录配置,如果你的系统也使用文件来管理配置,那么可以参考一下。 + +/data/confs/common/commerce // 电商业务的公共配置 + +/data/confs/commerce-zw // 电商业务兆维机房配置 + +/data/confs/commerce-yz // 电商业务亦庄机房配置 + +/data/confs/common/community // 社区业务的公共配置 + +/data/confs/community-zw // 社区业务兆维机房配置 + +/data/confs/community-yz // 社区业务亦庄机房配置 + + +那么,这是不是配置管理的最终形态呢?当然不是,你不要忘了把配置放在文件中的方式还存在的问题(我上面也提到过了),那就是,我们必须将服务重启后,才能让配置生效。有没有一种方法可以在不重启应用的前提下,也能让配置生效呢?这就需要配置中心帮助我们实现了。 + +配置中心是如何实现的? + +配置中心可以算是微服务架构中的一个标配组件了。业界也提供了多种开源方案供你选择,比较出名的有携程开源的 Apollo,百度开源的 Disconf,360 开源的 QConf,Spring Cloud 的组件 Spring Cloud Config 等等。 + +在我看来,Apollo 支持不同环境,不同集群的配置,有完善的管理功能,支持灰度发布、更改热发布等功能,在所有配置中心中功能比较齐全,推荐你使用。 + +那么,配置中心的组件在实现上,有哪些关键的点呢?如果你想对配置中心组件有更强地把控力,想要自行研发一套符合自己业务场景的组件,又要如何入手呢? + +配置信息如何存储 + +其实,配置中心和注册中心非常类似,其核心的功能就是对于配置项的存储和读取。所以,在设计配置中心的服务端时,我们需要选择合适的存储组件,来存储大量的配置信息,这里可选择的组件有很多。 + +事实上,不同的开源配置中心也使用了不同的组件,比如 Disconf、Apollo 使用的是 MySQL;QConf 使用的是 ZooKeeper。我之前维护和使用的配置中心就会使用不同的存储组件,比如微博的配置中心使用 Redis 来存储信息,而美图的则使用 Etcd。 + +而无论使用哪一种存储组件,你所要做的就是规范配置项在其中的存储结构。比如,我之前使用的配置中心用 Etcd 作为存储组件,支持存储全局配置、机房配置和节点配置。其中,节点配置优先级高于机房配置,机房配置优先级高于全局配置。也就是说,我们会优先读取节点的配置,如果节点配置不存在,再读取机房配置,最后读取全局配置。它们的存储路径如下: + +/confs/global/{env}/{project}/{service}/{version}/{module}/{key} // 全局配置 + +/confs/regions/{env}/{project}/{service}/{version}/{region}/{module}/{key} // 机房配置 + + /confs/nodes/{env}/{project}/{service}/{version}/{region}/{node}/{module}/{key} // 节点配置 + + +变更推送如何实现 + +配置信息存储之后,我们需要考虑如何将配置的变更推送给服务端,这样就可以实现配置的动态变更,不需要重启服务器就能让配置生效了。而我们一般会有两种思路来实现变更推送:一种是轮询查询的方式;一种是长连推送的方式。 + +轮询查询很简单,就是应用程序向配置中心客户端注册一个监听器,配置中心的客户端,定期地(比如 1 分钟)查询所需要的配置是否有变化,如果有变化则通知触发监听器,让应用程序得到变更通知。 + +这里有一个需要注意的点,如果有很多应用服务器都去轮询拉取配置,由于返回的配置项可能会很大,那么配置中心服务的带宽就会成为瓶颈。为了解决这个问题,我们会给配置中心的每一个配置项,多存储一个根据配置项计算出来的 MD5 值。 + +配置项一旦变化,这个 MD5 值也会随之改变。配置中心客户端在获取到配置的同时,也会获取到配置的 MD5 值,并且存储起来。那么在轮询查询的时候,需要先确认存储的 MD5 值,和配置中心的 MD5 是不是一致的。如果不一致,这就说明配置中心中,存储的配置项有变化,然后才会从配置中心拉取最新的配置。 + +由于配置中心里存储的配置项变化的几率不大,所以使用这种方式后,每次轮询请求就只是返回一个 MD5 值,可以大大地减少配置中心服务器的带宽。 + + + +另一种长连的方式,则是在配置中心服务端保存每个连接关注的配置项列表。这样,当配置中心感知到配置变化后,就可以通过这个连接,把变更的配置推送给客户端。这种方式需要保持长连,也需要保存连接和配置的对应关系,实现上要比轮询的方式复杂一些,但是相比轮询方式来说,能够更加实时地获取配置变更的消息。 + +而在我看来,配置服务中存储的配置变更频率不高,所以对于实时性要求不高,但是期望实现上能够足够简单,所以如果选择自研配置中心的话,可以考虑使用轮询的方式。 + +如何保证配置中心高可用 + +除了变更通知以外,在配置中心实现中,另外一个比较关键的点在于如何保证它的可用性,因为对于配置中心来说,它的可用性的重要程度要远远大于性能。这是因为我们一般会在服务器启动时,从配置中心中获取配置,如果配置获取的性能不高,那么外在的表现也只是应用启动时间慢了,对于业务的影响不大;但是,如果获取不到配置,很可能会导致启动失败。 + +比如,我们把数据库的地址存储在了配置中心里,如果配置中心宕机导致我们无法获取数据库的地址,那么自然应用程序就会启动失败。因此,我们的诉求是让配置中心“旁路化”。也就是说,即使配置中心宕机,或者配置中心依赖的存储宕机,我们仍然能够保证应用程序是可以启动的。那么这是如何实现的呢? + +我们一般会在配置中心的客户端上,增加两级缓存:第一级缓存是内存的缓存;另外一级缓存是文件的缓存。 + +配置中心客户端在获取到配置信息后,会同时把配置信息同步地写入到内存缓存,并且异步地写入到文件缓存中。内存缓存的作用是降低客户端和配置中心的交互频率,提升配置获取的性能;而文件的缓存的作用就是灾备,当应用程序重启时,一旦配置中心发生故障,那么应用程序就会优先使用文件中的配置,这样虽然无法得到配置的变更消息(因为配置中心已经宕机了),但是应用程序还是可以启动起来的,算是一种降级的方案。 + +课程小结 + +以上就是本节课的全部内容了。在这节课中,我带你了解了系统开发的过程中,我们是如何管理大量的配置项的,你需要了解的重点是: + +配置存储是分级的,有公共配置,有个性的配置,一般个性配置会覆盖公共配置,这样可以减少存储配置项的数量; + +配置中心可以提供配置变更通知的功能,可以实现配置的热更新; + +配置中心关注的性能指标中,可用性的优先级是高于性能的,一般我们会要求配置中心的可用性达到 99.999%,甚至会是 99.9999%。 + +这里你需要注意的是,并不是所有的配置项都需要使用配置中心来存储,如果你的项目还是使用文件方式来管理配置,那么你只需要,将类似超时时间等,需要动态调整的配置,迁移到配置中心就可以了。对于像是数据库地址,依赖第三方请求的地址,这些基本不会发生变化的配置项,可以依然使用文件的方式来管理,这样可以大大地减少配置迁移的成本。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/34降级熔断:如何屏蔽非核心系统故障的影响?.md b/专栏/高并发系统设计40问/34降级熔断:如何屏蔽非核心系统故障的影响?.md new file mode 100644 index 0000000..e98881b --- /dev/null +++ b/专栏/高并发系统设计40问/34降级熔断:如何屏蔽非核心系统故障的影响?.md @@ -0,0 +1,210 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 34 降级熔断:如何屏蔽非核心系统故障的影响? + 你好,我是唐扬。 + +到目前为止,你的电商系统已经搭建了完善的服务端和客户端监控系统,并且完成了全链路压测。现在呢,你们已经发现和解决了垂直电商系统中很多的性能问题和隐患。但是千算万算,还是出现了纰漏。 + +本来,你们对于应对“双十一”的考验信心满满,但因为欠缺了一些面对巨大流量的经验,在促销过程中出现了几次短暂的服务不可用,这给部分用户造成了不好的使用体验。事后,你们进行了细致的复盘,追查出现故障的根本原因,你发现,原因主要可以归结为两大类。 + +第一类原因是由于依赖的资源或者服务不可用,最终导致整体服务宕机。举例来说,在你的电商系统中就可能由于数据库访问缓慢,导致整体服务不可用。 + +另一类原因是你们乐观地预估了可能到来的流量,当有超过系统承载能力的流量到来时,系统不堪重负,从而出现拒绝服务的情况。 + +那么,你要如何避免再次出现这两类问题呢?我建议你采取降级、熔断以及限流的方案。限流是解决第二类问题的主要思路(下一节课,我会着重讲解)。今天这节课,我主要讲一下解决第一类问题的思路:降级和熔断。 + +不过在此之前,我先带你了解一下这个问题为何存在,因为你只有弄清楚出现故障的原理,才能更好地理解熔断降级带来的好处。 + +雪崩是如何发生的 + +局部故障最终导致全局故障,这种情况有一个专业的名词儿,叫做“雪崩”。那么,为什么会发生雪崩呢?我们知道,系统在运行的时候是需要消耗一些资源的,包括 CPU、内存等系统资源,也包括执行业务逻辑的时候,需要的线程资源。 + +举个例子,一般在业务执行的容器内,都会定义一些线程池来分配执行任务的线程,比如在 Tomcat 这种 Web 容器的内部,定义了线程池来处理 HTTP 请求;RPC 框架也给 RPC 服务端初始化了线程池来处理 RPC 请求。 + +这些线程池中的线程资源是有限的,如果这些线程资源被耗尽,那么服务自然也就无法处理新的请求,服务提供方也就宕机了。比如,你的垂直电商系统有四个服务 A、B、C、D,A 调用 B,B 调用 C 和 D。其中,A、B、D 服务是系统的核心服务(像是电商系统中的订单服务、支付服务等等),C 是非核心服务(像反垃圾服务、审核服务)。 + +所以,一旦作为入口的 A 流量增加,你可能会考虑把 A、B 和 D 服务扩容,忽略 C。那么 C 就有可能因为无法承担这么大的流量,导致请求处理缓慢,进一步会让 B 在调用 C 的时候,B 中的请求被阻塞,等待 C 返回响应结果。这样一来,B 服务中被占用的线程资源就不能释放。 + +久而久之,B 就会因为线程资源被占满,无法处理后续的请求。那么从 A 发往 B 的请求,就会被放入 B 服务线程池的队列中,然后 A 调用 B 响应时间变长,进而拖垮 A 服务。你看,仅仅因为非核心服务 C 的响应时间变长,就可以导致整体服务宕机,这就是我们经常遇到的一种服务雪崩情况。 + + + +那么我们要如何避免出现上面这种情况呢?从我刚刚的介绍中你可以看到,因为服务调用方等待服务提供方的响应时间过长,它的资源被耗尽,才引发了级联反应,发生雪崩。 + +所以在分布式环境下,系统最怕的反而不是某一个服务或者组件宕机,而是最怕它响应缓慢,因为,某一个服务或者组件宕机也许只会影响系统的部分功能,但它响应一慢,就会出现雪崩拖垮整个系统。 + +而我们在部门内部讨论方案的时候,会格外注意这个问题,解决的思路就是在检测到某一个服务的响应时间出现异常时,切断调用它的服务与它之间的联系,让服务的调用快速返回失败,从而释放这次请求持有的资源。这个思路也就是我们经常提到的降级和熔断机制。 + +那么降级和熔断分别是怎么做的呢?它们之间有什么相同点和不同点呢?你在自己的项目中要如何实现熔断降级呢? + +熔断机制是如何做的 + +首先,我们来看看熔断机制的实现方式。这个机制参考的是电路中保险丝的保护机制,当电路超负荷运转的时候,保险丝会断开电路,保证整体电路不受损害。而服务治理中的熔断机制指的是在发起服务调用的时候,如果返回错误或者超时的次数超过一定阈值,则后续的请求不再发向远程服务而是暂时返回错误。 + +这种实现方式在云计算领域又称为断路器模式,在这种模式下,服务调用方为每一个调用的服务维护一个有限状态机,在这个状态机中会有三种状态:关闭(调用远程服务)、半打开(尝试调用远程服务)和打开(返回错误)。这三种状态之间切换的过程是下面这个样子。 + +当调用失败的次数累积到一定的阈值时,熔断状态从关闭态切换到打开态。一般在实现时,如果调用成功一次,就会重置调用失败次数。 + +当熔断处于打开状态时,我们会启动一个超时计时器,当计时器超时后,状态切换到半打开态。你也可以通过设置一个定时器,定期地探测服务是否恢复。 + +在熔断处于半打开状态时,请求可以达到后端服务,如果累计一定的成功次数后,状态切换到关闭态;如果出现调用失败的情况,则切换到打开态。 + + + +其实,不仅仅微服务之间调用需要熔断的机制,我们在调用 Redis、Memcached 等资源的时候也可以引入这套机制。在我的团队自己封装的 Redis 客户端中,就实现了一套简单的熔断机制。首先,在系统初始化的时候,我们定义了一个定时器,当熔断器处于 Open 状态时,定期地检测 Redis 组件是否可用: + +new Timer("RedisPort-Recover", true).scheduleAtFixedRate(new TimerTask() { + + @Override + + public void run() { + + if (breaker.isOpen()) { + + Jedis jedis = null; + + try { + + jedis = connPool.getResource(); + + jedis.ping(); // 验证 redis 是否可用 + + successCount.set(0); // 重置连续成功的计数 + + breaker.setHalfOpen(); // 设置为半打开态 + + } catch (Exception ignored) { + + } finally { + + if (jedis != null) { + + jedis.close(); + + } + + } + + } + + } + +}, 0, recoverInterval); // 初始化定时器定期检测 redis 是否可用 + + +在通过 Redis 客户端操作 Redis 中的数据时,我们会在其中加入熔断器的逻辑。比如,当节点处于熔断状态时,直接返回空值以及熔断器三种状态之间的转换,具体的示例代码像下面这样: + +if (breaker.isOpen()) { + + return null; // 断路器打开则直接返回空值 + +} + +K value = null; + +Jedis jedis = null; + +try { + + jedis = connPool.getResource(); + + value = callback.call(jedis); + + if(breaker.isHalfOpen()) { // 如果是半打开状态 + + if(successCount.incrementAndGet() >= SUCCESS_THRESHOLD) {// 成功次数超过阈值 + + failCount.set(0); // 清空失败数 + + breaker.setClose(); // 设置为关闭态 + + } + + } + + return value; + +} catch (JedisException je) { + + if(breaker.isClose()){ // 如果是关闭态 + + if(failCount.incrementAndGet() >= FAILS_THRESHOLD){ // 失败次数超过阈值 + + breaker.setOpen(); // 设置为打开态 + + } + + } else if(breaker.isHalfOpen()) { // 如果是半打开态 + + breaker.setOpen(); // 直接设置为打开态 + + } + + throw je; + +} finally { + + if (jedis != null) { + + jedis.close(); + + } + +} + + +这样,当某一个 Redis 节点出现问题,Redis 客户端中的熔断器就会实时监测到,并且不再请求有问题的 Redis 节点,避免单个节点的故障导致整体系统的雪崩。 + +降级机制要如何做 + +除了熔断之外,我们在听业内分享的时候,听到最多的服务容错方式就是降级,那么降级又是怎么做的呢?它和熔断有什么关系呢? + +其实在我看来,相比熔断来说,降级是一个更大的概念。因为它是站在整体系统负载的角度上,放弃部分非核心功能或者服务,保证整体的可用性的方法,是一种有损的系统容错方式。这样看来,熔断也是降级的一种,除此之外还有限流降级、开关降级等等(限流降级我会在下一节课中提到,这节课主要讲一下开关降级)。 + +开关降级指的是在代码中预先埋设一些“开关”,用来控制服务调用的返回值。比方说,开关关闭的时候正常调用远程服务,开关打开时则执行降级的策略。这些开关的值可以存储在配置中心中,当系统出现问题需要降级时,只需要通过配置中心动态更改开关的值,就可以实现不重启服务快速地降级远程服务了。 + +还是以电商系统为例,你的电商系统在商品详情页面除了展示商品数据以外,还需要展示评论的数据,但是主体还是商品数据,在必要时可以降级评论数据。所以,你可以定义这个开关为“degrade.comment”,写入到配置中心中,具体的代码也比较简单,就像下面这样: + +boolean switcherValue = getFromConfigCenter("degrade.comment"); // 从配置中心获取开关的值 + +if (!switcherValue) { + + List comments = getCommentList(); // 开关关闭则获取评论数据 + +} else { + + List comments = new ArrayList(); // 开关打开,则直接返回空评论数据 + +} + + +当然了,我们在设计开关降级预案的时候,首先要区分哪些是核心服务,哪些是非核心服务。因为我们只能针对非核心服务来做降级处理,然后就可以针对具体的业务,制定不同的降级策略了。我给你列举一些常见场景下的降级策略,你在实际的工作中可以参考借鉴。 + +针对读取数据的场景,我们一般采用的策略是直接返回降级数据。比如,如果数据库的压力比较大,我们在降级的时候,可以考虑只读取缓存的数据,而不再读取数据库中的数据;如果非核心接口出现问题,可以直接返回服务繁忙或者返回固定的降级数据。 + +对于一些轮询查询数据的场景,比如每隔 30 秒轮询获取未读数,可以降低获取数据的频率(将获取频率下降到 10 分钟一次)。 + +而对于写数据的场景,一般会考虑把同步写转换成异步写,这样可以牺牲一些数据一致性和实效性来保证系统的可用性。 + +我想强调的是,只有经过演练的开关才是有用的开关,有些同学在给系统加了开关之后并不测试,结果出了问题真要使用的时候,却发现开关并不生效。因此,你在为系统增加降级开关时,一定要在流量低峰期的时候做验证演练,也可以在不定期的压力测试过程中演练,保证开关的可用性。 + +课程小结 + +以上就是本节课的全部内容了。本节课我带你了解了雪崩产生的原因,服务熔断的实现方式以及服务降级的策略,这里你需要了解的重点是: + +在分布式环境下最怕的是服务或者组件慢,因为这样会导致调用者持有的资源无法释放,最终拖垮整体服务。 + +服务熔断的实现是一个有限状态机,关键是三种状态之间的转换过程。 + +开关降级的实现策略主要有返回降级数据、降频和异步三种方案。 + +其实,开关不仅仅应该在你的降级策略中使用,在我的项目中,只要上线新的功能必然要加开关控制业务逻辑是运行新的功能还是运行旧的功能。这样,一旦新的功能上线后,出现未知的问题(比如性能问题),那么可以通过切换开关的方式来实现快速地回滚,减少问题的持续时间。 + +总之,熔断和降级是保证系统稳定性和可用性的重要手段,在你访问第三方服务或者资源的时候都需要考虑增加降级开关或者熔断机制,保证资源或者服务出现问题时,不会对整体系统产生灾难性的影响。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/35流量控制:高并发系统中我们如何操纵流量?.md b/专栏/高并发系统设计40问/35流量控制:高并发系统中我们如何操纵流量?.md new file mode 100644 index 0000000..38cb27e --- /dev/null +++ b/专栏/高并发系统设计40问/35流量控制:高并发系统中我们如何操纵流量?.md @@ -0,0 +1,133 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 35 流量控制:高并发系统中我们如何操纵流量? + 你好,我是唐扬。 + +上一节课里,我带你了解了微服务架构中常见的两种有损的服务保护策略:熔断和降级。它们都是通过暂时关闭某些非核心服务或者组件从而保护核心系统的可用性。但是,并不是所有的场景下都可以使用熔断降级的策略,比如,电商系统在双十一、618 大促的场景。 + +这种场景下,系统的峰值流量会超过了预估的峰值,对于核心服务也产生了比较大的影响,而你总不能把核心服务整体降级吧?那么在这个时候要如何保证服务的稳定性呢?你认为可以使用限流的方案。而提到限流,我相信你多多少少在以下几个地方出错过: + +限流算法选择不当,导致限流效果不好; + +开启了限流却发现整体性能有损耗; + +只实现了单机的限流却没有实现整体系统的限流。 + +说白了,你之所以出现这些问题还是对限流的算法以及实际应用不熟练,而本节课,我将带你了解这些内容,期望你能将这些经验应用到实际项目中,从而提升整体系统的鲁棒性。 + +究竟什么是限流 + +限流指的是通过限制到达系统的并发请求数量,保证系统能够正常响应部分用户请求,而对于超过限制的流量,则只能通过拒绝服务的方式保证整体系统的可用性。限流策略一般部署在服务的入口层,比如 API 网关中,这样可以对系统整体流量做塑形。而在微服务架构中,你也可以在 RPC 客户端中引入限流的策略,来保证单个服务不会被过大的流量压垮。 + +其实,无论在实际工作生活中还是在之前学习过的知识中,你都可能对限流策略有过应用,我给你举几个例子。 + +比如,到了十一黄金周的时候你想去九寨沟游玩,结果到了九寨沟才发现景区有了临时的通知,每天仅仅售卖 10 万张门票,而当天没有抢到门票的游客就只能第二天起早继续来抢了。这就是一种常见的限流策略,也就是对一段时间内(在这里是一天)流量做整体的控制,它可以避免出现游客过多导致的景区环境受到影响的情况,也能保证游客的安全。而且,如果你挤过地铁,就更能感同身受了。北京早高峰的地铁都会限流,想法很直接,就是控制进入地铁的人数,保证地铁不会被挤爆,也可以尽量保障人们的安全。 + +再比如,在 TCP 协议中有一个滑动窗口的概念,可以实现对网络传输流量的控制。你可以想象一下,如果没有流量控制,当流量接收方处理速度变慢而发送方还是继续以之前的速率发送数据,那么必然会导致流量拥塞。而 TCP 的滑动窗口实际上可以理解为接收方所能提供的缓冲区的大小。 + +在接收方回复发送方的 ACK 消息中,会带上这个窗口的大小。这样,发送方就可以通过这个滑动窗口的大小决定发送数据的速率了。如果接收方处理了一些缓冲区的数据,那么这个滑动窗口就会变大,发送方发送数据的速率就会提升;反之,如果接收方接收了一些数据还没有来得及处理,那么这个滑动窗口就会减小,发送方发送数据的速率就会减慢。 + + + +而无论是在一体化架构还是微服务化架构中,我们也可以在多个维度上对到达系统的流量做控制,比如: + +你可以对系统每分钟处理多少请求做限制; + +可以针对单个接口设置每分钟请求流量的限制; + +可以限制单个 IP、用户 ID 或者设备 ID 在一段时间内发送请求的数量; + +对于服务于多个第三方应用的开放平台来说,每一个第三方应用对于平台方来说都有一个唯一的 appkey 来标识,那么你也可以限制单个 appkey 的访问接口的速率。 + +而实现上述限制速率的方式是基于一些限流算法的,那么常见的限流的算法有哪些呢?你在实现限流的时候都有哪些方式呢? + +你应该知道的限流算法 + +固定窗口与滑动窗口的算法 + +我们知道,限流的目的是限制一段时间内发向系统的总体请求量,比如,限制一分钟之内系统只能承接 1 万次请求,那么最暴力的一种方式就是记录这一分钟之内访问系统的请求量有多少,如果超过了 1 万次的限制,那么就触发限流的策略返回请求失败的错误。如果这一分钟的请求量没有达到限制,那么在下一分钟到来的时候先重置请求量的计数,再统计这一分钟的请求量是否超过限制。 + +这种算法叫做固定窗口算法,在实现它的时候,首先要启动一个定时器定期重置计数,比如你需要限制每秒钟访问次数,那么简单的实现代码是这样的: + +private AtomicInteger counter; + +ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor(); + +timer.scheduleAtFixedRate(new Runnable(){ + + @Override + + public void run() { + + counter.set(0); + + } + +}, 0, 1, TimeUnit.SECONDS); + + +而限流的逻辑就非常简单了,只需要比较计数值是否大于阈值就可以了: + +public boolena isRateLimit() { + + return counter.incrementAndGet() >= allowedLimit; + +} + + +这种算法虽然实现非常简单,但是却有一个很大的缺陷 :无法限制短时间之内的集中流量。假如我们需要限制每秒钟只能处理 10 次请求,如果前一秒钟产生了 10 次请求,这 10 次请求全部集中在最后的 10 毫秒中,而下一秒钟的前 10 毫秒也产生了 10 次请求,那么在这 20 毫秒中就产生了 20 次请求,超过了限流的阈值。但是因为这 20 次请求分布在两个时间窗口内,所以没有触发限流,这就造成了限流的策略并没有生效。 + + + +为了解决这个缺陷,就有了基于滑动窗口的算法。 这个算法的原理是将时间的窗口划分为多个小窗口,每个小窗口中都有单独的请求计数。比如下面这张图,我们将 1s 的时间窗口划分为 5 份,每一份就是 200ms;那么当在 1s 和 1.2s 之间来了一次新的请求时,我们就需要统计之前的一秒钟内的请求量,也就是 0.2s~1.2s 这个区间的总请求量,如果请求量超过了限流阈值那么就执行限流策略。 + + + +滑动窗口的算法解决了临界时间点上突发流量无法控制的问题,但是却因为要存储每个小的时间窗口内的计数,所以空间复杂度有所增加。 + +虽然滑动窗口算法解决了窗口边界的大流量的问题,但是它和固定窗口算法一样,还是无法限制短时间之内的集中流量,也就是说无法控制流量让它们更加平滑。因此,在实际的项目中,我很少使用基于时间窗口的限流算法,而是使用其他限流的算法:一种算法叫做漏桶算法,一种叫做令牌筒算法。 + +漏桶算法与令牌筒算法 + +漏桶算法的原理很简单,它就像在流量产生端和接收端之间增加一个漏桶,流量会进入和暂存到漏桶里面,而漏桶的出口处会按照一个固定的速率将流量漏出到接收端(也就是服务接口)。 + +如果流入的流量在某一段时间内大增,超过了漏桶的承受极限,那么多余的流量就会触发限流策略,被拒绝服务。 + +经过了漏桶算法之后,随机产生的流量就会被整形成为比较平滑的流量到达服务端,从而避免了突发的大流量对于服务接口的影响。这很像倚天屠龙记里,九阳真经的口诀:他强由他强,清风拂山岗,他横由他横,明月照大江 。 也就是说,无论流入的流量有多么强横,多么不规则,经过漏桶处理之后,流出的流量都会变得比较平滑。 + +而在实现时,我们一般会使用消息队列作为漏桶的实现,流量首先被放入到消息队列中排队,由固定的几个队列处理程序来消费流量,如果消息队列中的流量溢出,那么后续的流量就会被拒绝。这个算法的思想是不是与消息队列削峰填谷的作用相似呢? + + + +另一种令牌桶算法的基本算法是这样的: + +如果我们需要在一秒内限制访问次数为 N 次,那么就每隔 1/N 的时间,往桶内放入一个令牌; + +在处理请求之前先要从桶中获得一个令牌,如果桶中已经没有了令牌,那么就需要等待新的令牌或者直接拒绝服务; + +桶中的令牌总数也要有一个限制,如果超过了限制就不能向桶中再增加新的令牌了。这样可以限制令牌的总数,一定程度上可以避免瞬时流量高峰的问题。 + + + +如果要从这两种算法中做选择,我更倾向于使用令牌桶算法,原因是漏桶算法在面对突发流量的时候,采用的解决方式是缓存在漏桶中, 这样流量的响应时间就会增长,这就与互联网业务低延迟的要求不符;而令牌桶算法可以在令牌中暂存一定量的令牌,能够应对一定的突发流量,所以一般我会使用令牌桶算法来实现限流方案,而 Guava 中的限流方案就是使用令牌桶算法来实现的。 + +你可以看到,使用令牌桶算法就需要存储令牌的数量,如果是单机上实现限流的话,可以在进程中使用一个变量来存储;但是如果在分布式环境下,不同的机器之间无法共享进程中的变量,我们就一般会使用 Redis 来存储这个令牌的数量。这样的话,每次请求的时候都需要请求一次 Redis 来获取一个令牌,会增加几毫秒的延迟,性能上会有一些损耗。因此,一个折中的思路是: 我们可以在每次取令牌的时候,不再只获取一个令牌,而是获取一批令牌,这样可以尽量减少请求 Redis 的次数。 + +课程小结 + +以上就是本节课的全部内容了。本节课我带你了解了限流的定义和作用,以及常见的几种限流算法,你需要了解的重点是: + +限流是一种常见的服务保护策略,你可以在整体服务、单个服务、单个接口、单个 IP 或者单个用户等多个维度进行流量的控制; + +基于时间窗口维度的算法有固定窗口算法和滑动窗口算法,两者虽然能一定程度上实现限流的目的,但是都无法让流量变得更平滑; + +令牌桶算法和漏桶算法则能够塑形流量,让流量更加平滑,但是令牌桶算法能够应对一定的突发流量,所以在实际项目中应用更多。 + +限流策略是微服务治理中的标配策略,只是你很难在实际中确认限流的阈值是多少,设置的小了容易误伤正常的请求,设置的大了则达不到限流的目的。所以,一般在实际项目中,我们会把阈值放置在配置中心中方便动态调整;同时,我们可以通过定期地压力测试得到整体系统以及每个微服务的实际承载能力,然后再依据这个压测出来的值设置合适的阈值。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/36面试现场第三期:你要如何准备一场技术面试呢?.md b/专栏/高并发系统设计40问/36面试现场第三期:你要如何准备一场技术面试呢?.md new file mode 100644 index 0000000..f5f3833 --- /dev/null +++ b/专栏/高并发系统设计40问/36面试现场第三期:你要如何准备一场技术面试呢?.md @@ -0,0 +1,19 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 36 面试现场第三期:你要如何准备一场技术面试呢? + + + + + + + + + + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/37计数系统设计(一):面对海量数据的计数器要如何做?.md b/专栏/高并发系统设计40问/37计数系统设计(一):面对海量数据的计数器要如何做?.md new file mode 100644 index 0000000..a38b506 --- /dev/null +++ b/专栏/高并发系统设计40问/37计数系统设计(一):面对海量数据的计数器要如何做?.md @@ -0,0 +1,151 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 37 计数系统设计(一):面对海量数据的计数器要如何做? + 你好,我是唐扬。 + +从今天开始,我们正式进入最后的实战篇。在之前的课程中,我分别从数据库、缓存、消息队列和分布式服务化的角度,带你了解了面对高并发的时候要如何保证系统的高性能、高可用和高可扩展。课程中虽然有大量的例子辅助你理解理论知识,但是没有一个完整的实例帮你把知识串起来。 + +所以,为了将我们提及的知识落地,在实战篇中,我会以微博为背景,用两个完整的案例带你从实践的角度应对高并发大流量的冲击,期望给你一个更加具体的感性认识,为你在实现类似系统的时候提供一些思路。今天我要讲的第一个案例是如何设计一个支持高并发大存储量的计数系统。 + +来看这样一个场景: 在地铁上,你也许会经常刷微博、点赞热搜,如果有抽奖活动,再转发一波,而这些与微博息息相关的数据,其实就是微博场景下的计数数据,细说起来,它主要有几类: + +微博的评论数、点赞数、转发数、浏览数、表态数等等; + +用户的粉丝数、关注数、发布微博数、私信数等等。 + +微博维度的计数代表了这条微博受欢迎的程度,用户维度的数据(尤其是粉丝数),代表了这个用户的影响力,因此大家会普遍看重这些计数信息。并且在很多场景下,我们都需要查询计数数据(比如首页信息流页面、个人主页面),计数数据访问量巨大,所以需要设计计数系统维护它。 + +但在设计计数系统时,不少人会出现性能不高、存储成本很大的问题,比如,把计数与微博数据存储在一起,这样每次更新计数的时候都需要锁住这一行记录,降低了写入的并发。在我看来,之所以出现这些问题,还是因为你对计数系统的设计和优化不甚了解,所以要想解决痛点,你有必要形成完备的设计方案。 + +计数在业务上的特点 + +首先,你要了解这些计数在业务上的特点是什么,这样才能针对特点设计出合理的方案。在我看来,主要有这样几个特点。 + +数据量巨大。据我所知,微博系统中微博条目的数量早已经超过了千亿级别,仅仅计算微博的转发、评论、点赞、浏览等核心计数,其数据量级就已经在几千亿的级别。更何况微博条目的数量还在不断高速地增长,并且随着微博业务越来越复杂,微博维度的计数种类也可能会持续扩展(比如说增加了表态数),因此,仅仅是微博维度上的计数量级就已经过了万亿级别。除此之外,微博的用户量级已经超过了 10 亿,用户维度的计数量级相比微博维度来说虽然相差很大,但是也达到了百亿级别。那么如何存储这些过万亿级别的数字,对我们来说就是一大挑战。 + +访问量大,对于性能的要求高。微博的日活用户超过 2 亿,月活用户接近 5 亿,核心服务(比如首页信息流)访问量级到达每秒几十万次,计数系统的访问量级也超过了每秒百万级别,而且在性能方面,它要求要毫秒级别返回结果。 + +最后,对于可用性、数字的准确性要求高。一般来讲,用户对于计数数字是非常敏感的,比如你直播了好几个月,才涨了 1000 个粉,突然有一天粉丝数少了几百个,那么你是不是会琢磨哪里出现问题,或者打电话投诉直播平台? + +那么,面临着高并发、大数据量、数据强一致要求的挑战,微博的计数系统是如何设计和演进的呢?你又能从中借鉴什么经验呢? + +支撑高并发的计数系统要如何设计 + +刚开始设计计数系统的时候,微博的流量还没有现在这么夸张,我们本着 KISS(Keep It Simple and Stupid)原则,尽量将系统设计的简单易维护,所以,我们使用 MySQL 存储计数的数据,因为它是我们最熟悉的,团队在运维上经验也会比较丰富。举个具体的例子。 + +假如要存储微博维度(微博的计数,转发数、赞数等等)的数据,你可以这么设计表结构:以微博 ID 为主键,转发数、评论数、点赞数和浏览数分别为单独一列,这样在获取计数时用一个 SQL 语句就搞定了。 + +select repost_count, comment_count, praise_count, view_count from t_weibo_count where weibo_id = ? + + +在数据量级和访问量级都不大的情况下,这种方式最简单,所以如果你的系统量级不大,你可以直接采用这种方式来实现。 + +后来,随着微博的不断壮大,之前的计数系统面临了很多的问题和挑战。 + +比如微博用户量和发布的微博量增加迅猛,计数存储数据量级也飞速增长,而 MySQL 数据库单表的存储量级达到几千万的时候,性能上就会有损耗。所以我们考虑使用分库分表的方式分散数据量,提升读取计数的性能。 + +我们用“weibo_id”作为分区键,在选择分库分表的方式时,考虑了下面两种: + +一种方式是选择一种哈希算法对 weibo_id 计算哈希值,然后依据这个哈希值计算出需要存储到哪一个库哪一张表中,具体的方式你可以回顾一下第 9 讲数据库分库分表的内容; + +另一种方式是按照 weibo_id 生成的时间来做分库分表,我们在第 10 讲谈到发号器的时候曾经提到,ID 的生成最好带有业务意义的字段,比如生成 ID 的时间戳。所以在分库分表的时候,可以先依据发号器的算法反解出时间戳,然后按照时间戳来做分库分表,比如,一天一张表或者一个月一张表等等。 + +因为越是最近发布的微博,计数数据的访问量就越大,所以虽然我考虑了两种方案,但是按照时间来分库分表会造成数据访问的不均匀,最后用了哈希的方式来做分库分表。 + + + +与此同时,计数的访问量级也有质的飞越。在微博最初的版本中,首页信息流里面是不展示计数数据的,那么使用 MySQL 也可以承受当时读取计数的访问量。但是后来在首页信息流中也要展示转发、评论和点赞等计数数据了。而信息流的访问量巨大,仅仅靠数据库已经完全不能承担如此高的并发量了。于是我们考虑使用 Redis 来加速读请求,通过部署多个从节点来提升可用性和性能,并且通过 Hash 的方式对数据做分片,也基本上可以保证计数的读取性能。然而,这种数据库 + 缓存的方式有一个弊端:无法保证数据的一致性,比如,如果数据库写入成功而缓存更新失败,就会导致数据的不一致,影响计数的准确性。所以,我们完全抛弃了 MySQL,全面使用 Redis 来作为计数的存储组件。 + + + +除了考虑计数的读取性能之外,由于热门微博的计数变化频率相当快,也需要考虑如何提升计数的写入性能。比如,每次在转发一条微博的时候,都需要增加这条微博的转发数,那么如果明星发布结婚、离婚的微博,瞬时就可能会产生几万甚至几十万的转发。如果是你的话,要如何降低写压力呢? + +你可能已经想到用消息队列来削峰填谷了,也就是说,我们在转发微博的时候向消息队列写入一条消息,然后在消息处理程序中给这条微博的转发计数加 1。这里需要注意的一点, 我们可以通过批量处理消息的方式进一步减小 Redis 的写压力,比如像下面这样连续更改三次转发数(我用 SQL 来表示来方便你理解): + +UPDATE t_weibo_count SET repost_count = repost_count + 1 WHERE weibo_id = 1; + +UPDATE t_weibo_count SET repost_count = repost_count + 1 WHERE weibo_id = 1; + +UPDATE t_weibo_count SET repost_count = repost_count +1 WHERE weibo_id = 1; + + +这个时候,你可以把它们合并成一次更新: + +UPDATE t_weibo_count SET repost_count = repost_count + 3 WHERE weibo_id = 1; + + +如何降低计数系统的存储成本 + +讲到这里,我其实已经告诉你一个支撑高并发查询请求的计数系统是如何实现的了。但是在微博的场景下,计数的量级是万亿的级别,这也给我们提了更高的要求,就是如何在有限的存储成本下实现对于全量计数数据的存取。 + +你知道,Redis 是使用内存来存储信息,相比于使用磁盘存储数据的 MySQL 来说,存储的成本不可同日而语,比如一台服务器磁盘可以挂载到 2 个 T,但是内存可能只有 128G,这样磁盘的存储空间就是内存的 16 倍。而 Redis 基于通用性的考虑,对于内存的使用比较粗放,存在大量的指针以及额外数据结构的开销,如果要存储一个 KV 类型的计数信息,Key 是 8 字节 Long 类型的 weibo_id,Value 是 4 字节 int 类型的转发数,存储在 Redis 中之后会占用超过 70 个字节的空间,空间的浪费是巨大的。如果你面临这个问题,要如何优化呢? + +我建议你先对原生 Redis 做一些改造,采用新的数据结构和数据类型来存储计数数据。我在改造时,主要涉及了两点: + +一是原生的 Redis 在存储 Key 时是按照字符串类型来存储的,比如一个 8 字节的 Long 类型的数据,需要 8(sdshdr 数据结构长度)+ 19(8 字节数字的长度)+1(’\0’)=28 个字节,如果我们使用 Long 类型来存储就只需要 8 个字节,会节省 20 个字节的空间; + +二是去除了原生 Redis 中多余的指针,如果要存储一个 KV 信息就只需要 8(weibo_id)+4(转发数)=12 个字节,相比之前有很大的改进。 + +同时,我们也会使用一个大的数组来存储计数信息,存储的位置是基于 weibo_id 的哈希值来计算出来的,具体的算法像下面展示的这样: + +插入时: + +h1 = hash1(weibo_id) // 根据微博 ID 计算 Hash + +h2 = hash2(weibo_id) // 根据微博 ID 计算另一个 Hash,用以解决前一个 Hash 算法带来的冲突 + +for s in 0,1000 + + pos = (h1 + h2*s) % tsize // 如果发生冲突,就多算几次 Hash2 + + if(isempty(pos) || isdelete(pos)) + + t[ pos ] = item // 写入数组 + +查询时: + +for s in 0,1000 + + pos = (h1 + h2*s) % tsize // 依照插入数据时候的逻辑,计算出存储在数组中的位置 + + if(!isempty(pos) && t[pos]==weibo_id) + + return t[pos] + +return 0 + +删除时: + +insert(FFFF) // 插入一个特殊的标 + + +在对原生的 Redis 做了改造之后,你还需要进一步考虑如何节省内存的使用。比如,微博的计数有转发数、评论数、浏览数、点赞数等等,如果每一个计数都需要存储 weibo_id,那么总共就需要 8(weibo_id)*4(4 个微博 ID)+4(转发数) + 4(评论数) + 4(点赞数) + 4(浏览数)= 48 字节。但是我们可以把相同微博 ID 的计数存储在一起,这样就只需要记录一个微博 ID,省掉了多余的三个微博 ID 的存储开销,存储空间就进一步减少了。 + +不过,即使经过上面的优化,由于计数的量级实在是太过巨大,并且还在以极快的速度增长,所以如果我们以全内存的方式来存储计数信息,就需要使用非常多的机器来支撑。 + +然而微博计数的数据具有明显的热点属性:越是最近的微博越是会被访问到,时间上久远的微博被访问的几率很小。所以为了尽量减少服务器的使用,我们考虑给计数服务增加 SSD 磁盘,然后将时间上比较久远的数据 dump 到磁盘上,内存中只保留最近的数据。当我们要读取冷数据的时候,使用单独的 I/O 线程异步地将冷数据从 SSD 磁盘中加载到一块儿单独的 Cold Cache 中。 + + + +在经过了上面这些优化之后,我们的计数服务就可以支撑高并发大数据量的考验,无论是在性能上、成本上和可用性上都能够达到业务的需求了。 + +总的来说,我用微博设计计数系统的例子,并不是仅仅告诉你计数系统是如何做的,而是想告诉你在做系统设计的时候需要了解自己系统目前的痛点是什么,然后再针对痛点来做细致的优化。比如,微博计数系统的痛点是存储的成本,那么我们后期做的事情很多都是围绕着如何使用有限的服务器存储全量的计数数据,即使是对开源组件(Redis)做深度的定制会带来很大的运维成本,也只能被认为是为了实现计数系统而必须要做的权衡。 + +课程小结 + +以上就是本节课的全部内容了。本节课我以微博为例带你了解了如何实现一套存储千亿甚至万亿数据的高并发计数系统,这里你需要了解的重点如下: + +数据库 + 缓存的方案是计数系统的初级阶段,完全可以支撑中小访问量和存储量的存储服务。如果你的项目还处在初级阶段,量级还不是很大,那么你一开始可以考虑使用这种方案。 + +通过对原生 Redis 组件的改造,我们可以极大地减小存储数据的内存开销。 + +使用 SSD+ 内存的方案可以最终解决存储计数数据的成本问题。这个方式适用于冷热数据明显的场景,你在使用时需要考虑如何将内存中的数据做换入换出。 + +其实,随着互联网技术的发展,已经有越来越多的业务场景需要使用上百 G 甚至几百 G 的内存资源来存储业务数据,但是对于性能或者延迟并没有那么高的要求,如果全部使用内存来存储无疑会带来极大的成本浪费。因此,在业界有一些开源组件也在支持使用 SSD 替代内存存储冷数据,比如Pika,SSDB,这两个开源组件,我建议你可以了解一下它们的实现原理,这样可以在项目中需要的时候使用。而且,在微博的计数服务中也采用了类似的思路,如果你的业务中也需要使用大量的内存,存储热点比较明显的数据,不妨也可以考虑使用类似的思路。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/38计数系统设计(二):50万QPS下如何设计未读数系统?.md b/专栏/高并发系统设计40问/38计数系统设计(二):50万QPS下如何设计未读数系统?.md new file mode 100644 index 0000000..f240a28 --- /dev/null +++ b/专栏/高并发系统设计40问/38计数系统设计(二):50万QPS下如何设计未读数系统?.md @@ -0,0 +1,124 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 38 计数系统设计(二):50万QPS下如何设计未读数系统? + 你好,我是唐扬。 + +在上一节课中我带你了解了如何设计一套支撑高并发访问和存储大数据量的通用计数系统,我们通过缓存技术、消息队列技术以及对于 Redis 的深度改造,就能够支撑万亿级计数数据存储以及每秒百万级别读取请求了。然而有一类特殊的计数并不能完全使用我们提到的方案,那就是未读数。 + +未读数也是系统中一个常见的模块,以微博系统为例,你可看到有多个未读计数的场景,比如: + +当有人 @你、评论你、给你的博文点赞或者给你发送私信的时候,你会收到相应的未读提醒; + +在早期的微博版本中有系统通知的功能,也就是系统会给全部用户发送消息,通知用户有新的版本或者有一些好玩的运营活动,如果用户没有看,系统就会给他展示有多少条未读的提醒。 + +我们在浏览信息流的时候,如果长时间没有刷新页面,那么信息流上方就会提示你在这段时间有多少条信息没有看。 + +那当你遇到第一个需求时,要如何记录未读数呢?其实,这个需求可以用上节课提到的通用计数系统来实现,因为二者的场景非常相似。 + +你可以在计数系统中增加一块儿内存区域,以用户 ID 为 Key 存储多个未读数,当有人 @ 你时,增加你的未读 @的计数;当有人评论你时,增加你的未读评论的计数,以此类推。当你点击了未读数字进入通知页面,查看 @ 你或者评论你的消息时,重置这些未读计数为零。相信通过上一节课的学习,你已经非常熟悉这一类系统的设计了,所以我不再赘述。 + +那么系统通知的未读数是如何实现的呢?我们能用通用计数系统实现吗?答案是不能的,因为会出现一些问题。 + +系统通知的未读数要如何设计 + +来看具体的例子。假如你的系统中只有 A、B、C 三个用户,那么你可以在通用计数系统中增加一块儿内存区域,并且以用户 ID 为 Key 来存储这三个用户的未读通知数据,当系统发送一个新的通知时,我们会循环给每一个用户的未读数加 1,这个处理逻辑的伪代码就像下面这样: + +List userIds = getAllUserIds(); + +for(Long id : userIds) { + + incrUnreadCount(id); + +} + + +这样看来,似乎简单可行,但随着系统中的用户越来越多,这个方案存在两个致命的问题。 + +首先,获取全量用户就是一个比较耗时的操作,相当于对用户库做一次全表的扫描,这不仅会对数据库造成很大的压力,而且查询全量用户数据的响应时间是很长的,对于在线业务来说是难以接受的。如果你的用户库已经做了分库分表,那么就要扫描所有的库表,响应时间就更长了。不过有一个折中的方法, 那就是在发送系统通知之前,先从线下的数据仓库中获取全量的用户 ID,并且存储在一个本地的文件中,然后再轮询所有的用户 ID,给这些用户增加未读计数。 + +这似乎是一个可行的技术方案,然而它给所有人增加未读计数,会消耗非常长的时间。你计算一下,假如你的系统中有一个亿的用户,给一个用户增加未读数需要消耗 1ms,那么给所有人都增加未读计数就需要 100000000 * 1 /1000 = 100000 秒,也就是超过一天的时间;即使你启动 100 个线程并发的设置,也需要十几分钟的时间才能完成,而用户很难接受这么长的延迟时间。 + +另外,使用这种方式需要给系统中的每一个用户都记一个未读数的值,而在系统中,活跃用户只是很少的一部分,大部分的用户是不活跃的,甚至从来没有打开过系统通知,为这些用户记录未读数显然是一种浪费。 + +通过上面的内容,你可以知道为什么我们不能用通用计数系统实现系统通知未读数了吧?那正确的做法是什么呢? + +要知道,系统通知实际上是存储在一个大的列表中的,这个列表对所有用户共享,也就是所有人看到的都是同一份系统通知的数据。不过不同的人最近看到的消息不同,所以每个人会有不同的未读数。因此,你可以记录一下在这个列表中每个人看过最后一条消息的 ID,然后统计这个 ID 之后有多少条消息,这就是未读数了。 + + + +这个方案在实现时有这样几个关键点: + +用户访问系统通知页面需要设置未读数为 0,我们需要将用户最近看过的通知 ID 设置为最新的一条系统通知 ID; + +如果最近看过的通知 ID 为空,则认为是一个新的用户,返回未读数为 0; + +对于非活跃用户,比如最近一个月都没有登录和使用过系统的用户,可以把用户最近看过的通知 ID 清空,节省内存空间。 + +这是一种比较通用的方案,即节省内存,又能尽量减少获取未读数的延迟。 这个方案适用的另一个业务场景是全量用户打点的场景,比如像下面这张微博截图中的红点。 + + + +这个红点和系统通知类似,也是一种通知全量用户的手段,如果逐个通知用户,延迟也是无法接受的。因此你可以采用和系统通知类似的方案。 + +首先,我们为每一个用户存储一个时间戳,代表最近点过这个红点的时间,用户点了红点,就把这个时间戳设置为当前时间;然后,我们也记录一个全局的时间戳,这个时间戳标识最新的一次打点时间,如果你在后台操作给全体用户打点,就更新这个时间戳为当前时间。而我们在判断是否需要展示红点时,只需要判断用户的时间戳和全局时间戳的大小,如果用户时间戳小于全局时间戳,代表在用户最后一次点击红点之后又有新的红点推送,那么就要展示红点,反之,就不展示红点了。 + + + +这两个场景的共性是全部用户共享一份有限的存储数据,每个人只记录自己在这份存储中的偏移量,就可以得到未读数了。 + +你可以看到,系统消息未读的实现方案不是很复杂,它通过设计避免了操作全量数据未读数,如果你的系统中有这种打红点的需求,那我建议你可以结合实际工作灵活使用上述方案。 + +最后一个需求关注的是微博信息流的未读数,在现在的社交系统中,关注关系已经成为标配的功能,而基于关注关系的信息流也是一种非常重要的信息聚合方式,因此,如何设计信息流的未读数系统就成了你必须面对的一个问题。 + +如何为信息流的未读数设计方案 + +信息流的未读数之所以复杂主要有这样几点原因。 + +首先,微博的信息流是基于关注关系的,未读数也是基于关注关系的,就是说,你关注的人发布了新的微博,那么你作为粉丝未读数就要增加 1。如果微博用户都是像我这样只有几百粉丝的“小透明”就简单了,你发微博的时候系统给你粉丝的未读数增加 1 不是什么难事儿。但是对于一些动辄几千万甚至上亿粉丝的微博大 V 就麻烦了,增加未读数可能需要几个小时。假设你是杨幂的粉丝,想了解她实时发布的博文,那么如果当她发布博文几个小时之后,你才收到提醒,这显然是不能接受的。所以未读数的延迟是你在设计方案时首先要考虑的内容。 + +其次,信息流未读数请求量极大、并发极高,这是因为接口是客户端轮询请求的,不是用户触发的。也就是说,用户即使打开微博客户端什么都不做,这个接口也会被请求到。在几年前,请求未读数接口的量级就已经接近每秒 50 万次,这几年随着微博量级的增长,请求量也变得更高。而作为微博的非核心接口,我们不太可能使用大量的机器来抗未读数请求,因此,如何使用有限的资源来支撑如此高的流量是这个方案的难点。 + +最后,它不像系统通知那样有共享的存储,因为每个人关注的人不同,信息流的列表也就不同,所以也就没办法采用系统通知未读数的方案。 + +那要如何设计能够承接每秒几十万次请求的信息流未读数系统呢?你可以这样做: + +首先,在通用计数器中记录每一个用户发布的博文数; + +然后在 Redis 或者 Memcached 中记录一个人所有关注人的博文数快照,当用户点击未读消息重置未读数为 0 时,将他关注所有人的博文数刷新到快照中; + +这样,他关注所有人的博文总数减去快照中的博文总数就是他的信息流未读数。 + + + +假如用户 A,像上图这样关注了用户 B、C、D,其中 B 发布的博文数是 10,C 发布的博文数是 8,D 发布的博文数是 14,而在用户 A 最近一次查看未读消息时,记录在快照中的这三个用户的博文数分别是 6、7、12,因此用户 A 的未读数就是(10-6)+(8-7)+(14-12)=7。 + +这个方案设计简单,并且是全内存操作,性能足够好,能够支撑比较高的并发,事实上微博团队仅仅用 16 台普通的服务器就支撑了每秒接近 50 万次的请求,这就足以证明这个方案的性能有多出色,因此,它完全能够满足信息流未读数的需求。 + +当然了这个方案也有一些缺陷,比如说快照中需要存储关注关系,如果关注关系变更的时候更新不及时,那么就会造成未读数不准确;快照采用的是全缓存存储,如果缓存满了就会剔除一些数据,那么被剔除用户的未读数就变为 0 了。但是好在用户对于未读数的准确度要求不高(未读 10 条还是 11 条,其实用户有时候看不出来),因此,这些缺陷也是可以接受的。 + +通过分享未读数系统设计这个案例,我想给你一些建议: + +缓存是提升系统性能和抵抗大并发量的神器,像是微博信息流未读数这么大的量级我们仅仅使用十几台服务器就可以支撑,这全都是缓存的功劳; + +要围绕系统设计的关键困难点想解决办法,就像我们解决系统通知未读数的延迟问题一样; + +合理分析业务场景,明确哪些是可以权衡的,哪些是不行的,会对你的系统设计增益良多,比如对于长久不登录用户,我们就会记录未读数为 0,通过这样的权衡,可以极大地减少内存的占用,减少成本。 + +课程小结 + +以上就是本节课的全部内容了,本节课我带你了解了未读数系统的设计,这里你需要了解的重点是: + +评论未读、@未读、赞未读等一对一关系的未读数可以使用上节课讲到的通用计数方案来解决; + +在系统通知未读、全量用户打点等存在有限的共享存储的场景下,可以通过记录用户上次操作的时间或者偏移量,来实现未读方案; + +最后,信息流未读方案最为复杂,采用的是记录用户博文数快照的方式。 + +这里你可以看到,这三类需求虽然都和未读数有关,但是需求场景不同、对于量级的要求不同,设计出来的方案也就不同。因此,就像我刚刚提到的样子,你在做方案设计的时候,要分析需求的场景,比如说数据的量级是怎样的,请求的量级是怎样的,有没有一些可以利用的特点(比如系统通知未读场景下的有限共享存储、信息流未读场景下关注人数是有限的等等),然后再制定针对性的方案,切忌盲目使用之前的经验套用不同的场景,否则就可能造成性能的下降,甚至危害系统的稳定性。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/39信息流设计(一):通用信息流系统的推模式要如何做?.md b/专栏/高并发系统设计40问/39信息流设计(一):通用信息流系统的推模式要如何做?.md new file mode 100644 index 0000000..31f848f --- /dev/null +++ b/专栏/高并发系统设计40问/39信息流设计(一):通用信息流系统的推模式要如何做?.md @@ -0,0 +1,97 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 39 信息流设计(一):通用信息流系统的推模式要如何做? + 你好,我是唐扬。 + +前两节课中,我带你探究了如何设计和实现互联网系统中一个常见模块——计数系统。它的业务逻辑其实非常简单,基本上最多只有三个接口,获取计数、增加计数和重置计数。所以我们在考虑方案的时候考察点也相对较少,基本上使用缓存就可以实现一个兼顾性能、可用性和鲁棒性的方案了。然而大型业务系统的逻辑会非常复杂,在方案设计时通常需要灵活运用多种技术,才能共同承担高并发大流量的冲击。那么接下来,我将带你了解如何设计社区系统中最为复杂、并发量也最高的信息流系统。这样,你可以从中体会怎么应用之前学习的组件了。 + +最早的信息流系统起源于微博,我们知道,微博是基于关注关系来实现内容分发的,也就是说,如果用户 A 关注了用户 B,那么用户 A 就需要在自己的信息流中,实时地看到用户 B 发布的最新内容,这是微博系统的基本逻辑,也是它能够让信息快速流通、快速传播的关键。 由于微博的信息流一般是按照时间倒序排列的,所以我们通常把信息流系统称为 TimeLine(时间线)。那么当我们设计一套信息流系统时需要考虑哪些点呢? + +设计信息流系统的关注点有哪些 + +首先,我们需要关注延迟数据,也就是说,你关注的人发了微博信息之后,信息需要在短时间之内出现在你的信息流中。 + +其次,我们需要考虑如何支撑高并发的访问。信息流是微博的主体模块,是用户进入到微博之后最先看到的模块,因此它的并发请求量是最高的,可以达到每秒几十万次请求。 + +最后,信息流拉取性能直接影响用户的使用体验。微博信息流系统中需要聚合的数据非常多,你打开客户端看一看,想一想其中需要聚合哪些数据?主要是微博的数据,用户的数据,除此之外,还需要查询微博是否被赞、评论点赞转发的计数、是否被关注拉黑等等。聚合这么多的数据就需要查询多次缓存、数据库、计数器,而在每秒几十万次的请求下,如何保证在 100ms 之内完成这些查询操作,展示微博的信息流呢?这是微博信息流系统最复杂之处,也是技术上最大的挑战。 + +那么我们怎么设计一套支撑高并发大流量的信息流系统呢?一般来说,会有两个思路:一个是基于推模式,另一个是基于拉模式。 + +如何基于推模式实现信息流系统 + +什么是推模式呢?推模式是指用户发送一条微博后,主动将这条微博推送给他的粉丝,从而实现微博的分发,也能以此实现微博信息流的聚合。 + +假设微博系统是一个邮箱系统,那么用户发送的微博可以认为是进入到一个发件箱,用户的信息流可以认为是这个人的收件箱。推模式的做法是在用户发布一条微博时,除了往自己的发件箱里写入一条微博,同时也会给他的粉丝收件箱里写入一条微博。 + +假如用户 A 有三个粉丝 B、C、D,如果用 SQL 表示 A 发布一条微博时系统做的事情,那么就像下面展示的这个样子: + +insert into outbox(userId, feedId, create_time) values("A", $feedId, $current_time); // 写入 A 的发件箱 + +insert into inbox(userId, feedId, create_time) values("B", $feedId, $current_time); // 写入 B 的收件箱 + +insert into inbox(userId, feedId, create_time) values("C", $feedId, $current_time); // 写入 C 的收件箱 + +insert into inbox(userId, feedId, create_time) values("D", $feedId, $current_time); // 写入 D 的收件箱 + + +当我们要查询 B 的信息流时,只需要执行下面这条 SQL 就可以了: + +select feedId from inbox where userId = "B"; + + +如果你想要提升读取信息流的性能,可以把收件箱的数据存储在缓存里面,每次获取信息流的时候直接从缓存中读取就好了。 + +推模式存在的问题和解决思路 + +你看,按照这个思路就可以实现一套完整的微博信息流系统,也比较符合我们的常识。但是,这个方案会存在一些问题。 + +首先,就是消息延迟。在讲系统通知未读数的时候,我们曾经提到过,不能采用遍历全量用户给他们加未读数的方式,原因是遍历一次全量用户的延迟很高,而推模式也有同样的问题。对明星来说,他们的粉丝数庞大,如果在发微博的同时还要将微博写入到上千万人的收件箱中,那么发微博的响应时间会非常慢,用户根本没办法接受。因此,我们一般会使用消息队列来消除写入的峰值,但即使这样,由于写入收件箱的消息实在太多,你还是有可能在几个小时之后才能够看到明星发布的内容,这会非常影响用户的使用体验。 + + + +在推模式下,你需要关注的是微博的写入性能,因为用户每发一条微博,都会产生多次的数据库写入。为了尽量减少微博写入的延迟,我们可以从两方面来保障。 + +一方面,在消息处理上,你可以启动多个线程并行地处理微博写入的消息。 + +另一方面,由于消息流在展示时可以使用缓存来提升读取性能,所以我们应该尽量保证数据写入数据库的性能,必要时可以采用写入性能更好的数据库存储引擎。 + +比如,我在网易微博的时候就是采用推模式来实现微博信息流的。当时为了提升数据库的插入性能,我们采用了 TokuDB 作为 MySQL 的存储引擎,这个引擎架构的核心是一个名为分形树的索引结构(Fractal Tree Indexes)。我们知道数据库在写入的时候会产生对磁盘的随机写入,造成磁盘寻道,影响数据写入的性能;而分形树结构和我们在11 讲中提到的 LSM 一样,可以将数据的随机写入转换成顺序写入,提升写入的性能。另外,TokuDB 相比于 InnoDB 来说,数据压缩的性能更高,经过官方的测试,TokuDB 可以将存储在 InnoDB 中的 4TB 的数据压缩到 200G,这对于写入数据量很大的业务来说也是一大福音。然而,相比于 InnoDB 来说,TokuDB 的删除和查询性能都要差一些,不过可以使用缓存加速查询性能,而微博的删除频率不高,因此这对于推模式下的消息流来说影响有限。 + +其次,存储成本很高。在这个方案中我们一般会这么来设计表结构: + +先设计一张 Feed 表,这个表主要存储微博的基本信息,包括微博 ID、创建人的 ID、创建时间、微博内容、微博状态(删除还是正常)等等,它使用微博 ID 做哈希分库分表; + +另外一张表是用户的发件箱和收件箱表,也叫做 TimeLine 表(时间线表),主要有三个字段,用户 ID、微博 ID 和创建时间。它使用用户的 ID 做哈希分库分表。 + + + +由于推模式需要给每一个用户都维护一份收件箱的数据,所以数据的存储量极大,你可以想一想,谢娜的粉丝目前已经超过 1.2 亿,那么如果采用推模式的话,谢娜每发送一条微博就会产生超过 1.2 亿条的数据,多么可怕!我们的解决思路是: 除了选择压缩率更高的存储引擎之外,还可以定期地清理数据,因为微博的数据有比较明显的实效性,用户更加关注最近几天发布的数据,通常不会翻阅很久之前的微博,所以你可以定期地清理用户的收件箱,比如只保留最近 1 个月的数据就可以了。 + +除此之外,推模式下我们还通常会遇到扩展性的问题。在微博中有一个分组的功能,它的作用是你可以将关注的人分门别类,比如你可以把关注的人分为“明星”“技术”“旅游”等类别,然后把杨幂放入“明星”分类里,将 InfoQ 放在“技术”类别里。那么引入了分组之后,会对推模式有什么样的影响呢? 首先是一个用户不止有一个收件箱,比如我有一个全局收件箱,还会针对每一个分组再分别创建一个收件箱,而一条微博在发布之后也需要被复制到更多的收件箱中了。 + +如果杨幂发了一条微博,那么不仅需要插入到我的收件箱中,还需要插入到我的“明星”收件箱中,这样不仅增加了消息分发的压力,同时由于每一个收件箱都需要单独存储,所以存储成本也就更高。 + +最后,在处理取消关注和删除微博的逻辑时会更加复杂。比如当杨幂删除了一条微博,那么如果要删除她所有粉丝收件箱中的这条微博,会带来额外的分发压力,我们还是尽量不要这么做。 + +而如果你将一个人取消关注,那么需要从你的收件箱中删除这个人的所有微博,假设他发了非常多的微博,那么即使你之后很久不登录,也需要从你的收件箱中做大量的删除操作,有些得不偿失。所以你可以采用的策略是: 在读取自己信息流的时候,判断每一条微博是否被删除以及你是否还关注这条微博的作者,如果没有的话,就不展示这条微博的内容了。使用了这个策略之后,就可以尽量减少对于数据库多余的写操作了。 + +那么说了这么多,推模式究竟适合什么样的业务的场景呢? 在我看来,它比较适合于一个用户的粉丝数比较有限的场景,比如说微信朋友圈,你可以理解为我在微信中增加一个好友是关注了他也被他关注,所以好友的上限也就是粉丝的上限(朋友圈应该是 5000)。有限的粉丝数可以保证消息能够尽量快地被推送给所有的粉丝,增加的存储成本也比较有限。如果你的业务中粉丝数是有限制的,那么在实现以关注关系为基础的信息流时,也可以采用推模式来实现。 + +课程小结 + +以上就是本节课的全部内容了。本节课我带你了解以推模式实现信息流的方案以及这个模式会存在哪些问题和解决思路,这里你需要了解的重点是: + +推模式就是在用户发送微博时,主动将微博写入到他的粉丝的收件箱中; + +推送信息是否延迟、存储的成本、方案的可扩展性以及针对取消关注和微博删除的特殊处理是推模式的主要问题; + +推模式比较适合粉丝数有限的场景。 + +你可以看到,其实推模式并不适合微博这种动辄就有上千万粉丝的业务,因为这种业务特性带来的超高的推送消息延迟以及存储成本是难以接受的,因此,我们要么会使用基于拉模式的实现,要么会使用基于推拉结合模式的实现。那么这两种方案是如何实现的呢?他们在实现中会存在哪些坑呢?又要如何解决呢?我将在下节课中带你着重了解。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/40信息流设计(二):通用信息流系统的拉模式要如何做?.md b/专栏/高并发系统设计40问/40信息流设计(二):通用信息流系统的拉模式要如何做?.md new file mode 100644 index 0000000..b91fa7f --- /dev/null +++ b/专栏/高并发系统设计40问/40信息流设计(二):通用信息流系统的拉模式要如何做?.md @@ -0,0 +1,102 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 40 信息流设计(二):通用信息流系统的拉模式要如何做? + 你好,我是唐扬。 + +在前一节课中,我带你了解了如何用推模式来实现信息流系统,从中你应该了解到了推模式存在的问题,比如它在面对需要支撑很大粉丝数量的场景时,会出现消息推送延迟、存储成本高、方案可扩展性差等问题。虽然我们也会有一些应对的措施,比如说选择插入性能更高的数据库存储引擎来提升数据写入速度,降低数据推送延迟;定期删除冷数据以减小存储成本等等,但是由于微博大 V 用户粉丝量巨大,如果我们使用推模式实现信息流系统,那么只能缓解这些用户的微博推送延迟问题,没有办法彻底解决。 + +这个时候你可能会问了:那么有没有一种方案可以一劳永逸地解决这个问题呢?当然有了,你不妨试试用拉模式来实现微博信息流系统。那么具体要怎么做呢? + +如何使用拉模式设计信息流系统 + +所谓拉模式,就是指用户主动拉取他关注的所有人的微博,将这些微博按照发布时间的倒序进行排序和聚合之后,生成信息流数据的方法。 + +按照这个思路实现微博信息流系统的时候你会发现:用户的收件箱不再有用,因为信息流数据不再出自收件箱,而是出自发件箱。发件箱里是用户关注的所有人数据的聚合。因此用户在发微博的时候就只需要写入自己的发件箱,而不再需要推送给粉丝的收件箱了,这样在获取信息流的时候,就要查询发件箱的数据了。 + +这个逻辑我还用 SQL 的形式直观地表达出来,方便你理解。假设用户 A 关注了用户 B、C、D,那么当用户 B 发送一条微博的时候,他会执行这样的操作: + +insert into outbox(userId, feedId, create_time) values("B", $feedId, $current_time); // 写入 B 的发件箱 + + +当用户 A 想要获取他的信息流的时候,就要聚合 B、C、D 三个用户收件箱的内容了: + +select feedId from outbox where userId in (select userId from follower where fanId = "A") order by create_time desc + + +你看,拉模式的实现思想并不复杂,并且相比推模式来说,它有几点明显的优势。 + +首先,拉模式彻底解决了推送延迟的问题,大 V 发微博的时候不再需要推送到粉丝的收件箱,自然就不存在延迟的问题了。 + +其次,存储成本大大降低了。在推模式下,谢娜的粉丝有 1.2 亿,那么谢娜发送一条微博就要被复制 1.2 亿条,写入到存储系统中。在拉模式下只保留了发件箱,微博数据不再需要复制,成本也就随之降低了。 + +最后,功能扩展性更好了。比如,微博增加了分组的功能,而你想把关注的 A 和 B 分成一个单独的组,那么 A 和 B 发布的微博就形成了一个新的信息流,这个信息流要如何实现呢?很简单,你只需要查询这个分组下所有用户(也就是 A 和 B),然后查询这些用户的发件箱,再把发件箱中的数据,按照时间倒序重新排序聚合就好了。 + +List uids = getFromGroup(groupId); // 获取分组下的所有用户 + +Long> ids = new ArrayList>(); + +for(Long id : uids) { + + ids.add(getOutboxByUid(id)); // 获取发件箱的内容 id 列表 + +} + +return merge(ids); // 合并排序所有的 id + + +拉模式之所以可以解决推模式下的所有问题,是因为在业务上关注数始终是有上限的,那么它是不是一个无懈可击的方案呢?当然不是,拉模式也会有一些问题,在我看来主要有这样两点。 + +第一点,不同于推模式下获取信息流的时候,只是简单地查询收件箱中的数据,在拉模式下,我们需要对多个发件箱的数据做聚合,这个查询和聚合的成本比较高。微博的关注上限是 2000,假如你关注了 2000 人,就要查询这 2000 人发布的微博信息,然后再对查询出来的信息做聚合。 + +那么,如何保证在毫秒级别完成这些信息的查询和聚合呢?答案还是缓存。我们可以把用户发布的微博 ID 放在缓存中,不过如果把全部用户的所有微博都缓存起来,消耗的硬件成本也是很高的。所以我们需要关注用户浏览信息流的特点,看看是否可能对缓存的存储成本做一些优化。 + +在实际执行中,我们对用户的浏览行为做了抽量分析,发现 97% 的用户都是在浏览最近 5 天之内的微博,也就是说,用户很少翻看五天之前的微博内容,所以我们只缓存了每个用户最近 5 天发布的微博 ID。假设我们部署 6 个缓存节点来存储这些微博 ID,在每次聚合时并行从这几个缓存节点中批量查询多个用户的微博 ID,获取到之后再在应用服务内存中排序后就好了,这就是对缓存的 6 次请求,可以保证在 5 毫秒之内返回结果。 + +第二,缓存节点的带宽成本比较高。你想一下,假设微博信息流的访问量是每秒 10 万次请求,也就是说,每个缓存节点每秒要被查询 10 万次。假设一共部署 6 个缓存节点,用户人均关注是 90,平均来说每个缓存节点要存储 15 个用户的数据。如果每个人平均每天发布 2 条微博,5 天就是发布 10 条微博,15 个用户就要存储 150 个微博 ID。每个微博 ID 要是 8 个字节,150 个微博 ID 大概就是 1kB 的数据,单个缓存节点的带宽就是 1kB * 10 万 = 100MB,基本上跑满了机器网卡带宽了。那么我们要如何对缓存的带宽做优化呢? + +在14 讲中我提到,部署多个缓存副本提升缓存可用性,其实,缓存副本也可以分摊带宽的压力。我们知道在部署缓存副本之后,请求会先查询副本中的数据,只有不命中的请求才会查询主缓存的数据。假如原本缓存带宽是 100M,我们部署 4 组缓存副本,缓存副本的命中率是 60%,那么主缓存带宽就降到 100M * 40% = 40M,而每组缓存副本的带宽为 100M / 4 = 25M,这样每一组缓存的带宽都降为可接受的范围之内了。 + + + +在经过了上面的优化之后,基本上完成了基于拉模式信息流系统方案的设计,你在设计自己的信息流系统时可以参考借鉴这个方案。另外,使用缓存副本来抗流量也是一种常见的缓存设计思路,你在项目中必要的时候也可以使用。 + +推拉结合的方案是怎样的 + +但是,有的同学可能会说:我在系统搭建初期已经基于推模式实现了一套信息流系统,如果把它推倒重新使用拉模式实现的话,系统的改造成本未免太高了。有没有一种基于推模式的折中的方案呢? + +其实我在网易微博的时候,网易微博的信息流就是基于推模式来实现的,当用户的粉丝量大量上涨之后,我们通过对原有系统的改造实现了一套推拉结合的方案,也能够基本解决推模式存在的问题,具体怎么做呢? + +方案的核心在于大 V 用户在发布微博的时候,不再推送到全量用户,而是只推送给活跃的用户。这个方案在实现的时候有几个关键的点。 + +首先,我们要如何判断哪些是大 V 用户呢?或者说,哪些用户在发送微博时需要推送全量用户,哪些用户需要推送活跃用户呢?在我看来,还是应该以粉丝数作为判断标准,比如,粉丝数超过 50 万就算作大 V,需要只推送活跃用户。 + +其次,我们要如何标记活跃用户呢?活跃用户可以定义为最近几天内在微博中有过操作的用户,比如说刷新过信息流、发布过微博、转发评论点赞过微博,关注过其他用户等等,一旦有用户有过这些操作,我们就把他标记为活跃的用户。 + +而对大 V 来说,我们可以存储一个活跃粉丝的列表,这个列表里面就是我们标记的活跃用户。当某一个用户从不活跃用户变为活跃用户时,我们会查询这个用户的关注者中哪些是大 V,然后把这个用户写入到这些大 V 的活跃粉丝列表里面,这个活跃粉丝列表是定长的,如果活跃粉丝数量超过了长度,就把最先加入的粉丝从列表里剔除,这样可以保证推送的效率。 + +最后,一个用户被从活跃粉丝列表中剔除,或者是他从不活跃变成了活跃后,由于他不在大 V 用户的活跃粉丝列表中,所以也就不会收到微博的实时推送,因此,我们需要异步地把大 V 用户最近发布的微博插入到他的收件箱中,保证他的信息流数据的完整性。 + + + +采用推拉结合的方式可以一定程度上弥补推模式的缺陷,不过也带来了一些维护的成本,比如说系统需要维护用户的在线状态,还需要多维护一套活跃的粉丝列表数据,在存储上的成本就更高了。 + +因此,这种方式一般适合中等体量的项目,当粉丝量级在百万左右,活跃粉丝数量在 10 万级别时,一般可以实现比较低的信息传播延迟以及信息流获取延迟,但是当你的粉丝数量继续上涨,流量不断提升之后,无论是活跃粉丝的存储还是推送的延迟都会成为瓶颈,所以改成拉模式会更好的支撑业务。 + +课程小结 + +以上就是本节课的全部内容了。本节课我带你了解了基于拉模式和推拉结合模式实现信息流系统的方案,这里你需要了解的几个重点是: + +在拉模式下,我们只需要保存用户的发件箱,用户的信息流是通过聚合关注者发件箱数据来实现的; + +拉模式会有比较大的聚合成本,缓存节点也会存在带宽的瓶颈,所以我们可以通过一些权衡策略尽量减少获取数据的大小,以及部署缓存副本的方式来抗并发; + +推拉结合的模式核心是只推送活跃的粉丝用户,需要维护用户的在线状态以及活跃粉丝的列表,所以需要增加多余的空间成本来存储,这个你需要来权衡。 + +拉模式和推拉结合模式比较适合微博这种粉丝量很大的业务场景,因为它们都会有比较可控的消息推送延迟。你可以看到,在这两节课程中我们灵活使用数据库分库分表、缓存消息队列、发号器等技术,实现了基于推模式、拉模式以及推拉结合模式的信息流系统,你在做自己系统的方案设计时,应该充分发挥每种技术的优势,权衡业务自身的特性,最终实现技术和业务上的平衡,也就是既能在业务上满足用户需求,又能在技术上保证系统的高性能和高可用。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/加餐数据的迁移应该如何做?.md b/专栏/高并发系统设计40问/加餐数据的迁移应该如何做?.md new file mode 100644 index 0000000..ca640e0 --- /dev/null +++ b/专栏/高并发系统设计40问/加餐数据的迁移应该如何做?.md @@ -0,0 +1,135 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 加餐 数据的迁移应该如何做? + 你好,我是唐扬。 + +在“数据库优化方案(二):写入数据量增加时,如何实现分库分表?”中我曾经提到,由于 MySQL 不像 MongoDB 那样支持数据的 Auto Sharding(自动分片),所以无论是将 MySQL 单库拆分成多个数据库,还是由于数据存储的瓶颈,不得不将多个数据库拆分成更多的数据库时,你都要考虑如何做数据的迁移。 + +其实,在实际工作中,不只是对数据库拆分时会做数据迁移,很多场景都需要你给出数据迁移的方案,比如说某一天,你的老板想要将应用从自建机房迁移到云上,那么你就要考虑将所有自建机房中的数据,包括 MySQL,Redis,消息队列等组件中的数据,全部迁移到云上,这无论对哪种规模的公司来说都是一项浩瀚的工程,所以你需要在迁移之前,准备完善的迁移方案。 + +“数据的迁移”的问题比较重要,也比较繁琐,也是开发和运维同学关注的重点。在课程更新的过程中,我看到有很多同学,比如 @每天晒白牙,@枫叶 11,@撒旦的堕落等等,在留言区询问如何做数据迁移,所以我策划了一期加餐,准备从数据库迁移和缓存迁移两个方面,带你掌握数据迁移的方法,也带你了解数据迁移过程中,需要注意的关键点,尽量让你避免踩坑。 + +如何平滑地迁移数据库中的数据 + +你可能会认为:数据迁移无非是将数据从一个数据库拷贝到另一个数据库,可以通过 MySQL 主从同步的方式做到准实时的数据拷贝;也可以通过 mysqldump 工具将源库的数据导出,再导入到新库,这有什么复杂的呢? + +其实,这两种方式只能支持单库到单库的迁移,无法支持单库到多库多表的场景。而且即便是单库到单库的迁移,迁移过程也需要满足以下几个目标: + +迁移应该是在线的迁移,也就是在迁移的同时还会有数据的写入; + +数据应该保证完整性,也就是说在迁移之后需要保证新的库和旧的库的数据是一致的; + +迁移的过程需要做到可以回滚,这样一旦迁移的过程中出现问题,可以立刻回滚到源库,不会对系统的可用性造成影响。 + +如果你使用 Binlog 同步的方式,在同步完成后再修改代码,将主库修改为新的数据库,这样就不满足可回滚的要求,一旦迁移后发现问题,由于已经有增量的数据写入了新库而没有写入旧库,不可能再将数据库改成旧库。 + +一般来说,我们有两种方案可以做数据库的迁移。 + +“双写”方案 + +第一种方案我称之为双写,其实说起来也很简单,它可以分为以下几个步骤: + +\1. 将新的库配置为源库的从库,用来同步数据;如果需要将数据同步到多库多表,那么可以使用一些第三方工具获取 Binlog 的增量日志(比如开源工具 Canal),在获取增量日志之后就可以按照分库分表的逻辑写入到新的库表中了。 + +\2. 同时,我们需要改造业务代码,在数据写入的时候,不仅要写入旧库,也要写入新库。当然,基于性能的考虑,我们可以异步地写入新库,只要保证旧库写入成功即可。但是,我们需要注意的是,需要将写入新库失败的数据记录在单独的日志中,这样方便后续对这些数据补写,保证新库和旧库的数据一致性。 + +\3. 然后,我们就可以开始校验数据了。由于数据库中数据量很大,做全量的数据校验不太现实。你可以抽取部分数据,具体数据量依据总体数据量而定,只要保证这些数据是一致的就可以。 + +\4. 如果一切顺利,我们就可以将读流量切换到新库了。由于担心一次切换全量读流量可能会对系统产生未知的影响,所以这里最好采用灰度的方式来切换,比如开始切换 10% 的流量,如果没有问题再切换到 50% 的流量,最后再切换到 100%。 + +\5. 由于有双写的存在,所以在切换的过程中出现任何的问题,都可以将读写流量随时切换到旧库去,保障系统的性能。 + +\6. 在观察了几天发现数据的迁移没有问题之后,就可以将数据库的双写改造成只写新库,数据的迁移也就完成了。 + +其中,最容易出问题的步骤就是数据校验的工作,所以,我建议你在未开始迁移数据之前先写好数据校验的工具或者脚本,在测试环境上测试充分之后,再开始正式的数据迁移。 + +如果是将数据从自建机房迁移到云上,你也可以使用这个方案,只是你需要考虑的一个重要的因素是:自建机房到云上的专线的带宽和延迟,你需要尽量减少跨专线的读操作,所以在切换读流量的时候,你需要保证自建机房的应用服务器读取本机房的数据库,云上的应用服务器读取云上的数据库。这样在完成迁移之前,只要将自建机房的应用服务器停掉,并且将写入流量都切到新库就可以了。 + + + +这种方案是一种比较通用的方案,无论是迁移 MySQL 中的数据,还是迁移 Redis 中的数据,甚至迁移消息队列都可以使用这种方式,你在实际的工作中可以直接拿来使用。 + +这种方式的好处是:迁移的过程可以随时回滚,将迁移的风险降到了最低。劣势是:时间周期比较长,应用有改造的成本。 + +级联同步方案 + +这种方案也比较简单,比较适合数据从自建机房向云上迁移的场景。因为迁移上云,最担心云上的环境和自建机房的环境不一致,会导致数据库在云上运行时,因为参数配置或者硬件环境不同出现问题。 + +所以,我们会在自建机房准备一个备库,在云上环境上准备一个新库,通过级联同步的方式在自建机房留下一个可回滚的数据库,具体的步骤如下: + +\1. 先将新库配置为旧库的从库,用作数据同步; + +\2. 再将一个备库配置为新库的从库,用作数据的备份; + +\3. 等到三个库的写入一致后,将数据库的读流量切换到新库; + +\4. 然后暂停应用的写入,将业务的写入流量切换到新库(由于这里需要暂停应用的写入,所以需要安排在业务的低峰期)。 + + + +这种方案的回滚方案也比较简单,可以先将读流量切换到备库,再暂停应用的写入,将写流量切换到备库,这样所有的流量都切换到了备库,也就是又回到了自建机房的环境,就可以认为已经回滚了。 + +上面的级联迁移方案可以应用在,将 MySQL 从自建机房迁移到云上的场景,也可以应用在将 Redis 从自建机房迁移到云上的场景,如果你有类似的需求可以直接拿来应用。 + +这种方案优势是简单易实施,在业务上基本没有改造的成本;缺点是在切写的时候需要短暂的停止写入,对于业务来说是有损的,不过如果在业务低峰期来执行切写,可以将对业务的影响降至最低。 + +数据迁移时如何预热缓存 + +另外,在从自建机房向云上迁移数据时,我们也需要考虑缓存的迁移方案是怎样的。那么你可能会说:缓存本来就是作为一个中间的存储而存在的,我只需要在云上部署一个空的缓存节点,云上的请求也会穿透到云上的数据库,然后回种缓存,对于业务是没有影响的。 + +你说的没错,但是你还需要考虑的是缓存的命中率。 + +如果你部署一个空的缓存,那么所有的请求就都穿透到数据库,数据库可能因为承受不了这么大的压力而宕机,这样你的服务就会不可用了。所以,缓存迁移的重点是保持缓存的热度。 + +刚刚我提到,Redis 的数据迁移可以使用双写的方案或者级联同步的方案,所以在这里我就不考虑 Redis 缓存的同步了,而是以 Memcached 为例来说明。 + +使用副本组预热缓存 + +在“缓存的使用姿势(二):缓存如何做到高可用?”中,我曾经提到,为了保证缓存的可用性,我们可以部署多个副本组来尽量将请求阻挡在数据库层之上。 + +数据的写入流程是写入 Master、Slave 和所有的副本组,而在读取数据的时候,会先读副本组的数据,如果读取不到再到 Master 和 Slave 里面加载数据,再写入到副本组中。那么,我们就可以在云上部署一个副本组,这样,云上的应用服务器读取云上的副本组,如果副本组没有查询到数据,就可以从自建机房部署的主从缓存上加载数据,回种到云上的副本组上。 + + + +当云上部署的副本组足够热之后,也就是缓存的命中率达到至少 90%,就可以将云机房上的缓存服务器的主从都指向这个副本组,这时迁移也就完成了。 + +这种方式足够简单,不过有一个致命的问题是:如果云上的请求穿透云上的副本组,到达自建机房的主从缓存时,这个过程是需要跨越专线的。 + +这不仅会占用较多专线的带宽,同时专线的延迟相比于缓存的读取时间是比较大的,一般,即使是本地的不同机房之间的延迟也会达到 2ms~3ms,那么,一次前端请求可能会访问十几次甚至几十次的缓存,一次请求就会平白增加几十毫秒甚至过百毫秒的延迟,会极大地影响接口的响应时间,因此在实际项目中我们很少使用这种方案。 + +但是,这种方案给了我们思路,让我们可以通过方案的设计在系统运行中自动完成缓存的预热,所以,我们对副本组的方案做了一些改造,以尽量减少对专线带宽的占用。 + +改造副本组方案预热缓存 + +改造后的方案对读写缓存的方式进行改造,步骤是这样的: + +\1. 在云上部署多组 mc 的副本组,自建机房在接收到写入请求时,会优先写入自建机房的缓存节点,异步写入云上部署的 mc 节点; + +\2. 在处理自建机房的读请求时,会指定一定的流量,比如 10%,优先走云上的缓存节点,这样虽然也会走专线穿透回自建机房的缓存节点,但是流量是可控的; + +\3. 当云上缓存节点的命中率达到 90% 以上时,就可以在云上部署应用服务器,让云上的应用服务器完全走云上的缓存节点就可以了。 + + + +使用了这种方式,我们可以实现缓存数据的迁移,又可以尽量控制专线的带宽和请求的延迟情况,你也可以直接在项目中使用。 + +课程小结 + +以上我提到的数据迁移的方案,都是我在实际项目中,经常用到的、经受过实战考验的方案,希望你能通过这节课的学习,将这些方案运用到你的项目中,解决实际的问题。与此同时,我想再次跟你强调一下本节课的重点内容: + +双写的方案是数据库、Redis 迁移的通用方案,你可以在实际工作中直接加以使用。双写方案中最重要的,是通过数据校验来保证数据的一致性,这样就可以在迁移过程中随时回滚; + +如果你需要将自建机房的数据迁移到云上,那么也可以考虑使用级联复制的方案,这种方案会造成数据的短暂停写,需要在业务低峰期执行; + +缓存的迁移重点,是保证云上缓存的命中率,你可以使用改进版的副本组方式来迁移,在缓存写入的时候,异步写入云上的副本组,在读取时放少量流量到云上副本组,从而又可以迁移部分数据到云上副本组,又能尽量减少穿透给自建机房造成专线延迟的问题。 + +如果你作为项目的负责人,那么在迁移的过程中,你一定要制定周密的计划:如果是数据库的迁移,那么数据的校验应该是你最需要花费时间来解决的问题。 + +如果是自建机房迁移到云上,那么专线的带宽一定是你迁移过程中的一个瓶颈点,你需要在迁移之前梳理清楚,有哪些调用需要经过专线,占用带宽的情况是怎样的,带宽的延时是否能够满足要求。你的方案中也需要尽量做到在迁移过程中,同机房的服务,调用同机房的缓存和数据库,尽量减少对于专线带宽资源的占用。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/期中测试10道高并发系统设计题目自测.md b/专栏/高并发系统设计40问/期中测试10道高并发系统设计题目自测.md new file mode 100644 index 0000000..7570360 --- /dev/null +++ b/专栏/高并发系统设计40问/期中测试10道高并发系统设计题目自测.md @@ -0,0 +1,21 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 期中测试 10道高并发系统设计题目自测 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/用户故事从“心”出发,我还有无数个可能.md b/专栏/高并发系统设计40问/用户故事从“心”出发,我还有无数个可能.md new file mode 100644 index 0000000..dd3dd4b --- /dev/null +++ b/专栏/高并发系统设计40问/用户故事从“心”出发,我还有无数个可能.md @@ -0,0 +1,87 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 用户故事 从“心”出发,我还有无数个可能 + 你好,我是 Longslee,很高兴与大家一起学习《高并发系统设计 40 问》。 + +我从事软件相关的职业已经有九年时间了,之前在一家税务行业类公司工作,目前在一家电信行业相关的公司,从事开发和运维工作。 + +我并不算“极客时间”的老用户,因为接触“极客时间”只有短短几个月,一开始只抱着试试看的心态,尝试着订阅了几门课程,后来便自然而然地将它当作工作之余,获取信息的必需品。 + +要说跟这门课结缘,还是在今年 10 月份,那时,我偶然打开“极客时间”,看到了《高并发系统设计 40 问》的课程,被开篇词的题目“你为什么要学习高并发系统设计”吸引了。开篇词中提到: + +公司业务流量平稳,并不表示不会遇到一些高并发的需求场景;为了避免遇到问题时手忙脚乱,你有必要提前储备足够多的高并发知识,从而具备随时应对可能出现的高并发需求场景的能力…… + +这些信息着实戳中了我。 + +回想起来,自己所处的行业是非常传统的 IT 行业,几乎与“互联网”不着边,所以我平时特别难接触一线的技术栈。然而,虽然行业传统,但并不妨碍日常工作中高并发的出现,比如,偶尔出现的线上促销活动。 + +单纯从我自己的角度出发,除了因为开篇词戳中之外,选择这个课程,还在于自己想拓宽视野、激发潜能,另一方面,当真的遇到“高并发”时,不至于望洋兴叹,脑海一片空白。 + +在课程设计上,每一节课的标题都是以问号结束,这种看似寻常的设计,很容易让我在学习时,联想到自己的实际工作,从而先问问自己:我们为什么要架构分层?如何避免消息重复?等等,自己有了一些答案后,再进入正式的学习,对概念性的知识查漏补缺。 + +我个人认为,这也算是这门课程的一个小的特色。唐扬老师抛出问题,并用自己的经验进行回答,让这篇文章有了一个很好的闭环。 + +目前来说,我所在的行业和项目,为了应对日益复杂的业务场景,和日渐频繁的促销活动,也在慢慢地转变,更多地引入互联网行业知识,产品也更加与时俱进。 + +作为这个行业的一员,在日常工作中,我自然也遇到了一些难题,碰到了一些瓶颈,但是在寻找解决方式的时候,往往局限在自己擅长的技术体系和历史的过往经验上。而在学习了这门课之后,我拓宽了眼界,会不自主地思考“是不是可以用今天学到的方式解决某些问题?”“当初选用的中间件和使用方式合不合理?”等等。 + +而且,就像我提到的,自己所处的行业在不断改变,其实,就目前的趋势来看,很早就存在的信息化产品和目前主流的互联网产品渐渐难以界定了。就比如高校的教务系统,听起来好像跟我们接触的各类网站大不一样,但是在开学的时候,又有多少选课系统能扛住同学们瞬间的巨大流量呢? + +《17 | 消息队列:秒杀时如何处理每秒上万次的下单请求?》讲的就是各厂处理可预见且短时间内大流量的“套路”,而我认为,这个“套路”也可以应用到大学的选课系统。因为教务系统在通常情况下都是很闲的,如果整体升级来提高 QPS 性价比太低,所以只要保证在选课时,服务的稳定性就好了。这里可以引入消息队列,来缓解数据库的压力,再通过异步拆分,提高核心业务的处理速度。 + +其实,还有好多节课都给我留下了深刻的印象,比如,第 2 讲、第 10 讲、第 13 讲等等。 + +单看《02 | 架构分层:我们为什么一定要这么做?》这个题目,我一开始会觉得“老生常谈”,软件分层在实际项目中运用的太多太多了,老师为什么单独拿出来一讲介绍呢?然而当我看到“如果业务逻辑很简单的话,可不可以从表示层直接到数据访问层,甚至直接读数据库呢?”这句话时,联系到了自己的实际业务: + +我所参与的一个工程,确实因为业务逻辑基本等同数据库逻辑,所以从表示层直接与数据访问层交互了。但是如果数据库或者数据访问层发生改动,那将要修改表示层的多个地方,万一漏掉了需要调整的地方,连问题都不好查了,并且如果以后再无意地引入逻辑层,修改的层次也将变多。 + +对我而言,这篇文章能够有触动我的地方,引发我的思考,所以在接下来的项目中,我坚持选用分层架构。 + +而《10 | 发号器:如何保证分库分表后 ID 的全局唯一性》给我的项目提供了思路:我的需求不是保证分库分表后,主键的唯一性,但由于需要给各个客户端分配唯一 ID,用客户端策略难免重复,所以在读到: + +一种是嵌入到业务代码里,也就是分布在业务服务器中。这种方案的好处是业务代码在使用的时候不需要跨网络调用,性能上会好一些,但是就需要更多的机器 ID 位数来支持更多的业务服务器。另外,由于业务服务器的数量很多,我们很难保证机器 ID 的唯一性,所以就需要引入 ZooKeeper 等分布式一致性组件,来保证每次机器重启时都能获得唯一的机器 ID…… + +我采取了类似发号器的概念,并且摒弃了之前 UUID 似的算法。采用发号器分发的 ID 后,在数据库排序性能有所提升,业务含义也更强了。 + +除此之外,在学习《13 | 缓存的使用姿势(一):如何选择缓存的读写策略?》之前,我的项目中没有过多地考虑,数据库与缓存的一致性。比如,我在写入数据时,选择了先写数据库,再写缓存,考虑到写数据库失败后事务回滚,缓存也不会被写入;如果缓存写入失败,再设计重试机制。 + +看起来好像蛮 OK 的样子,但是因为没有考虑到在多线程更新的情况下,确实会造成双方的不一致,所造成的后果是:有时候从前端查询到的结果与真实数据不符。后来,根据唐扬老师提到的 Cache Aside(旁路缓存)策略,我顿然醒悟,然后将这一策略用于该工程中,效果不错。这节课,我从唐扬老师的亲身经历中,学到了不少的经验,直接用到了自己的项目中。 + +真的很感谢唐扬老师,也很开心能够遇到这门课程,在这里,想由衷地表达自己的感谢之情。 + +那么我是怎么学习这门课程的呢?在这里,我想分享几点: + +知行合一 + +学完课程后,除了积极思考“能否用”“怎么用”“何时用”这些问题外,一定要趁热打铁,要么继续深入话题,翻阅其他资料,巩固下来;要么敲敲代码实现一遍,化为自己的技能;如果时间充裕,甚至可以立马着手改进项目。 + +留言区 = 挖宝区 + +每节课结束,我都会在留言板留下疑问,或者分享体验,我喜欢问问题其实是跟自己在大学时,参加的一场宣讲会有关。当时,来招聘的负责人是一位美国留学回来的台湾工程师,他介绍完后问大家有没有疑问,并没有人回答。 + +后来,他讲了一个经历,使我感慨良多。他说当他刚去美国大学的时候,教授讲完课就要答疑,一个白人学生提了一个,在中国学生看来十分简单且幼稚的问题,以后的每节课,这位白人同学都要提问,渐渐地,提的问题他都听不懂了!再后来,教授也不懂了。 + +所以,我会不断地发问,不懂就问,把留言区当作挖宝区,看大家的留言,进行思考。比如 @李冲同学的几个跟帖,就解答了我对布隆过滤器的误解,并且还知道了另一种布谷鸟过滤器。 + +勤做笔记 + +有的时候,我当时理解的比较透彻,可过了两三天之后,就有些模糊了,所以后来,我根据自己的理解写成思维导图形式,随时随地都可以翻阅。另外,在实现这些方案的代码后面,也可以写下相应的注释,Review 的时候还可以温故知新。 + +在最后,我也想分享一下自己为什么用专栏这种形式来学习。善用搜索引擎的同学们都有体会,搜索出来的知识分布在各处,雷同的不少,有经验的介绍甚少,我没办法在有限的时间内,将搜索到的知识形成体系。 + +当然了,要想系统地学习可以借助书籍。但是对我来说,书籍类学习周期长,章节之间的关联性也不大,容易学了这里忘了那里。书籍多是讲一个专业点,对于跨专业的知识经常一笔带过,而专栏,是有作者自己的理解在里边,前后之间有贯通,学习起来轻松愉悦。 + +就拿一致性 Hash 这个知识点来说,我从网上看了不少关于一致性 Hash 的文章,但没有看到应用,更别谈应用中的缺陷,有的描述甚至让我误认为节点变化后,数据也会跟着迁移。唐扬老师的《14 | 缓存的使用姿势(二):缓存如何做到高可用?》,倒是给了我网络上看不到的盲区,通过在留言区与老师交流后,颇有一种豁然开朗的收获感。 + +当然了,这些只是我个人的感受,见仁见智,你或许有自己的学习方法,也或许大家的起点不同,在这里,我只想把自己的真实感受分享出来,也十分感谢大家倾听我的故事。 + +总的来说,想要提升自己,并没有捷径,只有一步一步地踏实前行,从踩过的坑中,努力地爬出来。 + +对我来说,唐扬老师的《高并发系统设计 40 问》犹如及时雨一般的存在,弥补了我高并发相关知识上的缺陷,我相信,认真学完课程之后,自己的视野一定有所开拓,职业生涯也会进入新的篇章。 + + + + \ No newline at end of file diff --git a/专栏/高并发系统设计40问/结束语学不可以已.md b/专栏/高并发系统设计40问/结束语学不可以已.md new file mode 100644 index 0000000..f901ab1 --- /dev/null +++ b/专栏/高并发系统设计40问/结束语学不可以已.md @@ -0,0 +1,57 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 结束语 学不可以已 + 你好,我是唐扬。 + +时间一晃而过,四个月的学习已经接近尾声了,在 103 个日夜里,我们共同学习了 45 篇高并发系统设计的相关文章,从基础篇,逐渐扩展到演进篇,最终进行了实战分析和讲解。 + +这段日子里,我们一起沟通交流,很多同学甚至在凌晨还在学习、留言,留言区里经常会看到熟悉的身影,比如 @小喵喵,@吃饭饭,@Keith。还有一些同学分享了一些新的知识,比如 @蓝魔,是你们的积极和努力鼓励我不断前进,让我明白知识无止境。在写稿之余,我也订阅了几节极客时间的课程,也买了几本相关的书籍,努力为你们交付高质量的内容。这 103 个日夜虽然辛苦,但也是充满感恩的,在这里,我由衷感谢你的一路相伴! + +我知道,有一些同学希望多一些实践的案例分析,我是这样思考的,古人常说“源不深而望流之远,根不固而求木之长,不可”。一些理论基础是必要的,如水之源、树之根,是不能跨越的。另外,一个实践案例不能完全涵盖一个理论,相反一个理论可以支撑很多的实践案例。正所谓授之以鱼不如授之以渔,我们上数学课不也是要先讲公式的来源,再解决实际问题吗?相信对理论知识活学活用后,你在实际工作中,会收获难能可贵的经验财富,也会做出更好的技术方案。 + +回顾这些年的工作,我想和你分享几点我个人的看法。我刚开始工作时,经常听别人说程序员是有年纪限制的,35 岁是程序员的终结年龄,那时说实话我心里是有一些忐忑的,可随着年龄不断增长,我看到越来越多的人在 35 岁之后还在行业中如鱼得水,我想,35 这个数字并非强调个人的年纪,而是泛指一个阶段,强调在那个阶段,我们可能会因为个人的种种原因安于现状,不再更新自己的知识库,这是非常错误的。 + +化用《礼记》中的话,首先,我们要博学之。 你要不断革新知识,所谓的天花板其实更多的是知识性的天花板,活到老学到老才是你在这个行业的必胜法宝,所以,我们应该利用各种优质平台以及零散的时间学习,但是同时你要注意,现在的知识偏向碎片化,如何有条理、系统地学习,将知识梳理成体系,化作自己的内功,是比较关键和困难的。在这里我给你几点建议: + +基础知识要体系化,读书是一种很好的获取体系化知识的途径,比如研读《算法导论》提升对数据结构和算法的理解,研读《TCP/IP 协议详解》深入理解我们最熟悉的 TCP/IP 协议栈等等; + +多读一些经典项目的源代码,比如 Dubbo,Spring 等等,从中领会设计思想,你的编码能力会得到极大的提高; + +多利用碎片化的时间读一些公众号的文章,补充书里没有实践案例的不足,借此提升技术视野。 + +其次要慎思之。 诚然,看书拓展知识的过程中我们需要思考,在实际工作中我们也需要深入思考。没有一个理论可以适应所有的突发状况,高并发系统更是如此。它状况百出,我们最好的应对方法就是在理论的指导下,对每一次的突发状况都进行深入的总结和思考。 + +然后是审问之。 这种问既是“扪心自问”: + +这次的突发问题的根本原因是什么? + +以后如何避免同类问题的再次发生? + +解决这个问题最优的思路是什么? + +同时,也应该是一种他问,是与团队合作,头脑风暴之后的一种补充,我们说你有一个苹果,我有一个苹果我们相互交换,每个人依然只有一个苹果,但是你有一种思想,我也有一种思想,我们相互交换,每个人就有两种思想,所以不断进行团队交流也是一种好的提升自我的方式。 + +接着是明辨之。 进行了广泛的阅读,积累了大量的工作案例,还要将这些内化于心的知识形成清晰的判断力。某个明星微博的突然沦陷,社区系统的突然挂掉,只是分分钟的事情,要想成为一个优秀的架构师,你必须运用自身的本领进行清晰地判断,快速找到解决方案,只有这样才能把损失控制在最小的范围内。而这种清晰的判断力绝对是因人而异的,你有怎样的知识储备,有怎样的深入思考,就会有怎样清晰的判断力。 + +最后要笃行之。 学了再多的理论,做了再多的思考,也不能确保能够解决所有问题,对于高并发问题,我们还需要在实践中不断提升自己的能力。 + +相信你经常会看到这样的段子,比如很多人会觉得我们的固定形象就是“带着眼镜,穿着格子衬衫,背着双肩包,去优衣库就是一筐筐买衣服”。调侃归调侃,我们不必认真,也不必对外在过于追求,因为最终影响你职业生涯的,是思考、是内涵、是知识储备。那么如何让自己更精锐呢? + +我想首先要有梯度。我们总希望任何工作都能有个进度条,我们的职业生涯也应该有一个有梯度的进度条,比如,从职场菜鸟到大神再到财务自由,每一步要用多久的时间,如何才能一步一步上升,当然,未必人人能够如鱼得水,但有梦想总是好的,这样你才有目标,自己的生活才会有奔头。 + +有了梯度的目标之后,接下来要有速度,就像产品逼迫你一样,你也要逼迫自己,让自己不断地加油,不断地更新、提升、完善,尽快实现自己的职业目标。 + +具备了这两点,就有了一定的高度,你是站在一个目标高度俯视自己的生涯,是高屋建瓴,而不是盲目攀爬。之后你需要做到的是深度,有的朋友总想横向拓展自己的知识面,想要学习一些新奇的知识,这会提升技术视野,原本是无可厚非的,可如果因为追逐新的技术而放弃深入理解基础知识,那就有些得不偿失了。要知道,像是算法、操作系统、网络等基础知识很重要,只有在这些知识层面上有深入的理解,才能在学习新技术的时候举一反三,加快学习的速度,能够帮助你更快地提升广度。 + +你还要有热度。我们白天和产品经理“相爱相杀”,晚上披星戴月回家与家人“相爱相杀”,如果没有足够的工作热度,这样的日子循环往复,你怎么可能吃得消?而只有当你在自己的行业里规划了梯度、提升了速度、强化了深度、拓宽了广度,才会有足够的自信度,而当你有了自信,有了话语权,那时你就有了幸福感,自然会保有热度。在热度的烘焙下,你又开始新一轮规划,如此良性循环,你才会在工作上游刃有余,生活也会幸福快乐。 + +在文章结尾,我为你准备了一份调查问卷,题目不多,希望你能抽出两三分钟填写一下。我非常希望听听你对这个专栏的意见和建议,期待你的反馈!专栏的结束,也是另一种开始,我会将内容进行迭代,比如 11 月中旬到 12 月末,我有为期一个月的封闭期,在这期间没有来得及回复的留言,我会花时间处理完;再比如,会针对一些同学的共性问题策划一期答疑或者加餐。 + +最后,我想再次强调一下为什么要努力提升自己,提升业务能力,直白一点儿说,那是希望我们都有自主选择的权利,而不是被迫谋生;我有话语权,而不是被迫执行,随着年纪的增加,我越发觉得成就感和尊严,能够带给我们快乐。 + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/00开篇词打破四大认知局限,进阶高级性能工程师.md b/专栏/高楼的性能工程实战课/00开篇词打破四大认知局限,进阶高级性能工程师.md new file mode 100644 index 0000000..b94da1e --- /dev/null +++ b/专栏/高楼的性能工程实战课/00开篇词打破四大认知局限,进阶高级性能工程师.md @@ -0,0 +1,110 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 00 开篇词 打破四大认知局限,进阶高级性能工程师 + 你好,我是高楼。 + +从业十几年以来,我一直在做性能测试、性能分析、性能优化的工作。早年间我在各大测试论坛分享自己的工作经验,并形成了关于性能测试完整的知识链。后来,我开始自己带团队做项目,完整做过40多个项目,团队也从开始的四五个人发展到了现在的300 余人。与我合作过的人都了解,我做性能项目的宗旨就是上线不死,死了不收钱。 + +2019年,我在极客时间上线了第一个课程《性能测试实战30讲》。在这个课程中,我描述了自己认为在测试过程中重要的部分,比如整体概念梳理、性能分析思路等。我希望通过这个课程,可以抛出一个价值观——让性能变得有价值,以此刷新很多人对性能测试的认知,知道这个方向其实可以干很多事情。这也是我长期以来一直在做的事情。 + +性能工程师的四大认知局限 + +你可能会奇怪,为什么我还要写第二个课程呢? + +因为我想通过一个实战项目,为你展现性能项目工程级的落地思路以及真正的实施过程。从一个性能工程师的角度来看,即使你掌握了第一个课程的内容后,也还是会在项目的各个环节中遇到其他挑战。比如下面这张图中展示的: + + + +作为一个优秀的性能工程师,上面这张图中提到的性能计划、需求分析、瓶颈分析等问题的重要性不言而喻,但是它们却没有得到真正的重视,市面上也鲜少有对应的解决方案。 + +因此,我希望通过一个实战项目,从性能需求到最终的性能报告,带你走过整个完整的操作过程,让你透彻理解这些痛点问题,并一一攻克它们。 + +纵观现在的性能市场,我时常感觉悲哀,大家往往对性能有四大错误认知。 + +1. 过于关注性能中的某些工具 + +在很多咨询或是培训的现场,我经常能看到有着好多年经验,却只会JMeter、LoadRunner等几个性能工具的性能工程师。在监控领域也是一样,不乏觉得会一些操作系统或是语言的监控分析工具就可以上天了的工程师。 + +2. 只浮在表面 + +我看过很多性能项目、培训和演讲,一些人自恃有些背景经验,经常会在一些场合吹嘘。但是,当你问他如何落地以及具体的落地过程时,这些人就只会利用培训中的技巧来搪塞。 + +3. 只局限于性能团队,走不出去 + +性能对我来说一直是一个工程级的工作。可是很多做性能的工程师,经常连自己的团队都走不出去。比如说当系统有了性能瓶颈,我们能不能走出自己的团队,有理有据地指着开发、运维的鼻子骂几句?(当然,我并不推荐这种不礼貌的举动,它只是一种夸张的表达手法。) + +我给我带过的团队经常说的一句话就是,有了瓶颈出去跟开发打仗,一定要赢着回来!要不然就不要出去! + +因为经常有一种情况是,当缺少可以证明瓶颈根本原因的证据时,性能工程师就会像皮球一样被踢来踢去,比如“这个可能是啥啥问题,你去问下谁谁谁”、“这个可能是啥原因,你再试一下”等等。面对这样的场景,你觉得做性能还有什么意义吗? + +4. 无法体现到业务场面 + +其实,老板们想要的,就是一个类似这样问题的明确答案:当1000万人在线的时候,这个系统会不会死?而你作为一个性能工程师或者性能团队的负责人,你敢拍着胸脯说“死了我负责,到时候我卷铺盖就走人”这样的话吗? + +如果你敢这样说,那你得到的工资肯定是不一样的,就像买保险一样。可是在性能市场中,有谁敢给这样的业务保证呢? + +基于这样的市场现状,我希望通过这个课程将性能分析的真正价值体现出来,改变你原有的一些错误认知,帮助你成为一名优秀的性能工程师。这就需要我们把性能从“测试”引到“工程”的级别,因为只有这样,才是一个性能项目的真正价值可以体现的方式。 + +我会怎么给你讲这个课程? + +为了能让你更好地理解我要讲的内容,我专门搭建了一个完整的系统,我们课程所有的内容都将基于这个系统展开。 + +在这个项目中,我使用了Kubernetes+Docker+nginx_ingress/Java 1.8/Spring Cloud微服务(内置Tomcat)/Grafana+Prometheus+Exporters+SkyWalking/Redis/MySQL/RabbitMQ等技术组件搭建起了整个服务。这样的服务规模需要不少的时间做环境搭建,从硬件上架、安装操作系统开始,我整体用了近一个月的时间,使用了62C140G的硬件资源。 + +在搭建的过程中,一开始我考虑使用OpenStack做基础设施,希望能覆盖当前技术栈中的全部主流技术。但最后我还是放弃了,因为这样的资源量级用OpenStack是浪费资源的。此外,我在搭建过程中也遇到了很多杂七杂八的问题,比如多网卡队列、硬件超分等带来的整体架构问题,好在最后都一一得到解决。 + +我在这个系统中遇到的性能问题,以及我的分析过程都将在这个课程中呈现出来。我会从一个完整的性能项目的角度,给你详细解析整个性能项目是怎么做的。我还会从完整的性能分析决策树和性能瓶颈证据链的角度,带你分析如何定位一个瓶颈。而这个项目的分析数据、性能结果也都将真实地呈现给你,让你看到我所讲的分析方法和路径都是能够一一落地的。 + +学习这个课程,我建议你最好能动手实践,对课程中所讲的分析思路和方法有一个深入的体会和理解。如果你想自己搭建这样一套环境,硬件资源可以不要那么多,技术组件也可以分批搭建,毕竟不是所有的场景都需要整体的环境。对于一些比较复杂、容易出现问题的环节,我也会为你提供相应的指导性文档,帮助你顺利完成搭建。 + +如果我做的项目与课程中的项目不同,怎么办? + +你可能会有疑问,这门课讲的性能项目和你所做的项目不同怎么办?这一点你无需担心,因为我会为你描述一个通用型的性能项目,而且我们重在逻辑上的理解。所以这门课我会从以下三个关键点入手,这也是你接下来学习的主要方向。 + + + +首先,我们会更重分析。 + +对于性能来说,不会做脚本的团队还是非常少的。在这个课程中我将重分析,而不是重脚本。如果你是初学者,那我希望你能自学脚本等工具的基础操作。 + +那怎么理解“重分析”呢?简单来说就是从脚本开始,一直到具体的性能瓶颈,这样一个完整的分析过程。这也是我一直强调的性能技术的重心。 + +其次,我们会更注重分析链路的完整性。 + +在这门课程中,我不会详细去写一个个的技术点,比如Linux操作系统中输入top命令后的CPU信息us是什么意思,像这种度娘上都有的东西,就不用你再花钱学课程了。我在这门课里要讲的是,当us(用户态使用的CPU时间比)高了之后,应该怎么办? + +可能有人会说这个问题很简单,us高了之后就是查进程——查线程——查栈——查代码,老套路了。能这样讲的人,证明是有经验的性能工程师,但不一定是非常有经验的性能工程师。因为从步骤上来看,套路确实是这样。不过,当遇到一个具体的问题点时,清楚套路的人并不见得知道该做什么动作。 + +我们就拿打印堆栈来说,针对不同的问题和现象,我们应该打印多少次堆栈呢?比如当cswch/s高的时候,该怎么打印堆栈?当nvcswch/s高的时候,又该怎么打印堆栈?像类似的问题,我都会在这门课中把思路给你讲清楚。 + +最后,我们注重项目级完整性。 + +一个项目中应该具有的关键性能动作,也都将在这个课程中得到体现。比如说: + + +脚本中哪些关键点会影响到最终的结果,具体是如何影响的? +业务模型到底能不能完全符合生产环境统计出来的数据? +性能报告到底应该如何下结论? +性能团队应该给运维什么样的具体建议? +上线之后,怎么评估性能项目做得好不好? +…… + + +总之,我希望你能看到一个性能项目真实的落地过程,知道在一个性能项目的各个阶段应该做什么事情以及要做到什么样子,从一个更为宏观、全局的视角,真正吃透性能。这也是一名优秀的性能工程师必须要具备的能力。 + + + +最后,我想说,如果成为一名优秀的性能工程师是你的心之所向,如果你不甘于平庸,希望在性能这条路上走得更远,那就与我一起踏上这段旅程,我会把我从业十几年来的经验毫无保留地分享给你。 + +如果可以的话,你不妨在留言区聊聊你在性能测试方面的学习痛点,方便我在后续的课程里更有针对性地给你讲解。同时,也欢迎你把这一讲分享给身边志同道合的朋友,彼此加油打气,相互学习,更容易抵达终点。 + +关于课程读者群 + +点击课程详情页的链接,扫描二维码,就可以加入我们这个课程的读者群哦,希望这里的交流与思维碰撞能帮助你取得更大的进步,期待你的到来~ + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/01性能工程:为什么很多性能测试人员无法对性能结果负责?.md b/专栏/高楼的性能工程实战课/01性能工程:为什么很多性能测试人员无法对性能结果负责?.md new file mode 100644 index 0000000..0b8bf66 --- /dev/null +++ b/专栏/高楼的性能工程实战课/01性能工程:为什么很多性能测试人员无法对性能结果负责?.md @@ -0,0 +1,265 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 01 性能工程:为什么很多性能测试人员无法对性能结果负责? + 你好,我是高楼。 + +今天是我们这节课的第一讲,我要带给你一个全面的性能概念——RESAR性能工程,它跟性能测试的逻辑不太一样,具体哪儿不一样?下面我就具体给你讲讲。另外,在这个过程中,我也会让你全面、系统地感知到性能工程都要做些什么工作。相信这节课不仅会改变你对性能的认知,也能对你的性能项目全过程有一个指引。 + +要为性能负责,就不能局限在“测试”上 + +在《性能测试实战30讲》专栏中,我给出了性能测试的概念: + + +性能测试是针对系统的性能指标,建立性能测试模型,制定性能测试方案,制定监控策略,在场景条件之下执行性能场景,分析判断性能瓶颈并调优,最终得出性能结果来评估系统的性能指标是否满足既定值。 + + +到现在,我仍然认为这是对“性能测试”最合理的描述。实际上,这一概念在出来之后引起了一些争论,主要在于:性能测试的团队需要去做瓶颈定位和优化吗? + +我们先抛开概念本身,考虑这样一个问题:如果性能测试团队不做瓶颈定位和优化,那是否可以给出“上线后生产系统不会产生性能问题”这样的答案呢? + +如果不能,那要这个性能测试团队有什么用?仅仅是找找初级的技术问题吗?这就像一个病人去医院看病,手术做了,药也吃了,一顿折腾后问医生:“我什么时候可以好呢?”要是医生说:“我也不知道!”你想想病人心里会是什么感受,有没有一种遇到庸医的感觉? + +其实对应到性能测试中也是如此,我们的性能项目是有宏观目标的: + + +找到系统中的性能瓶颈并优化掉; +满足业务容量的要求,保障线上系统可以正常运行。 + + +我建议你仔细看一下第二个目标,然后再想想我们刚才那个问题:性能测试的团队需要做瓶颈定位和优化吗?现在你是不是已经有答案了?当然是必须做呀!可是在当前的性能市场中,我看到很多性能团队连第一个目标都做不到,更别提第二个目标了。 + +带着这个问题的答案,我们再回到前面给出的“性能测试”概念。不知道你有没有意识到,对于“性能测试”来说,上面那个定义可能足够完整。但对于“性能”而言,是不是我们做完定义中的事情就完了呢?并没有。 + +因为从一个完整的性能工程来看,一个系统上线并经过正常的业务场景之后,我们还需要做一件事情:把线上的性能数据拿回来,和性能测试过程中的数据做环比,看之前做的是否满足真实的业务场景。而环比的内容就是我们的性能模型、性能指标等。 + +经过对比之后,如果没有误差,就说明这个性能测试项目做得非常好;如果有误差,那就需要我们修正误差,以便下一次做得更加贴合真实的系统。所以你看,从一个完整的性能活动的角度来讲,刚刚我们回顾的“性能测试”概念缺少了一个环节,就是性能环比。 + +可是,性能环比又实在不能称为是“测试”的工作内容(请注意,我说的是:性能环比不是“测试”的工作内容,并没有说它不是性能团队的工作内容)。 + +也正因为如此,一直以来,不管我们怎么对“测试”这个概念做扩充,不管是将它“左移”还是“右移”(我也没搞懂啥左移右移的,不就是干活吗?说那么文艺干吗?),不管是做“灰度”还是“白盒”,只要一说是测试,它仍然会被限定在一个项目的某个时间段里。就像无论是敏捷、精益,还是瀑布,只要在一个具体的项目中,大家还是会普遍认为先有业务需求,再有产品设计,然后才有接下来的架构设计、研发、测试、运维。 + +也许有人会说,我可以不要测试,直接上线。是的,你可以这样做,只要能承担上线的风险就好了。可是,如果“测”和“不测”,上线都会有相同的问题,那这样的测试团队确实可以不要,“拉出去祭旗”即可。如果在你的经验中,“测”和“不测”出问题的比例是1:10,那估计测试就不会被放弃。这是一个非常合理的逻辑。 + +我们要做好性能,真正实现性能的那两个宏观目标,就不能只局限在“测试”上,不能将它当成是一个项目中的某一环,我们需要用“工程”的视角来看待“性能”这件事儿。 + +为性能结果负责需要三方面支持 + +讲到这,我们还需要考虑一个问题:如果真的把测试只局限在项目的某个时间段内,那测试人员需不需要对整个线上负责呢?我想你心里应该有答案了,那就是不需要,因为测试人员被局限在一个环节里了。 + +不过,我相信有很多测试人员还是背了不少性能问题的锅。 + +如果出了问题后你仅仅是被领导骂几句,工作也没丢,那倒没什么关系。可如果你是做第三方测试的,你为线上负过责吗?你敢负责吗?在我的职业生涯中,有无数次听说某个系统上线后出了性能问题,导致千万级、亿级的经济损失。说真的,作为性能团队的人,我们还真负不起这个责。 + +作为性能测试人员,如果要对性能结果负起责任,我们至少需要以下三方面的支持。注意,“测试”自然是不能局限在项目的某个时间段内的,否则这没法弄。 + + +技术细节 + + +首先,我们的技术细节需要做到和线上一致,比如说软硬件环境、网络架构、基础数据、测试场景、监控部署等等,这些我会在整个专栏中进行讲解。说到这,给你一个小提醒:在一个性能团队中,你自己的基础技能一定要能足够支撑起项目,这是一个前提。 + +关于技术细节要和线上一致的问题,有人可能会提到“线上全链路”。我想说的是,请不要看到一个概念就不理智地各种套用。在很多系统中,我们不可能在线上做这样的改造,原因很简单:如果你的全链路测试导致线上出了高级别事故,那把你一个团队废了都不解气。 + + +工作范围 + + +要对整个系统性能负起责任,我们性能测试人员的工作范围就需要扩大,并且要向前扩大到性能需求。 + +有人说性能需求不就是性能测试人员定的吗?这种无赖的锅,我们可背不起。因为一个性能需求,是由业务、架构、研发、测试、运维以及不懂技术的领导一起来定的。如果仅让测试人员来定性能需求,在我遇到过的项目中,有99.99%的机率会变成这个样子:这个性能项目仅仅是找找基本的技术瓶颈,有些性能团队甚至连技术瓶颈都找不到。 + +性能测试的工作范畴除了要向前扩大到性能需求外,还要再向后扩大到运维过程。我并不是在说性能团队的人要参与运维,而是我们要把运维过程中的数据拿回来做环比,然后迭代我们的性能实施过程。 + + +工作权限 + + +当然,工作范畴扩大了,也要做到权责对等。 + +我遇到过很多公司,他们的性能团队都是处在职低言轻的位置上:对外,干不过架构、研发、运维;对内,技术没有自信;对上,领导说啥就是啥;对下,哦,下面没人,所以不用对下。 + +你想想,在这样的局面里,有时连个系统、数据库的操作权限都没有,还能对性能做什么优化?对于一个你连优化都没有权限的系统,如果它的性能出了问题,肯定也不是你该背的锅。 + +那什么是我们要的工作权限呢?有两种:技术权限和指挥权限。 + +技术权限很容易理解,无非就是主机登录root、数据库DBA等权限。而指挥权限就是,我们在需要什么人做什么事情的时候,一定要能叫得动。比如你让运维查个生产数据,要是运维只给你一个白眼,这活就没法干了。所以,我们需要什么数据,会产生什么样的结果,一定要环环相扣,缺少了一个环节,那就走不下去。 + +当然了,我们说要有指挥权限,并不是让你瞎指挥别人做不相干的事情,比如“来,研发给我捏个肩膀”“来,运维去给我买个咖啡”……这分明就是找揍。 + +我们讲了这么多,那到底性能测试应该如何干呢?这就要引出“性能工程”这个概念了。 + +什么是性能工程? + +从“测试”到“工程”,看似是一个简单的描述变化,其实是完全不同的做事逻辑。 + +我先在这里下一个定义——RESAR性能工程。 + +我们平时说的性能工程,是将IT中的各种技术应用到具体的性能项目中的过程。而我提到的RESAR性能工程,是对性能项目过程中的各个具体的动作做更详细的描述,使之可以成为可以落地的具体实践。 + +“RESAR性能工程”这个名字是我自己定义的,你不用去网上搜索,现在还搜不到。下面我会为你描述RESAR性能工程的过程。注意啊,我们不讨论用什么样的研发模型(比如敏捷、DevOps等),因为这些都是过程的组织方式,我们暂时把它们抛到脑后,先来看看性能工程到底要干哪些事情。 + + +业务需求 + + +从整个项目的生存周期角度来看,有了业务需求之后,我们就要开始分析可能出现性能问题的业务关键点,像业务路径、业务热点数据、秒杀业务、实时峰值业务、日结批量等。然后再创建出业务模型。 + +对于新系统来说,我们就算是拍脑袋也要给出业务模型;如果是已有系统的业务模型,那我们就可以通过统计生产业务量的方式来得到。 + + +立项 + + +有了业务需求之后,技术项目就开始立项了。这时候需要具备性能架构思维的人介入到立项环节中,在技术选型、架构设计层面给予专业的意见,来规避以后可能出现的性能问题。 + +具体来讲,这位拥有性能架构思维的人需要做高可用、可伸缩可扩展、负载均衡SLB、TCP层优化、DNS优化、CDN优化等与性能相关并且是架构该干的事情。再细化一下,那就是各组件的线程池配置、连接池配置、超时配置、队列配置、压缩配置等细节。 + +有了这些内容之后,就开始做容量评估、容量模型建立、容量水位模拟等模型建立。 + + +研发 + + +接着我们就到了研发环节。在这一环节中,当一个功能实现后,性能团队要做的是:列出每一个方法在没有任何压力的情况下,它们的执行时间以及对象消耗的内存,以便后续做容量场景时做相应的计算。 + +这是一个琐碎的工作,不过我们可以用一些工具进行整体分析,不用一个个看方法和对象。通常,这个步骤在学术界有一个更为笼统的名字,你应该听过,叫白盒测试。 + +其实,行业中大部分人做白盒测试也只是看看功能是不是正常,关注性能的人少之又少。而且,这个工作经常由研发工程师来做。这里我们不讨论自己测自己有什么问题,毕竟我们不能否定所有研发工程师的责任心。 + +如果我们只从普遍的项目周期来看的话,在资本家极尽压缩的手段之下,研发工程师在业务功能研发出来后,基本已经精疲力尽了,还能有时间干这些活吗?这时候,性能团队的存在就有了价值,就是把代码拿过来做性能分析。 + +所以,不要再说性能团队的人不懂开发是合理的,从性能工程的角度看,我们测试人员需要具备一定的技能。 + + +测试 + + +有了完整的业务功能后,我们就到了测试环节,这时候性能测试工程师终于可以“正式”上场了。 + +在这一环节中,我们需要按照基准场景(单接口、单系统容量场景)、容量场景(峰值、日结、秒杀、日常等场景)、稳定性场景和异常场景的执行顺序,把前面所有和性能相关的工作都验证一遍。 + +关于异常场景是否要放在性能中的问题,其实一直都有着争论,不过我要说明一点,就是在我的概念中,只要是需要压力的场景,都可以放到性能中来做。 + + +运维 + + +在系统上线运维之后,我们还需要把运维过程中产生的业务数据和性能监控数据,与前面做的性能场景结果数据做环比。如果比出了问题,那就修正性能过程,然后再从修正点接着往下做。 + +在《性能测试实战30讲》专栏里,我用这张图总结了性能测试的概念: + + + +基于刚才讲的内容,我把它做一个变化:增加“业务分析和架构分析”、“环境准备”以及“生产运维”这三部分。 + + + +现在这张图就完整描述了RESAR性能工程的过程。 + +明白了性能工程要做哪些事情后,我们再来整体看一下“RESAR性能工程”。实施RESAR性能工程的要点是: + + +通过分析业务逻辑和技术架构,创建性能模型,制定性能方案,准备应用环境,设计并实施性能部署监控; +实现符合真实业务逻辑的压力; +构建性能分析决策树,通过监控手段获取各组件的性能计数器; +分析计数器的数据查找出性能瓶颈的根本原因并优化; +通过环比生产环境的性能数据修正场景。 + + +关于性能工程,你现在可能有很多疑问,下面我会着重给你讲一讲比较关键的几点。 + +一定的沟通成本是必要的 + +首先,还是来看我们这张性能工程的过程图。 + + + +在图中,你可能觉得业务和架构分析、性能需求指标、性能模型和生产运维这几个环节看起来比较单薄。实际上,它们的工作内容并不少,并且有可能花费你很高的沟通和操作成本,消耗较长的项目时间。不过,这三个环节是可以与其他工作并行的。所以,项目整体的周期并不会有所增加。 + +有人看到这里或许会问:“这样的性能工作岂不是要消耗很大的精力?费时又费钱,要是看不到产出,那还值得吗?”如果你也有此困惑,不妨看看我们前面讨论的内容: + + +如果性能测试团队不做瓶颈定位和优化,那是否可以给出“上线后生产系统不会产生性能问题”这样的答案呢? + +如果不能,那要这个性能测试团队有什么用?仅仅是找找初级的技术问题吗?这就像一个病人去医院看病,手术做了,药也吃了,一顿折腾后问医生:“我什么时候可以好呢?”要是医生说:“我也不知道!”你想想病人心里会是什么感受,有没有一种遇到庸医的感觉? + + +其实说到沟通和操作成本,可能是因为我遇到的工作环境大多比较“恶劣”,我经历过太多的江湖场面,所以非常清楚沟通的时间成本要远大于技术消耗的时间成本。不过,对于一个企业来说,这些工作一旦有了第一次流畅的执行后,往后几乎不会再消耗什么时间。而你如果把每天刷小视频和购物的时间拿来干这些活,也就足够了。 + +前段时间我给一个企业做咨询,从生产运维上拿数据回来做分析比对,只用了大概不到两个小时就把生产上的业务模型给弄出来了。不过,第一次做抽样的时间成本确实会高一些,因为我们可能需要搭建一些平台工具来支撑自己的想法。有了工具平台之后,后面的运维部分就比较简单了,不用太耗费精力。因为数据本来就在那里,性能团队只需要看一下那些数据。 + +不过在我遇到的客户场景中,经常可以看到性能团队的人想要个运维的监控数据都非常难。再加上如果你的技术又不怎么样,话说不出三句就让人怼得哑口无言,那别人就更会怀疑你要生产数据的潜在用心了。 + +所以,如果我刚接一个项目就看到这样的场景,那我肯定不会去问运维要数据来自己分析。我只会问他们要结果数据,并且我还会给他们定一个框,让他们就给框里的数据即可。 + +万一他们给不出来怎么办?没关系,你还记得小学生的绝招吗?告诉老师呀!经过沟通,领导知道了这些数据的用途,事情就好办了。如果运维觉得生产上的log都是关键的核心数据,那也没关系,让他们指定一个运维的人来天天陪着我就好了。在不断提供性能数据又反复加班的过程中,他陪着你干一段时间之后,就会主动把权限给你,哈哈。 + +其实话说回来,在性能工作中不管涉及到了什么职位或是什么背景的人,你都要记住一点,作为性能团队的人,在你和别的团队沟通时,一定要把问题提得精确具体,把为什么要这样做,这样做的成本、好处,以及不这样做的坏处,都说明白就可以了。 + +这样做有什么必要吗? + +我给你举个例子,你想想我们为什么要用真实环境中的业务比例来做测试模型?答案非常简单,因为你不这样做的话,测试出来的结果肯定不能回答生产容量的问题。如果一个业务的比例在生产上是10%,而你在性能场景中定为20%,那就可能产生完全不一样的结果。 + +在性能项目中,有很多因为沟通不畅而导致的执行偏差,所以我们一定要搞清楚各方想表达的具体在执行层面如何操作,这个沟通过程非常非常重要。 + +性能工程由谁来推动? + +我希望你能明白,我在这里做的所有努力都是为了让性能有一个完整的环路。说到这里,其实还有一个绝大的Bug,那就是性能工程由谁来推动。 + +关于项目工程级的东西,一定不是一个职低言轻的人能干得动的,即使授权也照样不行,因为这需要很强的项目管理能力。而上层领导又不懂技术,甚至不能理解为什么弄个性能要这么大动干戈(要是遇到这种情况,你可以这样解释:如果不关心线上会不会“死”的话,那就不用大动干戈)。 + +所以,性能工程一定要由一个职高言重的人来推动。至于具体的工作内容,由性能实施的人来承接就可以了。 + +“性能工程”不同于那些看似先进的概念 + +讲到这,你可能觉得有些概念与我所说的性能工程极为相似,比如全链路。我相信肯定有人会问,这时候是不是可以把全链路提到台面上来了?不是我贬低全链路的市场价值,在我们这个“说邮轮做筏子”的文化氛围里,很多企业都盯着大厂做事情,大厂做什么自己也跟着做什么,根本不仔细考虑这些事所产生的成本和代价。 + +其实大部分企业都消耗不起全链路的组织成本。如果仅仅做些技术改造,把链路改成旁路就叫做全链路,那实在是有些浅薄了。因为技术改造并不是关键的问题,关键在于你改造完了之后得跑起来。 + +做线上全链路的目的,是为了通过使用生产环境中的架构、软硬件环境、数据、网络结构等等,来达到模拟真实业务压力场景的目标。如果你做完了一连串的技术改造后,结果只运行了30%的业务压力,那就得不偿失了。如果你运行了100%甚至更高的业务压力,并且业务模型也符合生产的样子,那么恭喜你,这事做得非常有价值。 + +但是,很多系统不像互联网系统那样只有一个业务主流程。如果业务逻辑复杂度高,那出错的成本根本就不是一个企业能够承受的。我们不用脑袋考虑也能知道,这样不可能在线上做各种测试。所以,线上全链路这个思路,只对一些特定的业务场景有价值。而现在那些明明不适合却还要挤着脑袋往上凑的企业,再过几年自然会清醒下来。 + +至于DevOps,在这里我不会展开说,因为DevOps偏技术管理的角度。我也不想讨论什么左移、右移(我也没搞懂啥左移、右移的,不就是干活吗?说那么文艺干嘛?),因为“左移”“右移”这个词特别像是主动抢别人的活,有没有?一个是该干的活,一个是抢别人的活,从职责划分上听着就不那么名正言顺。就像我之前听到有人说“全栈”这个词一样,如今在我的生活中,它也只是酒桌上说笑的谈资而已。 + +在性能中,我不建议你用这些概念来规定边界,因为角度是不同的,而且也只有“工程”这个词符合我想表达的意思。讲到这,我希望你能明白,性能工程就应该是针对一个系统生命周期的完整的工程级活动。 + +性能工程不是飘在天上的 + +另外我还想强调一点,请不要拿着“工程”的幌子干“测试”的事情,也不要把工程说得飘在天上。只跟别人说一大堆原理,却没有一个落地的过程,那是彻头彻尾地耍流氓。我见过太多所谓的“专家”,谈起理论时一套一套的,但在落地的时候,就灰溜溜地往后躲。 + +我曾经在一个项目上遇到一个专家,他刚到工作现场时,那状态别提有多牛了。等他扯了一通后,我淡淡地说了一句:“来,解决问题吧”。谁知这哥们回了一句:“百度上查呀!”当时我都有买枪的冲动了,还是那种带梭子的重机枪。 + +关于性能工程中的概念,我还是要尽量说明一下:请你注意,我给你讲的概念都不是枯燥且飘在空中的东西,它们一定是可以落地的。并且在后面的课程中,我会让你看见它们具体是怎样落地的。 + +总结 + +好,我们这节课就讲到这里,我给你总结一下。 + +鉴于在当前的性能市场中,大家都太把“测试”这个词当成重点,而从测试的角度出发,又解决不了线上会不会“死”的问题。所以我把性能提到“工程”的层面来解析。如果一个企业可以从“工程”角度来完整地规划性能过程,那必然会超出当前“性能测试”的范畴。而且,也只有从“工程”角度出发,才能够真正保障一个系统的业务正常运行。 + +说到这,我们把性能工程再次定义一下。很显然,这是我们这节课的重点。 + +性能工程是指,通过分析业务逻辑和技术架构,创建性能模型,制定性能方案,准备应用环境,设计并实施性能部署监控,实现符合真实业务逻辑的压力,通过监控手段获取各组件的性能计数器,分析计数器采集出的数据,查找出性能瓶颈的根本原因并优化,最后通过环比生产环境的性能数据修正场景。 + +课后作业 + +最后,我想请你思考两个问题: + + +性能工程和全链路压测、DevOps等概念有什么区别? +描述下你理解的RESAR性能工程? + + +欢迎你在留言区与我交流讨论。当然了,你也可以把这节课分享给你身边的朋友,他们的一些想法或许会让你有更大的收获。我们下节课见! + +关于课程读者群 + +点击课程详情页的链接,扫描二维码,就可以加入我们这个课程的读者群哦,希望这里的交流与思维碰撞能帮助你取得更大的进步,期待你的到来~ + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/02关键概念:性能指标和场景的确定.md b/专栏/高楼的性能工程实战课/02关键概念:性能指标和场景的确定.md new file mode 100644 index 0000000..b4e8498 --- /dev/null +++ b/专栏/高楼的性能工程实战课/02关键概念:性能指标和场景的确定.md @@ -0,0 +1,194 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 02 关键概念:性能指标和场景的确定 + 你好,我是高楼。 + +上节课我们把性能从“测试”引到了“工程”级别。接下来,我们要理一理工程级别中几个重要的概念,包括: + + +性能需求指标; +性能场景; +性能分析决策树; +查找性能瓶颈证据链。 + + +这些概念贯穿整个性能工程,有了它们,我们就不会在性能项目中迷失方向。为什么这么说呢?接下来的课程里,我会给你一一分析。 + +为了能让你更好地消化这些内容,我们把这几个概念分成三节课来详细讲解。今天这节课我们先来看“性能需求指标”和“性能场景”。 + +性能需求指标 + +说到性能需求,真是我从业十几年来性能职场辛酸的起点。因为我几乎没有见过精准明确的需求,很多时候性能需求都变成了一句空话。如果你对此感触不深,我们不妨来看两个反面教材。 + +反面教材1: + + + +像这样的性能需求,基本上就是业务方的一种直观感觉,想看看单用户的操作响应,所以算不上是什么专业的性能测试需求。 + +不过你需要注意一点,这样的需求背后很容易埋着一个坑:列这个表的人可能想让系统在任何压力场景下都能达到这样的性能指标。那你就应该知道,明确性能需求是一个关键点,我们要明确在什么样的业务压力场景下要求这样的指标。在大压力的场景下,表格中所列的时间需求估计就实现不了了。因此,上面这张表格里的性能需求属于不合格的需求。 + +反面教材2: + +我们再来看看下面这个性能需求指标。 + + + +这个需求看起来非常清晰,但仍然有一个问题,那就是不够细化。为什么这么说呢? + +我给你举个例子,这里面有一个指标值是“前端账户服务类交易:≤5秒”,我们知道账户交易有不少功能,如果这里要求的是每个交易都不大于5秒的话,那就过于宽泛了。其他的需求也有类似的问题。 + +而且,表格里有一个指标是说“CPU平均使用率不高于80%”,这个技术需求看似很具体,可是我想问你,是什么样的CPU使用率?如果是us cpu的话,那是不是使用率不高于80%就可以保证系统是好的呢?还有没有其他的制约条件呢?要不要再看看CPU队列呢? + +我给你这么一讲,你有没有觉得上面这些需求指标都没法用了?其实,在实际的工作场景中还有很多反面教材。 + +那什么样的性能需求才是合理的呢?这就需要我们从四种不同的性能场景入手,区别对待。 + +基准场景的性能需求指标 + +我们先说下业务需求指标。我们可以列这样一个单业务性能指标的表格。 + + + +(这是一个明显为基准测试场景列的指标,我在这里只是简单列了几个重要的参数,其他参数你可以自行组装。) + +当然了,你要是喜欢的话,也可以把参数“标准方差”改为“响应时间的百分比”,比如90%、95%、99%这样的值。如果我们在刚才的表格里加上这类限制,就会变成下面这样: + + + +(注:这里的数据,我只是做一个示意展示,并不是说一定要满足这样的关系。) + +对此,你知道为什么我们要定标准方差或者是响应时间百分比吗?因为对于性能来说,当平均值是一个比较优秀的值时,有可能会出现非常抖动的情况。要知道,“标准方差”和“响应时间百分比”的作用并不一样。前者是为了描述一条曲线的上下浮动范围有多大,而后者是为了查看曲线的上限在哪里。 + +平时我常看的是标准方差,因为对于性能来说,系统受到一些瞬间毛刺的影响时,会出现一些比较高的值。当然了,这也并不能说明系统不够稳定,我们需要分析原因才能知道为什么会有毛刺。下面我们通过几张图来感受一下标准方差对曲线影响的重要程度。 + +当标准方差比较小的时候(标准方差约为2.5),示意图如下: + + + +当标准方差较大时(标准方差约为17.6),示意图如下: + + + +如果出现毛刺,示意图如下: + + + +(注:不知道你有没有发现,上面这张示意图其实是有问题的。因为显然上图是在固定的压力线程之下的,而中间有一个很高的毛刺,这就有问题了,如果全都是正确的事务,不应该出现这么高的毛刺。所以这种毛刺通常都是在压力过程中由事务错误导致的。 ) + +基准场景就是用这样的方式来限定业务需求指标的,那在容量场景中怎么办?我们需要添加什么参数来限定呢? + +容量场景的性能需求指标 + +对于容量场景来说,最重要的就是业务比例,也就是我们经常说的业务模型。同时,对于其他重要的性能参数也可以重新制定。示意如下: + + + +(注:这里的数据,我只是做个示意展示,并不是说一定要满足这样的关系。) + +你可以看到,在这个容量场景中我们确定了业务比例,也确定了总体TPS的需求指标,再通过百分比我们就可以计算出每个业务的TPS目标。此外,我还对响应时间做了限制。 + +从性能角度来看,这几个参数限制就足以定死一个场景了。从技术角度来说,这样的容量需求也是可测的。当然,容量场景也会有多个,这要取决于业务特性。 + +稳定性场景的性能需求指标 + +关于这个场景的指标,我在后面的课程中会给你详细讲解。这里我主要强调两点: + + +稳定性的时间长度要合理,也就是说要合理判断稳定性场景需要运行多长时间; +稳定性使用的TPS量级要合理,也就是说我们要合理判断稳定性场景应该用多大的压力执行。 + + +上述是稳定性场景中非常重要的两个指标。 + +异常场景的性能需求指标 + +针对该场景,你只需记住这个流程即可:针对系统的架构,先分析异常场景中的需求点,再设计相应的案例来覆盖。为什么要分析系统架构呢?因为在一个应用中,我们把功能测试完一遍之后,异常问题通常有两大类:其一是架构级的异常;其二是容量引起的性能异常。而对于架构级的异常,我们只能站在架构的角度进行分析。 + +描述完这些性能需求指标,我们是不是就可以往下走了呢?从性能技术的角度来说,的确可以往下走了。但是你别忘了,我们还没有描述用户级的指标。怎么办?这里就涉及到并发度的计算了。因为如果没有并发度的计算,我们就无法回答TPS和用户之间的关系。关于这一点,我会在后面的实践部分中给你做操作级的详细解释。 + +性能场景 + +通常情况下,有了性能需求指标之后,我们需要把这些性能需求指标一一对应到场景中,看它们符合哪个类型。 + +可能有人看到这里会说:“我觉得全都有了呀。其实不是!在我的性能工程理念中,场景绝对不只有脚本和业务模型这么点内容。我在上一个专栏中已经描述了场景设计和执行,有两个重点: + + +场景分为四类(基准、容量、稳定性、异常); +执行过程中要保持连续递增。 + + +在我们这个课程中,我要从工程级的角度把场景做更大的扩充。 + +你可能会想,场景是有多重要,至于吗?你可以把场景理解为是性能方案的落地,也是性能实施的核心,更是性能分析的起点……我大概能写出10个这样的句子来,来说明“场景”的重要性。 + +下面这张图是性能场景所包含的内容。 + + + +相信你可以感受到,在我的性能工程理念中,场景是一个复杂而宏大的概念。因为在我的工作中,只要场景跑起来,图中这些都是我要观察的内容。为什么?下面我就分别给你讲一讲,希望你能明白它们的重要性。 + + +性能脚本:性能脚本只是场景中用来施压的部分,它记录了这个场景要做的是哪些事情,是接口级脚本、业务级脚本,还是用户级脚本? + +参数化数据:这一点我在平时的培训中反复强调过多次,但是,仍然有很多人认为用少量的参数循环跑场景是合理的。这样的想法绝对是错的!因为如果严重的话,会直接导致结果不可用。 + +监控策略:请注意,在一开始的场景执行中,不要过度上监控工具,最好是先上全局监控工具。等有了问题之后,我们再重复执行场景,上定向监控工具。 + +执行控制:首先,我们得按“基准-容量-稳定性-异常”的逻辑执行;其次,在执行过程中要查看实时的数据曲线,并判断是停下来,还是继续,以及要分析哪些内容,以便我们清楚下一步要干什么事情。 + +场景调整:在这一步中我们需要明确很多东西,比如压力线程到底应该从多少开始,最大是多少;递增策略到底配置成什么样;要不要递减策略;持续时间是多长等等。 + +软硬件环境:在场景执行时我们脑子里要有概念,就是在这样的场景设计之下,软硬件的表现应该是什么样子,CPU、IO、内存、网络应该用多少,线程池是否合理等等,这些都要有经验上的判断和比对。 + +基础数据/铺底数据:不同的场景目标,对基础数据/铺底数据的要求是不一样的。而我们在性能场景中要求的基础数据/铺底数据就是和生产一致。 + +挡板/Mock/第三方:在场景中,对不可控的第三方一定要管控好,因为第三方的快慢会直接影响结果。这一步我们要根据场景的目标来。如果要测试的是真实生产逻辑,那就应该加上这一步;如果要测试的是,自己的系统有没有性能问题,那就可以屏蔽掉。但是在结果报告中,我们需要写明这个风险。 + + +在我的性能理念中,上述这些都是必须存在的概念,场景有了这些内容后才像一个样子。在后面的课程里,我也将为你详细描述如何把它们应用到具体的项目中。 + +有了完美的场景之后,并不代表我们就可以得到完美的答案。因为还有更重要的事情在等着你,那就是做性能的分析。性能分析的逻辑要比场景设计复杂得多,大体来说主要有两个重点: + + +性能分析决策树; +性能瓶颈证据链。 + + +我在前半生的职业生涯中,主要就是靠这两个思路支撑着我的工作,同时我也用这样的思路碾压过不少“马保国”们。在接下来的课程中,我会给你仔细讲解其中的精髓所在。 + +另外,还有两个关键点就是:构建性能分析决策树和判断性能瓶颈证据链,这些我也会在后面的课程中给你讲清楚。 + +总结 + +好,现在我们来总结一下这节课的内容。 + +从“性能测试”到“性能工程”的思路转换,并不是一句话,也不是画个图,写个文章,做个topic,就可以尽述的。我们只有在工作中将上面说的每一步应用到具体的工作中去,才是真正的工程。这也是我为你梳理性能概念的初衷。 + +我们再一起回顾下这节课的重点内容。 + +性能需求指标:没有业务指标就没有技术指标,而我们的工作就是让业务指标(比如并发用户数、在线用户数等)和技术指标(比如CPU、IO等)对应起来。在不同的性能场景中要定义好不同的性能需求指标,有些是自己看的,有些是给别人看的。 + +性能场景:其实性能场景真的不用有更多的分类了。我们讲的这四类场景(基准、容量、稳定性、异常)足够覆盖所有的性能执行过程。 + +课后作业 + +学完这节课后,请你再认真思考两个问题: + + +性能场景为什么只分为四类就够了? +你常见的性能需求指标都细化到了什么程度? + + +欢迎你在留言区写下你的思考和答案,和我交流讨论。如果你觉得这节课有收获,也欢迎把它分享给你身边的朋友,他们的一些想法或许会让你有更大的收获。我们下节课见! + +关于课程读者群 + +点击课程详情页的链接,扫描二维码,就可以加入我们这个课程的读者群哦,希望这里的交流与思维碰撞能帮助你取得更大的进步,期待你的到来~ + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/03核心分析逻辑:所有的性能分析,靠这七步都能搞定.md b/专栏/高楼的性能工程实战课/03核心分析逻辑:所有的性能分析,靠这七步都能搞定.md new file mode 100644 index 0000000..9c6b641 --- /dev/null +++ b/专栏/高楼的性能工程实战课/03核心分析逻辑:所有的性能分析,靠这七步都能搞定.md @@ -0,0 +1,136 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 03 核心分析逻辑:所有的性能分析,靠这七步都能搞定 + 你好,我是高楼。 + +我之前看过一些性能分析方法论,比如SEI负载测试计划过程、RBI方法论、性能下降曲线分析法等等,这些观点很多只是停留在概念和方法论的层面,并没有具体的落地细节,让人看了之后也不知道怎么进一步做下去。像这样的方法论,在我看来完全没有必要存在。 + +在这里我也延伸一下,国外的一些理念在被翻译成中文之后,有很多只是停留在被知道、被了解的阶段,并没有被广泛应用过。像我们刚才提到的那些方法论,可能有很多从事性能行业的人都不知道。可见,这样的方法论不管好不好,都是没有受众基础的。而那些少数知道的人,也只是将这些理论翻译过来作为大帽子扣在某些文档上,但具体干起活来,该怎么干还怎么干。 + +如果我只是这样空泛地跟你说这些方法论不好使,那就是骂街了,作为文化人,还是得有理有据才行。下面我们就来看一段具体的内容。 + +你在任何一个搜索工具(比如度娘、谷歌、360等等)上搜“性能测试方法论”这几个关键字,基本上都可以看到很多复制来复制去的内容。而这些内容基本上都是在描述一个测试的实施过程,并且这些实施过程也都基本停留在测试的阶段。比如下面这几段关于“SEI负载测试计划过程”的描述(内容可能有点长,不过这不是我们这节课的重点,你可以不用那么仔细去看)。 + + +SEI load Testing Planning Process是一个关注于负载测试计划的方法,其目标是产生“清晰、易理解、可验证的负载测试计划”。 + +SEI负载测试计划过程包括6个关注的区域:目标、用户、用例、生产环境、测试环境和测试场景。 + +①生产环境与测试环境的不同:由于负载测试环境与实际的生产环境存在一定的差异,因此,在测试环境上对应用系统进行的负载测试结果很可能不能准确反映该应用系统在生产环境上的实际性能表现,为了规避这个风险,必须仔细设计测试环境。 + +②用户分析:用户是对被测应用系统性能表现最关注和受影响最大的对象,因此,必须通过对用户行为进行分析,依据用户行为模型建立用例和场景。 + +③用例:用例是用户使用某种顺序和操作方式对业务过程进行实现的过程,对负载测试来说,用例的作用主要在于分析和分解出关键的业务,判断每个业务发生的频度、业务出现性能问题的风险等。 + + +从上面的描述可以看到,这里面都是偏向“测试”执行过程的内容。这个理论的提出者是Mark McWhinney,1992年,他在SEI和John H. Baumert写了一个类似CMMI的内容:《Software Measures and the Capability Maturity Model》。 + +在这份304页的白皮书里,Mark McWhinney描述了四个级别的软件度量和成熟度模型,分别是:可重复级、可定义级、可管理级和可优化级,其中描述的也都是过程、影响、成本、质量、稳定性这些内容。 + +像这样的定义本身没有问题,但如果是像CMMI那样,企业只是拿个证,并不遵循它来做具体的项目,那这个理论就没有意义了。 + +在性能行业中,我们想要实际落地,可是从SEI中又得不到具体的指引,这才是问题。因为我们要有具体的性能容量、性能瓶颈的分析落地,才能体现性能项目最终的价值。而这一部分的缺失,使得很多性能从业人员没有可参考的成长路径。(至于其他的性能方法论,我就不再一一解析了。你如果有兴趣,可以查一查。) + +这也是为什么在进入性能分析案例之前,我要和你先聊一下性能分析的核心逻辑。 + +在写《性能测试实战30讲》专栏中的第6讲时,我觉得已经把所有的核心分析逻辑都写完了,有一种呕心沥血的感觉,我也觉得自己不会再写分析逻辑了。可是在写这个专栏时,我还是觉得有些不够。 + +因为现在的性能工程师,最缺的就是分析思路。有很多人会各种工具,但是这些分析工具的数据拿出来应该如何组装成一串逻辑,又是很多人的难点。 + +如果从“测试”这个行业来看,性能分析的完整案例可以说非常少见。如果从运维或其他职位的角度来看的话,倒还是有一些的。但是纵观大部分的性能案例,都缺少一个提炼到更高一层的分析方法论。 + +所以,我觉得一个性能分析专栏,一定要有这样一讲内容来把分析逻辑说清楚。 + +不过,在写这一讲时,我没有之前那种呕心沥血的感觉了。因为我们这一讲的宗旨就是,把性能分析思路给固定下来。是的,你没有看错,我说的是“固定”下来,也就是说,这一讲之后,我们再也没有其他的分析思路了。 + +我把这个固定的分析思路称为“RESAR性能分析七步法”。(请你注意,这只是RESAR性能工程的一部分,并不是整个RESAR性能工程。) + +RESAR性能分析七步法 + +跟着RESAR性能工程理论,我们的分析逻辑是这样的: + + + +第一步:压力场景数据。 + +在我看来,压力工具提供的数据只有两个曲线最为重要:一个是TPS(你要是喜欢,也可以叫其他名字,像RPS、HPS、CPS之类,纠结名称并不是我们的关键),另一个是响应时间。 + +不管是什么压力工具,只要能给出这两个曲线即可,即便是你自己开发的多线程压力工具也无所谓。不管是线程、协程,只要可以根据业务逻辑发出相应的压力即可。 + +为什么说TPS和响应时间曲线最为重要,那其他的曲线,比如说吞吐量、点击率、错误率这些呢?错误率是有错误的时候才需要看的,这一点我想你应该不会有异议。而吞吐量、点击率之类的曲线,也必然会和TPS曲线是相同的趋势,所以我们不需要再单独分析。 + +因此在第一步,我们只需要从压力场景中获取TPS和响应时间曲线就可以了。 + +第二步:分析架构图。 + +接着是分析架构图,这一步我们需要做的是,看压力流量的路径。这主要是为了看分析链路的前后关系。如果业务逻辑复杂,部署也复杂,那我们就可以分为业务路径和部署路径。如果不复杂,那画一个路径就够了。 + +第三步:拆分响应时间。 + +在这里,我要着重跟你强调一下,在性能分析过程中,拆分响应时间是分析的关键起点。有很多人在看到响应时间高的时候,总是不往下拆分就开始猜测系统的性能瓶颈在哪里。如果你也是这样,这种思路你一定要转换过来,不要总是在现象上纠结。 + +第四步:全局监控分析。 + +话说现在很多看似拥有全局监控能力的工具平台,实际上还是会缺失一些计数器。所以,我们一定要根据性能分析决策树,来补全性能计数器。如果获取这些计数器,在当前的工具平台上实在有困难,那就通过其他的工具或命令来补充,这一点你要特别注意。 + +之前我给一个银行客户分析问题的时候,他们说各个层面的监控数据都有。但实际情况却是,与问题相关的计数器,他们是缺失的。这样的情况其实很普遍,很多公司往往只关注大层面的覆盖,忽视了具体计数器的完备。 + +“全局监控分析”‘这一步有个关键,就是你要对你所看到的计数器有足够的了解。如果你看了数据之后,没有任何反应,那就说明你还没有达到分析的能力。这个时候,你要么就是来看专栏,要么就是去看书,要么就是去查度娘(虽然度娘在这个时候也不好使),要么就是放弃。 + +那我们怎么知道一个全局计数器有没有问题呢?这就需要功底了,这些就是我经常说的计算机基础知识。性能分析的范围很大,不见得与它相关的所有知识的头上都会标着“性能”两个字。 + +经常会有人问GC频率达到多少是合理的?这就是很难回答的问题。只要GC不影响系统容量,那就是可以的。所以,我们得先看GC和系统容量曲线之间的关联关系,然后再做判断。 + +在性能分析中,没有哪个计数器可以直接跳出来告诉我们说“我有病!”,只能靠我们自己去判断它有没有病。 + +第五步:定向监控分析。 + +看了全局监控计数器之后,我们通过判断分析,知道哪个方向上有问题后,才去做定向的监控。千万不要一开始就弄什么代码层分析、具体参数调整、SQL调整啥的。不仅乱,而且不一定见成效。 + +在“定向监控分析”这一步有个关键判断,就是能不能和上面的全局监控计数器对应。当我们想找一个栈的时候,要知道为什么要去找栈;当我们要判断IO参数有问题时,也要知道为什么要去找IO参数。 + +这样一来,前后的逻辑关系就形成了我一直在RESAR性能工程中强调的一个词——证据链。 + +第六步:判断性能瓶颈点。 + +有了证据链,就一定要来到性能瓶颈点的判断过程。比如说,我们在栈中判断有没有锁的存在,那至少你要在栈中找到这个锁有哪些线程在等待,哪个线程持有。再比如说,我们要判断一个SQL慢,那至少你要把SQL的执行过程拿出来,看到底是哪一步有问题。 + +有了对性能瓶颈的判断,再往下走就是要找到解决方案。 + +第七步:确定解决方案。 + +其实,知道瓶颈点在哪里,也并不一定知道有什么解决方案。就像有人看到了栈中有锁,但也不知道怎么解锁;有人知道SQL慢,但也不知道如何优化SQL一样。不过,这一步是性能项目体现价值的关键点。不管前面做得有多么辛苦,给出解决方案总是我们性能人员的重点。 + +上述就是RESAR性能分析七步法,它在每个性能分析的案例中都会被使用。在具体的案例中,我们可能会选择其中的几步来做。当然,每个案例都走七步也是完全可以的。只是在我们分析的过程中,如果已经有了明确的问题点,就不用再往回分析了。 + +比如说,如果我们已经知道了问题点,直接定向监控分析就可以了,不用再走第四步。还有就是,如果性能瓶颈不会导致响应时间长,而是出现其他的问题,可能就不需要走第三步。这些内容你将在后面课程的案例中看到具体的应用。 + +总结 + +我们这节课讲的性能分析的核心逻辑,是RESAR性能工程中具体的性能瓶颈分析指导。没有它,就没有分析的具体落地步骤。但是如果在落地时不遵循这个核心逻辑,它也就没有价值了。 + +在这七步法中,会涉及到对应的知识体系,像在构建性能分析决策树、查找性能瓶颈证据链时,我们就需要强大的技术基础知识做支撑。如果一个人不具备全部的基础知识也没关系,可以组织一个团队共同来做这件事情。 + +对于我来说,RESAR性能分析七步法,是我做任何一个性能瓶颈定位时必须要依赖的逻辑,它帮助我解决了很多以前没有遇到过的问题。如果你想应用这个过程,那就请记住我一直强调的一句话:在性能分析中,你只需要知道下一步做什么就可以了,我们终会找到瓶颈的具体原因。 + +课后作业 + +最后,请你思考两个问题: + + +为什么在性能项目中需要RESAR性能分析七步法? +在你之前做过的调优案例中,用的是什么样的分析逻辑? + + +欢迎你在留言区写下你的思考和答案,和我交流讨论。如果你觉得这节课有收获,也欢迎把它分享给你身边的朋友,他们的一些想法或许会让你有更大的收获。我们下节课见! + +关于课程读者群 + +点击课程详情页的链接,扫描二维码,就可以加入我们这个课程的读者群哦,希望这里的交流与思维碰撞能帮助你取得更大的进步,期待你的到来~ + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/04如何构建性能分析决策树和查找瓶颈证据链?.md b/专栏/高楼的性能工程实战课/04如何构建性能分析决策树和查找瓶颈证据链?.md new file mode 100644 index 0000000..e34b608 --- /dev/null +++ b/专栏/高楼的性能工程实战课/04如何构建性能分析决策树和查找瓶颈证据链?.md @@ -0,0 +1,269 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 04 如何构建性能分析决策树和查找瓶颈证据链? + 你好,我是高楼。 + +上节课我给你讲了一个完整且固定的性能分析流程——RESAR性能分析七步法,它可以应用在任何性能分析案例中。在这个分析流程中,有两个关键的技术和思路,分别是性能分析决策树和性能瓶颈证据链。这也是我们在02讲中提到的,贯穿整个性能工程的两个重要概念。 + +今天这节课,我们一起来看看怎么一步步构建性能分析决策树和查找性能瓶颈证据链。 + +如何构建性能分析决策树? + +实际上,性能分析决策树在性能监控设计和性能瓶颈分析时都会被使用,并且在性能瓶颈分析时,我们必须要有决策树的思路。所以,这是我一定要给你描述的步骤。在后面课程的分析中,我们也会大量地用到“性能分析决策树”这个词。 + +首先,什么是性能分析决策树呢? + + +性能分析决策树是包括了系统架构中所有技术组件、所有组件中的模块以及模块对应计数器的完整的结构化树状图。 + + +在这句话中,有三个重要的层级,分别是组件、模块和计数器: + + + +在后面的课程中,我也会频繁使用这三个关键词。 + +不过,这个关于“性能分析决策树”的定义虽然很合理,但还是会让人感觉抓不住重点,就像看了哲学语句一样。但是IT技术并不是哲学,所以,我们还要把它细化下去。 + +构建性能分析决策树是我们了解一个系统非常关键的环节,总体来看,它分为4个步骤。 + +第一步:根据系统的架构,罗列出整个系统架构中的组件。 + +在我们这个课程搭建的系统中,整体架构的组件是这样的: + + + +对应上面这张图,我们就能罗列出该系统的所有组件,如下: + + + +第二步:深入细化组件中的每一个重要的模块。 + +由于我们这个系统中的组件太多,我们先选择其中一个比较重要的组件——操作系统,来做示例。因为操作系统是性能分析中非常重要的一个环节,几乎所有的问题都会体现到操作系统的计数器上。至于其他的组件,你可以根据我说的流程自行确定一下。 + +根据操作系统的特性,我们先画出它的重要模块: + + + +我在图中画了六个模块,其中一个是Swap。Swap的存在是为了让系统在没有内存可用的时候,可以用硬盘来做内存的交换分区。当Swap被用到时,其实就说明性能已经有了问题。所以,一般不建议在性能项目中使用Swap,我们应该在使用Swap之前就把性能问题解决掉。不过,在生产环境中,如果我们被逼无奈,也只能把Swap打开了。 + +至于图中其他几个模块,基本上是我们在性能分析中必须要看的内容。 + +第三步:列出模块对应的计数器。 + +在罗列计数器的时候,我们要注意把每个模块重要的计数器都囊括进来,千万不能漏掉重要的第一级计数器,如果漏掉的话,有些数据可能就需要重新跑了。 + +现在,我们来看其中一个重要的模块——CPU。我们可以通过top命令查看到CPU的几个重要的计数器: + +[root@k8s-worker-8 ~]# top +top - 00:38:51 up 28 days, 4:27, 3 users, load average: 78.07, 62.23, 39.14 +Tasks: 275 total, 17 running, 257 sleeping, 1 stopped, 0 zombie +%Cpu0 : 4.2 us, 95.4 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.4 st +%Cpu1 : 1.8 us, 98.2 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st +%Cpu2 : 2.1 us, 97.9 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st +%Cpu3 : 1.0 us, 99.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st + + +可以看到,top命令中有九个计数器,分别是:us/sy/ni/id/wa/hi/si/st/load average。前8个是CPU的计数器,这是毋庸置疑的。那最后一个load average是什么呢? + +在搜索引擎上,我们经常能看到一些关于load average的笼统描述。有人说load average高过CPU就说明系统负载高,也有人说load average和CPU并没有直接的关系,观点不一。 + +load average作为CPU一个非常重要的性能计数器,我们在用它做判断时,如果不能给出非常明确的判断方向,那就有大问题了。所以,我要给你好好描述一下。 + + +load average是1m/5m/15m内的可运行状态和不可中断状态的平均进程数。 + + +这个说法非常对,但中规中矩,而且不是非常具体。 + +对于“可运行状态”,我们比较容易理解。从上面代码块中的数据可以看到,tasks中有一个Running状态的任务数。不过,可运行状态不只是它,还有一些万事俱备只差CPU的情况。也就是说tasks中的Running状态的任务数,与load average的值之间并不是直接的等价关系。 + +同样在vmstat中,我们也能看到运行的任务数。在vmstat的proc列有两个参数:r 和 b。其中,r 是指正在运行状态和等待运行状态的进程,在man手册中是这样描述的: + +r: The number of runnable processes (running or waiting for run time). + + +对于不可中断状态,我们经常见到的就是等IO。当然,也不止是等IO,内存交换也会在这个状态里,这种等IO的情况会体现在vmstat中proc下面的b列。下面这个计数器就是vmstat的proc的b列的说明。 + +b: The number of processes in uninterruptible sleep. + + +所以我们可以看到,load average实际上就是vmstat中proc列的r与b之和。 + +其实,CPU不止有us/sy/ni/id/wa/hi/si/st/load average这九个计数器,它还有两个计数器藏在mpstat中,分别是%guest和%gnice。 + +[root@k8s-worker-8 ~]# mpstat -P ALL 2 +Linux 3.10.0-1127.el7.x86_64 (k8s-worker-8) 2021年02月15日 _x86_64_ (4 CPU) + +14时00分36秒 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle +14时00分38秒 all 5.13 0.00 3.21 0.00 0.00 0.26 0.00 0.00 0.00 91.40 +14时00分38秒 0 4.62 0.00 2.56 0.00 0.00 0.00 0.00 0.00 0.00 92.82 +14时00分38秒 1 4.57 0.00 3.05 0.00 0.00 0.00 0.00 0.00 0.00 92.39 +14时00分38秒 2 5.70 0.00 3.63 0.00 0.00 0.00 0.00 0.00 0.00 90.67 +14时00分38秒 3 5.70 0.00 4.66 0.00 0.00 0.00 0.00 0.00 0.00 89.64 + + +从下面这段描述中可以看到,如果在宿主机上看%guest和%gnice这两个参数,是比较有意义的,因为它可以说明Guest虚拟机消耗CPU的比例。如果你的宿主机上有多个虚拟机,你就可以通过这两个参数值来看虚拟机是不是消耗CPU太多,然后通过查进程的方式看看具体是哪一个虚拟机消耗得多。 + +%guest Show the percentage of time spent by the CPU or CPUs to run a virtual processor. +%gnice Show the percentage of time spent by the CPU or CPUs to run a niced guest. + + +所以,在Linux操作系统中,如果是宿主机,我们就需要看11个计数器。如果是虚拟机的话,看9个计数器(图中前9个)就可以了: + + + +讲到这里,我们已经罗列出与CPU相关的所有计数器了。我们前面提到,要根据Linux操作系统中的各个模块,把相应的计数器全都罗列出来。所以,其他模块的计数器我们也需要像这样完整地找出来。 + +当我们把Linux操作系统所有的关键一级计数器找完之后,就会得到这样一张图: + + + +请你注意,这些计数器里,有一些会比较关键,我根据自己的经验,把重要的计数器都标红了。当然,如果你对操作系统有足够的理解,也可以从不同的角度,用不同的思路,列出自己的图。要知道,罗列计数器只是一个体力活,只要你愿意,就能列出来。 + +第四步:画出计数器之间的相关性。 + +从上面的图可以看到,尽管我们列出了很多计数器,但是这些计数器之间的关系是什么,我们还不清楚。 + +在分析的时候,由于我们要根据相应的计数器,来判断问题的方向(有时候一个计数器并不足以支撑我们作出判断,那我们就需要多个计数器共同判断)。所以,我们要画出这些计数器之间的关系,这一步非常重要。 + +我根据自己的理解,画出了Linux操作系统中计数器之间的关系,如下所示: + + + +如果线画得太多,看起来会比较混乱,所以我只画出了几个最重要的关键相关性。 + +至此,我们就把Linux操作系统的性能分析决策树画完了,计数器也覆盖全了。不过,工作还没有结束,因为我们还需要找到合适的监控工具,来收集这些计数器的实时数据。 + +收集计数器的实时数据 + +请你注意,在收集计数器的实时数据时,可能不是一个监控工具就可以完全覆盖所有的计数器的。所以,在分析的时候,我们一定要清楚监控工具的局限在哪里。如果一个工具无法监控到全部的计数器,那就必须用多个工具相互补充,比如说对于Linux操作系统的监控,现在我们最常用的监控工具就是prometheus+grafana+node_exporter,像这样: + + + +这是我们经常用的监控Linux操作系统的模板。那这个模板的数据全不全呢?其实一对比就能发现,这个模板虽然可以覆盖大部分Linux操作系统的性能计数器,但是并不全,比如说网络的队列、内存的软/硬错误等,这些就没有覆盖到。 + +因此,我们在使用监控工具之前,一定要把性能分析决策树中的计数器,与监控工具中的计数器做对比,缺什么,我们就要在分析时用其他的监控工具或是命令来做补充。 + +请你记住一点,用什么监控工具并不重要,有没有监控到全部的计数器才重要。即便我们没有任何的监控工具,要是只敲命令也能监控到全部计数器的话,也是可以的。所以,我希望你不要迷信工具。 + +到这里,整个性能分析决策树还没有结束。因为在这个系统的架构中还有其他的技术组件,而我们的任务就是把这些技术组件,都按照我们前面讲的那四个步骤,画出相应的性能分析决策树,最终形成一张完整的大图。 + +在我们这个系统中,如果画出全部的技术组件和模块的话,就会看到下面这张图: + + + +整个图我没有全部展开,要是展开的话就太大了,会完全看不到末端的计数器。不过你放心,在后面课程的分析案例中,我会让你看到如何应用这个性能分析决策树来做相应的问题分析。 + +讲到这里,我就把性能分析决策树完整地给你描述完了,步骤也列清楚了。古人有云:授人以鱼不如授人以渔。所以,我希望你看到这些步骤之后,可以画出你自己项目中完整的性能分析决策树。 + +我还要强调一点,这里我们只列出了第一级的性能计数器。如果第一级计数器有问题,而我们还不能判断出问题的原因,那就需要接着找第二级、第三级、第四级…..关于怎么找更深层级的性能计数器,我会在接下来证据链的部分给你讲解。 + +不过,在我们一开始梳理性能分析决策树时,没必要把所有层级的性能计数器都列出来。因为可能我们整个项目做完了,都没有用到全部的计数器,全列出来容易浪费时间。只有我们看懂了第一级的计数器,并且判断出问题的方向,才有可能需要看更深层的计数器。 + +所以,你要注意,理解每个计数器的含义才是至关重要的。如果不理解计数器的含义,也不知道如何运用计数器,那我们就不可能知道怎么去做分析。 + +有了性能分析决策树之后,我们如何应用它呢?接下来,就不得不讲一讲性能瓶颈的证据链了。 + +怎么查找性能瓶颈证据链? + +在每次做培训或者性能分析时,我都会强调,性能分析中有一个非常关键的词,那就是“证据链”。 + +如果性能分析没有证据链,那么分析思路就是跳跃的。通俗点讲,就是蒙,根据经验蒙,根据资料蒙。这种跳跃的分析思路是非常容易出错的。所以,我们在分析性能瓶颈时,一定要有理有据、顺藤摸瓜。 + +那具体怎么来判断呢?接下来我会通过一个例子给你讲解。 + +全局监控分析 + +在进入这个例子之前,我需要跟你强调一点,在性能分析中,监控分析可以分为两个部分:全局监控分析和定向监控分析。全局监控分析是指将整个架构中具有概括性的计数器都分析一遍。也只有从全局监控计数器,我们才能看到性能问题的第一层现象。 + +比如说,如果我们想找到哪一行代码有消耗CPU的问题,首先,我们是不知道具体是哪一行代码的,但是在CPU计数器上会体现出CPU使用率高的现象。而全局监控就是为了查看CPU消耗是不是比较高,当我们看到CPU消耗高的时候,再往下找是哪一行代码消耗CPU比较高,这就用到了定向监控分析的思路。 + +所以在性能分析的过程中,我通常会分为全局监控分析和定向监控分析两个阶段。而全局监控分析既可以用监控平台,也可以用命令。 + +在我之前做过的一个项目中,有一个主机有24颗CPU,我在场景执行过程中看到了这样的数据: + + + +这就是我们前面提到的,性能分析决策树中CPU监控的具体命令。所以我们接下来的分析逻辑就是:根据性能瓶颈的分析应用,选择相应的监控手段,覆盖性能分析决策树中需要监控的计数器,然后再进一步细化分析。 + +在上面这张图中,我们看到所有CPU的%us使用率并没有很高,%id也不小,还有一些剩余。但是%si(软中断)这一项,唯独第22颗CPU的%si有21.4%之高。那这个软中断合理不合理呢? + +有人可能会说,这也没有太高嘛,能有什么问题?如果我们只看%si平均值的话,可能确实发现不了问题的存在。但如果仔细看图中更详细的数据,就会有不一样的结论了,这也是为什么我们要把每颗CPU的使用率都先列出来的原因。 + +我们说,当一个应用跑着的时候,如果应用代码消耗了很多CPU,那%us的使用率应该会变高,但是在上面我们看到的并不是%us高。并且在合理的情况下,每个CPU都应该被使用上,也就是说这些CPU的使用率应该是均衡的。 + +但是在这个图中,我们看到只有CPU 22的%si使用率比较高,为21.4%。并且软中断(%si)只使用了24颗CPU中的一颗。这个软中断显然是不合理的。 + +定向监控分析 + +那既然软中断不合理,我们自然是要知道这个软中断到底中断到哪里去了,为什么只中断到一颗CPU上。所以,我们要去查一下软中断的数据。 + +在Linux操作系统中,有不少工具可以查看软中断,其中一个重要的工具就是/proc/softirqs。这个文件记录了软中断的数据。在我们这个例子中,因为有24颗CPU,数据看起来实在比较长。所以,我先把一些CPU数据过滤掉了,只留下图中这些数据来分析。 + + + +在这张图中,我标注出了CPU 22和它对应的模块名NET_RX。你可能会奇怪,怎么一下子就找到了这个模块呢?这是因为CPU 22的使用率最高,在它上面产生的中断数自然要比其他CPU高得多。 + +由于 /proc/softirqs文件中的各个计数器都是累加值,其他模块在各个CPU上的累加值比例都没有太大的差别,只有NET_RX模块在不同CPU上的计数值差别很大。所以,我们可以作出这样的判断:CPU 22的使用率高是因为NET_RX。 + +我们看“NET_RX”这个名字就能知道,这个模块的意思是网络接收数据。那在网络数据接收的过程中,什么东西会导致网络的中断只中断在一颗CPU上呢? + +我们知道,网络的接收是靠队列来缓存数据的,所以我们接下来得去查一下网络接收的队列有多少个。我们可以通过/sys/class/net/<网卡名>/queues/路径,查看到这个网络队列:- + + +不难看出,RX队列确实只有一个,这也就意味着,所有的网络接收数据都得走这一个队列。既然如此,那自然不可能用到更多的CPU,只能用一颗了。 + +讲到这里,我要多说明一点:因为我们这节课重在讲解分析的逻辑,所以我在这里就不做更加细致的分析了。如果你要想更深入地理解网络中断逻辑,可以去翻一下Linux源码,看看net_rx_action函数。不过归根到底,中断在系统层的调用都是do_softirqs函数,所以当我们用perf top -g命令查看CPU热点的时候,你同样也可以看到上面我所描述的逻辑。 + +既然我们知道了网络接收队列只有一个,那上述问题的解决思路自然也就出来了:多增加几个队列,让更多的CPU来做中断的事情。因为网络中断是为了把数据从网卡向TCP层传输,所以队列一旦变多了,传输速度也会变得快一些。 + +所以,我们的解决方案就是增加队列: + + + +在我们这个例子中,我把队列增加到了8个,这样网络接收数据就会用到8颗CPU。如果你想用更多的CPU也可以,这里我们有24颗CPU,那就可以设置24个队列。如果你用的是虚拟机,对于这个改动,你可以在KVM的XML参数中增加一个队列参数来实现;如果你用的是物理机,那你就只能换网卡了。 + +现在我们把整个分析逻辑都理清楚了,下面就按照这个逻辑把对应的证据链画一下: + + + +根据图中展示的逻辑,当我们看到%si(软中断)高时,就去查看cat/proc/interrupts目录。其实对于这个软中断,有两个目录可以体现出来,一个是cat/proc/interrupts,另一个是cat/proc/softirqs。前者不仅包括软中断,还包括硬中断,如果我们看到软中断高,其实直接看cat/proc/softirqs就可以了。 + +紧接着,我们要找到对应的模块,然后再找到这个模块的实现原理,最后我们给出相应的解决方案。 + +这样一来,这个问题的完整证据链就找到了。 + +相信你通过这个例子可以看出,性能瓶颈的证据链其实就是,性能分析决策树在具体应用过程中完整的分析逻辑的记录。 + +请你注意,我们一开始并不会直接去看cat/proc/softirqs的内容,因为这个太定向、太具体了。我们一定要先看全局的数据,然后再一步一步往下去找,这样才是合理的。不难发现,我们从全局监控分析拿到的数据,和从定向监控分析拿到的数据是不一样的,因为它们是不同的角度,这一点你也要格外注意。 + +总结 + +这节课和上节课一起,我把整个分析逻辑总结为:RESAR性能分析七步法,这是性能分析方法论中最重要的核心逻辑。不过,这些内容“孤篇盖全唐”的可能性很小,毕竟性能分析涉及到的所有细节无法在短短两节课里尽述。 + +所以,我把我认为最重要的部分都给你描述出来了,当你在实际应用时,可以按照这个思路,实现你的性能分析决策树和性能瓶颈证据链。前提是,你要理解你的系统,理解你的架构,并且要理解我给你讲的分析逻辑。 + +在这节课中,我为你讲解了两个重要的内容:性能分析决策树的构建,以及性能瓶颈证据链的查找。这是我们在每一个性能问题的分析过程中,都必须经历的。只有把决策树和证据链具体落地,我们才能在性能分析中无往不利。 + +课后作业 + +最后,请你思考一下: + + +如何构建你自己的性能分析决策树? +举一个你做过的性能分析中有完整证据链的案例。 + + +欢迎你在留言区与我交流讨论。当然了,你也可以把这节课分享给你身边的朋友,他们的一些想法或许会让你有更大的收获。我们下节课见! + +关于课程读者群 + +点击课程详情页的链接,扫描二维码,就可以加入我们这个课程的读者群哦,希望这里的交流与思维碰撞能帮助你取得更大的进步,期待你的到来~ + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/05性能方案:你的方案是否还停留在形式上?.md b/专栏/高楼的性能工程实战课/05性能方案:你的方案是否还停留在形式上?.md new file mode 100644 index 0000000..eccf8c5 --- /dev/null +++ b/专栏/高楼的性能工程实战课/05性能方案:你的方案是否还停留在形式上?.md @@ -0,0 +1,505 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 05 性能方案:你的方案是否还停留在形式上? + 你好,我是高楼。 + +性能方案在性能项目中是非常重要的文档之一,它指导着整个项目的执行过程,同时也约束着项目的边界,定义相关人员的职能。但令人痛心的是,如今它变得“微不足道”。 + +在很多常见的性能项目中,性能方案就是一个文档,并且是一个静态的文档。里面写的东西是什么,项目后续会不会按这个内容去做,基本上没有人关心。它就成了一个形式,只有在评审方案的时候才会被拿出来看看。甚至在一些第三方测试项目中,我看到有些甲方连方案的内容都不看,直接问有没有。如果有就过去了。你看,一个必需的交付物却无人关心。 + +在我的性能工程理念中,性能方案是一个重量级的文档。在性能项目中,它被叫成是“性能测试方案”。在我这里,我要把“测试”二字拿掉。为什么要拿掉?因为这取决于我在前面课程中提到的性能工程理念,我希望把整个项目的过程都描述在方案中。 + +我讲的性能方案和那些常见的性能方案究竟有什么区别呢?我们不妨先来看看,后者普遍都是什么样的。 + +这些目录相信你并不陌生,我们经常能看到有这样目录的性能测试方案。 + + + +这里我就不一一列举了,再看更多的目录,其实也是类似的。这样的目录大纲,在我看来分为这么几个部分。 + + +常规项目信息:比如说测试背景、测试范围、测试准则、测试环境、实施准备、组织结构、项目风险、里程碑。 +性能实施信息:比如说测试模型、测试策略、监控策略。 +项目输出:比如说测试脚本、测试用例/测试场景、监控采集数据,测试报告、调优报告。 + + +从性能测试方案的角度来说,这些内容似乎够了。但是,如果抛弃掉“测试”这个视角,从一个完整的性能项目的角度来看,这些内容其实还不够。 + +以前经常有人问我要一个性能项目方案模板,我一直不太理解,就这么一个目录,为什么还非得要呢?自己一个字一个字也照样写得出来吧。后来我慢慢理解了,他们要的其实不是大纲目录,而是一个完整的性能方案内容。 + +不过,我们知道,项目实施的性能方案基本上都不太可能直接发出来,即便做了脱敏,一些内容也可以看出是属于某些企业的。所以,出于职场的素养,这些内容不得不放在硬盘里,直到过时,直到烂掉。这也就是为什么我们在网上看不到非常完整的性能方案。 + +可是,尽管网上的方案不完整,在性能市场上,我们还是看到有太多的性能方案是抄来抄去的,总体的结构大同小异。这也就导致了在性能项目中,大量的方案都只有形式上的意义。 + +因为我们这个课程需要基于一个完整的项目来编写,所以,我把这个项目整体的方案写在这里。你将看到,我认为的真正完整并且有意义的性能方案是什么样子,希望能给你一些启发。 + +由于性能方案的内容比较多,并且相对琐碎,我给你整理了一张性能方案的目录表格,你可以对应这张表格,来学习具体的内容。 + + + +性能项目实施方案 + +背景 + +项目背景 + +我们刚才提到,这个课程需要搭建一个比较完整的性能项目。但由于各企业的商业软件有限制,我们只能选择一个开源的项目,并且这个项目最好可以覆盖常见的技术栈,以便能给你提供更多可借鉴的内容。 + +基于上述原因,我们搭建了一套电商项目。对此,我要说明两点:第一,这个项目是较为完整的;第二,当前电商的系统比较典型,并且这个项目完全开源,便于我们改造。 + +不过,也因为这是一个开源的项目,功能和性能都不知道会有什么样的问题,我们只有在性能实施的过程中一步步去发掘,所以这是一个非常符合我们当前目标的项目。 + +性能目标 + + +根据经典的电商下单流程,测试当前系统的单接口最大容量。 +根据业务比例设计容量场景,充分利用当前资源,找到当前系统的性能瓶颈,并优化,以达到系统的最佳运行状态。 +根据稳定性场景,判断当前系统可支持的系统最大累加容量。 +根据异常场景,判断当前系统中的异常对性能产生的影响。 + + +在每一个性能项目中,性能目标都会影响项目的整个过程。因此,对目标的把握将决定一个性能项目的走向。 + +记得我在之前的一个项目中,客户方要求做到支持1000万人在线,项目不算小,开发团队有300人左右。到那里后,我一看只有两个性能测试人员,而且其中一个还是刚毕业,还处于打野练级的状态。于是,我就过去找他们科技部的老大说,这个项目我做不了。因为根据这个目标和这样的人员配置,我清楚这个坑根本不是我能填得上的,所以得赶紧认怂。 + +后来,那个科技部的老大问,需要什么样的资源才能做下去呢?于是我提了几个必需的条件,直到这些条件都满足了,我才敢接这个项目。 + +我举这个例子是想让你明白,性能目标在上下级眼中根本是不一样的,而我这样的处理,是希望把性能目标在上下级的脑袋中变得一致。这一点很重要。 + +测试范围 + +需要测试的特性 + +电商主流程,如下所示: + + + +不需要测试的特性 + +批量业务。 + +准则 + +启动准则 + + +确定系统逻辑架构和部署架构和生产一致。 +确定基础数据和生产一致或按模型缩放。 +确定业务模型可以模拟生产真实业务。 +环境准备完毕,包括:- +4.1. 功能验证通过。- +4.2. 各组件基础参数梳理并配置正确。- +4.3. 压力机到位,并部署完毕。- +4.4. 网络配置正确,连接通畅,可以满足压力测试需求。 +测试计划、方案评审完毕。 +架构组、运维组、开发组、测试组及相关专家人员到位。 + + +结束准则 + + +达到项目要求的性能需求指标。 +关键性能瓶颈已解决。 +完成性能测试报告和性能调优报告。 + + +暂停/再启动准则 + +1. 暂停准则 + + +系统环境变化:举例:系统主机硬件损坏、网络传输时间超长、压力发生器出现损坏、系统主机因别的原因需升级暂停等。 +测试环境受到干扰,比如服务器被临时征用,或服务器的其他使用会对测试结果造成干扰。 +需要调整测试环境资源,如操作系统、数据库参数等。 +该测试机型无法达到规划指标要求。 +出现测试风险中列出的问题。 + + +2. 再启动准则 + + +测试中发现问题得以解决。 +测试环境恢复正常。 +测试风险中出现的问题已解决。 +环境调整完毕。 + + +业务模型和性能指标 + +业务模型/测试模型 + + + +请你注意,这个模型并不是随便填写的,而是直接从生产环境中取得的业务比例。关于如何从生产中取出这样的业务比例,有很多种手段。这个并不复杂,通过统计日志就可以做到。 + +不过,在有些企业中,生产数据都在运维手里,性能团队怎么也得不到,因为没有权限,就连做业务模型的数据都没有。如果是这样的话,那性能项目是可以直接终止的,因为做了也没有多大的意义,最多也就是找那些瞎吹牛的架构师和乱写代码的开发人员,犯的一些错而已。 + +业务指标/性能指标 + + + +在不清楚项目目标TPS的情况下,我们暂定目标TPS为1000。为什么暂定1000呢?因为根据经验来说,在这样的硬件环境下,定为1000并不算高,除非是没有合理的软件架构。 + +系统架构图 + +系统技术栈 + +系统技术栈是让我们知道整个架构中用了哪些技术组件。而这些技术组件中有哪些常见的性能瓶颈点,有哪些性能参数,我们都可以在查看技术栈时得到一些相关信息。而在后续的工作中,我们也要整理出相应的关键性能参数配置。 + +下面这张表格,就是我们在后续课程的案例分析中,会用到的技术栈。我在搭建这个系统时,考虑的是尽量覆盖当前技术市场中的主流技术组件。 + + + +系统逻辑架构图 + +画系统的逻辑架构图是为了后续性能分析的时候,脑子里能有一个业务路径。我们在做性能分析时,要做响应时间的拆分,而只有了解了逻辑架构图才可以知道从哪里拆到哪里。 + + + +系统部署架构图 + +画部署架构图是为了让我们知道有多少节点、多少机器。在执行容量场景时,你的脑子里要有一个概念,就是这样的部署架构最大应该可以支持多少的容量上限。 + +此外,对一些无理的性能需求,你看了部署架构之后,其实就可以拒绝。比如说前段时间有个人跟我说,他们有一个CRM系统,在做性能的时候,说要达到1万的并发用户。而实际上,那个系统就算是上线了,总用户数可能都不到1万。 + + + +性能实施前提条件 + +硬件环境 + +通过对整体硬件资源的整理,我们可以根据经验知道容量大概能支持多少的业务量级,而不至于随便定无理的指标。比如说,当看到下面表格中这样的硬件配置,我想没有人会把指标定为10000TPS。因为即使是对于最基础的接口层来说,这样的硬件也支持不了这么大的TPS。 + + + +我们可以看到,当前服务器总共使用在应用中的资源是:64C的CPU资源,128G的内存资源。NFS服务器不用在应用中,故不计算在内。因为单台机器的硬件资源相对较多,所以在后续的工作中,我们可以将这些物理机化为虚拟机使用,以方便应用的管理。 + +在成本上,所有物理机加在一起大概8万左右的费用,这其中还包括交换机、机柜、网线等各类杂七杂八的费用。 + +我之所以会对硬件的成本进行一个说明,主要是因为在当前的性能行业中,很少有性能工程师去做成本的计算。我们说性能项目的目标是让线上的系统运行得更好,与此同时,我们也要知道使用了多少成本在运行业务系统。 + +在当前的性能行业中,有大量的线上主机处于高成本低使用率的状态当中,这是极大的资源浪费和成本消耗。我经常在性能项目中,看到一台256C512G的硬件服务器里,只运行了一个4G JVM的Tomcat,性能工程的价值完全没有在这样的项目中应用起来。 + +因此,我时常会痛心疾首地感慨性能行业的不景气: + + +企业中没有意识到性能的价值,觉得摆个高配置的硬件服务器,业务系统的性能就能好起来。其实完全不是这样。 + +性能市场从业人员完全没有把性能的价值,透明化地体现出来。并且很多性能人员自身的技术能力不足,这也让一个企业完全看不到性能本该有的价值。 + + +鉴于此,作为性能从业人员,我们必须要了解硬件配置和整体业务容量之间的关系。 + +工具准备 + +测试工具 + + + +在测试过程中,我们将使用JMeter的backend Listener把数据直接发到InfluxDB中,然后再由Grafana来展现。我们不使用JMeter的分布式执行功能或本地收集数据的功能,因为这样会消耗本地的IO。 + +然而,现在还是有很多性能人员,仍然在项目中频繁地使用那些性能工具的低性能操作手段,同时还在不断抱怨性能这么差。对于这种现状,我希望你可以明白一点:我们要理智地使用工具,不要觉得一个性能测试工具拿起来就可以用。 + +监控工具 + + + +根据RESAR性能工程中的全局-定向的监控思路,我们在选择第一层监控工具时,要采集全量的全局计数器,采集的计数器包括各个层级,这里请参考前面的架构图。 + +但是,请你注意,在全局监控中,我们要尽量避免使用定向的监控手段,比如说java应用中的方法级监控、数据库中的SQL监控等。因为在项目开始之初,我们不能确定到底在哪个层面会出现问题,所以不适合使用定向监控思路。 + +那全局监控怎么来做才最合理呢?这里我们可以参考线上运维的监控手段。注意,我们在性能监控过程中,尽量不要自己臆想,随意搭建监控工具。 + +有时我们可能为了能监控得更多,会在测试环境中用很多监控手段。但实际上,线上运维时并不用那些手段,这就导致了监控对资源的消耗大于生产环境的资源消耗,我们也就得不到正常有效的结果。 + +前面我们提到在选择第一层监控工具时,需要采集全量的全局计数器。在我们采集好全局的计数器后,还需要分析并发现性能问题,然后再通过查找证据链的思路,来找性能瓶颈的根本原因。 + +数据准备 + +基础数据 + + + +在性能工程中,我们一直强调基础数据要满足两个特性: + + +满足生产环境的真实数据分布:要想做到这一点,最合理的方式是脱敏生产数据。如果你要自己造数据的话,也一定要先分析业务逻辑。在我们这个系统中,我造了243万条用户数据和250万条地址数据。 + +参数化数据一定要使用基础数据来覆盖真实用户:一直以来,很多人都在使用少量数据做大压力,这种逻辑是完全不对的。在性能脚本中一定要用基础数据来做参数化,而用多少数据取决于性能场景的设计。 + + +性能设计 + +场景执行策略 + +场景递增策略 + +对于性能场景,我一直在强调一个观点,那就是性能的场景必须满足两个条件: + + +连续 +递增 + + +所以在这次的执行过程中,我也会把这两点应用到下面的业务场景中。 + +你也许会问,如果不连续递增的话会有什么问题呢?比如说下面这样的图: + + + +在图中画红框的地方,其实就是递增带来的性能问题表现。因为在递增过程中,被测系统的资源要动态分配。系统会不会在这个时候抖动,我们完全可以从这样的图中看出来,而这样的场景才是真实的线上场景。 + +如果不连续递增,就不会有图中红框这样的部分。当然了,要是不连续递增,也就不能模拟出线上的真实场景。 + +高老师画重点!敲黑板了!要模拟生产场景,连续递增一定要做到的,不容迟疑。 + +而在不同工具中,设置连续递增的方式是不同的。 + +LoadRunner设计如下: + + + +JMeter设计如下: + + + +总之,请你记住,在设计场景中,我们一定要做到上面这种连续递增的样子。 + +业务场景 + +在RESAR性能工程中,性能场景只需要这四类即可: + + + +执行顺序先后为:基准场景、容量场景、稳定性场景、异常场景。 + +请你注意,除了这四类性能场景外,再没有其他类型的场景了。在每一个场景分类中,我们都可以设计多个具体的场景来覆盖完整的业务。 + +下面我给你一一解释一下。 + +1. 基准场景 + +我经常看到有人说,用脚本加上三五个线程跑上多少次迭代,就算是基准场景了。你可以想想这样的场景意义何在?它仅能验证一下脚本和场景是正确的而已。所以,我不把这样的步骤称为基准场景。 + +在我的RESAR性能工程理念中,基准场景必须是容量场景的前奏。具体怎么做呢?那就是在基准场景中,我们也要通过递增连续的场景做到最大TPS。也就是说在基准场景中,我们要把单接口或单业务压到最大TPS,然后来分析单接口或单业务的瓶颈点在哪里。 + +可能你会问,在基准场景中有没有必要做调优的动作呢? + +根据我的经验,应该先判断当前单接口或单业务的最大TPS,有没有超过目标TPS。如果超过,并且响应时间也在业务可接受的范围之内,那就不用调优。如果没有超过,那必须要做调优。 + +另外,根据RESAR性能工程理论,性能执行的第一阶段目标就是把资源用光,第二阶段的目标是将系统优化到满足业务容量。要知道,任何一个系统要调优都是无止境的,而我们的目标是要保证系统的正常运行。 + +因为在我们这个课程的示例项目中只有一个系统,所以,我们先做接口级的,然后把接口拼装成完整的业务量,并实现业务模型,然后再在容量场景中执行。在这里,我们将执行测试范围中接口的基准场景。 + +2. 容量场景 + +有了基准场景的结果之后,我们就进入了容量场景的阶段。在容量场景中,我们还是要继续秉承“连续、递增”的执行思路,最重要的是,要实现我们前面提到的业务模型,来真实模拟线上的业务场景。 + +我们可以经常看到,现在很多的性能项目里,大部分性能需求都提得不是很具体,从而导致性能场景的模型和生产场景不一致,这是一个严重的问题。 + +还有一个严重的问题是,即便业务模型和生产一致了,也会由于性能工程师在执行过程中没有严格模拟业务模型中的比例,性能场景的结果变得毫无意义。要知道,在执行过程中,响应时间会随着压力的增加而增加,我们仅用线程数来控制比例是非常不理智的,因为在执行的过程中会出现业务比例失衡。 + +那应该如何控制这个比例关系呢?如果你是用JMeter的话,可以使用Throughput Controller来控制业务比例,如下所示: + + + +当然,你也可以用其他方式来实现。总之,在场景执行结束之后,我们要把业务比例做统计,并且要和业务比例对比,当比例一致时,才算是合理的场景。 + +在容量场景中,我们还有一个要确定的事情,就是什么是最大的TPS。 + +我想请你看一下这张图,你觉得最大的TPS是多少呢? + + + +你是不是想说最大的TPS是700? + +不管你给出的是不是这个答案,在我的性能理念中,我都想跟你强调一点:容量场景的最大TPS是指最大的稳定TPS。 + +那么你看,上面这张图已经抖动了,已经不稳定了,我们再去找它的最大TPS还有什么意义呢?你敢让一个生产系统运行在这样抖动的状态中吗?所以,对于上图中这样的TPS曲线,我会把最大的稳定TPS定为第三个阶梯,也就是在600左右,而不会定在700。 + +另外,也请你注意,在性能场景中,特别是在容量场景中,经常有人提到“性能拐点”这个词,并且把性能拐点称作是判断性能瓶颈的关键知识。对此,我先不做评判,我们来看一下什么是拐点,它在数学中的定义是这样的: + + +拐点,又称反曲点,在数学上指改变曲线向上或向下方向的点。直观地说,拐点是使切线穿越曲线的点(即连续曲线的凹弧与凸弧的分界点)。 + + +那么在TPS曲线中,你真的能找到这样的点吗?反正我是找不到。就以我们上面那张图为例,图中哪里是拐点呢?也许有人会说这个曲线没有拐点。咳咳,那就没得聊了…… + +可见,性能拐点其实是一个在具体执行过程中非常有误导性的概念。请你以后尽量不要再用“性能拐点”这个词来尝试描述性能的曲线,除非你是真的看到了拐点。 + +3. 稳定性场景 + +在完成了容量场景之后,我们就要进入稳定性场景的阶段了。到现在为止,在性能的市场中,还没有人能给出一个稳定性场景应该运行多长时间的确切结论。我们知道根据业务属性不同,稳定性场景也有不同的设计思路,可是这样说起来未免有些空泛。所以,我在这里给出一个稳定性场景的运行指导思路。 + +在稳定性场景中,我们只有两个关键点: + +第一个关键点:稳定性场景的时长。 + +关于稳定性场景的时间,我经常看到网上有人说一般运行两小时、7*24小时之类的话。可是,什么叫“一般”,什么又叫“不一般”呢?作为从业十几年的老鸟,我从来没有按照这样的逻辑执行过,也从来没有看到过这些运行时长的具体来源,只看到过很多以讹传讹的文章。 + +在性能领域中,这样的例子实在太多了,现在我也见怪不怪,毕竟保持本心做正确事情最为重要。下面我给你解释一下什么才是合理的稳定性场景时长。 + +我们知道,一个系统上线之后,运维人员肯定会做运维巡检,如果发现有问题就会去处理。有的系统是有固定的运维周期,比如说会设定固定的Job来做归档之类的动作;有的系统是根据巡检的结果做相应的动作。 + +而稳定性要做的就是保证在运维周期之内业务可以正常。 所以,在性能的稳定性场景中,我们要完全覆盖业务容量。比如说对于下面这张图: + + + +在运维周期内,有1亿笔业务容量。根据上面容量场景中的测试结果,假设最大稳定TPS是500,那稳定性场景的执行时长就是: +\[稳定性时长 = 100000000 \\div 500 \\div 3600 \\div 24 \\approx 2.3 (天)\]通过这样的计算,我们就能知道稳定性场景应该跑多长时间,这也是唯一合理的方式。 + +第二个关键点:用多大的TPS来执行。 + +对此,我看到网上有人提到,用最大TPS的80%来运行稳定性场景。这里我不禁要问了,为什么?凭什么不能用最大的来运行呢? + +记得我在做培训的时候,有过多次这样的讨论。有人说,之所以用最大TPS的80%,是因为在执行稳定性场景时不能给系统太大的压力,否则容易导致系统出现问题。 + +这种说法就奇怪了。既然容量场景都能得出最大的TPS,为什么稳定性就不能用呢?如果用最大的TPS执行稳定性场景会出现问题,那这些问题不正是我们希望测试出来的性能问题吗?为什么要用低TPS来避免性能问题的出现呢? + +所以,用最大TPS的80%来做稳定性场景是一个错误的思路。 + +在我的性能理念中,在执行稳定性场景时,完全可以用最大的稳定TPS来运行,只要覆盖了运维周期之内的业务容量即可。如果你不用最大的稳定TPS来运行,而是用低TPS来运行,那也必须要覆盖运维周期之内的业务容量。 + +讲到这里,我觉得上述内容足以指导你做出正确合理的稳定性场景测试了。 + +4. 异常场景 + +对于异常场景,有些企业是把它放到非功能场景分类中的,这个我倒觉得无所谓。不管放在哪里都是要有人执行的。我之所以把异常场景放在性能部分,是因为这些异常场景需要在有压力的情况下执行。 + +对于常规的异常场景,我们经常做的就是: + + +宕主机; + +宕网卡; + +宕应用。 + + +除此之外,在现在微服务盛行的时代,我们还有了新的招——宕容器。 + +当然,你也可以用一些所谓的“混沌工程“的工具来实现对容器的随机删除、网络丢包、模拟CPU高等操作,不过,这就是一个大话题了。在这后面的课程里,我会设计几个常用的异常性能场景来带你看下效果。 + +监控设计 + +全局监控 + +其实,有了前面的监控工具部分,监控设计就已经出现在写方案之人的脑子里了。对于我们这个课程所用的系统,全局监控如下所示: + + + +从上图来看,我们使用Prometheus/Grafana/Spring Boot Admin/SkyWalking/Weave Scope/ELK/EFK就可以实现具有全局视角的第一层监控。对工具中没有覆盖的第一层计数器,我们只能在执行场景时再执行命令来补充了。 + +定向监控 + +那后面的定向监控怎么办呢?在这里我也大体列一下常用的工具。不过,请你注意,这些工具是在有问题的时候才会去使用。 + + + +其实在性能分析中,除了表格中的这三个工具之外,还有很多工具会在查找性能瓶颈证据链时使用,我在这里无法全部罗列出来,只能根据系统使用到的技术组件,大概罗列一下我能想到的常用工具。在后续课程的操作中,如果你发现我们用了表格中没有列出的工具,也请你不要惊讶。 + +项目组织架构 + +在性能方案中,我们一定要画出项目的组织架构图,并且请你一定要在这部分写明各组织人员的工作范围和职责,避免出现扯皮的情况。我大体画一下常见的组织架构: + + + +这是我按照事情来划分的,而非职场中的工作职位,我觉得这是一个合理的组织架构。在这张图中,性能脚本工程师所负责的事情,其实是现在大部分性能从业人员都在做,并且仅仅在做的事。至于性能分析工程师,在很多性能项目中几乎不存在,也没有这样的固定职位。其实,性能分析工程师很有必要存在。 + +此外,架构师、开发工程师、运维工程师都需要在支持性能分析的状态。请注意,我说的“支持”,并不是指站在旁边看着,而是在有了问题之后,要能具体地给出支持,而非推诿责任。 + +业务方是性能的业务需求来源,这是一定要有的。如果业务方提不出来什么合理的性能需求,那这个项目基本上会是稀碎的样子。 + +至于老板这个角色,在性能项目中,我经常看到的老板都不懂什么叫性能,只会叫着要支持XXX并发用户数,支持XXX在线用户数。其实,这样的老板沟通起来也很简单,就是拿结果给他就好了。不过,在性能项目的执行过程中,当资源不足时,请你一定要让老板知道,同时降低老板的预期,要不然在后续的沟通中会非常费劲。 + +成果输出 + +过程性输出 + + +脚本 +场景执行结果 +监控结果 +问题记录 + + +在性能项目中,过程性输出有这些内容就够了,不用更多,当然,也不能更少了。我经常看到很多性能项目在执行完之后,除了有一份性能测试报告之外,什么过程性输出都没有。我实在不理解这样的企业是怎么积累性能经验的。所以,我还是要规劝你一句,在性能项目中,尽量多做一些归档整理的工作,以备在后面的项目中查阅,并实现自己的技术积累。 + +结果输出 + +通常情况下,在我做的性能项目中,都会输出两个最终交付的文档:一个是性能场景执行结果记录的报告(就是现在我们常写的性能测试报告),另一个是性能调优报告。 + +性能项目测试报告 + +性能项目测试报告想必大家见得多了,这里我只强调几点: + + +性能结果报告一定要有结论,而不是给出一堆“资源使用率是多少”、“TPS是多少”、“响应时间是多少”这种描述类的总结语。你想想,性能结果都在这个报告中了,谁还看不见怎么滴?还要你复述一遍吗?我们要给出“当前系统可支持XXX并发用户数,XXX在线用户数”这样的结论。 + +一定不要用“可能”、“或许”、“理应”这种模棱两可的词,否则就是在赤裸裸地耍流氓。 + +性能结果报告中要有对运维工作的建议,也就是要给出关键性能参数的配置建议,比如线程池、队列、超时等。 + +性能结果报告中要有对后续性能工作的建议。 + + +性能调优报告 + +为什么我要强调单独写调优报告呢?因为调优报告才是整个性能项目的精华,调优报告中一定要记录下每一个性能问题的问题现象、分析过程、解决方案和解决效果。可以说,调优报告完全是一个团队技术能力的体现。 + +项目风险分析 + +对于性能项目的风险,我把比较常见的风险列在这里: + + +业务层的性能需求不明确 +环境问题 +数据问题 +业务模型不准确 +团队间协调沟通困难 +瓶颈分析不到位,影响进度 +…… + + +在我们这个课程所用的项目中,比较大的风险就是: + + +硬件资源有限。 +项目时间不可控,因为出了问题,并没有人支持,只能自己搞。 + + +不过请你放心,我会努力克服困难,把这个项目的执行过程都记录下来。 + +到这里,整个性能项目实施方案就结束了。如果你认真看到了这里,那么恭喜你,你已经超越了很多人。我为你点赞! + +总结 + +在这节课里,我把一个性能方案该有的内容以及要写到什么程度,都给你梳理了一遍。希望能给你一些借鉴。 + +性能方案是一个性能项目的重要输出。如果你是在项目中做快速迭代,可能并不需要写如此复杂并且重量级的文档。因为文档里描述的很多工作都已经做过了,你可能只需要跟着版本去做迭代比对就好了。 + +但对于一个完整的项目来说,性能方案就显得极为重要。因为它指导了这个项目的整个过程。在性能方案中,我们强调了几个重点:业务模型、性能指标、系统架构图、场景设计、监控设计等,它们都会对整个项目的质量起到关键作用。 + +最后,我希望你可以在后续的项目中,尝试去写一个完整的性能方案。 + +课后作业 + +学完这节课,请你思考两个问题: + + +如何精确模拟业务模型? +为什么我们要强调系统架构图的重要性? + + +欢迎你在留言区与我交流讨论。当然了,你也可以把这节课分享给你身边的朋友,他们的一些想法或许会让你有更大的收获。我们下节课见! + +关于课程读者群 + +点击课程详情页的链接,扫描二维码,就可以加入我们这个课程的读者群哦,希望这里的交流与思维碰撞能帮助你取得更大的进步,期待你的到来~ + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/06如何抽取出符合真实业务场景的业务模型?.md b/专栏/高楼的性能工程实战课/06如何抽取出符合真实业务场景的业务模型?.md new file mode 100644 index 0000000..087e287 --- /dev/null +++ b/专栏/高楼的性能工程实战课/06如何抽取出符合真实业务场景的业务模型?.md @@ -0,0 +1,256 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 06 如何抽取出符合真实业务场景的业务模型? + 你好,我是高楼。 + +我们知道,业务模型一直是性能项目中很重要的环节。在容量场景中,每个业务比例都要符合真实业务场景的比例。如果不符合,那场景的执行结果也就没有意义了。 + +但是,我们经常可以看到,很多性能从业人员因为对业务模型的抽取过程不够了解,或者是拿不到具体的数据,导致业务模型和生产业务场景不匹配,进而整个性能项目都变得毫无意义。 + +也有大量的项目,并没有拿历史业务数据做统计,直接非常笼统地拍脑袋,给出相应的业务模型,这样显然也是不合理的。可是,这种情况在金融、互联网等行业中十分常见。 + +当然,也有人为了让业务模型和真实业务场景尽可能匹配,会直接拿生产环境的请求进行回放。可是,即便我们拿生产环境的请求录制回放了,也不能保证业务模型和未来的业务场景一致,因为未来的业务场景会随着业务推广而变化。 + +所以说,我们在做场景时首先要明白,当前的场景是要模拟历史业务场景,还是未来业务场景。 + +如果是未来的业务场景,那就要靠业务团队给出评估,而非性能团队。不过,在当前的性能市场中,经常有企业要求性能团队给出业务模型,这显然是不理智的。首先,性能团队的业务背景不如业务团队更熟悉;其次,他们对业务市场的把握也不够专业。 + +其实,在真实的工作场景中,业务模型的确认从来都不应该由一个团队来做,而应该由业务团队、架构团队、开发团队、运维团队和性能团队共同确定,并最终由项目的最上层领导确认。 + +如果一个系统有历史业务数据,那我们获得业务模型就有背景数据了。这时候,性能团队应该从历史业务数据中抽取出各场景的业务模型。如果系统没有历史数据,那就应该像对未来业务模型评估一样,需要各团队协作给出当前的业务模型。 + +正是基于我们前面提到的各种问题,经常有性能从业人员问我,我们应该如何从历史业务数据中抽取出业务模型?可能你也有这样的困惑,下面我们就来详细地说一说,同时我会借助实例为你展示一个具体的过程。 + +大体上来说,抽取真实业务模型有两个大步骤: + + +抽取生产业务日志。这一步可以通过很多种手段来实现。这节课我给你展示两种比较常见的手段。一种是当没有日志统计系统时,使用awk命令来抽取;另一种是使用ELFK来抽取。 +梳理业务逻辑。 + + +对于第一步而言,我们抽取生产业务日志是为了得到对应的业务比例。下面我们先来看看怎么用命令抽取生产业务日志。 + +使用命令抽取生产业务日志 + +这里我以少量的Nginx日志举例。在Nginx中,日志格式通常如下所示: + +120.220.184.157 - - [26/Oct/2020:14:13:05 +0800] "GET /shopping/static/skin/green/green.css HTTP/1.1" 200 4448 0.004 0.004 "https://www.xxx.cn/shopping/" "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.101 Safari/537.36" "124.127.161.254" +120.220.184.203 - - [26/Oct/2020:14:13:05 +0800] "GET /shopping/static/js/manifest.0e5e4fd8f66f2b389f6a.js HTTP/1.1" 200 2019 0.003 0.003 "https://www.xxx.cn/shopping/" "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.101 Safari/537.36" "124.127.161.254" +120.220.184.149 - - [26/Oct/2020:14:13:05 +0800] "GET /shopping/static/js/app.cadc2ee9c15a5c1b9eb4.js HTTP/1.1" 200 138296 0.100 0.005 "https://www.xxx.cn/shopping/" "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.101 Safari/537.36" "124.127.161.254" + + +以上数据内容及顺序可以在Nginx配置文件中配置。我们的目标是抽取其中某个时间内的每秒的访问量,所以,我们只需要把对应的时间取出来统计一下即可,命令如下: + +cat 20201026141300.nginx.log|awk '{print $4}' |uniq -c + + +我们得出下面的结果: + +5 [26/Oct/2020:14:13:05 + 3 [26/Oct/2020:14:13:06 + 14 [26/Oct/2020:14:13:07 + 4 [26/Oct/2020:14:13:08 + 1 [26/Oct/2020:14:13:09 + 2 [26/Oct/2020:14:13:10 + 1 [26/Oct/2020:14:13:12 + 2 [26/Oct/2020:14:13:20 + 14 [26/Oct/2020:14:13:23 + 1 [26/Oct/2020:14:13:24 + 2 [26/Oct/2020:14:13:26 + 2 [26/Oct/2020:14:13:29 + 9 [26/Oct/2020:14:13:30 + 9 [26/Oct/2020:14:13:31 + 1 [26/Oct/2020:14:13:32 + 13 [26/Oct/2020:14:13:35 + 2 [26/Oct/2020:14:13:37 + 20 [26/Oct/2020:14:13:38 + 2 [26/Oct/2020:14:13:39 + 33 [26/Oct/2020:14:13:44 + 17 [26/Oct/2020:14:13:46 + 5 [26/Oct/2020:14:13:47 + 23 [26/Oct/2020:14:13:48 + 29 [26/Oct/2020:14:13:49 + 4 [26/Oct/2020:14:13:50 + 29 [26/Oct/2020:14:13:51 + 26 [26/Oct/2020:14:13:52 + 22 [26/Oct/2020:14:13:53 + 57 [26/Oct/2020:14:13:59 + 1 [26/Oct/2020:14:14:02 + + +这样就可以知道哪段时间里的访问量最高了。这里我们其实可以灵活运用,如果你只想取到分钟、某小时、某天都可以做相应的命令调整。例如,我们想取到分钟级,只要加上相应的截取命令就可以了,如下所示: + +cat 20201026141300.nginx.log|awk '{print $4}' |cut -c 2-18|uniq -c + + +对应的结果如下: + + 352 26/Oct/2020:14:13 + 1 26/Oct/2020:14:14 + + +上述结果的意思是,在我这个日志中有两分钟内的数据,第一分钟中有352个请求,第二分钟中只有一个请求。 + +如果你想请求URL来做统计,那么就可以修改命令: + +cat 20201026141300.nginx.log|awk '{print $7}' |cut -c 1-50|uniq -c + + +结果如下: + +................ + 1 /shopping/checkLogin + 1 /shopping/home/floor + 1 /sso/loginOut + 1 /shopping/home/navigation + 6 /shopping/home/floor + 2 /shopping/home/floorGoods + 1 /shopping/home/sysConfig + 4 /shopping/home/floorGoods + 1 /shopping/home/floor + 1 /sso/loginOut +................ + + +此时我们取日志中的第七个数据,然后截取统计。这样我们就可以知道,每个时间段内每个请求的数量,也就可以得到相应的业务比例了。 + +上面这些命令你只要灵活运用,处理数据量不太大的文件是没什么问题的。 + +使用ELFK抽取生产业务日志 + +如果你想使用ELFK抽取日志,具体可以按如下步骤来做: + + +安装ELFK。这里的ELFK是指ElasticSearch/Logstash/FileBeat/Kibana的组合。具体安装方法可以搜索一下,网上的教程多如牛毛。 +配置好ELFK后,在Kibana的Discover界面就能看到收集的信息。注意,一条日志对应的就是一次命中。 +通过选择时间段就可以看到有多少请求在这个时间段内。 +要想得到接口请求的百分比,可以点击“Dashboard”中的“可视化”,创建一个Lens可视化面板,选择相应的URL字段就可以看到各个接口的百分比了。 + + +总体来说,用ELFK抽取生产日志得到业务模型,可以分为两个阶段。 + +第一个阶段是统计大时间段的日志信息,然后逐渐缩小范围,比如说按年、月、天、时、分的顺序。这一步是为了将系统的峰值请求覆盖住。 + +第二个阶段是细化所选择的时间段。虽然我们在第一个阶段已经把时间段细化到分了,但由于我们的场景得到的结果是按秒来统计TPS的,所以我们要再细化到生产环境的TPS级别。这样就可以把生产的业务场景和测试中的场景进行对比了。 + +下面我通过实例给你详细讲一讲这两个阶段。 + +第一个阶段:统计大时间段的日志。 + +在查看ELFK中的数据时,建议你尽可能选择覆盖全部业务场景的时间段。比如说,我们要选择峰值时间段,一开始要选择时间段的范围设置大一些,这样才不会漏掉数据。然后再通过柱状图的高低做范围缩小。 + +通过这样的操作就可以知道生产环境中各业务接口的总体百分比了。 + +其实,实时将相应的日志输出到ELFK中,是很多企业对日志处理的常用方法。这样不仅可以实现对日志的灵活查找,也可以实现对日志的长时间存储,并且也可以做更多的后续处理,生成可视化图形之类的。在这里,我们来实际操作一下。 + + + +如上图所示,我们在Kibana中截取了一段时间的日志,这段日志总共有6,624,578次请求。你可以用Kibana直接生成下面这样的表格视图: + + + +这样你就可以知道哪些请求比较靠前。为什么我没有显示总数呢?因为在一段时间之内的每个请求,我们要生成相应的柱状图,如果看到它们的集中时间段是相同的,那就做一个场景即可;如果不同,则要做多个场景。下面我们来搜索一下。 + + +/mall-member/sso/login- + + +/mall-portal/home/content + + + + + +/mall-member/member/address/list + + + + +其他接口的图类似,我就不一一列了。 + +看图上的数据时间点,在我这个例子中,所有的请求量级的时间点都是相同的,所以我们只需要做一个场景即可全部覆盖。请你务必要注意,在你的实际项目中,并不见得会是这样。如果出现某个请求的高并发时间点和其他的请求不在同一时间点,就一定要做多个场景来模拟,因为场景中的业务模型会发生变化。 + +在我这个示例中,我们把数据量也列在表格中,同时求出比例关系,也就是拿某请求的数量除以总请求数,如下所示: + + + +这是在这一个时间段的业务平均比例。 + +通过第一阶段,我们已经可以知道哪个时间段的请求高了。但是这个时间段范围是5分钟,这对于任何一个系统来说,都算是比较集中的时间段了。但是我们的动作还没有结束,因为我们不仅要知道哪一段的用户操作比较集中,还要知道的是生产上能达到的TPS有多高。所以还需要细化,只有细化了我们才能知道具体生产的TPS。 + +第二个阶段:细化时间段 + +通过主面图中的Timestamp,可以看到是时间间隔是5分钟,我们选择最高请求的时间段,点进去。 + + + +这样我们就得到时间间隔为5秒的图了: + + + +然后按命中次数来计算TPS,就可以得到如下结果: + +\(生产TPS = 9278 \\div 5 = 1,855.6\) + +当然了,你还可以再细化,得到毫秒级的图: + + + +通过这种方式,我们就可以得知一个系统在生产环境中的峰值的TPS有多大。1855.6这个值是我们要定的测试环境中总的TPS。如果达不到这个值,那就要接着优化,或者增加硬件资源。 + +通常情况下,得到总TPS之后,我们要根据测试目标,分三种方式处理这个总TPS,而这三种方式都是以业务目标为前提的。 + + +业务无变化,应用版本有小变化(通常只是小的功能变化或修改Bug):在这种情况下,我们只要将计算出来的TPS做为性能场景总的TPS指标即可。 +业务无变化,应用版本有大变化(比如说架构变更):在这种情况下,我们只要将计算出来的TPS做为性能场景总的TPS指标即可。 +业务有变化,应用版本有大变化:在这种情况下,我们要根据业务估算的增量来做相应的TPS增量计算。如果根据业务变化趋势预估会增加20%,那你就可以在上面计算的总TPS的基础上,再加上对应的20%即可。 + + +梳理业务逻辑 + +在上面的接口得到业务模型之后,我们就可以根据接口的量级梳理业务逻辑,以便更真实地模拟生产业务场景。其实在上面的步骤中,我们已经按顺序做了排列,你可以看一下前面的表格。 + +所以在这个示例中,大概有58%的用户会完整地走完流程。为什么是58%呢?因为登录的业务比例是12%,而后面下单比例是7%。所以是:- +\( 7\\%\\div12\\% \\approx 58\\% \) + +整体流程说明 + +最后我们来梳理一下整体的流程: + + + +请注意,上面的业务场景在实际的项目业务统计过程中可以有多个。这个思路可以解决任何性能场景和生产场景不一致的问题。 + +总结 + +最后,我们再一起回顾下这一讲的重点内容。在业务模型抽取时我们要注意几个关键点: + + +抽取时间:抽取时间一定要能覆盖生产系统的峰值时间点; +抽取范围:抽取的范围要足够大,因为在一些场景中,即便不是峰值,但由于某个业务量较大,也会出现资源消耗大的情况; +业务比例在场景中的实现:得到业务模型之后,我们在性能场景中一定要配置出对应的业务比例,不能有大的偏差。 + + +只要做到以上几点,性能场景基本上就不会和真实业务场景有大的差异了。 + +课后作业 + +学完这节课后,请你认真思考两个问题: + + +为什么性能场景中要模拟出真实业务比例? +抽取生产数据并最终得到业务模型的步骤是什么? + + +欢迎你在留言区与我交流讨论。当然了,你也可以把这节课分享给你身边的朋友,他们的一些想法或许会让你有更大的收获。我们下节课见! + +关于课程读者群 + +点击课程详情页的链接,扫描二维码,就可以加入我们这个课程的读者群哦,希望这里的交流与思维碰撞能帮助你取得更大的进步,期待你的到来~ + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/07性能场景的数据到底应该做成什么样子?.md b/专栏/高楼的性能工程实战课/07性能场景的数据到底应该做成什么样子?.md new file mode 100644 index 0000000..7b37052 --- /dev/null +++ b/专栏/高楼的性能工程实战课/07性能场景的数据到底应该做成什么样子?.md @@ -0,0 +1,341 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 07 性能场景的数据到底应该做成什么样子? + 你好,我是高楼。 + +在性能项目中,性能数据是非常重要的输入资源。但是我经常看到有人拿着少得可怜的数据,来做比较大的压力,这显然不符合真实的场景,虽然拿到的结果很好看,但并不会得到什么有价值的结果。所以,今天我们就来讲一下性能场景中的数据到底应该做成什么样子。 + +在RESAR性能工程中,场景里使用的数据需要满足两个方面: + + +第一,数据要符合真实环境中的数据分布,因为只有这样,我们才能模拟出相应的IO操作; +第二,要符合真实用户输入的数据,以真正模拟出真实环境中的用户操作。 + + +而这两个方面分别对应着两类数据:铺底数据和参数化数据。我们先来看铺底数据。 + +铺底数据 + +在通常的线上系统架构中,系统中用到的数据分为两部分:静态数据(图中红色点)和动态数据(图中绿色点),这也是我们在性能场景中需要存入的铺底数据。- + + +从这个简单的结构图中不难看出,如果没有铺底数据,那就相当于是一个空系统。但是在生产环境中,这个系统肯定不会是空的,所以要有足够的数据在里面。如果数据不真实,我们就无法模拟出生产上有真实数据的场景,比如应用的内存占用、数据库IO能力、网络吞吐能力等。 + +其中,对于静态数据而言,我们最容易出现的问题是,一想到它占的网络带宽大,就觉得要用CDN了 ;或者是觉得不模拟静态数据,就是不符合真实场景,不支持我们的优化结果了。其实,数据放在哪里,怎么做最合理,怎么做成本最低,这些都需要综合考虑,并不是一味跟风,别人怎么做我们就要怎么做。 + +我曾经看到有的官方门户网站明明没几个流量,却在做技术规划的时候,非要把零星的几个图片放到CDN上去,以显示自己设计的架构有多先进。 + +我也经常看到一些企业认为网站上的图片很重要,出于不懂技术又要寻找安全感的逻辑,非要把图片都放到自己的服务器里。本来图片就很大,一张有3~4M,用户一访问,自然就会吵吵着慢。 + +像这两种极端都不可取。要知道,当外行指使内行干活的时候,基本上没什么好结果,因为有些外行觉得只要压力发起就可以了,在细节上根本不在乎结果会怎么样。在我看来,处理这样问题的最合理的方式是先分析业务逻辑,再判断技术架构怎么实现。 + +我们知道,静态数据通常有两个可以存放的地方,一个是服务端的Web层,另一个是CDN。对于大系统而言,流量大,网络带宽自然就要求得多。在这种情况下,数据必然要放CDN,你也没有其他选择(当然了,你可以选择不同的CDN厂商)。 + +对于一些小的业务系统,由于用的人并不多,整体网络流量要求也少,那我们就可以把静态数据直接放到负载均衡服务器(比如Nginx)或应用服务器中去。用户访问一次之后,后续的访问直接走本地缓存就可以了,对系统的压力也不会产生多大的影响。 + +静态数据讲完了,我们再来看动态数据,这就需要我们好好分析一下了,因为有些动态数据是可以放到CDN中的。 + +还是前面那张图,在我们不用任何预热加载的情况下,这些动态数据都是存放在数据库中的。当我们使用预热加载时,这些数据就会转到缓存中去(当然,这也取决于架构设计和代码实现),变成下图中这样: + + + +所以按照这样的逻辑,真实场景中业务操作的数据量实际有多少,我们就要模拟出多少,不然会出现一些问题。当模拟数据量与实际数据量差别较大时,会对数据库、缓存等造成不同的影响。下面我列出了五点,为你具体分析一下。 + + +对数据库压力的区别 + + +假设线上系统中有100万的用户量,而我们在做压力测试时,由于没有生产数据,造数据又比较麻烦,所以就直接使用1000条甚至更低的用户量来做性能场景。那一个表里有100万条数据和1000条数据的差别是什么呢?我们来实际操作一下。 + +在这里,有一个前提条件:同样的硬件环境,同样的数据库,同样的表结构,同样的索引,只是两张表的数据不同。 + +两条SQL如下: + +select * from ob_tuning.temp1_1000 where id = '3959805'; +select * from ob_tuning.temp2_100w where id = '3959805'; + + +因为表内的数据量不同,所以结果如下: + + + +可以看到,查询时间一个是19ms,一个是732ms。我们不妨再来看一下表的操作细节。 + +第一个表(用户量为1000)的操作细节: + + + +第二个表(用户量为100万)的操作细节: + + + +这里我们只需要对比“executing”这一行就能看到明显的差距,它告诉我们当执行这个语句时,需要的CPU时间明显因为数据量的增加而增加了。所以我们不难发现,如果你没有足够的铺底数据放在性能场景中,那一开始便注定了悲剧的结果。 + + +缓存的区别 + + +数据量的多少在缓存中有很明显的区别,如下图所示: + + + +也就是说,场景中用的数据量越多,缓存必然要求越大。 + + +压力工具使用的区别 + + +压力工具中使用的数据多少,不仅影响着压力工具本身需要的内存,同时也影响着性能场景的执行结果。这一点,我们会在后面的课程中详细讲到。 + + +网络的区别 + + +其实不同的数据量,不管是走缓存,还是数据库,对客户端和服务器之间的网络消耗都是差不多的。只要不是缓存在客户那边,都是要走到服务器里转一圈的。所以我们认为,数据量是多还是少,对客户端和服务器之间的网络的压力没有什么区别。如果你用的是CDN,那可以做另外的考虑。 + + +应用的区别 + + +如果不是在应用中直接缓存数据,我们也认为对应用没什么区别,反正不管是什么样的请求过来,都是要到缓存或数据库中去取数据的,应用的Self Time不会有什么差别,方法依旧要执行。但是,如果你的应用是直接在应用的缓存中存数据的,那就有区别了,同样也是数据量越大,对内存的要求就越大。 + +基于以上几点,我们可以看到有两个比较重要的环节:数据库和缓存,这是直接的影响。 + +那间接的影响有什么呢?比如说,数据在数据库中执行得慢了,在同步调用的应用中必然需要更多的应用线程来处理。 + +我们假设有一个100TPS的系统,先忽略其他时间,只看数据库时间。如果数据库执行需要10ms,那应用只需要一个线程就能处理完了。如果数据库需要100ms,而我们仍然想达到100TPS,那应用就得有10个线程来同时处理。 + + + +与此同时,整个链路上的所有线程、队列、超时等都会因为受到数据量的影响而产生大的变化。所以,我们要想模拟出生产时候的样子,在铺底数据上一定不能含糊。 + +参数化数据 + +有了前面铺底数据的分析,我们在做参数化的时候就会明确很多。不过,在场景中应该用多少量的数据,是性能场景中最容易出问题的一个环节。 + +参数化数据量应该是多少,取决于场景运行多长时间。而在场景运行中,我们通常要用到两类数据:唯一性数据和可重复使用的数据。 + +对于唯一性数据(比如用户数据)来说,我们需要使用多少参数化数据是非常容易计算的。比如一个运行半小时的场景,TPS如果是100的话,那就需要18万的数据量,计算过程如下: + +\(数据量 = 30min \\times 60s \\times 100TPS = 18w\) + +对于可重复使用的数据量,我们需要分析真实业务场景中是如何重复的,比如说电商系统中商品的数据量,我们在做参数化的时候就可以重复,毕竟多个人是可以同时购买同一个商品的。我们假设平均有1000个用户在10个商品中,那当我们有18万个用户时,就需要1800个商品: + +\(商品数量 = 18w用户 \\div 1000用户 \\times 10 商品 = 1800 商品\) + +上述就是唯一性数据量和可重复使用数据量的计算方式。 + +你可能会问,如果参数化数据量太大,在压力工具中处理不了怎么办?比如说我们在用JMeter处理文件参数化数据时,如果参数化文件太长,会导致JMeter消耗更多的时间。其实像这种参数化数据量要求多的情况,我们可以采用连接远程缓存(比如Redis)或数据库(比如MySQL)的方式来做参数化。 + + +连接Redis做参数化 + + +方法一:直接在JMeter中写Beanshell连接Redis取数据。 + +import redis.clients.jedis.Jedis; + //连接本地的 Redis 服务 +Jedis jedis = new Jedis("172.16.106.130",30379); +log.info("服务正在运行: "+jedis.ping()); +String key = vars.get("username"); +String value = vars.get("token"); +vars.put("tokenredis",jedis.get(key)); + + +方法二:使用Redis Data Set组件。 + + + +这两种方式都可以用Redis做参数化的数据源。 + + +连接MySQL做参数化 + + +第一步:创建一个JDBC Connection Configuration。 + + + +同时,配置好连接信息,比如用户名密码等:- + + +第二步:创建一个JDBC Request。 + + + +用JDBC Request把数据取回来: + + + +第三步:用${user_name}引用参数。 + +完成上述三步,我们就实现了用数据库的方式做参数化。 + +知道了RESAR性能工程中需要什么样的数据后,我们接下来聊一下如何造数据。 + +如何造数据? + +因为我们这个项目中的电商平台是开源的,数据库也完全是空的,系统中没有任何的数据。所以,我们虽然只是实现了电商的主流程,但需要的数据量仍然不少。这些数据包括: + + +用户数据; +地址数据; +商品数据; +订单数据。 + + +下面我们具体考虑一下数据量应该怎么设置。 + +根据我们第5讲中的性能方案,登录TPS如果是每秒150,并且如果按容量场景的需求,在场景连续递增时,大概在20分钟内(这是一个经验值,在具体的场景执行中也会有变化)会递增到最大值,然后再执行10分钟,也就是说总时间大概为30分钟。 + +但是因为场景是递增的,一开始我们并没有要求达到150TPS,同时登录场景TPS最大值能达到多少,我们现在也没法预知。根据经验来看的话,登录的TPS在当前的硬件环境中,就算是不走缓存,达到三、四百应该是没有多大问题的。 + +如果按最大400TPS来算,跑半个小时,需要的数据量就是54万,而我们造出来的用户量要远远大于这个量级。这里我们就先造200万的用户量,因为地址的数据量肯定大于用户的数据量,所以会多于200万。 + +我们先查一下当前的数据库中有多少数据量,再确定要造多少数据。 + + + +这个数据量级明显是不够的,太少了。下面我们来看看怎么造出那么多的数据量。 + +我们造的数据主要分为两种:用户数据和订单数据。 + + +登陆用户 + + +对于登陆用户数据而言,我们要先了解表结构,因为造出来的数据只有符合业务逻辑才能使用。我们先看一下用户表结构和数据。 + +用户表: + +- + + +地址表: + +- + + +根据我的经验,造数据时不要往数据库里直接写存储过程插数据,除非你非常清楚表之间的关系,并且存储过程又写得非常溜。否则你会把数据库弄得一团乱,最后不得不在数据库的表里改数据,这是非常被动的做法。在这里,我推荐你使用接口直接调用来造数据,这个操作比较简单,也比较安全。 + +如果你想用代码来造数据,那就需要做下面这些分析。 + +在这里,我们的用户表和地址表之间是有对应关系的,你可以通过下面这段代码查看到,地址表中的MemberID就是用户ID号。 + +@Override +public int add(UmsMemberReceiveAddress address) { + UmsMember currentMember = memberService.getCurrentMember(); + address.setMemberId(currentMember.getId()); + return addressMapper.insert(address); +} + + +其实造用户数据就是实现注册流程。你可以先分析下用户注册的代码,直接把其中的注册代码部分拿过来用就行了。具体调用代码如下: + + + +看到这里,你可能会想,造数据需要关心注册流程吗?其实如果我们是调接口造数据,就不需要;但如果写代码开启了多线程来造数据,我们就需要了解接口之间的调用关系了。 + +下面我们截出中间一部分来分析它们的调用关系: + + + +因为注册用户表中的密码都是加密的,所以我们可以通过注册用户实现类代码,如下: + +@Override +public void register(String username, String password, String telephone, String authCode) { +............................... + //获取默认会员等级并设置 + UmsMemberLevelExample levelExample = new UmsMemberLevelExample(); + levelExample.createCriteria().andDefaultStatusEqualTo(1) ; + List memberLevelList = memberLevelMapper.selectByExample(levelExample); + if (!CollectionUtils.isEmpty(memberLevelList)) { + umsMember.setMemberLevelId(memberLevelList.get(0).getId()); + } + //插入用户 + memberMapper.insert(umsMember); + umsMember.setPassword(null); +} + + +了解了上面的内容之后,我们就可以直接写一段代码来造用户数据了,具体请见:《造用户代码.java》 + +有了用户数据,我们还需要下单用户的地址等详细信息,只有这样才能完成下单。所以,接下来我们就开始分析怎么造出可以下单的地址数据。 + + +用户地址 + + +首先,我们要根据用户地址资源路径找到Controller层,查看用户地址的代码调用关系,如下: + + + +然后找到用户地址的关键代码: + +@Override +public int add(UmsMemberReceiveAddress address) { + UmsMember currentMember = memberService.getCurrentMember(); + address.setMemberId(currentMember.getId()); + //插入地址 + return addressMapper.insert(address); +} + + +从这段代码中,我们可以观察到这几个信息: + + +调用地址接口需要用户登陆态,通过登陆态来解析用户ID号; +用户ID号是地址代码中的MemberID号; +用户ID号是自增加。 + + +具体参考请见《造用户地址代码.java》。 + +通过上面的代码编写,然后再开启Java线程池与多线程,我们就可以把基础数据快速造完了。下面是造用户地址数据的时间记录(每台电脑配置不一样,用的数据也许会有差异): + + + +通过以上手段,我们最后造出如下数据量: + + + +表中的订单数据会在做基准场景时补充上去。等这些数据量都有了,我们在容量场景中就有了足够的铺底数据。 + +总结 + +在这节课里,我们一起学习了性能场景中的数据到底应该做成什么样子。对于造数据而言,方法有很多,我们不用拘泥于某种造数据的手段,只要能快速造出足够的数据量就好。在RESAR性能工程中,性能场景需要两类数据:铺底数据和参数化数据。其中,铺底数据需要满足这三个条件: + + +一定要造出符合生产量级的数据量; +数据量要真实模拟出生产的数据分布; +数据要真实可用。 + + +参数化数据需要满足这两个条件: + + +参数化数据量要足够; +要符合真实用户的输入数据。 + + +有了以上这些知识,我们就不会在造数据时出现混乱的情况了。 + +课后作业 + +这就是今天的全部内容,最后给你留两个思考题吧: + + +为什么要造出符合生产量级的数据量? +为什么参数化时要用符合真实用户的输入数据? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果这节课让你有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/08并发、在线和TPS到底是什么关系?.md b/专栏/高楼的性能工程实战课/08并发、在线和TPS到底是什么关系?.md new file mode 100644 index 0000000..3c84503 --- /dev/null +++ b/专栏/高楼的性能工程实战课/08并发、在线和TPS到底是什么关系?.md @@ -0,0 +1,438 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 08 并发、在线和TPS到底是什么关系? + 你好,我是高楼。 + +在性能领域中,我们经常用“并发用户数”来判断一个系统是否达到性能需求,比如说用“系统支持1000用户”这样的描述来说明性能需求。但是并发是怎么个并发法?它和TPS之间是什么关系?并发用户数和在线用户数又是什么关系呢? + +这样的问题长期以来困扰着性能工程师们。不管是网上看到的文章或者是各个群里的讨论,我们都能听到不同的声音。所以,我即便是冒着引起争论的危险,也要写一下这个问题。 + +典型的争论 + +有一天,有个小伙跟我说,他和同事们看到我上一个专栏《性能测试实战30讲》中的文章后,在公司会议室吵翻了天,有一个同事还把微积分都搬出来了。我很高兴听到这样的争论,就像战国时期的稷下学宫一样,不争论,哪有那么辉煌的文明高峰呢。 + +他们的争论点是这样的:有一个项目,性能目标是对一个底层是Kubernetes、上层是微服务架构的系统进行容量评估(系统性能验证)。而他们的争论点就在于这个容量评估的方法。 + +对于评估方法,他们分成了两个流派。第一种是根据DAU(Daily Active User,日活跃用户数量)和用户业务模型,推导出并发用户数(工具中未来的线程数)。而第二种反对第一种,认为第一种估算不合理,要站在服务端层面,去推导 服务端要承载的 并发请求数(TPS)。 + +这个小伙说,在两个流派争论的过程中,有一些概念无法达成一致,包括用户、工具中的线程数、TPS和响应时间。后来他又告诉我,第一种流派的评估方法突出用户,但没有考虑用户的动作。第二种流派则从用户操作的角度出发,按照操作频率,计算“用户操作次数/时间段”,以这个为需要达到的TPS,加上他们自己设定的容忍度RT,反过来推算并发用户数。 + +在争议的过程中,大家都没说服对方。第一种认为第二种偏差可能会更大;第二种认为,不讲业务指标换算成技术指标就是耍流氓。其中有一个同事,甚至弄出了一个公式: + +\(并发用户数 = TPS \\times RT\) + +所以,这场争论的结果就是:没有结果。 + +现在我们不妨思考一下,上面的争论中到底谁对呢?他们给出的公式哪个合理呢? + +我给你举个例子来说(为了简化问题,以下示意图不考虑响应时间的变化): + + + +你看,在这个示意图中,压力线程是5个,在1秒内每个线程可以完成5次请求。根据上面的公式,我们应该得到的结论是: + +\(并发 = TPS \\times RT = 25(事务总数) \\times 0.2(响应时间) = 5\) + +这个5,显然是并发线程的个数,但是这个并发线程是从用户角度出发的吗?显然不是的。因为从示意图上看,每一个事务才是一个真实的用户。 + +这就涉及到一个关键的概念,并发到底是什么? + +在百度百科上是这样描述的: + + +并发,在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。 + + +在WiKi上是这样描述的: + + +In computer science, concurrency is the ability of different parts or units of a program, algorithm, or problem to be executed out-of-order or in partial order, without affecting the final outcome. This allows for parallel execution of the concurrent units, which can significantly improve overall speed of the execution in multi-processor and multi-core systems. In more technical terms, concurrency refers to the decomposability of a program, algorithm, or problem into order-independent or partially-ordered components or units of computation. + + +这两个描述看上去有点不太一样,所以我们要理解它们其中的共性和差异。如果你用英文描述的角度来理解并发,我觉得不用运行性能场景就能知道并发是多少,数一下处理器有几个核就行了。如果你用中文描述的角度来理解,那就必须考虑在“时间段”内完成了多少。 + +说了这么多,你还要注意一点,这些描述都是在处理器的层级来描述的。 + +那站在用户的角度,你觉得怎么描述更合理呢?在我看来,更合理的描述是:并发是在单位时间内完成的事务(T)的个数。如果这个事务(T)是用户的操作,那就是并发的用户了。 + +我们现在再回到前面的那个例子,公式中如果对应的是100TPS,就是100并发了。而不是10个并发,因为10个并发是没有“时间段”的概念的。 + +行业内的谬传 + +在《性能测试实战30讲》中的第3讲,我也描述过并发和在线之间的关系。其中也描述了两个在网上最常见的且被描述成“业界标准”、“经典公式”的计算公式,来自于一篇Eric Man Wong 写的文章——《Method for Estimating the Number of Concurrent Users》。为了给你省些麻烦,我把这两个公式列在这里。 + + +平均并发用户数的计算(公式一):- +\(C = nL \\div T\)- +其中: C 是平均的并发用户数;n 是 Login Session 的数量;L 是 Login session 的平均长度;T 指考察的时间段长度。 + +并发用户数峰值计算(公式二):- +\(C’ \\approx C + 3\\times \\sqrt\[\]{C}\)- +其中:C’指并发用户数的峰值,C 就是上一个公式中得到的平均的并发用户数。该公式是假设用户的 Login session 产生符合泊松分布而估算得到的。 + + +显然上述公式中没有提“在线用户数”。而在原文中,“在线用户数”是假设出来的,包括Little定律,和这两个公式的逻辑是一样的。以上两个公式的问题,我在上一个专栏中已经详细描述了,这里不再啰嗦了,如果你有兴趣,可以去看《性能测试实战30讲》中的第3讲。只是在上一篇中,我没有对其中的公式进行详细的解释,所以我觉得写得不够完整。 + +下面我跟你讲一下这两个公式为什么不能称为“业界标准”、“经典公式”。 + +首先,在原文中,作者用这张图表达了用户并发的状态。 + + + +并且假设了用户到达率是符合泊松分布的,于是代入了泊松分布的公式。作者因为泊松分布是用于对随机和独立事件的到达率进行建模的最可行、最常用的工具,并且在大部分的统计学书中都能找得到,所以就假设了这个前提。 + +但是,这个跳跃直接就把很多情况给过滤掉了,因为你的系统到达率可能不是泊松分布,而是其他分布,比如说这些分布类型: + + + +如果想确定你的系统是属于哪种分布,就需要分析用户数据。虽然泊松分布很常用,但对于一个特定的系统来说,还是不行的,比如说地铁系统。2018年,有一篇针对北京地铁客流量的分析论文中就有说明,北京地铁客流量随时间分布如下: + + + +针对上述客流进行分析之后,作者得到客流数据是符合伽玛分布的,于是根据不同的高峰时段进行了细分,然后得到如下分布拟合结果: + + + +由此,你就可以知道,在文章《Method for Estimating the Number of Concurrent Users》中,假设用户到达率是符合泊松分布的,只描述了一种可能的结果,所以我们前面提到的这两个计算公式,自然也就不能成为“业界标准”。 + + +平均并发用户数的计算(公式一):- +\(C = nL \\div T\)- +其中: C 是平均的并发用户数;n 是 Login Session 的数量;L 是 Login session 的平均长度;T 指考察的时间段长度。 + +并发用户数峰值计算(公式二):- +\(C’ \\approx C + 3\\times \\sqrt\[\]{C}\)- +其中:C’指并发用户数的峰值,C 就是上一个公式中得到的平均的并发用户数。该公式是假设用户的 Login session 产生符合泊松分布而估算得到的。 + + +并且在这篇文章中,作者在后面又将泊松分布近似到了正态分布,而第二个公式就是通过标准正态分布中平均值等于0、标准差等于1,对应到标准正态分布的统计表中查找的结果。 + +那么,问题就来了: + + +我们的系统是像上图中展示的那样,一个用户和系统没有等待时间的交互吗?这只是假设了一个最简单的场景。如果你的场景不是这个最简单的场景,那公式一就不好使了。 + +在公式二中假设了C是泊松分布的,这就意味着,你要想使用这个公式,首先就得确定在你的系统中,用户的到达率是符合泊松分布的。而我们在系统中做这样的分析时,你会看到,很多系统都是无法满足这个条件的。 + +在原文中也说明了,C说的是平均值。正因为是平均值,所以这个C和并发用户的峰值误差会比较大。 + +公式二是通过将泊松分布近似到标准正态分布中平均值等于0、标准差等于1的情况下得出的,那你想想,你的系统中平均用户数符合这个条件吗? + +这两个公式实际上是针对一个最简单的业务,进行的假设推导。而我们的系统,可能支持的是多种业务操作,那对于这些业务操作,我们是否需要把每个都算一遍呢? + +在技术的层面,不管是在线用户,还是并发用户,都是要体现到请求级别的。但是这两个公式显然没有达到这个级别。在原文中,作者是拿请求做了示例,但是这只是用来算请求速率和带宽的。 + +还有一个大问题就是,这种并发用户数估算方法是在一个业务功能上做的。如果一个系统有多个业务功能,那显然就不能这样计算了。 + + +综上,你就可以知道,所谓的“业界公认”的计算公式,其实有很多的限制条件。并且,我们很难在真实的场景中,把它的这个逻辑套用到自己的系统中去。 + +在2011年,有一篇国内的论文《The Estimation Method of Common Testing Parameters in Software Stress Testing》用了切比雪夫不等式来做的计算,你有兴趣也可以去看一下。 + +我并不是想否认这些人所做的努力,我只是希望性能从业人员能看清楚问题在哪里。如果你做了各种统计分析之后,发现能够满足原文中的各种假设条件,那上面的公式就可以用。如果不满足,那显然我们不能生搬硬套。 + +实践出真知 + +既然我们在行业内对并发用户、在线用户、TPS这个关键的关系如此重视,又没有统一可用的落地参考,而一些人的努力也得不到有效的印证。那我们是不是就没有办法了呢? + +当然不是。接下来,我想通过一个具体的实践,让你看到这个关键点的推导逻辑,然后你再来思考如何在自己的系统中落地。 + +在这里,我用一个电商系统的下单示例来做操作,请你不要过于关注系统是什么类型的,我希望你能瞪大眼睛看清楚这里面的逻辑。 + +我先说明一下,因为我要做的操作是**从用户角度出发的。所以,在这里我搭建了一个有用户界面的系统来做这个示例,这主要是为了给你讲清楚在线用户、并发用户和TPS之间的关系。 + +这个示例的前端操作总共有7步,如下所示: + + + +(注:上图中显示的最后一个图是退出后的界面,没有操作,所以总共是7步操作。) + +现在我们就是要知道这个操作全过程中产生了哪些请求。具体的请求日志如下所示: + +{"client_ip":"59.109.155.203","local_time":"27/Mar/2021:23:16:50 +0800","request":"GET / HTTP/1.1","status":"200","body_bytes_sent":"23293","http_x_forwarded_for":"-","upstream_addr":"127.0.0.1:8180","request_time":"0.001","upstream_response_time":"0.000"} +中间省略98行 +{"client_ip":"59.109.155.203","local_time":"27/Mar/2021:23:21:00 +0800","request":"GET /resources/common/fonts/iconfont.ttf?t=1499667026218 HTTP/1.1","status":"200","body_bytes_sent":"159540","http_x_forwarded_for":"-","upstream_addr":"127.0.0.1:8180","request_time":"0.259","upstream_response_time":"0.005"} + + +这是一个用户从打开首页、登录、选择商品、下单、退出整个流程的全部操作日志,总共100条。我们先不管是静态资源还是接口调用。现在我们主要来说一下,这些请求是怎么转化为TPS的,而TPS和在线用户、并发用户之间又是什么关系。 + +在线用户和TPS之间的关系 + +我们一定要从实际操作的级别来看在线用户和TPS之间的关系才可以,要不然只是臆想,是无法服众的。 + +上面我们已经通过一个用户的操作抓取了相应的日志(包括静态资源)。这个用户也显然就是一个在线用户了。 + + +单个在线用户的TPS计算 + + +从上面的时间窗口来看,这个用户的整个操作流程是从23:16:50到23:21:00,时间窗口总共是250秒(这么巧,是一个吉利数字),请求总共是100个。但是我们通常都会设置事务的,对不对?这时我们就得来掰扯掰扯事务是怎么定义的了。 + + +如果你把事务T设置为每个请求一个事务,那显然,你就不用计算了,一个用户需要的就是0.4TPS。对应的TPS计算如下:- +\(1(用户)\\times 100(请求数) \\div 250(时间窗口) \\approx 0.4(请求数/秒)\) + +如果你把事务定义到每个业务操作的级别,对应前面我们说的,总共是7个业务操作,而这7个业务操作是在250秒内完成的,那对应的TPS就是:- +\(1(用户)\\times 7(单业务操作级事务)\\div 250(时间窗口)\\approx 0.028 (TPS)\) + +也就是说如果你把事务定义在业务操作级别,在这个示例中,一个用户就需要0.028TPS。请注意,这里面的每一个事务的请求数并不一致哦。 + +如果你把事务定义到整个用户级别(通常情况下,业务部门会这样要求,因为只有做完了这些步骤才是一个业务完成了),那显然这250秒内只完成了1个事务。那对应的TPS就是:- +\(1(用户)\\times 1(完整用户级事务)\\div 250(时间窗口) \\approx 0.004 (TPS)\) + + +你看,把事务大小定义在不同级别时,我们得到的结果必然是不一样的。所以,我们如果在项目中只是简单地说,性能需求是要达到多少多少TPS这样的笼统需求,就必然会导致不同的人理解的TPS内容不一样。所以,如果有人让你实现1000TPS,那你就要问,T是什么级别的? + +请你注意,在这个逻辑中,我没有把业务模型加进来一起讨论,因为加了业务模型,反而会让问题变得更复杂。 + + +多在线用户的TPS计算 + + +上面的计算是根据一个用户的操作进行的,那如果是另一个用户呢?再操作一遍指定不会是恰好250秒了吧。并且,如果有成千上万个用户呢?那也必然不会全都用250秒。所以,这个前提条件就成了一个难点。 + +为此,我们先假设(注意,我这里做的假设只是为了后续的计算方便,并不是说这个假设条件是成立的)系统中有100000用户都是平均250秒完成业务,并且是在一个小时内完成的(这个数据已经非常集中了)。那你就可以计算需要多少TPS了。 + + +请求级的TPS:- +\((100000(用户) \\times 100(请求数)) \\div 3600(秒) \\approx 2,777.78(TPS)\) + +单业务操作级TPS:- +\((100000(用户) \\times 7(业务操作))) \\div 3600(秒) \\approx 194.44(TPS)\) + +用户级TPS:- +\((100000(用户) \\times 1(用户级) \\div 3600(秒) \\approx 27.78(TPS)\) + + +通过这样的计算,我们就可以知道需要多少TPS来和在线用户对应。 + + +峰值在线用户的TPS计算 + + +显然上面是按一小时内所有的用户都平均分布的方式算的,如果有峰值呢?这个算法就不对了吧?这就是为什么我说要历史业务峰值的原因,具体统计过程请见我们第6讲内容。 + +线上业务峰值的统计时间段越短,显然是越准确的。如果我们从生产上统计出来10万用户是在1小时内完成的。其中,1万用户在1个小时内的某1分钟内完成业务。这样的数据其实已经达到大型电商的秒杀级别了。那根据上面的计算方式,我们可以得到: + + +请求级的TPS:- +\((10000(用户) \\times 100(请求数)) \\div 60(秒) \\approx 16,666.67(TPS)\) + +单业务操作级TPS:- +\((10000(用户) \\times 7(业务操作))) \\div 60(秒) \\approx 1,166.67(TPS)\) + +用户级TPS:- +\((10000(用户) \\times 1(用户级) \\div 60(秒) \\approx 166.67(TPS)\) + + +想要得到精确的峰值TPS,其实很明显的前提就是统计的时间段够不够精准。 + +通过以上的计算过程,我们可以知道在包括静态资源的时候,在线用户数怎么转化到相对应的不同级别的TPS。对于不包括静态资源的计算过程,你也可以根据上面的逻辑自行计算。 + +并发用户和TPS之间的关系 + +从上面的在线用户计算示例中,相信你已经发现,在日志中两个操作之间的是有时间间隔的。那如果一个用户在操作的时候没有间隔,TPS应该是多少呢? + +通过JMeter录制浏览器的行为,我们先把同样的操作步骤变成JMeter脚本,然后再回放一下,抓一下日志,看看在没有停顿的时候,一个完整的用户流程需要多长时间。日志如下: + +{"client_ip":"59.109.155.203","local_time":"28/Mar/2021:01:08:56 +0800","request":"GET / HTTP/1.1","status":"200","body_bytes_sent":"23293","http_x_forwarded_for":"-","upstream_addr":"127.0.0.1:8180","request_time":"0.109","upstream_response_time":"0.109"} +中间省略98行 +{"client_ip":"59.109.155.203","local_time":"28/Mar/2021:01:09:02 +0800","request":"GET /resources/common/fonts/iconfont.ttf?t=1499667026218 HTTP/1.1","status":"200","body_bytes_sent":"159540","http_x_forwarded_for":"-","upstream_addr":"127.0.0.1:8180","request_time":"0.005","upstream_response_time":"0.005"} + + +从时间戳上来看,从第一个请求到最后一个请求,共有100个请求,总共用了6秒(请你注意这个响应时间,为了让你看得更清楚,我只截了一个用户的完整请求。实际上这里应该是用压力场景中的包括这些请求的平均响应时间)。 + +同样地,我们来计算一下对应的TPS。 + + +请求级的TPS:- +\(1(用户) \\times 100(请求数) \\div 6(秒) \\approx 16.67(TPS)\) + +单业务操作级TPS:- +\(1(用户) \\times 7(业务操作) \\div 6(秒) \\approx 1.17(TPS)\) + +用户级TPS:- +\(1(用户) \\times 1(用户级) \\div 6(秒) \\approx 0.17(TPS)\) + + +我们可以对应算一下,一个没有停顿的用户(并发用户)相当于多少个有停顿的用户(在线用户)呢?在这个转换的过程中,我们暂时不考虑请求的区别。那么,显然是: + +\(16.67\\div0.4=1.17\\div0.028=0.17\\div0.004 ≈ 41.79(倍)\) + +你用哪个级别的TPS来算都是一样的。 + +这样,我们就清楚了,并发度就是: + +\(1(并发用户) \\div 41.79(在线用户) \\approx 2.4\\% (也即是6/250) \) + +那么,如果你录制了脚本并且没有设置停顿时间(你可以叫Think Time或等待时间),如果你想支持的是10万在线用户在一小时内完成所有业务,那么支持的对应并发用户数就是: + +\( 100000(在线用户)\\times 2.4\\% = 2,400(并发用户) \) + +而我们一个线程跑出来的请求级的TPS是16.67,要想模拟出10万用户的在线,我们需要的压力线程数就是: + +\(2,777.78(10万在线用户时的请求级TPS) \\div 16.67(一个压力线程的请求级TPS) \\approx 167(压力线程)\) + +讲到这里,我们总结一下前面所讲的公式。 + +在线用户数和压力线程之间的关系: + + +用请求级TPS计算: + +\(压力线程 = \\frac{(在线用户数 \\times 单用户请求数)}{峰值采样时间段} \\div 一个压力线程的请求级TPS\) + +用单业务操作级TPS计算: + +\(压力线程 = \\frac{(在线用户数 \\times 单用户业务操作数)}{峰值采样时间段} \\div 一个压力线程的业务操作级TPS\) + +用用户级TPS计算: + +\(压力线程 = \\frac{(在线用户数 \\times 单用户完整业务数(也就是1)}{峰值采样时间段} \\div 一个压力线程的用户级TPS\) + + +并发用户数的计算: + + +\(并发用户数 = 在线用户数\\times\\frac{有停顿时间的单线程TPS}{无停顿时间的单线程TPS}\) + + +并发度: + + +\(并发度 = \\frac{并发用户}{在线用户} \\times 100\\% (取值要在同一时间段)\) + + +从以上的计算逻辑中,我们可以看到,这其中有几个关键数据: + + +在线用户数。这个值可以从日志中取到; +在线用户数统计的时间段。这个值也可以从日志中取到; +用户级操作的完整业务流时间(记得多采样一些数据,计算平均时间)。这个值也是从日志中取到; +无停顿时间的完整业务流时间。这个值从压力工具中可以取到; +单用户完整业务流的请求数。这个值可以从日志中取到。 + + +“思考时间”到底怎么用? + +在性能行业中,在线用户和并发用户换算的过程里,有一个概念我们是万万不能跳过的,那就是“思考时间”。因为有太多的人想用思考时间来描述真实在线用户操作时的停顿了,所以,下面我们就来说说这个重要的概念。 + +思考时间自从Mercury(LoadRunner最原始的厂商)进入中国市场灌输BTO(Business Technique Optimization)概念时,就随着LoadRunner的普遍使用而渐渐地深入人心。 + +但是,如果你想用它,却没有那么容易。 + +在前面的示例中,我们看到了一个用户的完整的业务流操作用了250秒,其中就包括了思考时间。对于用户来说是做了7个操作,但是对于系统来说是什么呢?我们先看一下这些操作在时间上的分布。 + + + +(注:上图中多出来的请求是一些自动触发的,我们可以忽略掉。) + +你可以看到,每个操作之间实际上都是有间隔的。而这个时间间隔就是我们在性能脚本中经常说的思考时间(Think Time)。如果你想设置思考时间,就得把每两个操作之间的时间间隔拿到。 + +并且,注意哦!你不是只取一个用户的就够了,而是要把大量的真实用户的操作时间间隔拿到,然后再做平均值、标准方差的计算,最后再配置到压力工具中。 + +在我的工作经验中,几乎没有一家企业可以做到这一点的。每当我看到这样的情形时,我都建议他们不要用思考时间了,因为即使用了也并不能说明他们模拟了真实用户的行为。 + +为什么不能用用户会话的超时时间来计算? + +因为用户的在线时间比较难统计,并且操作的间隔也比较难得到,所以有人提出用用户登录之后的会话(Session)超时时间来做计算。我先画一个示意图,再来给你解析一下。 + + + +(注:在上图中,一个带箭头的线表示一个完整的用户级的业务流操作。) + +用这个思路来计算并发用户的人,通常都会这样说: + + +你看,一个用户进入系统之后会做一些操作,这时并发是1;但第一个用户还没操作完,第二个用户就进来了,这时的并发就是2;那也有可能用户接着进来,所以并发也有可能变成3… + + +是不是看起来非常合理?在我们前面提到的那个《Method for Estimating the Number of Concurrent Users》中就用了这个思维逻辑。那问题是什么呢?问题有两个: + + +问题1,你能画出图中的红线吗?显然不能,因为它们是时间点呀!你在系统中做统计的时候,怎么可能拿到时间点的数据呢?不管你的日志记得有多细,就算到纳秒级,那也是时间段。 + +问题2,在系统中,用户的行为可以像图中这样用一条直线来表示吗?显然也不能,从前面我们截的用户操作间隔图中就可以看到,一个用户在操作期间都认为自己是在线的,但是在请求的级别,中间其实是有停顿的。所以,即使一个用户一直在系统中操作,他发出的请求,也不会像水流一样源源不断。 + + +我们知道,Session是一串保存在用户端和系统中的字符串。在用户和系统交互的过程中,带着Session就可以识别请求。但是,并不是说用户和系统因为Session的存在,就一直有交互并且没有间隔。 + +所以,计算Session个数,可以让我们知道有多少用户是在线的,但是,并不是说这些用户和系统有交互。 + +对于Session的配置,如果它的有效期是30分钟,那在这30分钟内,用户的操作都会被识别。但是,在这30分钟内,用户并不见得要有请求,就连TCP连接都可能没有保持。对于短连接的系统,请求结束,TCP连接会立即断掉;对于长连接系统,我们要发心跳才能保持连接,不过也仅仅是保持连接,也不见得有数据交互。 + +所以,Session仅仅是消耗着保存字符串的那部分内存,来做用户和系统之间的识别,并不能用来做性能中的并发用户数计算。 + +RPS和TPS之间到底有没有争议? + +我记得在网上看到过一篇文章,大意是说不建议用TPS(每秒事务数)来衡量系统的性能,而是建议用RPS(每秒请求数)衡量。并且,文章把TPS模式描述为“令人震惊的存在行业多年的误操作”。在我的学员群中,也有过类似的讨论。 + +对于RPS和TPS,你可以看到很多人各执一词,并且针锋相对。关键是,这些人居然谁都说服不了谁,然后这个问题就变成了一个哲学问题。 + +看了一圈文章之后,如果我理解的没错,大概的争议点是这样的: + + +TPS是从压力工具的角度来说的,但是因为TPS会受到响应时间的影响,所以不建议采用TPS模式。 + +在接口串行请求时,由于各种异常问题的出现,TPS不能反映出后端服务的每秒请求数。 + +TPS反映的是业务视角,RPS反映的是服务端视角。 + + +这些说法看似是成立的,但是有什么误差呢。下面我们来一条一条理解一下。 + + +在请求-响应的同步逻辑中,TPS必然是和响应时间成反比关系的。那么受响应时间影响,TPS也是合情合理的。而我们要分析的就是,这种响应时间会变长的性能问题。难道用了RPS模式就不关注响应时间了吗? + +在异步逻辑中,我们要是只关注发送出去多少请求,显然无法反映出系统的完整的处理能力。所以,第一点争论其实是不存在的。 + +即便接口是串行的,并且后端流程长,会在各个节点产生多个请求,那后端请求也肯定是大于压力工具中的TPS的。那在一个固定的场景中,压力工具中的TPS和后端的请求数,不是必然成线性关系吗? + +如果有异常出现,有报错啥的,导致了后端某些服务的请求变少了,这种情况不就正是我们要分析的性能问题吗? 所以,第二点也是不应该有争议的。 + +这个说法就更奇怪了。本来就没有人把压力工具中的TPS和服务端的RPS混为一谈。这两者是不同的统计手法,为什么会作为争议出现呢?它们本来就在不同的角度,更不应该做为对立的论点呀。所以,第三点也是不应该有争议的。 + + +我用一个示意图来说明一下请求和TPS之间的关系: + + + +如上图所示,如果压力工具中的一个线程(图中人的位置)发出一个请求(也就是在图中0的位置),系统中共产生了4个请求(图中的0、1、2、3位置)。不管这些请求是同步还是异步,这些请求都是真实存在的。如果再来一个线程,也发同样的一个请求,那系统中必然总共产生8个请求,这个逻辑很清楚。 + +如果我们把压力工具中线程的请求做为一个T(压力工具中的事务数),那它对应的后端就应该是4个R(后端请求总数)。请你注意,在压力工具中是无法统计出后端的4个请求的,而且,这也是没有必要统计的。这个统计工作,我们应该留给业务监控、日志监控的系统去做,不用再去增加压力工具的负担。 + +显然请求和TPS是线性关系,除非你发的不是这个请求,而是其他的请求,或者是你改变了参数。 + +如果你愿意关注后端RPS,就去关注;如果愿意关注压力工具的TPS,也无所谓。但是,在一个项目的具体实践中,不管是RPS还是TPS,一定要说出来,并且大家都能有同样的理解的。 + +既然TPS、RPS是线性的,那我们实在是没有必要把这两个角度当成是对立面来看待,因为这不仅会增加性能理解的复杂度,也没有实际的价值。也就是说,这根本就不是一个争议点。 + +总结 + +在这节课中,我努力地把在线用户数、并发用户数、并发度和TPS之间的关系做了深入的剖析。如果你在跟不同职位的人沟通时,请注意关心一下他们想说的并发、在线、TPS到底在哪个层级,因为要是不在一个频道上,是无法达成一致结论的。 + +在你做性能项目时,如果可以取得其中的关键数据,那就可以根据我们前面讲的相应公式做计算。而这个计算逻辑,不止是对HTTP有效,对任何一个协议也都是有效的。 + +在这节课中,我也把在线用户数、并发用户数、并发度和TPS之间存在的误解做了详细的说明,也对一些行业谬传做了深入的解析。从中你可以知道,偏向业务层或TPS层的思路都是不对的,只有将它们关联起来,才是合理的从技术到业务的思考逻辑。 + +希望你能理解,并将它们之间的关系真正理透。 + +课后作业 + +这就是今天的全部内容,最后请你思考一下: + + +如何获取有效的在线用户的TPS(不管是哪个层级的TPS)? +性能场景中不包括静态资源的隐患是什么? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果这节课让你有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/09如何设计全局和定向监控策略?.md b/专栏/高楼的性能工程实战课/09如何设计全局和定向监控策略?.md new file mode 100644 index 0000000..fe98a73 --- /dev/null +++ b/专栏/高楼的性能工程实战课/09如何设计全局和定向监控策略?.md @@ -0,0 +1,155 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 09 如何设计全局和定向监控策略? + 你好,我是高楼。 + +纵观软件性能行业的发展历程,十几年前,当性能测试刚刚在国内出现的时候,我们只守着工具,不管是在培训还是在工作中,只要学会了性能测试工具,就可以横行市场。那个时候,会不会使用LoadRunner,就是会不会做性能的标准。 + +然而,性能测试行业发展到现在,我们仍然能看到在很多场合中,大家还是在讲性能测试理论和思维,还是在讲性能测试工具的使用和实现。虽然也有性能监控部分的数据说明,但大部分也都只是停留在数据的罗列上,描述一下CPU 90%、内存不足、IO 100M之类的现象。 + +至于为什么会是CPU 90%?如何定位到具体的原因?解决方案又是什么?大部分性能工程师都是不知道的,甚至连思路都说不上来。这就是当下行业的现状了。 + +前段时间,我看到一个微信群里展开了一场讨论。有一个人去面试性能职位,被问到“某一天夜里,生产上的数据库的CPU突然飙升,该怎么去定位问题原因”。群里议论纷纷,有人说是因为固定的批量执行计划;有人说要看监控数据,看慢SQL等等,总之就是一群人在猜来猜去。 + +最后,面试官直接给出答案:因为Redis被击穿,导致数据库压力大,所以CPU高。看到这个答案,当时就有人觉得这和题目中描述的现象并没有什么直接的逻辑关系。 + +通过这个事情,我们可以看到,性能监控数据不足带来的问题就是没有分析的证据链。而我一直在强调,从现象到结论要有完整的分析链路,只有这样才是真正的性能分析,否则就是在连蒙带猜做性能。 + +现在有很多企业(不管是互联网大厂,还是金融机构等)的监控都看似做得挺全面,但其实并没有监控层级的细化。而不做监控的细化,导致的问题就是,经常会出现需要分析某个数据时,只能重新运行场景去抓取数据,并且还要临时添加监控工具。 + +所以基于上述种种现状,今天这节课我要跟你讲一讲如何设计全局定向监控策略,我希望你能明白从全局到定向的思路,事先设计好监控策略的重要性。 + +在设计监控策略时,我们第一步是分析架构。通过分析架构,我们要确定有哪些需要监控的点。 + +分析架构 + +我们先列出这个课程所示例的系统中都有哪些机器。 + + + +在前面的第4讲里,我们已经画出了系统架构,如下所示: + + + +从上面的信息中,我们要列出需要监控的组件,也就是下面的这张表格。请你注意,对于上面的各层实例,我们现在只配置了一个,但并不是说我们只需要一个,在后续的测试过程中,当需要增加实例时,我们再增加。 + + + +监控工具选型 + +基于上面的组件列表,我们接下来要选择相应的监控工具。有一点你要注意,这是我们选择的第一层监控工具,也就是全局监控工具。对于定向监控中需要的工具,我们现在还无法确定,因为定向监控工具取决于性能分析过程中有什么问题。 + +在我们通过全局监控计数器发现问题之后,想要定位问题的具体原因是什么,就需要分析更详细的监控数据。但是这一点,全局监控计数器无法做到,所以我们需要选择合适的定向监控工具,得到更细的监控数据,我称之为定向监控。 + +不难理解,全局监控和定向监控的区别就是,全局监控是第一层的监控,它可以将一个技术组件的各个模块的关键性能体现出来,比如说操作系统的CPU就是典型的全局监控计数器。 + +下面我们来看怎么选择全局监控工具。 + + +全局监控策略和工具选型 + + +我们说,全局监控是为了判断整个系统的瓶颈点在什么方向,但并不能给出具体的原因。基于这一点,我们在选择全局监控工具时,要注意几个关键点: + + +数据精准:这一点非常重要,因为对于性能计数器来说,数据的精准直接决定了下一步的步骤。 + +成本低:这里的成本包括费用和人工成本。不管是成型的收费产品、免费产品、自主研发产品,还是组合产品,费用都是容易计算的,我就不多啰嗦了。对于人工成本,我们直接拿员工的工资和时间计算就行。如果是做临时的项目,我建议最好选择比较流行、通用的监控工具。 + +范围大:也就是监控工具要足以覆盖全局监控计数器。在第4讲中,我们讲了怎么构建性能分析决策树,而监控工具要做的就是,把性能分析决策树列出的计数器都尽量覆盖全。如果工具能力实在有限,又没时间扩展,那就要在选择好工具之后,明确哪些计数器无法监控到。然后在性能分析的过程中,使用命令弥补工具上的不足。 + +历史数据可保存:在性能项目中,实时查看性能数据是必要的,而监控的历史数据可保存也至关重要。因为在场景执行结束后,我们做性能分析和性能报告时会使用到历史数据。 + + +基于这几点,接下来我们就要选出对应的全局监控工具。 + +根据这个系统的架构,我们选择的工具要监控到这几个层面:第一层,物理主机;第二层,KVM虚拟机;第三层,Kubernetes套件;第四层,各种应用所需要的技术组件。 + + + +因此,对应的监控工具如下表所示。 + + + +以上工具都是免费、开源的,可以完全满足我们的监控需求,我们只要部署一下就行。对于其中的操作系统监控工具,我们在第4讲RESAR性能分析逻辑中就已经说明了它的局限性,你要是忘记了,可以再回顾一下。 + +在我们这个系统中,物理机和KVM都是完整的操作系统,所以我们直接用第4讲中的node_exporter就可以完全覆盖。但是,往上一层的Kubernetes,我们怎么才能全面监控呢?这里就涉及到Kubernetes的监控套件了。现在,我们来看一个Kubernetes全局监控套件,如下所示: + +- + + +类似这样的模板有很多,我就不一一列举了。虽然各个工具展示的方式不同,但都能达到我们全局监控Kubernetes的目标。所以,我们只需要选择一个合适自己业务系统的Kubernetes监控套件就可以了。 + +其实,如何选择一个监控套件来实现各层的监控需求,是全局监控的一个难点。在全局监控中,我一直在强调一个词——“分层”。因为在我参与过的项目中,经常有人说:“我们的监控是全的。”但当我自己动手查看时,只看到操作系统级的数据,而其上运行的其他内容就没有了。 + +还有一个我亲身经历的例子。我在给一个金融机构做培训时,他们说线上有问题,让我帮着分析一下。同时,他们还胸有成竹地跟我说:“我们的监控数据是很全的,只是不知道问题在哪里。” + +可是,我拿过数据一看,发现没有Java线程级的数据,他们的监控平台也不支持细化到线程级。而从系统的数据来看,这恰巧又是一个线程的问题。于是,他们就只有重新采集数据。等数据再拿过来,问题在哪里一目了然。 + +这就是全局监控数据缺失,进而导致分析链路断裂的典型例子。所以,全局监控的完整性是性能分析非常重要的部分。 + + +定向监控策略和工具选型 + + +完成了全局监控之后,性能场景就可以运行起来了。但是当我们遇到问题时,我们在全局监控数据中就只能看到第一层的计数器,比如说CPU高、内存不足、IO高、网络带宽大等信息。从这些信息中,我们无法知道做什么样的优化才能使CPU降下来、内存使用变少、IO变低、网络变小。 + +所以,这时候我们必须要做定向监控,定向监控就是为了寻找更细节的证据。在RESAR性能工程中,我之所以把数据分为全局和定向,是因为性能分析是有逻辑链路的。如果不做区分,只是一股脑地全看,会让你有一种数据很多,但不知道哪个是关键数据的感觉。 + +请你注意,在我的分析理念中,全局和定向是必须分开的。因为对于全局监控数据,我们会一直采集并保存一段时间,这样对系统整体的性能影响并不大。可是,如果我们对定向数据也一直采集的话,就会影响系统整体的性能,比如说线程栈的数据采集、对象的内存消耗采集等等,这些操作其实对性能都有影响,不管工具厂商吹嘘得有多完美,我们在实践中已经有足够的数据可以证明这一点。 + +不过,当前市场上的很多监控工具是不区分全局监控和定向监控的。所以,在我们前面罗列的全局监控工具中,你也可以看到定向监控需要的数据。比如说,我们在用JvisualVM监控Java的时候,不仅能看到CPU、JVM、Class、Thread等全局信息,也能看到栈、方法、对象等定向信息。 + +对于Java微服务应用,我们用表格中列出的工具,其实就可以看到比较细节的数据了。像方法级、对象级这些,Spring Boot Admin、JvisualVM和其他的一些JDK自带的监控工具等都可以做到。如果我们在使用中觉得哪里不足,还可以再考虑其他的定向监控工具。 + +而有了对Spring Cloud微服务的监控工具之后,在提供服务的过程中,我们需要看到的是业务链路,这时上面的对单个微服务进行监控的工具就做不到了。 + +所以这里,我用APM工具SkyWalking来链路的监控。 + +在SkyWalking中,我们不仅能看到链路图,也可以用它看到更细化的数据。这张图就是SkyWalking中的链路图,我把它定义为全局监控。 + + + +下面这张图是用SkyWalking工具看到的更细化的数据,我把这样的数据定义为定向监控数据。 + + + +这个图展示的是定向分析的一个中间环节。我们从图中可以看到一个请求对应的每一段的耗时,比如说,一个接口调用另一个接口、JDBC、Redis等后续组件。当我们发现哪一段耗时比较长的时候,就可以到耗时长的那个组件上,根据定向监控的数据接着往下分析了。 + +通过上述内容,我们知道了在定向监控时需要哪些数据。所以在我们分析完系统架构之后,也要对定向监控工具进行选型,把需要的工具都准备好,以免出现有问题时无工具可用的情况。不过,定向监控只是先准备好,不用一开始就使用,这一点你要切记。 + +在这里,我列出了在我们这个示例系统中可能会用到的定向分析工具,我主要考虑是覆盖系统级、代码级、数据库级和缓存级。 + + + +这样一来,我们在后续的性能分析工作中,就不用再临时抓瞎到处找工具了。 + +总结 + +在我的逻辑中,全局和定向必须要分开,这一点我在前面跟你强调过,不分开就会导致资源浪费,并且我们需要的数据还有可能是缺失的。 + +另外,请你注意,监控的全面性直接取决于项目级性能分析决策树的构建,也就是说用什么工具并不是关键,关键在于这些监控工具有没有把性能分析决策树的树叶都覆盖全。 + +在选择监控工具时,我们主要考虑的是成本、范围、层次、使用的延续性等因素。只有合理的监控策略和监控工具,才能让性能分析决策树真地落地,才能让性能瓶颈证据链的查找具有可能性。 + +最后,我还想提醒你一点,请不要认为监控到技术组件这个层级就足够了,把对应技术组件的模块和模块对应的计数器都覆盖到才是重要的。因为在分析瓶颈的过程中,我们要找到计数器之间的关联性,如果有一个计数器缺失,就会导致分析中断。 + +课后作业 + +这就是今天的全部内容,我给你留了两道题以巩固今日所学,请你思考一下: + + +如何判断自己选择的性能监控工具,有没有覆盖全性能分析决策树? +为什么不建议选择更多的定向监控分析工具? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果这节课让你有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/10设计基准场景需要注意哪些关键点?.md b/专栏/高楼的性能工程实战课/10设计基准场景需要注意哪些关键点?.md new file mode 100644 index 0000000..a59951b --- /dev/null +++ b/专栏/高楼的性能工程实战课/10设计基准场景需要注意哪些关键点?.md @@ -0,0 +1,301 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 10 设计基准场景需要注意哪些关键点? + 你好,我是高楼。 + +在前面的课程中我们提到过,在RESAR性能工程中,场景分为四类:基准、容量、稳定性、异常。每一类场景都对应着不同的目标。 + +其中,基准场景是为了找到系统中明显的配置及软件Bug,同时也为容量场景提供可对比的基准数据。在RESAR性能工程的逻辑中,基准场景是非常重要、非常重要的部分,而不是随意试验一下场景能不能跑起来,是要有确定的结论的。 + +在这节课中,我要给你解释几个基本的问题,比如线程数应该如何确定,压力线程的连续递增的重要性,以及如何将之前所讲的分析思路应用在具体的分析案例中。 + +下面我们一起来看一看。 + +性能场景分类 + +在设计性能场景时,我们首先要清楚场景的目标是什么。在一些项目中,我们通常会拿到这样的需求: + + +评估一下系统能支持的最大容量。这显然是为了知道当前的系统容量,目标很明确; +测试并优化系统以支持线上的业务目标。这个需求显然有了优化的必要; +测试并评估未来几年内,性能容量是否可以满足业务发展。这个需求显然是要求测试未来的业务场景。 + + +这是我们经常拿到的几类性能需求,基于此,我把场景按照目标划分为三类: + + +验证:评估当前系统容量; +调优:评估并优化当前系统; +推算:评估并推算未来系统容量。 + + +这种分类和我们一直强调的按类型分类(也就是基准、容量、稳定性、异常)是什么关系呢?这里我画一张图说明一下: + + + +从图中可以明显看出这两种分类之间的关系:我们首先要确定性能场景的目标,然后再设计对应的具体场景。 + +你要注意,对于图中的三种目标,位于下方的目标是包含它上方的目标的,比如以调优为目标的场景,包括了以验证为目标的场景。 + +有了这些基本的了解后,下面我再给你详细解释一下。 + +1. 按目标分类 + +对于按目标划分出的这三种性能场景,我们结合RESAR性能过程图具体来看看。 + + + + +性能验证 + + +性能验证(测试)是指针对当前的系统、当前的模型、当前的环境,验证一下版本是否有性能的变化。注意,在这个阶段中,我们不做复杂的性能监控,不做性能分析,也不调优。 + +在目前的性能市场中,大部分项目都处于性能验证的状态。如果对于一个已经在线上稳定运行了很久的系统来说,我们去做版本更新的验证倒是无可厚非的,只要比对一下数据就可以了。这种项目周期通常在一两周以内,不会更长了,而且也不用更长,除非有大的性能瓶颈。 + +对于性能验证的项目,其实很多人一直在做“性能场景执行”和“性能结果/报告”这两个步骤。其他的步骤也不是不做,只是会拿之前的文档做个修改,走个过场,想着反正也没人仔细看。所以,性能验证这个项目就变成了:来一个版本,用同样的脚本、同样的环境、同样的数据,执行一遍就好了。 + +当这样的执行多了以后,你就会产生一种误解:原来性能就是这样无聊地一轮一轮执行下去,还是熟悉的姿势、还是熟悉的味道……在我遇到的性能从业人员中,有很多人都是在这样的项目中认识了性能,从而认为自己的技术还挺好的,觉得性能也不怎么难。 + + +性能调优 + + +性能调优是指针对当前的系统、当前的模型、当前的环境,做性能监控、性能分析和性能优化,并且要给出具体的结论。这是我们大部分项目都应该做到,但实际上没有做到的。 + +如果一个项目需要给出“系统上线后以什么样的容量能力来运行”这样的结论,那么这个场景目标的细化是相当关键的。 + +现在,很多性能项目最缺少的就是给出一个明确的结论。什么叫“给出结论”呢?你说我写了TPS是多少、CPU使用率是多少,这叫结论吗?对不起,我觉得这不叫结论。 + +结论应该是有业务含义的,比如说支持1000万用户在线、支持1万用户并发等等,这才叫结论。 + +不管你给出多少TPS,只要老板或是其他人问:“那我上了1000万用户之后,系统会不会死呢?”你会有一种被敲了一闷棍的感觉,不知道该如何回答。而这个时候,给对方的感觉就是,这个性能做得没什么具体的价值。不管你有多累多辛苦,在这种情况下,性能的价值都会被低估。 + +前段时间,我跟一个十几年的朋友聊天,就聊到了这个话题:性能如何才能体现出价值。我说,如果是我做的项目,我会给出这样的承诺,那就是在我执行的性能场景范围之内,我要保证线上不会死。如果死了,我觉得这个性能项目就不应该收费了。 + +这就像你买了一个手机,回来一用,发现打不了电话,你觉得这时候怎么办?不是退货就是换货,还要生一肚子闷气。 + +既然如此,那我们做性能为什么就给不了这样的承诺呢?你想想,如果你做完了一个项目,却不能告诉对方这个系统能不能好好活着,那人家还要你干嘛,直接砍掉这个项目就好,还省了成本。 + +此外,从RESAR性能工程的过程图来看,对于性能调优的项目,我们需要完成从“性能需求指标”到“生产运维”的整个过程。注意,这整个过程不是走过场,而是每一步都要精雕细琢。 + + +性能推算 + + +性能估算针对的是未来的系统、未来的模型、未来的环境,我们要对此做出严谨的业务增长模型分析,并在场景执行过程中进行性能监控、分析和优化,同时给出具体的结论。很多项目都想做到性能估算,可往往都只走了过场。 + +其实,在性能估算的场景目标中,如果要估算的未来时间并不遥远,那我们根据业务的发展趋势的确可以推算得出来,并且这也是合理的场景。就怕遇到那种狮子大开口的需求,一说到估算,就是系统十年不宕机。 + +对于性能估算的项目,我们同样需要完成从“性能需求指标”到“生产运维”的整个过程。其中,有两个环节与性能调优项目中的不同,那就是“性能需求指标”和“性能模型”。 + +在性能估算项目中,性能需求指标和性能模型一定不是由性能测试人员来决定的,而是由整个团队来决定。上到老板,下到基层员工,都要有统一的认识。要不然等项目做完了之后,你就无法回答老板那个“能不能支持1000万在线“的问题。 + +上述就是我们按照目标划分出的三类性能场景,这里我用一张图帮你总结一下,我希望你能对它们有了一个清楚的了解。 + + + +2. 按过程分类 + +我们说,性能场景还可以按照过程分类,而这个“过程”,说的其实就是我们应该怎样执行性能场景、性能场景应该从哪里开始的问题。不知道你记不记得,我之前在第5讲中画过这样一张图: + + + +从图中可以看到,我一直强调的是这四种场景执行过程: + + +基准场景 +容量场景 +稳定性场景 +异常场景 + + +请你记住:性能场景中需要且仅需要这四种场景。 + +你可能会问,就这么绝对吗?是的,我就是这么固执。 + +在正式的性能场景(需要给出结果报告的性能场景)中,我还要再强调两个关键词:“递增”和“连续”。为了说明这两个关键词有多么重要,我特意用红框红字给你写在下面,希望你能重视。 + + + +这两个关键词是我们在性能场景中一定要做到的。因为,在我们的生产环境里没有不连续的情况,并且在我们的生产环境中,用户量肯定是一个由少到多、有起伏变化的过程。而且,也只有这两个关键词能把场景的基调给定下来。所以,我一直在反复反复强调它们。 + +也许有人会说,我就是试一下看看场景能不能执行起来,也得这么干吗?嗯……那倒不用,请退出去把门带上。 + +下面我们来说一下在基准场景执行过程中,我们要重点关注什么。 + +基准场景 + +在我们对一个系统完全不了解的情况下,我们先要搞清楚系统大概的容量能力是多少,具体要从哪里开始呢?就是从基准场景开始。 + +比如说在我们这个电商系统中,我们要测试11个业务。那是不是可以一上来就把这11个业务脚本都做出来,上去压呢?那肯定是不行的,因为我们还不知道每一个业务能跑到多大的TPS,有没有性能瓶颈。如果直接混合去压,会导致多个性能问题一起暴露出来并产生相互的影响,这样的话我们分析起来会比较困难。 + +所以,我们要先做单接口的基准场景。那具体怎么做呢?我们来看一个例子。首先,我们拿几个用户测试一下登录接口的基本性能(请你注意,这个尝试的过程本身并不是基准场景)。如下所示: + + + +从图中,我们至少可以看出,1个压力线程大概会产生20TPS。 + +那单接口的容量达到多少才不影响混合的容量场景呢?很显然,如果这是一个单登录接口,就必须高过50TPS,这是最起码的。而我们现在用的是8C16G的机器,根据CRUD的测试经验,即使不走缓存,这样的操作要达到500TPS应该没什么问题。 + +那在一个线程能产生20个TPS的前提下,我们先假设这个接口能达到的最大500TPS都是线性的,那就需要: +\[线程数 = 500 TPS \\div 20 TPS = 25 个线程\]同时,因为1个压力线程大概会产生20TPS,从TPS曲线上看还是上升的比较快的,所以我会考虑把Duration(场景的持续时间)放长一点,目的是让压力不要增加得太快,而在这个缓慢增加的过程中观察各类曲线的变化,以判断后续的动作以及最大容量。我会这样来确定场景的加压过程。 + + + +在图中,我上到了30个线程,这里也可以不要高出那么多,只要高出25个线程就可以了。我把Ramp-up period设置为600秒,也就是20秒上一个线程,这样就会产生一个明显的连续递增的过程。 + +现在,我们总结一下整个思路: + + +先确定单线程运行时的TPS值; +根据系统最大的预估容量设置场景中的线程数、递增参数等。强调一下,如果你不会预估容量,可以直接多加一些线程,然后在递增的过程中查看曲线的变化; +确定正式基准场景的压力参数。 + + +对于其他接口,我们也用这样的思路一个个执行下去。当然,对于这个过程,我们也需要在测试过程中不断地修正。 + +现在,我们根据上面讲述的过程,总结一下基准场景的目的: + + +获得单接口最大TPS:如果单接口最大TPS没有超过容量场景中的要求,那就必须要调优。那如果超过了,是不是就不需要调优了呢?我们接着看第二个目的。 + +解决单接口基准场景中遇到的性能问题:也就是说,当我们在做单接口测试时,碰到了性能瓶颈一定要分析,这就涉及到了性能分析逻辑。所以,性能分析基本上可以分为两大阶段: + + + +第一阶段:硬件资源用完。即在基准场景中,我们要把CPU、内存、网络、IO等资源中的任一个耗尽,因为在这种情况下,我们很容易从全局监控的性能计数器中看到现象,可以接着去跟踪分析。 + +第二阶段:优化到最高TPS。即在基准场景中,我们要把单接口的TPS调到最高,以免成为容量场景中的瓶颈点。 + + +如果第一阶段的目标达不到,那么不用多想,我们肯定要找瓶颈在哪里。要是硬件资源已经用完了,TPS也满足了容量场景中的要求,那么,从成本的角度来考虑,这个项目就不需要再进行下去了。如果硬件资源用完了,但TPS没有满足容量场景中的要求,那就必须优化。 + +下面我们先来执行一个单接口场景,看一下上面的思路如何落地的。 + +登录接口 + +按照上面所讲的基准场景的设计步骤,我们先试运行一下这个接口的基准场景。注意,在基准测试中,试运行的过程只是为了看一下基本的接口响应时间,并不是为了完成基准场景。 + + + +啊,满目疮痍呀! + +从图中看,虽然场景执行时间并不长,但是10个线程上来就报了错,响应时间和TPS也都达到了让人伤心的程度,只有12.5TPS。这可怎么办? + +没办法,我们只有分析这个过程了。接下来的内容就是我对问题的分析过程。主要是看一下,我们前面提到的性能分析思路是如何落地的。 + + +问题现象 + + +如上图所示,这现象老明显了。 + + +分析过程 + + +从我一直提倡的RESAR性能分析逻辑上来说,针对响应时间长的问题,我们首先要做的就是拆分时间。由于这个系统已经部署了SkyWalking,我们自然要果断地用它看看时间浪费在了哪里。 + + + +你看图中,一个Token的SelfDuration居然要5秒多!唉,开源项目的坑还是多呀。看起来功能似乎都实现了,连Star都好几万了,但是完全没性能意识。 + +不过这样也好,这下我们可有的玩了。我们做性能分析的人,就是要收拾这样的烂系统,才能快速成长嘛。 + +话说回来,既然Token接口响应时间长,我们在SkyWaking中又看不到完整的调用栈,那么接下来就有两个动作可以做: + + +打印一个完整的栈,看看调用链路。 +不打印栈,直接连到Java进程中看方法的时间消耗。 + + +我们用第一个方法看到调用链路,也还是要跟踪具体方法的耗时,只有这样才能把证据链走下去。在这里,我就直接用第二个方法了。 + +在第二个方法中,我们要看方法的时间消耗,可以使用的工具其实有很多,像JDB/JvisualVM/Arthas这些都可以。这里我们用Arthas来跟踪一下。 + +首先,我们跟踪一下那个Token的方法。 + +trace com.dunshan.mall.auth.controller.AuthController postAccessToken '#cost > 1000' -n 3 +trace org.springframework.security.oauth2.provider.endpoint.TokenEndpoint postAccessToken '#cost > 1000' -n 3 +trace org.springframework.security.oauth2.provider.token.AbstractTokenGranter getOAuth2Authentication '#cost > 1000' -n 3 +trace org.springframework.security.authentication.AuthenticationManager getOAuth2Authentication '#cost > 500' -n 3 +trace org.springframework.security.authentication.ProviderManager authenticate '#cost > 500' -n 3 +trace org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider authenticate '#cost > 500' -n 3 + + +我们用上面的语句一层层跟踪下去,最终来到了这里: + + + +请你注意,我们即使不用Arthas,采用其他的工具也可以达到同样的效果。所以,请你不要迷恋工具,要迷恋就迷恋哥。 + +既然这个authenticate方法耗时比较长,那我们就打开源代码看看这一段是什么东西。 + + + +接着,我们调试跟踪进去,看到如下部分: + + + +原来,这里是一个加密算法BCrypt。 + + +优化方案 + + +我解释一下,Bcrypt在加密时,每一次HASH出来的值是不同的,并且特别慢! + +我们跟踪到这里,解决方案其实比较明确了,那就是用更快的加密方式,或者去掉这个加密算法。我们把更换加密方式这个问题留给开发去解决。作为性能分析人员,我决定把这个加密算法直接去掉,咱们先往下走。 + + +优化效果 + + +优化效果如下: + + + +从图中可以看到,对于同样的线程数,现在TPS从20涨到了80了。 + +从这个简单的分析逻辑中,你可以看到,我们通过响应时间的拆分跟踪,知道了哪个方法慢,再进一步去分析这个方法,确定解决方案。这就是一个最简单的RESAR性能分析七步法的应用,看起来我们似乎在这个分析过程中跳过了七步法中的分析架构图这样的步骤,但实际上在我们分析的过程中,是跳不开的,因为不管是看架构图,还是看调用链,都是要在脑子中有架构逻辑的。 + +在基准场景中,我们还会遇到各种问题,后面我都会一一记录下来,希望能给你一些借鉴。 + +总结 + +根据RESAR性能工程理论,在性能场景中,我们按执行过程,可以将场景分为四类:基准场景、容量场景、稳定性场景和异常场景。这些场景各有目的,在这节课中,我们主要描述了基准场景的逻辑,并给出了实例。 + +基准场景有两个重要的目的: + + +获得单接口最大TPS; + +解决单接口基准场景中遇到的性能问题。 + + +这两个目的对我们很重要,都是为了容量场景打基础的。 + +在这节课中,我主要想让你感受一下性能分析的过程。当然了,我们最后的这个优化效果其实还没有达到我对性能的要求。不过,你放心,在后面的课程中你将看到更多的分析逻辑。 + +来,跟哥往下走。 + +课后作业 + +最后,请你思考一下: + + +为什么RESAR性能工程按过程只分为四类场景? +在分析代码时间时,我们如何跟踪Java的执行耗时,有多少种手段? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果这节课让你有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/11打开首页之一:一个案例,带你搞懂基础硬件设施的性能问题.md b/专栏/高楼的性能工程实战课/11打开首页之一:一个案例,带你搞懂基础硬件设施的性能问题.md new file mode 100644 index 0000000..1a854c4 --- /dev/null +++ b/专栏/高楼的性能工程实战课/11打开首页之一:一个案例,带你搞懂基础硬件设施的性能问题.md @@ -0,0 +1,515 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 11 打开首页之一:一个案例,带你搞懂基础硬件设施的性能问题 + 你好,我是高楼。 + +这节课我要带你来看一个完整的性能分析案例的第一部分,用打开首页接口做压力场景,来分析下性能问题。通过这个案例,你将看到各种基础硬件设施层面的性能问题,比如由虚机超分导致的性能问题、CPU运行模式下的性能问题、IO高、硬件资源耗尽但TPS很低的问题等等。 + +如果你是从零开始做一个完整的项目,那么这些问题很可能是你首先要去面对的。并且,把它们解决好,是性能分析人员必备的一种能力。同时,你还会看到针对不同计数器采集的数据,我们的分析链路是不同的,而这个分析链路就是我一直强调的证据链,如果你不清楚可以再回顾一下第3讲。 + +通过这节课,我希望你能明白,有些性能问题其实并没有那么单一,而且不管性能问题出在哪里,我们都必须去处理。 + +好,不啰嗦了,下面我们就把打开首页接口的性能瓶颈仔细扒一扒。 + +看架构图 + +在每次分析性能瓶颈之前,我都会画这样一张图,看看这个接口会涉及到哪些服务和技术组件,这对我们后续的性能分析会有很大的帮助。 + + + +如果你有工具可以直接展示,那就更好了。如果没有,那我建议你不要自信地认为自己可以记住一个简单的架构。相信我,哪怕是在纸上简单画一画,都会对你后面的分析思路有很大的帮助。 + +回到上面这张图,我们可以清楚地看到这个打开首页的逻辑是:User - Gateway(Redis)- Portal - (Redis,MySQL)。 + +顺便看下代码逻辑 + +在做打开首页的基准场景之前,我建议你先看一眼这个接口的代码实现逻辑,从代码中可以看到这个接口在做哪些动作。根据这些动作,我们可以分析它们的后续链路。 + +这个代码的逻辑很简单,就是列出首页上的各种信息,然后返回一个JSON。 + +public HomeContentResult contentnew() { + HomeContentResult result = new HomeContentResult(); + if (redisService.get("HomeContent") == null) { + //首页广告 + result.setAdvertiseList(getHomeAdvertiseList()); + //品牌推荐 + result.setBrandList(homeDao.getRecommendBrandList(0, 6)); + //秒杀信息 + result.setHomeFlashPromotion(getHomeFlashPromotion()); + //新品推荐 + result.setNewProductList(homeDao.getNewProductList(0, 4)); + //人气推荐 + result.setHotProductList(homeDao.getHotProductList(0, 4)); + //专题推荐 + result.setSubjectList(homeDao.getRecommendSubjectList(0, 4)); + redisService.set("HomeContent", result); + } + Object homeContent = redisService.get("HomeContent"); + // result = JSON.parseObject(homeContent.toString(), HomeContentResult.class); + result = JSONUtil.toBean(JSONUtil.toJsonPrettyStr(homeContent), HomeContentResult.class); + + return result; +} + + +我们可以看到,这里面一共调用了6个方法,并且这些方法都是直接到数据库里做了查询,如此而已。 + +确定压力数据 + +了解完代码逻辑后,我们上10个线程试运行一下,看看在一个个线程递增的过程中,TPS会有什么样的趋势。 + +运行之后,我们得到这样的结果: + + + +从结果来看,在一开始,一个线程会产生40左右的TPS。这里我们就要思考一下了:如果想要执行一个场景,并且这个场景可以压出打开首页接口的最大TPS,我们应该怎么设置压力工具中的线程数、递增策略和持续执行策略呢? + +对此,我们先看看Portal应用节点所在机器的硬件使用情况,了解一下TPS趋势和资源使用率之间的关系。这个机器的情况如下图所示(注意,我跳过了Gateway所在的节点): + + + +可以看到,当前Portal节点所在的机器是8C16G(虚拟机),并且这个机器基本上没什么压力。 + +现在我们先不计算其他资源,只考虑8C16G的配置情况。如果TPS是线性增长的话,那么当该机器的CPU使用率达到 100%的时候,TPS大概就是800左右。因此,我们压力工具中的线程数应该设置为: +\[ 线程数 = 800 TPS \\div 40 TPS = 20 个线程\]不过,在压力持续的过程中,TPS和资源使用率之间的等比关系应该是做不到的。因为在压力过程中,各种资源的消耗都会增加一些响应时间,这些也都属于正常的响应时间损耗。 + +在确定了压力工具的线程数之后,我们再来看递增策略怎么设置。 + +我希望递增时间可以增加得慢一些,以便于我们查看各环节性能数据的反应。根据第2讲中的性能分析决策树,在这样的场景中,我们有不少计数器需要分析查看,所以我设置为30秒上一个线程,也就是说递增周期为600秒。 + +在确定好压力参数后,我们的试运行场景就可以在JMeter中设置为如下值: + + 20 + 600 + true + 700 + + +设置好试运行参数后,我们就可以在这样的场景下进一步设置足够的线程来运行,以达到资源使用率的最大化。 + +你可能会疑惑:难道不用更高的线程了吗?如果你想做一个正常的场景,那确实不需要用更高的线程了;如果你就是想知道压力线程加多了是什么样子,那你可以试试。我在性能场景执行时,也经常用各种方式压着玩。 + +不过,话说回来,确实有一种情况需要我们正儿八经地增加更多的压力,那就是你的响应时间已经增加了,可是增加得又不多,TPS也不再上升。这时候,我们拆分响应时间是比较困难的,特别是当一些系统很快的时候,响应时间可能只是几个毫秒之间。所以,在这种情况下,我们需要多增加一些线程,让响应时间慢的地方更清晰地表现出来,这样也就更容易拆分时间。 + +通过压力场景的递增设置(前面算的是只需要20个线程即可达到最大值,而这里,我把压力线程设置为100启动场景,目的是为了看到递增到更大压力时的TPS趋势以及响应时间的增加,这样更容易做时间的拆分),我们看到这个接口的响应时间确实在慢慢增加,并且随着线程数的增加,响应时间很快就上升到了几百毫秒。这是一个明显的瓶颈,我们自然是不能接受的。 + + + +接下来,我们就要好好分析一下这个响应时间究竟消耗到了哪里。 + +拆分时间 + +我们前面提到,打开首页的逻辑是:User - Gateway(Redis)- Portal - (Redis,MySQL),那我们就按照这个逻辑,借助链路监控工具SkyWalking把响应时间具体拆分一下。 + + + + +User —Gateway之间的时间消耗 + + + + +我们看到,User - Gateway之间的时间消耗慢慢上升到了150毫秒左右。 + + +Gateway响应时间 + + + + +gateway上也消耗了150毫秒,这就说明user到gateway之间的网络并没有多少时间消耗,在毫秒级。 + + +Gateway —Portal之间的时间消耗 + + + + +在Portal上,响应时间只消耗了50毫秒左右。我们再到Portal上看一眼。 + + +Portal响应时间 + + + + +Portal的响应时间是50毫秒左右,和我们上面看到的时间一致。 + +通过上述对响应时间的拆分,我们可以确定是Gateway消耗了响应时间,并且这个时间达到了近100毫秒。所以,我们下一步定位的目标就是Gateway了。 + +定位Gateway上的响应时间消耗 + +第一阶段:分析st cpu + +既然Gateway上的响应时间消耗很高,我们自然就要查一下这台主机把时间消耗在了哪里。 + +我们的分析逻辑仍然是先看全局监控,后看定向监控。全局监控要从整个架构开始看起,然后再确定某个节点上的资源消耗。注意,在看全局监控时,我们要从最基础的查起,而分析的过程中最基础的就是操作系统了。 + +通过top命令,我们可以看到Gateway节点上的资源情况,具体如下: + + + +其中,st cpu达到了15%左右。我们知道,st cpu是指虚拟机被宿主机上的其他应用或虚拟机抢走的CPU,它的值这么高显然是不太正常的。所以,我们要进一步查看st cpu异常的原因。 + +我们用mpstat命令先来看看宿主机(运行Gateway的虚拟机所在的物理机)上的资源表现: + + + +可以看到,CPU还有20%没有用完,说明宿主机还有空间。不过,宿主机的CPU使用率已经不小了,而消耗这些宿主机的就只有虚拟机里的应用。所以,我们要查一下是不是某个虚拟机的CPU消耗特别高。宿主机上的KVM列表如下: + + [root@dell-server-3 ~]# virsh list --all + Id 名称 状态 +---------------------------------------------------- + 12 vm-jmeter running + 13 vm-k8s-worker-8 running + 14 vm-k8s-worker-7 running + 15 vm-k8s-worker-9 running + +[root@dell-server-3 ~]# + + +可以看到,在这个宿主机上跑了四个虚拟机,那我们就具体看一下这四个虚拟机的资源消耗情况。 + + +vm-jmeter + + + top - 23:42:49 up 28 days, 8:14, 6 users, load average: 0.61, 0.48, 0.38 +Tasks: 220 total, 1 running, 218 sleeping, 1 stopped, 0 zombie +%Cpu0 : 6.6 us, 3.5 sy, 0.0 ni, 88.5 id, 0.0 wa, 0.0 hi, 0.0 si, 1.4 st +%Cpu1 : 6.5 us, 1.8 sy, 0.0 ni, 88.2 id, 0.0 wa, 0.0 hi, 0.4 si, 3.2 st +KiB Mem : 3880180 total, 920804 free, 1506128 used, 1453248 buff/cache +KiB Swap: 2097148 total, 1256572 free, 840576 used. 2097412 avail Mem + + PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND + 7157 root 20 0 3699292 781204 17584 S 27.8 20.1 1:09.44 java + 9 root 20 0 0 0 0 S 0.3 0.0 30:25.77 rcu_sched + 376 root 20 0 0 0 0 S 0.3 0.0 16:40.44 xfsaild/dm- + + + +vm-k8s-worker-8 + + +top - 23:43:47 up 5 days, 22:28, 3 users, load average: 9.21, 6.45, 5.74 +Tasks: 326 total, 1 running, 325 sleeping, 0 stopped, 0 zombie +%Cpu0 : 20.2 us, 3.7 sy, 0.0 ni, 60.7 id, 0.0 wa, 0.0 hi, 2.9 si, 12.5 st +%Cpu1 : 27.3 us, 7.4 sy, 0.0 ni, 50.2 id, 0.0 wa, 0.0 hi, 3.7 si, 11.4 st +%Cpu2 : 29.9 us, 5.6 sy, 0.0 ni, 48.5 id, 0.0 wa, 0.0 hi, 4.9 si, 11.2 st +%Cpu3 : 31.2 us, 5.6 sy, 0.0 ni, 47.6 id, 0.0 wa, 0.0 hi, 4.5 si, 11.2 st +%Cpu4 : 25.6 us, 4.3 sy, 0.0 ni, 52.7 id, 0.0 wa, 0.0 hi, 3.6 si, 13.7 st +%Cpu5 : 26.0 us, 5.2 sy, 0.0 ni, 53.5 id, 0.0 wa, 0.0 hi, 4.1 si, 11.2 st +%Cpu6 : 19.9 us, 6.2 sy, 0.0 ni, 57.6 id, 0.0 wa, 0.0 hi, 3.6 si, 12.7 st +%Cpu7 : 27.3 us, 5.0 sy, 0.0 ni, 53.8 id, 0.0 wa, 0.0 hi, 2.3 si, 11.5 st +KiB Mem : 16265688 total, 6772084 free, 4437840 used, 5055764 buff/cache +KiB Swap: 0 total, 0 free, 0 used. 11452900 avail Mem + + PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND +13049 root 20 0 9853712 593464 15752 S 288.4 3.6 67:24.22 java + 1116 root 20 0 2469728 57932 16188 S 12.6 0.4 818:40.25 containerd + 1113 root 20 0 3496336 118048 38048 S 12.3 0.7 692:30.79 kubelet + 4961 root 20 0 1780136 40700 17864 S 12.3 0.3 205:51.15 calico-node + 3830 root 20 0 2170204 114920 33304 S 11.6 0.7 508:00.00 scope + 1118 root 20 0 1548060 111768 29336 S 11.3 0.7 685:27.95 dockerd + 8216 techstar 20 0 2747240 907080 114836 S 5.0 5.6 1643:33 prometheus +21002 root 20 0 9898708 637616 17316 S 3.3 3.9 718:56.99 java + 1070 root 20 0 9806964 476716 15756 S 2.0 2.9 137:13.47 java +11492 root 20 0 441996 33204 4236 S 1.3 0.2 38:10.49 gvfs-udisks2-vo + + + +vm-k8s-worker-7 + + +top - 23:44:22 up 5 days, 22:26, 3 users, load average: 2.50, 1.67, 1.13 +Tasks: 308 total, 1 running, 307 sleeping, 0 stopped, 0 zombie +%Cpu0 : 4.2 us, 3.5 sy, 0.0 ni, 82.3 id, 0.0 wa, 0.0 hi, 1.7 si, 8.3 st +%Cpu1 : 6.2 us, 2.7 sy, 0.0 ni, 82.8 id, 0.0 wa, 0.0 hi, 1.4 si, 6.9 st +%Cpu2 : 5.2 us, 2.8 sy, 0.0 ni, 84.0 id, 0.0 wa, 0.0 hi, 1.0 si, 6.9 st +%Cpu3 : 4.5 us, 3.8 sy, 0.0 ni, 81.2 id, 0.0 wa, 0.0 hi, 1.4 si, 9.2 st +%Cpu4 : 4.4 us, 2.4 sy, 0.0 ni, 83.3 id, 0.0 wa, 0.0 hi, 1.4 si, 8.5 st +%Cpu5 : 5.5 us, 2.4 sy, 0.0 ni, 84.5 id, 0.0 wa, 0.0 hi, 1.0 si, 6.6 st +%Cpu6 : 3.7 us, 2.7 sy, 0.0 ni, 85.6 id, 0.0 wa, 0.0 hi, 0.7 si, 7.4 st +%Cpu7 : 3.1 us, 1.7 sy, 0.0 ni, 84.7 id, 0.0 wa, 0.0 hi, 1.4 si, 9.0 st +KiB Mem : 16265688 total, 8715820 free, 3848432 used, 3701436 buff/cache +KiB Swap: 0 total, 0 free, 0 used. 12019164 avail Mem + + PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND +18592 27 20 0 4588208 271564 12196 S 66.9 1.7 154:58.93 mysqld + 1109 root 20 0 2381424 105512 37208 S 9.6 0.6 514:18.00 kubelet + 1113 root 20 0 1928952 55556 16024 S 8.9 0.3 567:43.53 containerd + 1114 root 20 0 1268692 105212 29644 S 8.6 0.6 516:43.38 dockerd + 3122 root 20 0 2169692 117212 33416 S 7.0 0.7 408:21.79 scope + 4132 root 20 0 1780136 43188 17952 S 6.0 0.3 193:27.58 calico-node + 3203 nfsnobo+ 20 0 116748 19720 5864 S 2.0 0.1 42:43.57 node_exporter +12089 techstar 20 0 5666480 1.3g 23084 S 1.3 8.5 78:04.61 java + 5727 root 20 0 449428 38616 4236 S 1.0 0.2 49:02.98 gvfs-udisks2-vo + + + +vm-k8s-worker-9 + + + top - 23:45:23 up 5 days, 22:21, 4 users, load average: 12.51, 10.28, 9.19 +Tasks: 333 total, 4 running, 329 sleeping, 0 stopped, 0 zombie +%Cpu0 : 20.1 us, 7.5 sy, 0.0 ni, 43.3 id, 0.0 wa, 0.0 hi, 13.4 si, 15.7 st +%Cpu1 : 20.1 us, 11.2 sy, 0.0 ni, 41.4 id, 0.0 wa, 0.0 hi, 11.9 si, 15.3 st +%Cpu2 : 23.8 us, 10.0 sy, 0.0 ni, 35.4 id, 0.0 wa, 0.0 hi, 14.2 si, 16.5 st +%Cpu3 : 15.1 us, 7.7 sy, 0.0 ni, 49.1 id, 0.0 wa, 0.0 hi, 12.2 si, 15.9 st +%Cpu4 : 22.8 us, 6.9 sy, 0.0 ni, 40.5 id, 0.0 wa, 0.0 hi, 14.7 si, 15.1 st +%Cpu5 : 17.5 us, 5.8 sy, 0.0 ni, 50.0 id, 0.0 wa, 0.0 hi, 10.6 si, 16.1 st +%Cpu6 : 22.0 us, 6.6 sy, 0.0 ni, 45.1 id, 0.0 wa, 0.0 hi, 11.0 si, 15.4 st +%Cpu7 : 19.2 us, 8.0 sy, 0.0 ni, 44.9 id, 0.0 wa, 0.0 hi, 9.8 si, 18.1 st +KiB Mem : 16265688 total, 2567932 free, 7138952 used, 6558804 buff/cache +KiB Swap: 0 total, 0 free, 0 used. 8736000 avail Mem + + PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND +24122 root 20 0 9890064 612108 16880 S 201.0 3.8 1905:11 java + 2794 root 20 0 2307652 161224 33464 S 57.7 1.0 1065:54 scope + 1113 root 20 0 2607908 60552 15484 S 13.8 0.4 1008:04 containerd + 1109 root 20 0 2291748 110768 39140 S 12.8 0.7 722:41.17 kubelet + 1114 root 20 0 1285500 108664 30112 S 11.1 0.7 826:56.51 dockerd + 29 root 20 0 0 0 0 S 8.9 0.0 32:09.89 ksoftirqd/4 + 6 root 20 0 0 0 0 S 8.2 0.0 41:28.14 ksoftirqd/0 + 24 root 20 0 0 0 0 R 8.2 0.0 41:00.46 ksoftirqd/3 + 39 root 20 0 0 0 0 R 8.2 0.0 41:08.18 ksoftirqd/6 + 19 root 20 0 0 0 0 S 7.9 0.0 39:10.22 ksoftirqd/2 + 14 root 20 0 0 0 0 S 6.2 0.0 40:58.25 ksoftirqd/1 + + +很显然,worker-9的si(中断使用的CPU)和st(被偷走的CPU)都不算低。那这种情况就比较奇怪了,虚拟机本身都没有很高的CPU使用率,为什么st还这么高呢?难道CPU只能用到这种程度? + +来,我们接着查下去。 + +第二阶段:查看物理机CPU运行模式 + +在这个阶段,我们要查一下服务里有没有阻塞。就像前面提到的,我们要从全局监控的角度,来考虑所查看的性能分析计数器是不是完整,以免出现判断上的偏差。不过,我去查看了线程栈的具体内容,看到线程栈中并没有Blocked啥的,那我们就只能再回到物理机的配置里看了。 + +那对于物理机CPU,我们还有什么可看的呢?即使你盖上被子蒙着头想很久,从下到上把所有的逻辑都理一遍,也找不出什么地方会有阻塞。那我们就只有看宿主机的CPU运行模式了。 + +-- 物理机器1 +[root@hp-server ~]# cpupower frequency-info +analyzing CPU 0: + driver: pcc-cpufreq + CPUs which run at the same hardware frequency: 0 + CPUs which need to have their frequency coordinated by software: 0 + maximum transition latency: Cannot determine or is not supported. + hardware limits: 1.20 GHz - 2.10 GHz + available cpufreq governors: conservative userspace powersave ondemand performance + current policy: frequency should be within 1.20 GHz and 2.10 GHz. + The governor "conservative" may decide which speed to use + within this range. + current CPU frequency: 1.55 GHz (asserted by call to hardware) + boost state support: + Supported: yes + Active: yes + +-- 物理机器2 +[root@dell-server-2 ~]# cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor +powersave +[root@dell-server-2 ~]# cpupower frequency-info +analyzing CPU 0: + driver: intel_pstate + CPUs which run at the same hardware frequency: 0 + CPUs which need to have their frequency coordinated by software: 0 + maximum transition latency: Cannot determine or is not supported. + hardware limits: 1.20 GHz - 2.20 GHz + available cpufreq governors: performance powersave + current policy: frequency should be within 1.20 GHz and 2.20 GHz. + The governor "powersave" may decide which speed to use + within this range. + current CPU frequency: 2.20 GHz (asserted by call to hardware) + boost state support: + Supported: no + Active: no + 2200 MHz max turbo 4 active cores + 2200 MHz max turbo 3 active cores + 2200 MHz max turbo 2 active cores + 2200 MHz max turbo 1 active cores + +-- 物理机器3 +[root@dell-server-3 ~]# cpupower frequency-info +analyzing CPU 0: + driver: intel_pstate + CPUs which run at the same hardware frequency: 0 + CPUs which need to have their frequency coordinated by software: 0 + maximum transition latency: Cannot determine or is not supported. + hardware limits: 1.20 GHz - 2.20 GHz + available cpufreq governors: performance powersave + current policy: frequency should be within 1.20 GHz and 2.20 GHz. + The governor "powersave" may decide which speed to use + within this range. + current CPU frequency: 2.20 GHz (asserted by call to hardware) + boost state support: + Supported: no + Active: no + 2200 MHz max turbo 4 active cores + 2200 MHz max turbo 3 active cores + 2200 MHz max turbo 2 active cores + 2200 MHz max turbo 1 active cores + +-- 物理机器4 +[root@lenvo-nfs-server ~]# cpupower frequency-info +analyzing CPU 0: + driver: acpi-cpufreq + CPUs which run at the same hardware frequency: 0 + CPUs which need to have their frequency coordinated by software: 0 + maximum transition latency: 10.0 us + hardware limits: 2.00 GHz - 2.83 GHz + available frequency steps: 2.83 GHz, 2.00 GHz + available cpufreq governors: conservative userspace powersave ondemand performance + current policy: frequency should be within 2.00 GHz and 2.83 GHz. + The governor "conservative" may decide which speed to use + within this range. + current CPU frequency: 2.00 GHz (asserted by call to hardware) + boost state support: + Supported: no + Active: no + + +可以看到,没有一个物理机是运行在performance模式之下的。 + +在这里,我们需要对CPU的运行模式有一个了解: + + + +既然我们是性能分析人员,那自然要用performance模式了,所以我们把CPU模式修改如下: + +-- 物理机器1 +[root@hp-server ~]# cpupower -c all frequency-set -g performance +Setting cpu: 0 +Setting cpu: 1 +Setting cpu: 2 +Setting cpu: 3 +Setting cpu: 4 +Setting cpu: 5 +Setting cpu: 6 +Setting cpu: 7 +Setting cpu: 8 +Setting cpu: 9 +Setting cpu: 10 +Setting cpu: 11 +Setting cpu: 12 +Setting cpu: 13 +Setting cpu: 14 +Setting cpu: 15 +Setting cpu: 16 +Setting cpu: 17 +Setting cpu: 18 +Setting cpu: 19 +Setting cpu: 20 +Setting cpu: 21 +Setting cpu: 22 +Setting cpu: 23 +Setting cpu: 24 +Setting cpu: 25 +Setting cpu: 26 +Setting cpu: 27 +Setting cpu: 28 +Setting cpu: 29 +Setting cpu: 30 +Setting cpu: 31 +[root@hp-server ~]# cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor +performance +[root@hp-server ~]# + +-- 物理机器2 +[root@dell-server-2 ~]# cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor +powersave +[root@dell-server-2 ~]# cpupower -c all frequency-set -g performance +Setting cpu: 0 +Setting cpu: 1 +Setting cpu: 2 +Setting cpu: 3 +Setting cpu: 4 +Setting cpu: 5 +Setting cpu: 6 +Setting cpu: 7 +Setting cpu: 8 +Setting cpu: 9 +Setting cpu: 10 +Setting cpu: 11 +Setting cpu: 12 +Setting cpu: 13 +Setting cpu: 14 +Setting cpu: 15 +[root@dell-server-2 ~]# cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor +performance +[root@dell-server-2 ~]# + +-- 物理机器3 +[root@dell-server-3 ~]# cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor +powersave +[root@dell-server-3 ~]# cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor +powersave +[root@dell-server-3 ~]# cpupower -c all frequency-set -g performance +Setting cpu: 0 +Setting cpu: 1 +Setting cpu: 2 +Setting cpu: 3 +Setting cpu: 4 +Setting cpu: 5 +Setting cpu: 6 +Setting cpu: 7 +Setting cpu: 8 +Setting cpu: 9 +Setting cpu: 10 +Setting cpu: 11 +Setting cpu: 12 +Setting cpu: 13 +Setting cpu: 14 +Setting cpu: 15 +[root@dell-server-3 ~]# cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor +performance +[root@dell-server-3 ~]# + +-- 物理机器4 +[root@lenvo-nfs-server ~]# cpupower -c all frequency-set -g performance +Setting cpu: 0 +Setting cpu: 1 +Setting cpu: 2 +Setting cpu: 3 +[root@lenvo-nfs-server ~]# cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor +performance +[root@lenvo-nfs-server ~]# + + +在我们一顿操作猛如虎之后,性能会怎么样呢? + +结果,性能并没有好起来……这里我就不截图了,因为图和一开始的那张场景运行图一样。 + +在这里我们要知道,以上的分析过程说明不止是这个问题点,还有其他资源使用有短板我们没有找到。没办法,我们只能接着查。 + +总结 + +在这节课中,我们通过压力工具中的曲线,判断了瓶颈的存在。然后通过SkyWalking拆分了响应时间。 + +在确定了响应时间消耗点之后,我们又开始了两个阶段的分析:第一个阶段的证据链是从现象开始往下分析的,因为st cpu是指宿主机上的其他应用的消耗导致了此虚拟机的cpu资源被消耗,所以,我们去宿主机上去查了其他的虚拟机。这里我们要明确CPU资源应该用到什么样的程度,在发现了资源使用不合理之后,再接着做第二阶段的判断。 + +在第二阶段中,我们判断了CPU运行模式。在物理机中,如果我们自己不做主动的限制,CPU的消耗是没有默认限制的,所以我们才去查看CPU的运行模式。 + +但是,即便我们分析并尝试解决了以上的问题,TPS仍然没什么变化。可见,在计数器的分析逻辑中,虽然我们做了优化动作,但系统仍然有问题。只能说我们当前的优化手段,只解决了木桶中的最短板,但是其他短板,我们还没有找到。 + +请你注意,这并不是说我们这节课的分析优化过程没有意义。要知道,这些问题不解决,下一个问题也不会出现。所以,我们这节课的分析优化过程也非常有价值。 + +下节课,我们接着来找打开首页接口的性能瓶颈。 + +课后作业 + +最后,请你思考一下: + + +为什么我们看到虚拟机中st cpu高,就要去查看宿主机上的其他虚拟机?如果在宿主机上看到st cpu高,我们应该做怎样的判断? +CPU的运行模式在powersave时,CPU的运行逻辑是什么? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果这节课让你有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/12打开首页之二:如何平衡利用硬件资源?.md b/专栏/高楼的性能工程实战课/12打开首页之二:如何平衡利用硬件资源?.md new file mode 100644 index 0000000..7e21c2e --- /dev/null +++ b/专栏/高楼的性能工程实战课/12打开首页之二:如何平衡利用硬件资源?.md @@ -0,0 +1,322 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 12 打开首页之二:如何平衡利用硬件资源? + 你好,我是高楼。 + +针对打开首页接口的性能问题,我们在上节课中确定了是Gateway在消耗响应时间,达到了近100毫秒。于是,我们开始定位Gateway上的响应时间消耗。 + +在第一阶段的时候,我们关注了应用所在的主机,同时还了解到,宿主机总共有四台机器;在第二阶段,我们查看了物理机的CPU模式。并尝试通过修改CPU运行模式来优化性能。可是,问题仍然没有解决,TPS没见提升,响应时间依旧很长。 + +今天这节课,我们进入第三阶段,继续分析其他的瓶颈点,比如wa cpu、资源均衡使用、网络带宽等问题。其中,在性能的分析逻辑里,资源均衡使用是一个非常容易被忽略,但又极为重要的方面。我们通常都盯着计数器给出的数值有什么异常,而不是考虑资源怎么做相应的调配。 + +在我们这个案例中,系统是用k8s来管理资源的,所以我们必须要关注资源的均衡使用,避免出现有些服务性能很差,却和性能好的服务分配同样资源的情况。另外,网络资源在k8s中会跨越好几层,我们也要着重关注一下。 + +在学习这节课时,我建议你多思考下资源的均衡使用问题。现在,我们就开始今天的课程。 + +定位gateway上的响应时间消耗 + +第三阶段:NFS服务器的wa cpu偏高 + +根据分析的逻辑,我们仍然是先看全局监控数据,思路依旧是“全局-定向”,这是我一贯的顺序了。 + +因此,我们现在再来查一下全局监控计数器,得到下面这样的视图: + +[root@lenvo-nfs-server ~]# top +top - 00:12:28 up 32 days, 4:22, 3 users, load average: 9.89, 7.87, 4.71 +Tasks: 217 total, 1 running, 216 sleeping, 0 stopped, 0 zombie +%Cpu0 : 0.0 us, 4.0 sy, 0.0 ni, 34.8 id, 61.2 wa, 0.0 hi, 0.0 si, 0.0 st +%Cpu1 : 0.0 us, 4.7 sy, 0.0 ni, 27.8 id, 67.6 wa, 0.0 hi, 0.0 si, 0.0 st +%Cpu2 : 0.0 us, 6.1 sy, 0.0 ni, 0.0 id, 93.9 wa, 0.0 hi, 0.0 si, 0.0 st +%Cpu3 : 0.0 us, 7.6 sy, 0.0 ni, 3.4 id, 82.8 wa, 0.0 hi, 6.2 si, 0.0 st +KiB Mem : 3589572 total, 82288 free, 775472 used, 2731812 buff/cache +KiB Swap: 8388604 total, 8036400 free, 352204 used. 2282192 avail Mem + + +可以看到,计数器wa的CPU使用率偏高,其中Cpu2的wa已经达到90%以上。我们知道,wa cpu是指CPU在读写的时候,所产生的IO等待时间占CPU时间的百分比。那么,它现在竟然这么高,是因为写操作有很多吗? + +这时候我们就要关注下IO的状态了,因为IO慢绝对是一个性能问题。通过iostat命令,我们看到IO状态如下: + +[root@lenvo-nfs-server ~]# iostat -x -d 1 +Linux 3.10.0-693.el7.x86_64 (lenvo-nfs-server) 2020年12月26日 _x86_64_ (4 CPU) +.................. +Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util +sda 0.00 0.00 94.00 39.00 13444.00 19968.00 502.44 108.43 410.80 52.00 1275.59 7.52 100.00 + + +Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util +sda 0.00 18.00 137.00 173.00 17712.00 43056.00 392.05 129.46 601.10 38.80 1046.38 3.74 115.90 +.................. + + +你可以看到,IO使用率达到了100%,说明IO的过程实在是太慢了。 + +接下来,我们再查查Block Size是多少,算一下当前IO到底是随机读写还是顺序读写。虽然大部分操作系统都默认Block Size是4096,但是,本着不出小错的原则,我们还是查一下比较放心。 + +我们先确定磁盘的格式是什么: + +[root@lenvo-nfs-server ~]# cat /proc/mounts +................... +/dev/sda5 / xfs rw,relatime,attr2,inode64,noquota 0 0 +................... +[root@lenvo-nfs-server ~]# + + +通过上述命令可以知道,这个磁盘是XFS格式。那我们就用下面这个命令来查看Block Size: + +[root@lenvo-nfs-server ~]# xfs_info /dev/sda5 +meta-data=/dev/sda5 isize=512 agcount=4, agsize=18991936 blks + = sectsz=512 attr=2, projid32bit=1 + = crc=1 finobt=0 spinodes=0 +data = bsize=4096 blocks=75967744, imaxpct=25 + = sunit=0 swidth=0 blks +naming =version 2 bsize=4096 ascii-ci=0 ftype=1 +log =internal bsize=4096 blocks=37093, version=2 + = sectsz=512 sunit=0 blks, lazy-count=1 +realtime =none extsz=4096 blocks=0, rtextents=0 +[root@lenvo-nfs-server ~]# + + +结果显示,Block Size是4096。同时,我们也可以看到读写基本上都是顺序的,不是随机。 + +那我们就来计算一条数据,确认一下顺序写的能力。如果全部是随机写,那么: + +\(次数=(43056\\times 1024)\\div 4096=10,764次\) + +但是,实际上写只有173次,所以确实是顺序写了。 + +问题又来了,一次写多少个Block呢? + +\((43056\\times1024)\\div173\\div4096\\approx 62个\) + +我们得出,一次写62个Block。从这样的数据来看,说明顺序写的能力还是不错的。因为对普通磁盘来说,应用在读写的时候,如果是随机写多,那写的速度就会明显比较慢;如果顺序写多,那么写的速度就可以快起来。 + +你发现了吗?虽然当前磁盘的顺序写能力不错,但是等待的时间也明显比较多。所以,接下来,我们得查一下是什么程序写的。这里我们用iotop命令查看: + +Total DISK READ : 20.30 M/s | Total DISK WRITE : 24.95 M/s +Actual DISK READ: 20.30 M/s | Actual DISK WRITE: 8.27 M/s + TID PRIO USER DISK READ DISK WRITE SWAPIN IO> COMMAND +12180 be/4 root 2.39 M/s 16.01 M/s 0.00 % 35.94 % [nfsd] +12176 be/4 root 3.20 M/s 0.00 B/s 0.00 % 32.25 % [nfsd] +12179 be/4 root 3.03 M/s 6.43 M/s 0.00 % 32.23 % [nfsd] +12177 be/4 root 2.44 M/s 625.49 K/s 0.00 % 31.64 % [nfsd] +12178 be/4 root 2.34 M/s 1473.47 K/s 0.00 % 30.43 % [nfsd] +12174 be/4 root 2.14 M/s 72.84 K/s 0.00 % 29.90 % [nfsd] +12173 be/4 root 2.91 M/s 121.93 K/s 0.00 % 24.95 % [nfsd] +12175 be/4 root 1894.69 K/s 27.71 K/s 0.00 % 24.94 % [nfsd] +............... + + +可以看到,IO都是NFS写过来的。那NFS的流量又是从哪里来的呢?从下面的数据来看,这些流量是从各个挂载了NFS盘的机器写过来的,这是我们一开始部署应用的时候,考虑统一使用NFS来做IO的思路。因为这个机器挂载了一个大容量的磁盘,为了保证磁盘够用,就把多个主机挂载了NFS盘。 + + 191Mb 381Mb 572Mb 763Mb 954Mb +mqqqqqqqqqqqqqqqqqqqvqqqqqqqqqqqqqqqqqqqvqqqqqqqqqqqqqqqqqqqvqqqqqqqqqqqqqqqqqqqvqqqqqqqqqqqqqqqqqqq +172.16.106.119:nfs => 172.16.106.130:multiling-http 1.64Mb 2.04Mb 3.06Mb + <= 26.2Mb 14.5Mb 19.8Mb +172.16.106.119:nfs => 172.16.106.100:apex-mesh 1.43Mb 2.18Mb 3.79Mb + <= 25.5Mb 14.2Mb 14.4Mb +172.16.106.119:nfs => 172.16.106.195:vatp 356Kb 1.27Mb 1.35Mb + <= 9.71Mb 7.04Mb 7.41Mb +172.16.106.119:nfs => 172.16.106.56:815 7.83Kb 4.97Kb 4.81Kb + <= 302Kb 314Kb 186Kb +172.16.106.119:nfs => 172.16.106.79:device 11.0Kb 7.45Kb 7.57Kb + <= 12.4Kb 22.0Kb 28.5Kb +172.16.106.119:ssh => 172.16.100.201:cnrprotocol 2.86Kb 2.87Kb 5.81Kb + <= 184b 184b 525b +169.254.3.2:60010 => 225.4.0.2:59004 2.25Kb 2.40Kb 2.34Kb + <= 0b 0b 0b +169.254.6.2:60172 => 225.4.0.2:59004 2.25Kb 2.40Kb 2.34Kb + <= 0b 0b 0b +172.16.106.119:nfs => 172.16.106.149:986 0b 1.03Kb 976b + <= 0b 1.26Kb 1.11Kb + + +qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq +TX: cum: 37.0MB peak: 31.9Mb rates: 3.44Mb 5.50Mb 8.22Mb +RX: 188MB 106Mb 61.8Mb 36.2Mb 41.8Mb +TOTAL: 225MB 111Mb 65.2Mb 41.7Mb 50.1Mb + + +我们在Total DISK WRITE和Total DISK READ 中可以看到,读写能力才达到 20M。没办法,既然wa这个机器的能力不怎么好,那就只有放弃统一写的思路。不过,为了不让机器的IO能力差成为应用的瓶颈点,我们还是再尝试一下这两个动作: + + +第一,把MySQL的数据文件移走; +第二,把Log移走。 + + +接着我们执行场景,希望结果能好。 + +可是,在我查看了TPS和RT曲线后,很遗憾地发现,结果并没有改善。TPS依然很低并且动荡非常大: + + + +看来我们的努力并没有什么效果,悲剧!就这样,命运让我们不得不来到第四个阶段。 + +第四阶段:硬件资源耗尽,但TPS仍然很低 + +这个阶段我们查什么呢?仍然是全局监控的数据。我们来看一下所有主机的Overview资源: + + + +从上图中可以看到,虚拟机k8s-worker-8的CPU使用率已经很高了,达到了95.95%。那我们就登录到这台虚拟机上,看看更详细的全局监控数据: + + + +因为CPU不超分了,所以我们可以很明显地看到,k8s-worker-8中的CPU被耗尽。从进程上来看,CPU是被我们当前正在测试的接口服务消耗的。并且在这台虚拟机上,不止有Portal这一个进程,还有很多其他的服务。 + +那我们就把Portal服务调度到一个不忙的worker上去,比如移到worker-3(6C16G)上: + + + +得到如下结果: + + + +我们看到,TPS已经有所上升了,达到了近300,性能确实变好了一些。但是,这个数据还不如我们一开始不优化的结果,毕竟一开始还能达到300TPS呢。那我们就接着分析当前的瓶颈在哪里。 + +我们先来看一下主机的性能数据: + + + +其中,worker-8的CPU使用率达到了90.12%。为什么这个CPU还是如此之高呢?我们继续来top一下,看看worker-8的性能数据: + + + +你看,在process table中,排在最上面的是Gateway服务,说明是Gateway进程消耗的CPU最多。既然如此,我们自然要看看这个进程中的线程是不是都在干活。 + + + +我们看到上图中全是绿色的,也就是说Gateway中的线程一直处于Runnable状态,看来工作线程确实挺忙的了。而在前面的worker-8性能数据中,si cpu已经达到了16%左右。所以结合这一点,我们来看一下实时的软中断数据: + + + +可以看到网络软中断一直在往上跳,这说明确实是网络软中断导致si cpu变高的。网络软中断的变化是我们根据证据链找下来的。证据链如下: + + + +我们再看一下网络带宽有多大: + + + +可以看到,网络带宽倒是不大。 + +从上述Gateway的工作线程、软中断数据和网络带宽情况来看,Gateway只负责转发,并没有什么业务逻辑,也没有什么限制。所以,针对TPS上不去的原因,似乎除了网络转发能力比较差之外,我们再找不到其他解释了。 + +这个思路其实是需要一些背景知识的,因为我们通常用网络带宽来判断网络是不是够用,但是这是不够的。你要知道,在网络中当小包过多的时候,网络带宽是难以达到线性流量的。所以,我们这里的网络带宽即便不会很高,也会导致网络软中断的增加和队列的出现。 + +既然如此,那我们就把这个Gateway也从worker-8移到worker-2(6C16G)上去,做这一步是为了减少网络软中断的争用。我们再看一下集群的整体性能: + + + +看起来不错哦,worker-8 的 CPU 使用率降到了 56.65%,同时worker-3 的 CPU 使用率升到了 70.78%。不过网络带宽有几个地方变红了,这个我们后面再分析。至少我们从这里看到,压力是起来了。 + +我们回来看一下压力的情况: + + + +TPS已经达到1000左右了!棒棒的,有没有!我们画一个TPS对比图庆祝一下: + + + +其实到这里,打开首页这个接口的基准场景就可以结束了,因为我们已经优化到了比需求还要高的程度。只是从技术角度来说,一个系统优化到最后是会有上限的,所以,我们仍然需要知道这个上限在哪里。 + +第五阶段:硬件资源还是要用完 + +现在压力把worker-3的CPU资源用得最高,用到了70.78%。那么,下面我们就要把这个机器的硬件资源给用完,因为只有将资源都用尽,我们才能判断系统容量的最上限。这也就是我一直强调的,要将性能优化分为两个阶段:一是把资源用起来;二是把容量调上去。就算不是CPU资源,把其他的资源用完也可以。 + +既然这时候压力已经把worker-3的CPU资源用到了70.78%,那我们就到这个应用中看一下线程把CPU用得怎么样。 + + + +你看,这里面的线程确实都忙起来了。 + +既然如此,那我们把Tomcat和JDBC连接的最大值都改到80,再来看一下TPS的表现(请你注意,这里只是一个尝试,所以改大即可,并没有什么道理。在后续的测试过程中,我们还要根据实际情况来做调整,就是不能让线程太大,也不能不够用)。 + +为了让压力能直接压到一个节点上,我们跳过Ingress,用分段的测法直接把压力发到服务上。然后,我们去Pod里设置一个node port把服务代理出来,再修改一下压力脚本。得到结果如下: + + + +TPS还是抖动大。那我们接着看全局监控: + + + +看上图就可以知道,有几个主机的带宽都飘红了,而其他的资源使用率并没有特别高。前面我们有说过,分析网络问题,不应该只看网络带宽,还要分析其他的内容,下面我们就得分析一下网络带宽。 + +我们到监控工具中看一下网络的流量,你可以看到确实有一些非被测应用在占用带宽,并且占得还不小: + + + +我们再看总体带宽,发现已经用了4G多: + + + +为了弄清楚那些与被测系统无关的应用,会对带宽消耗产生影响进而影响TPS,我们现在先把影响带宽的应用都删除了,比如Weave Scope、Monitoring的监控工具等,从列表中来看这些应用占了不小的带宽。 + +然后我们再次测试,发现TPS有所上升,关键是稳定了很多: + + + +我们可以看到,TPS已经上升到了1200左右,可见带宽对TPS还是造成了不小的影响。 + +接着,我们查一下网络的队列,发现应用所在的服务器上面已经出现了不小的 Recv_Q。 + +Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timer +tcp 759 0 10.100.69.229:8085 10.100.140.32:35444 ESTABLISHED 1/java off (0.00/0/0) +tcp 832 0 10.100.69.229:34982 10.96.224.111:3306 ESTABLISHED 1/java keepalive (4871.85/0/0) +tcp 1056 0 10.100.69.229:34766 10.96.224.111:3306 ESTABLISHED 1/java keepalive (4789.93/0/0) +tcp 832 0 10.100.69.229:35014 10.96.224.111:3306 ESTABLISHED 1/java keepalive (4888.23/0/0) +tcp 3408 0 10.100.69.229:34912 10.96.224.111:3306 ESTABLISHED 1/java keepalive (4855.46/0/0) +tcp 3408 0 10.100.69.229:35386 10.96.224.111:3306 ESTABLISHED 1/java keepalive (5019.30/0/0) +tcp 3392 0 10.100.69.229:33878 10.96.224.111:3306 ESTABLISHED 1/java keepalive (4495.01/0/0) +tcp 560 0 10.100.69.229:35048 10.96.224.111:3306 ESTABLISHED 1/java keepalive (4888.23/0/0) +tcp 1664 0 10.100.69.229:34938 10.96.224.111:3306 ESTABLISHED 1/java keepalive (4855.46/0/0) +tcp 759 0 10.100.69.229:8085 10.100.140.32:35500 ESTABLISHED 1/java off (0.00/0/0) +tcp 832 0 10.100.69.229:35114 10.96.224.111:3306 ESTABLISHED 1/java keepalive (4921.00/0/0) +tcp 1056 0 10.100.69.229:34840 10.96.224.111:3306 ESTABLISHED 1/java keepalive (4822.69/0/0) +tcp 1056 0 10.100.69.229:35670 10.96.224.111:3306 ESTABLISHED 1/java keepalive (5117.60/0/0) +tcp 1664 0 10.100.69.229:34630 10.96.224.111:3306 ESTABLISHED 1/java keepalive (4757.16/0/0) + + +从这里来看,网络已经成为了下一个瓶颈(关于这一点,我们在后续的课程里会讲)。 + +如果你想接着调优,还可以从应用代码下手,让应用处理得更快。不过,对于基准测试来说,一个没有走任何缓存的接口,在一个6C16G的单节点虚拟机上能达到这么高的TPS,我觉得差不多了。 + +接下来,我们还要去折腾其他的接口,所以,我们对这个接口的优化到这里就结束了。 + + + +总结 + +在打开首页这个接口的基准场景中,涉及到了很多方面的内容。从一开始的信息整理,比如访问路径、查看代码逻辑、场景试运行等,都是在为后面的分析做准备。 + +而当我们看到响应时间高,然后做拆分时间这一步,就是我一直在RESAR性能工程中强调的“分析的起点”。因为在此之前,我们用的都是压力工具上的数据,只是把它们罗列出来就好了,没有任何分析的部分。 + +对于拆分时间,我们能用的手段有多种,你可以用你喜欢的方式,像日志、APM工具,甚至抓包都是可以的。拆分了时间之后,我们就要分析在某个节点上响应时间高的时候,要怎么做。这时就用到了我一直强调的“全局-定向”监控分析思路。 + +在每一个阶段,你一定要清楚地定义优化的方向和目标,否则容易迷失方向。特别是对于一些喜欢把鼠标操作得特别快的同学,容易失去焦点,我劝你慢点操作,想清楚下一步再动。 + +而我们上述整个过程,都依赖于我说的性能分析决策树。从树顶往下,一层层找下去,不慌不乱,不急不燥。 + +只要你想,就能做到。 + +课后作业 + +最后,我给你留三个思考题。 + + +当st cpu高的时候,你要去看什么? + +当wa cpu高的时候,你要去看什么? + +为什么我们要把硬件资源用完? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果你读完这篇文章有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/13用户登录:怎么判断线程中的Block原因?.md b/专栏/高楼的性能工程实战课/13用户登录:怎么判断线程中的Block原因?.md new file mode 100644 index 0000000..226cf86 --- /dev/null +++ b/专栏/高楼的性能工程实战课/13用户登录:怎么判断线程中的Block原因?.md @@ -0,0 +1,424 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 13 用户登录:怎么判断线程中的Block原因? + 你好,我是高楼。 + +这节课我们接着来“玩”一下用户登录。在第10讲的课程中,我们以登录功能为例做了一些分析,来说明基准场景中的一些要点。但是,我们还没有把它优化完,所以这节课还要接着来折腾它。 + +用户登录说起来只是一个很普通的功能,不过它的逻辑一点也不简单。因为登录过程要对个人的信息进行对比验证,验证过程中又要调用相应的加密算法,而加密算法是对性能要求很高的一种功能。复杂的加密算法安全性高,但性能就差;不复杂的加密算法性能好,但安全性低,这是一个取舍的问题。 + +另外,还有Session存储和同步。对于个大型的系统来说,不管你在哪个系统访问,在调用其他系统时如果需要验证身份就要同步Session信息,并且在做业务时,我们也要把相应的Session信息带上,不然就识别不了。 + +你看,登录功能实际上会涉及到很多的业务,它其实一点也不简单。所以,这节课我会带着你好好分析用户登录功能,并带你了解在压力过程中业务逻辑链路和整体TPS之间的关系。同时,也希望你能学会判断线程中的BLOCKED原因。 + +修改加密算法 + +还记得在第10讲中,我们在基准场景中对登录业务的测试结果吗?在10个压力线程下,TPS达到了100左右。 + + + +同时,在第10讲中,我们发现了加密算法BCrypt效率低之后,讨论了两种优化方式:一种是用更快的加密方式,另一种是去掉这个加密算法。当时,我选择把加密算法BCrypt直接去掉。在这节课中,我们来试试第一种方式,把它改为MD5,具体有两个动作: + + +更改加密算法。之前的BCrypt加密算法虽然安全性高,但性能差,所以建议改成MD5。 +加载所有用户到Redis中。 + + +我们再跑一遍压力场景。注意,在跑这一遍之前,我们只是更改了加密算法,并没有执行加载缓存的动作。我希望一次只做一个动作来判断结果(但是上面两个动作我们都要做哦,请你接着看下去),结果如下: + + + +从上面的结果来看,性能有些上升了,但是还没达到我想要的样子。我希望性能有突飞猛进的增加,而不是现在这样不温不火的样子,看着就来气。所以,我们还是要继续“收拾收拾”这个接口,使用缓存,看下效果如何。 + +检验缓存的效果 + +为了确定缓存对后续的性能优化产生了效果,我们可以用两个手段来检验效果: + + +把参数化数据量降下来,只用少量的数据测试一下(请注意,我们只是尝试一下,并不是说用少量的数据来运行场景是对的); +直接加载全部缓存。 + + +我们得到这样的结果: + + + +从曲线上看,登录接口能达到300TPS左右了。但是,我还是觉得不够好,因为从硬件资源上来看,再根据我以往的经验,它应该还能达到更高才对。 + +而在分析的过程中,再也没有第11讲和第12讲中提到的硬件资源的问题,但是在这里我们通过查看全局监控数据,看到的是us cpu高,说明确实都是业务逻辑在消耗CPU资源了。所以,我们就只有从登陆逻辑入手,来优化这个问题了。 + +修改登录的逻辑 + +通过阅读源代码,我整理了这个系统的原登录逻辑: + + + +这个逻辑看着比较啰嗦,其中Member服务调auth服务,倒还能理解。可是,Auth服务为什么还要到Member里取用户名呢?自己直接查缓存或DB不香吗?从架构设计的角度来看,为了避免共享数据库,这样的设计似乎也没啥。只是在我们的优化过程中,需要根据实际环境来做判断。 + +在我们这个环境中,需要把DB共用,这样Auth服务就可以直接使用数据库,而不用再从Member绕一圈。所以,我们先改成下面这种新的登录逻辑,这样就可以减少一次调用。 + + + +修改之后,登录TPS如下: + + + +从结果上来看,TPS确实有增加,已经到700以上了。很好。 + +这时候是不是就可以结束分析了呢?不是,我们还需要知道当前的瓶颈点在哪,因为根据我的性能理念,每个业务都会有瓶颈点,不管优化到什么程度,除非一直把硬件资源耗光。所以,我们继续接着分析。 + +看架构图 + +还是一样,在分析性能瓶颈之前,我们先来看架构图,了解用户登录接口会涉及到哪些服务和技术组件。 + + + +从这个架构图中可以看到,登录操作跨了Gateway/Member/Auth三个服务,连接了Redis/MySQL两个组件。图中的MongoDB虽然看上去有线,但实际上登录并没有用上。 + +了解这些信息之后,我们按照分析逻辑,一步步来分析问题。 + +拆分时间 + +我们前面提到,修改登录逻辑后的TPS如下: + + + +可以看到,响应时间已经上升到了100ms左右,所以,我们现在要找出这个时间消耗在了哪里。你可能已经注意到,图中的用户增加到了150。这是为了把响应时间拉大,便于我们分析。下面我们把这个响应时间拆分一下,看看问题出在哪里。 + + +Gateway服务上的时间 + + + + + +Member服务上的时间 + + + + + +Auth服务上的时间 + + + + +我们看到,Member服务上的时间消耗是150ms左右,Auth服务上的时间消耗有60ms左右。Member服务是我们要着重分析的,因为它的响应时间更长。而Auth上虽然时间不太长,但是也达到了60ms左右,从经验上来说,我觉得还是有点稍长了,最好平均能到50ms以下,所以我们也要稍微关心一下。 + +全局监控 + +我们的分析逻辑雷打不动,依旧是先看全局监控,后看定向监控。从下面这张全局监控图的数据来看,worker-7和worker-8的CPU使用率比其他的要高。 + + + +既然worker-7/8的CPU使用率要高一点,那我们就要查一下这两个节点上跑着什么样的服务。所以我们来看一下POD的分布,大概看一下每个POD在哪个worker节点上,以便后面分析POD相互之间的影响: + + + +原来,在worker-7和worker-8上,分别运行着响应时间稍高的Auth服务和Member服务。对于这两个服务,我们都要分析,只是得一个一个来,那我们就从auth服务开始。 + +你可能会问:为什么要先从Auth服务下手呢?没啥原因,就是看它的CPU更红一点。你还可能奇怪:图中其他地方也红了,为什么不关注呢?我来逐一给你分析一下。 + + +图中的worker-1和worker-2,内存使用率相对较大,达到了70%以上。从经验上来说,我几乎没有怎么关心过Linux的内存使用率,除非出现大量的page faults。因为Linux内存在分配给应用程序使用之后,是会体现在Cache当中的。被应用程序Cache住的内存在操作系统上来看都是被使用的,但实际上可能并未真的被使用,这时操作系统会把这部分Cache内存计算到available内存当中,所以说,我们直接看操作系统级别的空闲内存是分析不出问题来的。 + +在worker-2上,我们看到TCP的Time Wait达到近3万,不过这也不是我关心的点,因为Time Wait是正常的TCP状态,只有端口不够用、内存严重不足,我才会稍微看一眼。 + +至于worker-1和worker-2的上下行带宽,看起来真是不大。在内网结构中,我们在测试的时候,内网带宽达到过好几Gbps,这点带宽还不足以引起我们的重视。 + + +所以,我们要“收拾”的还是worker-7和worker-8。 + +既然Auth服务在worker-7上,member服务在worker-8上,就像前面说的,我们不如就从Auth服务开始。 + +Auth服务定向分析 + +对于Auth服务,我们从哪里开始分析呢?其实,我们可以按部就班。既然是Auth服务导致worker-7的CPU使用率偏高,那我们就可以走下面这个证据链: + + + +按照这个证据链,我们应该先看进程。不过,仗着傻小子火气壮(俗称:艺高人胆大),我直接就去看线程状态了,想看看能不能凭经验蒙对一把。于是,我打开了Spring Boot Admin的线程页面: + + + +有没有满目疮痍的感觉?人生就是这样,到处都有惊吓。 + +在我颤抖着手点开一些红色的地方之后,看到了类似这样的信息: + + + +可以看到,阻塞数非常大,达到了842。此外,锁拥有者ID是86676,锁拥有者名称是线程823。 + +我们抓两段栈出来看一下,找一下锁之间的关系: + +-- 第一处: +"http-nio-8401-exec-884" #86813 daemon prio=5 os_prio=0 tid=0x00007f2868073000 nid=0x559e waiting for monitor entry [0x00007f2800c6d000] + java.lang.Thread.State: BLOCKED (on object monitor + at java.security.Provider.getService(Provider.java:1035) + - waiting to lock <0x000000071ab1a5d8> (a sun.security.provider.Sun) + at sun.security.jca.ProviderList.getService(ProviderList.java:332) +..................... + at com.dunshan.mall.auth.util.MD5Util.toMD5(MD5Util.java:11) + at com.dunshan.mall.auth.config.MyPasswordEncoder.matches(MyPasswordEncoder.java:23) +..................... + at com.dunshan.mall.auth.controller.AuthController.postAccessToken$original$sWMe48t2(AuthController.java:46 + at com.dunshan.mall.auth.controller.AuthController.postAccessToken$original$sWMe48t2$accessor$jl0WbQJB(AuthController.java) + at com.dunshan.mall.auth.controller.AuthController$auxiliary$z8kF9l34.call(Unknown Source) +..................... + at com.dunshan.mall.auth.controller.AuthController.postAccessToken(AuthController.java) +..................... + + +-- 第二处: +"http-nio-8401-exec-862" #86728 daemon prio=5 os_prio=0 tid=0x00007f28680d6000 nid=0x553a waiting for monitor entry [0x00007f2802b8c000] + java.lang.Thread.State: BLOCKED (on object monitor + at sun.security.rsa.RSACore$BlindingParameters.getBlindingRandomPair(RSACore.java:404) + - waiting to lock <0x000000071ddad410> (a sun.security.rsa.RSACore$BlindingParameters) + at sun.security.rsa.RSACore.getBlindingRandomPair(RSACore.java:443) + ..................... + at com.dunshan.mall.auth.controller.AuthController.postAccessToken$original$sWMe48t2(AuthController.java:46) + at com.dunshan.mall.auth.controller.AuthController.postAccessToken$original$sWMe48t2$accessor$jl0WbQJB(AuthController.java) + at com.dunshan.mall.auth.controller.AuthController$auxiliary$z8kF9l34.call(Unknown Source) +..................... + at com.dunshan.mall.auth.controller.AuthController.postAccessToken(AuthController.java) +..................... + + +这两个栈的内容并不是同一时刻出现的,说明这个BLOCKED一直存在。但是不管怎么样,这个栈在做RSA加密,它和Token部分有关。 + +其中,线程http-nio-8401-exec-884是BLOCKED状态,那就说明有其他线程持有这个锁,所以我们自然要看一下线程栈中的waiting to lock 。其实,如果你有经验的话,一下子就能知道这里面是什么问题。不过,我们做性能分析的人要讲逻辑。 + +我在这里啰嗦几句,当你碰到这种锁问题,又不知道具体原因的时候,要下意识地去打印一个完整的栈来看,而不是再到Spring Boot Admin里胡乱点。为什么不建议你这么做?原因有这么几个: + + +由于线程太多,点着看逻辑关系比较累; +不断在刷,眼晕; +我不喜欢。 + + +所以,对于前面遇到的锁问题,我们首先要做的就是到容器中的jstack里打印一下栈,把它下载下来,然后祭出工具打开看一眼。 + +你可能会问,为什么不用Arthas之类的工具直接在容器里看?主要是因为Arthas的Dashboard在Thread比较多的时候,看起来真心累。 + +下面这张图就是jstack打印出来的栈,在下载之后用工具打开的效果。 + + + +是不是有种买彩票的感觉?看起来有那么多的BLOCKED状态的线程(多达842个),居然一个都没蒙到!我本来想抓BLOCKED状态的线程,并且线程描述是“Waiting on monitor”,但是,从上面的线程描述统计来看,一个也没见。哼,真生气。 + +这时候,身为一个做性能分析的人,我们一定要记得倒杯茶,静静心,默默地把jstack连续再执行几遍。我在这里就连续执行了10遍,然后再找每个栈的状态。 + +终于,Waiting on monitor来了: + + + +看起来有得玩了!接下来让我们看看究竟是谁阻塞住了上面的线程。 + +我们先在相应的栈里,找到对应的持有锁的栈。下面是栈中的阻塞关系。 + + +第一个栈 + + + + + +第二个栈 + + + + +你要注意,这是两个栈文件。所以,我们要分别从这两个栈文件里找到各自的对应等待关系。下面这段代码就对应了上面的Waiting线程。 + +-- 第一处 +"http-nio-8401-exec-890" #86930 daemon prio=5 os_prio=0 tid=0x00007f28680a5800 nid=0x561d waiting for monitor entry [0x00007f2800263000] + java.lang.Thread.State: BLOCKED (on object monitor + at java.security.Provider.getService(Provider.java:1035) + - locked <0x000000071ab1a5d8> (a sun.security.provider.Sun) + at sun.security.jca.ProviderList.getService(ProviderList.java:332) + ..................... + at com.dunshan.mall.auth.util.MD5Util.toMD5(MD5Util.java:11) + at com.dunshan.mall.auth.config.MyPasswordEncoder.matches(MyPasswordEncoder.java:23) +..................... + at com.dunshan.mall.auth.controller.AuthController.postAccessToken$original$sWMe48t2(AuthController.java:46) + at com.dunshan.mall.auth.controller.AuthController.postAccessToken$original$sWMe48t2$accessor$jl0WbQJB(AuthController.java) + at com.dunshan.mall.auth.controller.AuthController$auxiliary$z8kF9l34.call(Unknown Source) + at org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.InstMethodsInter.intercept(InstMethodsInter.java:86) + at com.dunshan.mall.auth.controller.AuthController.postAccessToken(AuthController.java) + + +-- 第二处 +"http-nio-8401-exec-871" #86739 daemon prio=5 os_prio=0 tid=0x00007f28681d6800 nid=0x5545 waiting for monitor entry [0x00007f2801a7b000] + java.lang.Thread.State: BLOCKED (on object monitor + at sun.security.rsa.RSACore$BlindingParameters.getBlindingRandomPair(RSACore.java:404) + - locked <0x000000071ddad410> (a sun.security.rsa.RSACore$BlindingParameters) + at sun.security.rsa.RSACore.getBlindingRandomPair(RSACore.java:443) +..................... + at com.dunshan.mall.auth.controller.AuthController.postAccessToken$original$sWMe48t2(AuthController.java:46) + at com.dunshan.mall.auth.controller.AuthController.postAccessToken$original$sWMe48t2$accessor$jl0WbQJB(AuthController.java) + at com.dunshan.mall.auth.controller.AuthController$auxiliary$z8kF9l34.call(Unknown Source) + at org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.InstMethodsInter.intercept(InstMethodsInter.java:86) + at com.dunshan.mall.auth.controller.AuthController.postAccessToken(AuthController.java) + + +你看上面locked这一行的锁ID,既然找到了这两处持有锁的栈,那我们就通过上面的栈,到源码中找到这两处栈的代码: + +-- 第一处同步代码块 + public synchronized Service getService(String type, String algorithm) { + checkInitialized() + // avoid allocating a new key object if possible + ServiceKey key = previousKey + if (key.matches(type, algorithm) == false) { + key = new ServiceKey(type, algorithm, false); + previousKey = key + + if (serviceMap != null) { + Service service = serviceMap.get(key) + if (service != null) { + return service; + + + ensureLegacyParsed() + return (legacyMap != null) ? legacyMap.get(key) : null; + + + +-- 第二处同步代码块 + // return null if need to reset the parameters + BlindingRandomPair getBlindingRandomPair( + BigInteger e, BigInteger d, BigInteger n) { + + + if ((this.e != null && this.e.equals(e)) || + (this.d != null && this.d.equals(d))) { + + + BlindingRandomPair brp = null; + synchronized (this) { + if (!u.equals(BigInteger.ZERO) && + !v.equals(BigInteger.ZERO)) + + + brp = new BlindingRandomPair(u, v); + if (u.compareTo(BigInteger.ONE) <= 0 || + v.compareTo(BigInteger.ONE) <= 0) { + + + // need to reset the random pair next time + u = BigInteger.ZERO + v = BigInteger.ZERO + } else { + u = u.modPow(BIG_TWO, n) + v = v.modPow(BIG_TWO, n) + + } // Otherwise, need to reset the random pair. + + return brp; + + + + return null; + + +你可以看到,第一处是JDK中提供的getService类采用了全局同步锁定,导致的分配key时产生争用,这个其实在JDK的Bug List中有过描述,详见JDK-7092821。准确来说,它不算是Bug,如果你想改的话,可以换一个库。 + +第二处是JDK中提供的RSA方法,是为了防范时序攻击特意设计成这样的。RSA中有大素数的计算,为了线程安全,RSA又加了锁。关于RSA的逻辑,你可以去看下源代码的/sun/security/rsa/RSACore.java中的逻辑。 + +不过,RSA是一种低效的加密方法,当压力发起来的时候,这样的synchronized类必然会导致BLOCKED出现。对此,在源码中有下面这样一段注释,其中建议先计算u/v,可以提高加密效率。 + + * Computing inverses mod n and random number generation is slow, s + * it is often not practical to generate a new random (u, v) pair for + * each new exponentiation. The calculation of parameters might even be + * subject to timing attacks. However, (u, v) pairs should not be + * reused since they themselves might be compromised by timing attacks, + * leaving the private exponent vulnerable. An efficient solution to + * this problem is update u and v before each modular exponentiation + * step by computing: + * + * u = u ^ 2 + * v = v ^ 2 + + * The total performance cost is small + + +既然我们已经知道了这两个BLOCKED产生的原因,那下一步的操作就比较简单了。 + + +针对第一处锁:实现自己的方法,比如说实现一个自己的分布式锁。 +针对第二处锁:换一个高效的实现。 + + +至此,我们就找到了应用中BLOCKED的逻辑。因为我们这是一个性能专栏,所以我就不再接着整下去了。如果你是在一个项目中,分析到这里就可以把问题扔给开发,然后去喝茶了,让他们伤脑筋去,哈哈。 + +不过,这只是一句玩笑而已,你可别当真。作为性能分析人员,我们要给出合情合理并且有证据链的分析过程,这样我们和其他团队成员沟通的时候,才会更加简单、高效。 + +Member服务定向分析 + +分析完Auth服务后,我们再来看看Member服务的性能怎么样。因为全局监控数据前面我们已经展示了,所以这里不再重复说明,我们直接来拆分一下对Member服务调用时的响应时间。 + + +Gateway上的响应时间 + + + + + +Member上的响应时间 + + + + + +Auth上的响应时间 + + + + +从上面的信息来看,这几段都有不同的时间消耗:Member服务上有80毫秒左右,Auth服务上已经有60毫秒左右,明显是有点高了。 + +我们登录到Member这个服务中,先看整体的资源使用情况。这里我用了最经典传统的top命令: + +%Cpu0 : 63.8 us, 12.4 sy, 0.0 ni, 9.2 id, 0.0 wa, 0.0 hi, 14.2 si, 0.4 st +%Cpu1 : 60.3 us, 11.7 sy, 0.0 ni, 11.0 id, 0.0 wa, 0.0 hi, 16.6 si, 0.3 st +%Cpu2 : 59.4 us, 12.0 sy, 0.0 ni, 14.1 id, 0.0 wa, 0.0 hi, 13.8 si, 0.7 st +%Cpu3 : 59.8 us, 12.1 sy, 0.0 ni, 11.7 id, 0.0 wa, 0.0 hi, 15.7 si, 0.7 st + + +从CPU使用分布上来看,其他计数器都还正常,只是si有点高。这是一个网络中断的问题,虽然有优化的空间,但是受基础架构所限,性能提升得不太多,这也是为什么现在很多企业都放弃了虚拟化,直接选择容器化的一个原因。 + +针对这个网络中断的问题,我将在后面的课程中仔细给你扒一扒,这节课我们暂且不做过多的讲解。 + +总结 + +这节课我用登录功能给你串了一个完整的性能分析场景。 + +在前面代码修改的部分,性能分析过程是比较快的,我们就是看看哪里的代码逻辑会消耗更多的时间。这个思路就是前面提到的us cpu的证据链。 + +而接下来我们在分析Auth服务的时候,是先从拆分时间开始一步步走到代码里的,其中最核心的部分是从CPU到栈,再到BLOCKED的判断。当我们看到栈上有BLOCKED的时候,要记得打印栈信息。但是因为有些锁会非常快速地获取和释放,所以就可能会出现打印栈时,看到等某个锁的栈信息,但是整个栈文件中却没有这把锁的情况。这个时候,你就要注意了,我们一定要去连续地多打几次栈,直到抓到对应的锁。 + +这是分析栈中锁的一个关键,因为我们经常会看到等锁的栈信息,看不到持有锁的栈信息。而连续多打几次栈,就是为了把持有锁和等待锁的栈同时打印出来,否则我们就找不出分析的逻辑了。 + +接着,当我们看到了持有锁的栈之后,就根据自己业务代码的调用逻辑,一层层地去找是哪里加的锁。至于这个锁加的合理不合理,就和业务逻辑有关了。作为性能分析人员,这个时候我们就可以把开发、业务、架构等人拉到一起讨论。这个锁要不要改,不是做性能的人说了算,而是大家一起说了算。 + +通过上述的分析,相信你可以看到,在我的性能分析逻辑中,从现象到原理,都需要搞清楚。 + +课后作业 + +最后,我给你留几个思考题来巩固今日所学。 + + +为什么看到BLOCKED的栈时要连续多打几次栈信息? +为什么从性能分析中要从现象到原理? +低效的代码有什么优化思路? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果你读完这篇文章有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/14用户信息查询:如何解决网络软中断瓶颈问题?.md b/专栏/高楼的性能工程实战课/14用户信息查询:如何解决网络软中断瓶颈问题?.md new file mode 100644 index 0000000..74a91a5 --- /dev/null +++ b/专栏/高楼的性能工程实战课/14用户信息查询:如何解决网络软中断瓶颈问题?.md @@ -0,0 +1,259 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 14 用户信息查询:如何解决网络软中断瓶颈问题? + 你好,我是高楼。 + +这节课我们接着来整另一个接口:用户信息查询。通过这个接口,我们一起来看看,当网络软中断过高时,会对TPS产生什么样的影响。其实对于这一点的判断,在很多性能项目中都会出现,而其中的难点就在于,很多人都无法将软中断跟响应时间慢和TPS所受到的影响关联起来。今天我就带你来解决这个问题。 + +同时,我也会给你讲解如何根据硬件配置及软件部署情况,做纯网络层的基准验证,以确定我们判断方向的正确性,进而提出具有针对性的优化方案。而我们最终优化的效果,会通过TPS对比来体现。 + +压力数据 + +我们先来看用户信息查询的压力数据情况如何。因为我们现在测试的是单接口,而用户信息查询又需要有登录态,所以我们要先跑一部分Token数据出来,再执行用户信息查询接口。 + +准备好Token数据后,第一次用户信息查询如下: + + + +这个步骤只是试验一下,持续时间长是为了查找问题。从上图来看,这个接口的起点不错,已经达到750左右。 + +不过,性能瓶颈也比较明显:响应时间随着压力线程的增加而增加了,TPS也达到了上限。对于这样的接口,我们可以调优,也可以不调优,因为这个接口当前的TPS可以达到我们的要求。只不过,本着“活着不就是为了折腾”的原则,我们还是要分析一下这个接口的瓶颈到底在哪里。 + +还是按照我们之前讲过的分析思路,下面我们来分析这个问题。 + +看架构图 + +从链路监控工具中,我们拉出架构图来,这样简单直接,又不用再画图了,真的是懒人必知技能。 + + + +从上图可以知道,用户信息查询的路径是User - Gateway - Member - MySQL。 + +你也许会问,图中不是还有Redis、MongoDB、Monitor吗?是的,这些我们也要记在脑子里。这个接口用到了Redis,如果接口有问题,变慢了,我们也要分析;MongoDB并没有用上,所以我们不管它;Monitor服务是Spring Boot Admin服务,我们也暂且不管它,后面需要用到的时候再说。 + +注意,这一步是分析的铺垫,是为了让我们后面分析时不会混乱。 + +拆分响应时间 + +在场景数据中,我们明显看到响应时间慢了,那我们就要知道慢在了哪里。我们根据上面的架构图知道了用户信息查询接口的路径,现在就要拆分这个响应时间,看一看每一段消耗了多长时间。 + +如果你有APM工具,那可以直接用它查看每一段消耗的时间。如果你没有,也没关系,只要能把架构图画出来,并把时间拆分了就行,不管你用什么招。 + +另外,我啰嗦一句,请你不要过分相信APM工具厂商的广告,咱们还是要看疗效。在追逐技术的同时,我们也需要理智地判断到底是不是需要。 + +具体的拆分时间如下: + + +User -Gateway + + + + + +Gateway上消耗的时间 + + + + + +Gateway -Member + + + + + +Member上消耗的时间 + + + + + +Member到DB的时间 + + + + +我把上述拆分后的时间都整理到我们的架构图中: + + + +看到这张图,思路变得特别清晰了,有没有?根据上图的时间拆分,我们明显看到Member服务上消耗时间更多一点,所以下一步我们去关注Member服务。 + +全局监控分析 + +还是一样,我们先看全局监控: + + + +其中,worker-8的CPU用得最多,我们先从这里下手。 + +这里我要跟你强调一下,在全局监控的思路中,不是说我们看了哪些数据,而是我们要去看哪些数据。这时候,你就必须先有一个全局计数器的东西。比如说在Kubernetes里,我们就要有这样的思路: + + + +也就是说,我们要先把全局监控的计数器都罗列出来,然后再一个一个查去。 + +其实,这里面不止是罗列那么简单,它还要有相应的逻辑。那怎么弄懂这个逻辑呢?这就要依赖于性能分析人员的基础知识了。我经常说,要想做全面的性能分析,就必须具备计算机基础知识,而这个知识的范围是很大的。之前我画过一张图,现在我做了一些修正,如下所示: + + + +图中这些内容,都是我们做性能分析时会遇到的东西。有人可能会说,这些已经远远超出性能工程师的技能范围了。所以我要再强调一下,我讲的一直都是性能工程。在整个项目的性能分析中,我并不限定技术的范围,只要是用得上,我们都需要拿出来分析。 + +前面我们说是worker-8上的CPU资源用得最多,所以我们来查一下被测的服务,也就是Member服务,是不是在worker-8上。 + + + +从上图看,Member服务确实是在worker-8上。 + +那下一步我们就要进到这个节点查看一下。查看之后,如果全是us cpu消耗,那我觉得这个基准测试就可以结束了。因为对于一个应用来说,us cpu高本来就是非常合理的情况。 + +之前一个做第三方测试的人跑过来跟我说,甲方爸爸不喜欢看到CPU使用率太高,让他想尽一切办法把CPU降下来。可是他没有什么招,所以就来问我该怎么办。 + +我问他测试的目标是什么。他回答客户并不关心TPS啥的,只说要把CPU降下来。我说这简单,你把压力降下来,CPU不就降下来了吗?本来以为只是一句调侃的话,结果他真去做了,并且还被客户接受了!后来我反思了一下,因为自己错误引导了性能行业的发展方向。 + +从职业的角度来说,我们对一些不懂的客户,最好要有一个良好的沟通,用对方能听懂的语言来解释。不过,在不该让步的时候,我们也不能让步。这才是专业的价值,不能是客户要什么,我们就给什么。 + +现在我们来看一下这个节点的top数据: + +[root@k8s-worker-8 ~]# top +top - 02:32:26 up 1 day, 13:56, 3 users, load average: 26.46, 22.37, 14.54 +Tasks: 289 total, 1 running, 288 sleeping, 0 stopped, 0 zombie +%Cpu0 : 73.9 us, 9.4 sy, 0.0 ni, 3.5 id, 0.0 wa, 0.0 hi, 12.5 si, 0.7 st +%Cpu1 : 69.8 us, 12.5 sy, 0.0 ni, 4.3 id, 0.0 wa, 0.0 hi, 12.8 si, 0.7 st +%Cpu2 : 71.5 us, 12.7 sy, 0.0 ni, 4.2 id, 0.0 wa, 0.0 hi, 10.9 si, 0.7 st +%Cpu3 : 70.3 us, 11.5 sy, 0.0 ni, 6.1 id, 0.0 wa, 0.0 hi, 11.5 si, 0.7 st +KiB Mem : 16266296 total, 3803848 free, 6779796 used, 5682652 buff/cache +KiB Swap: 0 total, 0 free, 0 used. 9072592 avail Mem + + + PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND +30890 root 20 0 7791868 549328 15732 S 276.1 3.4 23:17.90 java -Dapp.id=svc-mall-member -javaagent:/opt/skywalking/agent/sky+ +18934 root 20 0 3716376 1.6g 18904 S 43.9 10.3 899:31.21 java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitFor+ + 1059 root 20 0 2576944 109856 38508 S 11.1 0.7 264:59.42 /usr/bin/kubelet --bootstrap-kubeconfig=/etc/kubernetes/bootstrap- + 1069 root 20 0 1260592 117572 29736 S 10.8 0.7 213:48.18 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd. +15018 root 20 0 5943032 1.3g 16496 S 6.9 8.6 144:47.90 /usr/lib/jvm/java-1.8.0-openjdk/bin/java -Xms2g -Xmx2g -Xmn1g -Dna+ + 4723 root 20 0 1484184 43396 17700 S 5.9 0.3 89:53.45 calico-node -felix + + +在这个例子中,我们看到si cpu(软中断消耗的CPU)有10%多,其实这只是一个瞬间值,在不断跳跃的数据中,有很多次数据都比这个值大,说明si消耗的CPU有点高了。对于这种数据,我们就要关心一下了。 + +定向监控分析 + +我们进一步来看软中断的变化,既然软中断消耗的CPU高,那必然是要看一下软中断的计数器了: + + + +上图是一张瞬间的截图,而在实际的观察过程中,我们是要多看一会儿时间的。请你注意图中这些有白底的数字,在观察中,这些数值增加的越大说明中断越高。而我在观察的过程中,看到的是NET_RX变化的最大。 + +现在,从si cpu高到NET_RX中断多的逻辑基本上清楚了:因为NET_RX都是网络的接收,所以NET_RX会不断往上跳。 + +不过,请你注意,这个中断即使是正常的,也需要不断增加。我们要判断它合理不合理,一定要结合si cpu一起来看。并且在网络中断中,不止是软中断,硬中断也会不断增加。 + +从上图来看,网络中断已经均衡了,没有单队列网卡的问题。我们再看一下网络带宽。 + + + +总共用了50Mb的带宽,中断就已经达到10%,也就是说带宽没有完全用满,可是中断已经不低了,这说明我们的数据包中还是小包居多。 + +于是我们做如下调整。调整的方向 就是增加队列长度和缓冲区大小,让应用可以接收更多的数据包。 + +-- 增加网络的队列长度 +net.core.netdev_max_backlog = 10000 (原值:1000) +- 增加tomcat的队列长度为10000(原值1000) +server: + port: 8083 + tomcat + accept-count: 10000 +-- 改变设备一次可接收的数据包数量 +net.core.dev_weight = 128 (原值64) +-- 控制socket 读取位于等待设备队列中数据包的微秒数 +net.core.busy_poll = 100 (原值0) +-- 控制 socket 使用的接收缓冲区的默认大小 +net.core.rmem_default = 2129920 (原值:212992) +net.core.rmem_max = 2129920 (原值:212992) +-- 繁忙轮询 + net.core.busy_poll = 100 + 这个参数是用来控制了socket 读取位于等待设备队列中数据包的微秒数 + + +一顿操作猛如虎之后,原本满怀希望,然而再次查了TPS曲线之后,发现并没有什么卵用,让我们把一首《凉凉》唱出来。 + +我仔细想了一遍发送和接收数据的逻辑。既然上层应用会导致us cpu高,而si cpu高是因为网卡中断多引起的,那我们还是要从网络层下手。所以,我做了网络带宽能达到多高的验证。我先列一下当前的硬件配置。 + + + +我们通过iperf3直接测试网络,试验内容如下: + + + +从上面的数据可以看到,在不同的层面进行纯网络测试,si是有很大区别的。当网络流量走了KVM+Kubernetes+Docker的结构之后,网络损失居然这么高,si cpu也上升了很多。 + +这也解释了为什么现在很多企业放弃虚拟化,直接用物理机来跑Kubernetes了。 + +由于当前K8s用的是Calico插件中的IPIP模式,考虑到BGP模式的效率会高一些,我们把IPIP模式改为BGP。这一步也是为了降低网络接收产生的软中断。 + +那IPIP和BGP到底有什么区别呢?对于IPIP来说,它套了两次IP包,相当于用了一个IP层后,还要用另一个IP层做网桥。在通常情况下,IP是基于MAC的,不需要网桥;而BGP是通过维护路由表来实现对端可达的,不需要多套一层。但是BGP不是路由协议,而是矢量性协议。关于IPIP和BGP更多原理上的区别,如果你不清楚,我建议你自学一下相关的网络基础知识。 + +我们把IPIP修改为BGP模式之后,先测试下纯网络的区别,做这一步是为了看到在没有应用压力流量时,网络本身的传输效率如何: + + + +根据上面的测试结果,将带宽在不同的网络模式和包大小时的具体数值整理如下: + + + +可以看到,BGP的网络能力确实要强一些,差别好大呀。 + +我们再接着回去测试下接口,结果如下: + + + +再看软中断,看一下BGP模式下的软中断有没有降低: + +top - 22:34:09 up 3 days, 55 min, 2 users, load average: 10.62, 6.18, 2.76 +Tasks: 270 total, 2 running, 268 sleeping, 0 stopped, 0 zombie +%Cpu0 : 51.6 us, 11.5 sy, 0.0 ni, 30.0 id, 0.0 wa, 0.0 hi, 6.6 si, 0.3 st +%Cpu1 : 54.4 us, 9.4 sy, 0.0 ni, 28.2 id, 0.0 wa, 0.0 hi, 7.7 si, 0.3 st +%Cpu2 : 55.9 us, 11.4 sy, 0.0 ni, 26.9 id, 0.0 wa, 0.0 hi, 5.9 si, 0.0 st +%Cpu3 : 49.0 us, 12.4 sy, 0.0 ni, 32.8 id, 0.3 wa, 0.0 hi, 5.2 si, 0.3 st +KiB Mem : 16266296 total, 7186564 free, 4655012 used, 4424720 buff/cache +KiB Swap: 0 total, 0 free, 0 used. 11163216 avail Mem + + +优化效果 + +通过上面的调整结果,我们看到了软中断确实降低了不少,但是我们还是希望这样的优化体现到TPS上来,所以我们看一下优化之后TPS的效果。 + + + +si cpu有降低: + + + +总结 + +当我们看到一个接口已经满足了业务要求时,从成本上来说,我们不应该花时间再去收拾它。但是,从技术上来说,我们对每一个接口的性能结果,都要达到“知道最终瓶颈在哪里”的程度。这样才方便我们在后续的工作中继续优化。 + +在这节课的例子中,我们从si cpu开始分析,经过软中断查找和纯网络测试,定位到了Kubernetes的网络模式,进而我们选择了更加合理的网络模式。整个过程穿过了很长的链路,而这个思维也是在我在宣讲中一贯提到的“证据链”。 + +最后,我还是要强调一遍,性能分析一定要有证据链,没有证据链的性能分析就是耍流氓。我们要做正派的老司机。 + +课后作业 + +我给你留两道题,请你思考一下: + + +为什么看到NET_RX中断高的时候,我们会想到去测试一下纯网络带宽? +你能总结一下,这节课案例的证据链吗? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果你读完这篇文章有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/15查询商品:资源不足有哪些性能表现?.md b/专栏/高楼的性能工程实战课/15查询商品:资源不足有哪些性能表现?.md new file mode 100644 index 0000000..90ff858 --- /dev/null +++ b/专栏/高楼的性能工程实战课/15查询商品:资源不足有哪些性能表现?.md @@ -0,0 +1,455 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 15 查询商品:资源不足有哪些性能表现? + 你好,我是高楼。 + +这节课,我们来收拾“查询商品”这个接口。虽然这次的现象同样是TPS低、响应时间长,但是,这个接口走的路径和之前的不一样,所以在分析过程中会有些新鲜的东西,你将看到在资源真的不足的情况下,我们只有增加相应节点的资源才能提升性能。 + +在我的项目中,我一直都在讲,不要轻易给出资源不足的结论。因为但凡有优化的空间,我们都要尝试做优化,而不是直接告诉客户加资源。而给出“增加资源”这个结论,也必须建立在有足够证据的基础上。在这节课中,你也将看到这一点。 + +话不多说,我们直接开始今天的内容。 + +压力场景数据 + +对于查询商品接口,我们第一次试执行性能场景的结果如下: + + + +你看,TPS只有250左右,并且响应时间也明显随着压力的增加而增加了,看起来瓶颈已经出现了,对吧?根据哥的逻辑,下一步就是看架构图啦。 + +先看架构图 + +我们用APM工具来看看这个接口的架构。 + + + +你看,从压力机到Gateway服务、到Search服务、再到ES-Client,这个APM工具也只能帮我们到这里了。因为我们用的是ElasticSearch 7来做的搜索服务的支撑,而这个skywalking工具也没有对应的Agent,所以后面并没有配置skywalking。 + +在这里,我要多啰嗦几句。现在的APM工具大多是基于应用层来做的,有些运维APM采集的数据会更多一些,但也是响应时间、吞吐量等这样的信息。对于性能分析而言,现在的APM工具有减少排查时间的能力,但是在组件级的细化定位上还有待提高。虽然AI OPS也被提到了台面,但是也没见过哪个公司上了AIOPS产品后,就敢不让人看着。 + +总之,从细化分析的角度,我们在定位问题的根本原因时,手头有什么工具就可以用什么工具,即使什么工具都没有,撸日志也是照样能做到的,所以我建议你不要迷信工具,要“迷信”思路。 + +下面我们来拆分下这个接口的响应时间,看看这个案例的问题点在哪里。 + +拆分响应时间 + +“在RESAR性能分析逻辑中,拆分响应时间只是一个分析的起点。”这是我一直在强调的一句话。如果性能工程师连这个都不会做,就只能好好学习天天向上了。 + +根据架构图,我们拆分响应时间如下。 + + +Gateway服务上的响应时间: + + + + + +Search服务上的响应时间: + + + + + +ES Client的响应时间: + + + + +一层层看过之后,我们发现查询商品这个接口的响应时间消耗在了ES client上面。而在我们这个查询的路径上,在gateway/search服务上,我们并没有做什么复杂的动作。 + +既然知道了响应时间消耗在哪里,下面我们就来定位它,看能不能把TPS优化起来。 + +全局监控 + +根据高老师的经验,我们还是从全局监控开始,看全局监控可以让我们更加有的放矢。在分析的过程中,经常有人往下走了几步之后,就开始思维混乱、步伐飘逸。因为数据有很多,所以分析时很容易从一个数据走到不重要的分支上去了。而这时候,如果你心里有全局监控数据,思路就会更清晰,不会在无关的分支上消耗时间。 + +回到我们这个例子中,从下面的k8s worker(也就是k8s中的node,在我们的环境中我习惯叫成worker,就是为了体现:在我的地盘,我爱叫啥就叫啥)的数据上来看,似乎没有一个worker的资源使用率是特别高的。 + + + +请你注意,在k8s中看资源消耗,一定不要只看worker这个层面,因为这个层面还不够,一个worker上可能会运行多个pod。从上图来看,由于worker层面没有资源消耗,但是时间又明显是消耗在ES client上的,所以,接下来我们要看一下每一个pod的资源使用情况。 + + + +咦,有红色。你看,有两个与ES相关的POD,它们的CPU都飘红了,这下可有得玩了。既然是与ES相关的POD,那我们就把ES所有的POD排个序看看。 + + + +从上图的数据来看,有一个ES Client 消耗了67%的CPU,有两个ES Data消耗了99%的CPU,ES本来就是吃CPU的大户,所以我们接下来要着重分析它。 + +这里我再说明一点,我们从前面的worker资源使用率一步一步走到这里,在分析方向上是合情合理的,因为这些都是属于我提到的全局监控的内容。 + +定向分析 + +现在我们就来扒一扒ES,看看它在哪个worker节点上。罗列Pod信息如下: + +[root@k8s-master-1 ~]# kubectl get pods -o wide | grep elasticsearch +elasticsearch-client-0 1/1 Running 0 6h43m 10.100.230.2 k8s-worker-1 +elasticsearch-client-1 1/1 Running 0 6h45m 10.100.140.8 k8s-worker-2 +elasticsearch-data-0 1/1 Running 0 7h8m 10.100.18.197 k8s-worker-5 +elasticsearch-data-1 1/1 Running 0 7h8m 10.100.5.5 k8s-worker-7 +elasticsearch-data-2 1/1 Running 0 7h8m 10.100.251.67 k8s-worker-9 +elasticsearch-master-0 1/1 Running 0 7h8m 10.100.230.0 k8s-worker-1 +elasticsearch-master-1 1/1 Running 0 7h8m 10.100.227.131 k8s-worker-6 +elasticsearch-master-2 1/1 Running 0 7h8m 10.100.69.206 k8s-worker-3 +[root@k8s-master-1 ~]# + + +现在就比较清晰了,可以看到,在整个namespace中有两个ES client,三个ES data,三个ES master。 + +我们来画一个细一点的架构图,以便在脑子里记下这个逻辑: + + + +再结合我们在全局分析中看到的资源使用率图,现在判断至少有两个问题: + + +ES client请求不均衡; +ES data CPU 高。 + + +下面我们一个一个来分析。 + +ES client请求不均衡 + +从上面的架构图中可以看到,search服务连两个ES client,但是只有一个ES client的CPU使用率高。所以,我们需要查一下链路,看看ES的service: + +[root@k8s-master-1 ~]# kubectl get svc -o wide | grep search +elasticsearch-client NodePort 10.96.140.52 9200:30200/TCP,9300:31614/TCP 34d app=elasticsearch-client,chart=elasticsearch,heritage=Helm,release=elasticsearch-client +elasticsearch-client-headless ClusterIP None 9200/TCP,9300/TCP 34d app=elasticsearch-client +elasticsearch-data ClusterIP 10.96.16.151 9200/TCP,9300/TCP 7h41m app=elasticsearch-data,chart=elasticsearch,heritage=Helm,release=elasticsearch-data +elasticsearch-data-headless ClusterIP None 9200/TCP,9300/TCP 7h41m app=elasticsearch-data +elasticsearch-master ClusterIP 10.96.207.238 9200/TCP,9300/TCP 7h41m app=elasticsearch-master,chart=elasticsearch,heritage=Helm,release=elasticsearch-master +elasticsearch-master-headless ClusterIP None 9200/TCP,9300/TCP 7h41m app=elasticsearch-master +svc-mall-search ClusterIP 10.96.27.150 8081/TCP 44d app=svc-mall-search +[root@k8s-master-1 ~]# + + +你看,整个namespace中有一个client service(解析出来的是VIP,访问此服务时不会绕过K8s的转发机制),还有一个client-headless service(解析出来的是POD IP,访问这个服务时会绕过K8s的转发机制)。 + +接下来,我们查一下为什么会出现访问不均衡的情况。 + +通过查看search服务的ES配置,我们看到如下信息: + + elasticsearch: + rest: + uris: elasticsearch-client:9200 + username: elastic + password: admin@123 + + +看到我们这里是用的elasticsearch-client:9200,我们再来看一下client service的配置: + +--- +apiVersion: v1 +kind: Service +metadata: + annotations: + meta.helm.sh/release-name: elasticsearch-client + meta.helm.sh/release-namespace: default + creationTimestamp: '2020-12-10T17:34:19Z' + labels: + app: elasticsearch-client + app.kubernetes.io/managed-by: Helm + chart: elasticsearch + heritage: Helm + release: elasticsearch-client + managedFields: + - apiVersion: v1 + fieldsType: FieldsV1 + fieldsV1: + 'f:metadata': {} + 'f:spec': + 'f:ports': {} + manager: Go-http-client + operation: Update + time: '2020-12-10T17:34:19Z' + name: elasticsearch-client + namespace: default + resourceVersion: '4803428' + selfLink: /api/v1/namespaces/default/services/elasticsearch-client + uid: 457e962e-bee0-49b7-9ec4-ebfbef0fecdd +spec: + clusterIP: 10.96.140.52 + externalTrafficPolicy: Cluster + ports: + - name: http + nodePort: 30200 + port: 9200 + protocol: TCP + targetPort: 9200 + - name: transport + nodePort: 31614 + port: 9300 + protocol: TCP + targetPort: 9300 + selector: + app: elasticsearch-client + chart: elasticsearch + heritage: Helm + release: elasticsearch-client + sessionAffinity: None + type: NodePort + + +从上面的配置来看,sessionAffinity也配置为None了,也就是说这个service不以客户端的IP来保持session。因为在这个环境配置中,Type为NodePort,而我们在k8s中配置的转发规则是iptables。所以说,service是依赖iptables的规则来做后端转发的。 + +接下来,我们检查一下iptables的转发规则。 + +我们先来看iptables中关于ES client的规则: + +[root@k8s-master-1 ~]# iptables -S KUBE-SERVICES -t nat|grep elasticsearch-client|grep 9200 +-A KUBE-SERVICES ! -s 10.100.0.0/16 -d 10.96.140.52/32 -p tcp -m comment --comment "default/elasticsearch-client:http cluster IP" -m tcp --dport 9200 -j KUBE-MARK-MASQ +-A KUBE-SERVICES -d 10.96.140.52/32 -p tcp -m comment --comment "default/elasticsearch-client:http cluster IP" -m tcp --dport 9200 -j KUBE-SVC-XCX4XZ2WPAE7BUZ4 +[root@k8s-master-1 ~]# + + +可以看到,service的规则名是KUBE-SVC-XCX4XZ2WPAE7BUZ4,那我们再去查它对应的iptables规则: + +[root@k8s-master-1 ~]# iptables -S KUBE-SVC-XCX4XZ2WPAE7BUZ4 -t nat +-N KUBE-SVC-XCX4XZ2WPAE7BUZ4 +-A KUBE-SVC-XCX4XZ2WPAE7BUZ4 -m comment --comment "default/elasticsearch-client:http" -j KUBE-SEP-LO263M5QW4XA6E3Q +[root@k8s-master-1 ~]# +[root@k8s-master-1 ~]# iptables -S KUBE-SEP-LO263M5QW4XA6E3Q -t nat +-N KUBE-SEP-LO263M5QW4XA6E3Q +-A KUBE-SEP-LO263M5QW4XA6E3Q -s 10.100.227.130/32 -m comment --comment "default/elasticsearch-client:http" -j KUBE-MARK-MASQ +-A KUBE-SEP-LO263M5QW4XA6E3Q -p tcp -m comment --comment "default/elasticsearch-client:http" -m tcp -j DNAT --to-destination 10.100.227.130:9200 + + +问题来了,这里好像没有负载均衡的配置(没有probability参数),并且根据iptables规则也只是转发到了一个ES client上。到这里,其实我们也就能理解,为什么在全局监控的时候,我们只看到一个ES client有那么高的CPU使用率,而另一个ES client却一点动静都没有。 + +但是,这里的iptables规则并不是自己来配置的,而是在部署k8s的时候自动刷进去的规则。现在只有一条规则了,所以只能转发到一个POD上去。 + +那我们就再刷一遍ES的POD,重装一下ES的POD,看k8s自己能不能刷出来负载均衡的iptables规则。重来一遍之后,我们再来看iptables规则: + +[root@k8s-master-1 ~]# iptables -S KUBE-SVC-XCX4XZ2WPAE7BUZ4 -t nat +-N KUBE-SVC-XCX4XZ2WPAE7BUZ4 +-A KUBE-SVC-XCX4XZ2WPAE7BUZ4 -m comment --comment "default/elasticsearch-client:http" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-IFM4L7YNSTSJP4YT +-A KUBE-SVC-XCX4XZ2WPAE7BUZ4 -m comment --comment "default/elasticsearch-client:http" -j KUBE-SEP-5RAP6F6FATXC4DFL +[root@k8s-master-1 ~]# + + +哟,现在刷出来两条iptables规则了,看来之前在我们不断折腾的部署过程中,ES client一直是有问题的。 + +在上面的iptables规则里,那两条iptables的上一条中有一个关键词“——probability 0.50000000000”。我们知道,iptables的匹配规则是从上到下的,既然上一条的匹配是随机0.5,也就是说只有50%的请求会走第一条规则,那下一条自然也是随机0.5了,因为总共只有两条规则嘛。这样一来就均衡了。 + +我们再接着做这个接口的压力场景,看到如下信息: + + + +看起来ES client均衡了,对不对? + +它对应的TPS,如下: + + + +明显TPS提升了60左右。 + +ES client请求不均衡的问题解决了,现在,我们还要来看一下ES data单节点CPU高的问题。 + +ES Data CPU使用率高 + + +第一阶段:加一个CPU + + +在TPS提升之后,我们再来看一下全局监控数据。 + + + +看起来比一开始好多了。基于前面分析ES client的经验,我们就先来查一下ES data的iptables规则: + +-- 查看下有哪些ES data的POD +[root@k8s-master-1 ~]# kubectl get pods -o wide | grep data +elasticsearch-data-0 1/1 Running 0 10h 10.100.18.197 k8s-worker-5 +elasticsearch-data-1 1/1 Running 0 10h 10.100.5.5 k8s-worker-7 +elasticsearch-data-2 1/1 Running 0 10h 10.100.251.67 k8s-worker-9 + + +-- 查看ES data对应的iptables规则 +[root@k8s-master-1 ~]# iptables -S KUBE-SERVICES -t nat|grep elasticsearch-data +-A KUBE-SERVICES ! -s 10.100.0.0/16 -d 10.96.16.151/32 -p tcp -m comment --comment "default/elasticsearch-data:http cluster IP" -m tcp --dport 9200 -j KUBE-MARK-MASQ +-A KUBE-SERVICES -d 10.96.16.151/32 -p tcp -m comment --comment "default/elasticsearch-data:http cluster IP" -m tcp --dport 9200 -j KUBE-SVC-4LU6GV7CN63XJXEQ +-A KUBE-SERVICES ! -s 10.100.0.0/16 -d 10.96.16.151/32 -p tcp -m comment --comment "default/elasticsearch-data:transport cluster IP" -m tcp --dport 9300 -j KUBE-MARK-MASQ +-A KUBE-SERVICES -d 10.96.16.151/32 -p tcp -m comment --comment "default/elasticsearch-data:transport cluster IP" -m tcp --dport 9300 -j KUBE-SVC-W4QKPGOO4JGYQZDQ + + +-- 查看9200(外部通信)对应的规则 +[root@k8s-master-1 ~]# iptables -S KUBE-SVC-4LU6GV7CN63XJXEQ -t nat +-N KUBE-SVC-4LU6GV7CN63XJXEQ +-A KUBE-SVC-4LU6GV7CN63XJXEQ -m comment --comment "default/elasticsearch-data:http" -m statistic --mode random --probability 0.33333333349 -j KUBE-SEP-ZHLKOYKJY5GV3ZVN +-A KUBE-SVC-4LU6GV7CN63XJXEQ -m comment --comment "default/elasticsearch-data:http" -m statistic --mode random --probability 1 -j KUBE-SEP-6ILKZEZS3TMCB4VJ +-A KUBE-SVC-4LU6GV7CN63XJXEQ -m comment --comment "default/elasticsearch-data:http" -j KUBE-SEP-JOYLBDPA3LNXKWUK + + +-- 查看以上三条规则的转发目标 +[root@k8s-master-1 ~]# iptables -S KUBE-SEP-ZHLKOYKJY5GV3ZVN -t nat +-N KUBE-SEP-ZHLKOYKJY5GV3ZVN +-A KUBE-SEP-ZHLKOYKJY5GV3ZVN -s 10.100.18.197/32 -m comment --comment "default/elasticsearch-data:http" -j KUBE-MARK-MASQ +-A KUBE-SEP-ZHLKOYKJY5GV3ZVN -p tcp -m comment --comment "default/elasticsearch-data:http" -m tcp -j DNAT --to-destination 10.100.18.197:9200 +[root@k8s-master-1 ~]# iptables -S KUBE-SEP-6ILKZEZS3TMCB4VJ -t nat +-N KUBE-SEP-6ILKZEZS3TMCB4VJ +-A KUBE-SEP-6ILKZEZS3TMCB4VJ -s 10.100.251.67/32 -m comment --comment "default/elasticsearch-data:http" -j KUBE-MARK-MASQ +-A KUBE-SEP-6ILKZEZS3TMCB4VJ -p tcp -m comment --comment "default/elasticsearch-data:http" -m tcp -j DNAT --to-destination 10.100.251.67:9200 +[root@k8s-master-1 ~]# iptables -S KUBE-SEP-JOYLBDPA3LNXKWUK -t nat +-N KUBE-SEP-JOYLBDPA3LNXKWUK +-A KUBE-SEP-JOYLBDPA3LNXKWUK -s 10.100.5.5/32 -m comment --comment "default/elasticsearch-data:http" -j KUBE-MARK-MASQ +-A KUBE-SEP-JOYLBDPA3LNXKWUK -p tcp -m comment --comment "default/elasticsearch-data:http" -m tcp -j DNAT --to-destination 10.100.5.5:9200 +[root@k8s-master-1 ~] + + +Everything is perfect!!规则很合理。ES Data总共有三个pod,从逻辑上来看,它们各占了三分之一。 + +在前面的ES client分析中,我们讲到,第一个POD是0.5,下一条自然也只剩下0.5,这很容易理解。现在,ES data的部分有三条iptables规则,我们来简单说明一下。 + + +通常我们理解的iptables就是一个防火墙。不过,要是从根本上来讲,它不算是一个防火墙,只是一堆规则列表,而通过iptables设计的规则列表,请求会对应到netfilter框架中去,而这个netfilter框架,才是真正的防火墙。其中,netfilter是处于内核中的,iptables就只是一个用户空间上的配置工具而已。 + +我们知道iptables有四表五链。四表是:fileter表(负责过滤)、nat表(负责地址转换)、mangle表(负责解析)、raw表(关闭nat表上启用的连接追踪);五链是:prerouting链(路由前)、input链(输入规则)、forward链(转发规则)、output链(输出规则)、postrouting链(路由后)。 + +而在这一部分,我们主要是看nat表以及其上的链。对于其他的部分,如果你想学习,可自行查阅iptables相关知识。毕竟,我还时刻记得自己写的是一个性能专栏,而不是计算机基础知识专栏,哈哈。 + + +从上面的信息可以看到,我们的这个集群中有三个ES data服务,对应着三条转发规则,其中,第一条规则的匹配比例是:0.33333333349;第二条比例:0.50000000000;第三条是1。而这三条转发规则对应的POD IP和端口分别是:10.100.18.197:9200、10.100.251.67:9200、10.100.5.5:9200,这也就意味着,通过这三条iptables规则可以实现负载均衡,画图理解如下: + + + +我们假设有30个请求进来,那ES Data 0上就会有30x0.33333333349=10个请求;对于剩下的20个请求,在ES Data 1上就会有20x0.50000000000=10个请求;而最后剩下的10个请求,自然就到了ES Data 2上。这是一个非常均衡的逻辑,只是在iptables规则中,我看着这几个数据比例,实在是觉得别扭。 + +既然明白了这个逻辑,下面我们还是把查询商品接口的场景压起来看一下: + + + +从数据上来看,经常会出现ES data 某个节点消耗CPU高的情况。可是,对应到我们前面看到的全局worker监控界面中,并没有哪个worker的CPU很高。所以,在这里,我们要查一下ES Data中的cgroup配置,看它的限制是多少。 + + + +也就是说,ES data的每个POD都是配置了一颗CPU,难怪CPU使用率动不动就红了。 + +还有一点你要记住,前面我们在查看data列表的时候发现,ES data 0 在worker-5上,ES data 1 在worker-7上,ES data 2 在worker-9上。而我们现在看到的却是,它们都各自分到了一个CPU。既然如此,那我们就再添加一个CPU,然后再回去看一下worker-5/7/9的反应。为什么只加一个CPU呢?因为从worker-7上来看,现在的CPU使用率已经在50%左右了,要是加多了,我怕它吃不消。 + + + +看一下压力场景执行的效果: + + + +似乎……不怎么样?TPS并没有增加。 + + +第二阶段:加副本 + + +我们再看加了CPU之后的全局POD监控: + + + +还是只有一个ES data的CPU使用率高,所以我想查一下ES中的数据分布。因为负载均衡的问题解决了,并且知道有三个ES data节点。现在我们就要知道是不是每个节点都被访问到了。 + +pms 0 p 10.100.18.199 _w 32 17690 18363 6.7mb 7820 true true 8.5.1 false +pms 0 p 10.100.18.199 _15 41 2110 0 465.7kb 5500 true true 8.5.1 true +pms 0 p 10.100.18.199 _16 42 21083 30255 9.5mb 5900 true true 8.5.1 false +pms 0 p 10.100.18.199 _17 43 2572 0 568kb 5500 true true 8.5.1 true +pms 0 p 10.100.18.199 _18 44 1403 0 322.9kb 5500 true true 8.5.1 true +pms 0 p 10.100.18.199 _19 45 1856 0 414.1kb 5500 true true 8.5.1 true +pms 0 p 10.100.18.199 _1a 46 1904 0 423kb 5500 true true 8.5.1 true + + +为啥数据都在一个节点上(都是10.100.18.199)?看起来只有一个数据副本的原因了。 + +green open pms A--6O32bQaSBrJPJltOLHQ 1 0 48618 48618 55.1mb 18.3mb + + +所以,我们先把副本数加上去,因为我们有三个data节点,所以这里加三个副本: + +PUT /pms/_settings +{ + "number_of_replicas": 3 +} + +我们再次查看ES中的数据分布,如下所示: + +pms 0 r 10.100.18.200 _w 32 17690 18363 6.7mb 7820 true true 8.5.1 false +pms 0 r 10.100.18.200 _15 41 2110 0 465.7kb 5500 true true 8.5.1 true +pms 0 r 10.100.18.200 _16 42 21083 30255 9.5mb 5900 true true 8.5.1 false +pms 0 r 10.100.18.200 _17 43 2572 0 568kb 5500 true true 8.5.1 true +pms 0 r 10.100.18.200 _18 44 1403 0 322.9kb 5500 true true 8.5.1 true +pms 0 r 10.100.18.200 _19 45 1856 0 414.1kb 5500 true true 8.5.1 true +pms 0 r 10.100.18.200 _1a 46 1904 0 423kb 5500 true true 8.5.1 true +pms 0 p 10.100.251.69 _w 32 17690 18363 6.7mb 7820 true true 8.5.1 false +pms 0 p 10.100.251.69 _15 41 2110 0 465.7kb 5500 true true 8.5.1 true +pms 0 p 10.100.251.69 _16 42 21083 30255 9.5mb 5900 true true 8.5.1 false +pms 0 p 10.100.251.69 _17 43 2572 0 568kb 5500 true true 8.5.1 true +pms 0 p 10.100.251.69 _18 44 1403 0 322.9kb 5500 true true 8.5.1 true +pms 0 p 10.100.251.69 _19 45 1856 0 414.1kb 5500 true true 8.5.1 true +pms 0 p 10.100.251.69 _1a 46 1904 0 423kb 5500 true true 8.5.1 true +pms 0 r 10.100.140.10 _w 32 17690 18363 6.7mb 7820 true true 8.5.1 false +pms 0 r 10.100.140.10 _15 41 2110 0 465.7kb 5500 true true 8.5.1 true +pms 0 r 10.100.140.10 _16 42 21083 30255 9.5mb 5900 true true 8.5.1 false +pms 0 r 10.100.140.10 _17 43 2572 0 568kb 5500 true true 8.5.1 true +pms 0 r 10.100.140.10 _18 44 1403 0 322.9kb 5500 true true 8.5.1 true +pms 0 r 10.100.140.10 _19 45 1856 0 414.1kb 5500 true true 8.5.1 true +pms 0 r 10.100.140.10 _1a 46 1904 0 423kb 5500 true true 8.5.1 true + + +我们接着压起来,看看POD的资源: + + + +现在看着是不是开心多了?data节点的CPU都用起来了。 + +我们再看一下worker的资源: + +[root@k8s-master-1 ~]# kubectl get pods -o wide | grep data +elasticsearch-data-0 1/1 Running 0 16m 10.100.18.199 k8s-worker-5 +elasticsearch-data-1 1/1 Running 0 17m 10.100.251.68 k8s-worker-9 +elasticsearch-data-2 1/1 Running 0 18m 10.100.140.9 k8s-worker-2 + + +现在ES Data的POD分布到2、5、9三这个worker上去了,我们查看下全局监控: + + + +嗯,不错,ES data的POD把资源用起来了。其实这里要想继续调,还可以把CPU加大,ES本来就是吃CPU、内存的大户。不过,我们前面在配置的时候,给ES data的CPU也确实太小了。这个问题,并不是我故意设计出来的,而是当时在部署的时候,没考虑到这些。 + +最后,我们来看优化后的效果: + + + +呀呀呀,你看TPS压都压不住呀,很快涨到900左右了!这个优化结果很好。 + +现在回过头来看第一个阶段,我们加CPU没有效果,主要还是因为副本数量太少。其实,在ES的优化中,还有很多细节可以玩。只不过,在我们这个课程中,我希望给你的是一个整体的分析思路和逻辑,而不是纠结于每个细节上的参数。所以,在这里,我们就不再说具体参数的调整了。 + +如果你想在ES上做更多的优化,可以在分析完业务之后,确定一下ES的架构、数据索引、分片等信息,然后再来设计一个合理的ES部署。 + +总结 + +在这节课中,我们看到APM工具也有无能为力的地方。所以说,当我们分析到一个具体组件之后,要想再往下分析,就得靠自己的技术功底了。 + +在出现请求不均衡的时候,我们一定要先去看负载均衡的逻辑有没有问题。当看到ES client不均衡时,我们去看了iptables的原理,在发现iptables只有一个转发规则的时候,接下来要做的当然就是重刷转发规则了。 + +在ES client转发均衡了之后,我们在ES data单节点上又看到CPU使用率过高。由于ES data在POD当中,我们自然就要想到去看cgroup的限制。 + +而在添加了CPU之后,我们发现TPS并没有提高,这时候就要去看ES的逻辑了。ES的强大之处就在于多副本多分片的查询能力,所以,我们增加了副本之后,CPU就用起来了,这是一个合理的优化结果,TPS也自然提高了。 + +经过一系列的动作,我们终于把资源给用起来了。这也是我一直在强调的,性能优化第一个阶段的目标,就是把资源给用起来,然后再考虑更细节的优化。 + +课后作业 + +最后,我给你留两道题,请你思考一下: + + +当负载出现不均衡时,主要的分析方向是什么? +什么时候才需要去看组件内部的实现逻辑? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果你读完这篇文章有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/16商品加入购物车:SQL优化和压力工具中的参数分析.md b/专栏/高楼的性能工程实战课/16商品加入购物车:SQL优化和压力工具中的参数分析.md new file mode 100644 index 0000000..1bf3f2f --- /dev/null +++ b/专栏/高楼的性能工程实战课/16商品加入购物车:SQL优化和压力工具中的参数分析.md @@ -0,0 +1,513 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 16 商品加入购物车:SQL优化和压力工具中的参数分析 + 你好,我是高楼。 + +今天这节课,我用商品加入购物车接口,来给你讲一讲SQL优化和压力工具中的参数分析。 + +对于SQL的优化,很多人一看到数据库资源使用率高,就猜测是SQL有问题。这个方向看起来没错,但是,具体是哪个SQL有问题,以及有什么样的问题,往往回答不出来。因此,这节课我会教你怎么根据资源使用率高,快速定位到有问题的SQL,并做出相应的调整。此外,你还将看到,当压力工具的参数使用不合理时,我们应该如何处理由此产生的数据库锁的问题。 + +现在,我们就开始这节课的分析。 + +压力数据 + +对于商品加入购物车这个接口,我们第一次运行的性能场景结果如下: + + + +看着有一种想哭的感觉,有没有?从这张图来看,问题不止一个。我用自己在有限的职业生涯中吸收的天地之灵气,打开天眼一看,感觉这里有两个问题: + + +TPS即使在峰值的时候,也不够高,才50左右; +TPS在峰值的时候,有大量的错误产生。 + + +那哪个问题更重要呢?有人可能说,明显应该处理错误呀,有错误看着不眼晕吗?如果你是有强迫症的人,那没办法,可以先处理错误。 + +不过,在我看来,先处理TPS不高的问题也是可以的。因为虽然有错误产生,但并不是全错呀,只有5%的错,你着个啥急。 + +可是,不管怎么着,我们都要走性能分析决策树的思路。 + +看架构图 + + + +这个接口的逻辑清晰明了:压力工具 - Gateway - Cart - Member。 + +我打算先分析TPS不高、响应时间变长的问题,这个问题可以在压力曲线图的前半段中看出来。所以,接下来,我们的分析就从拆分响应时间开始。 + +如果你想在这样的场景中先处理错误 ,那就从查日志开始。其实,这些错误是容易处理的,因为它们给出了非常明确的方向指示。 + +分析的第一阶段 + +拆分响应时间 + +这次我们截小图。 + + +User - Gateway: + + + + + +Gateway - Cart: + + + + + +Cart - Member : + + + + + +Cart - MySQL: + + + + + +Member - MySQL: + + + + +从响应时间上来看,我们需要先收拾MySQL,并且是和Cart服务相关的SQL,因为Cart - MySQL之间的响应时间有点长。 + +全局分析 + +按照我们的惯例,还是得来看一下全局监控。 + + + +既然worker-1上的CPU使用率很高,那我们就去看看worker-1上运行着什么服务。 + +你也许会问,网络的下载带宽也飘红了啊,已经达到100Mb以上了。这就涉及到怎么理解计数器的问题了。这里的网络虽然飘红了,但也只有100多Mb,它飘红只是因为Grafana DashBoard的阈值设置问题。如果你不想让它飘红,也可以把阈值设置得高一点。并且对于网络来说,100多Mb,真的不算大。 + +我们来看一下worker-1上有什么。 + +[root@k8s-master-2 ~]# kubectl get pods -o wide|grep k8s-worker-1 +elasticsearch-data-1 1/1 Running 1 11d 10.100.230.57 k8s-worker-1 +elasticsearch-master-0 1/1 Running 0 3d11h 10.100.230.60 k8s-worker-1 +mysql-min-d564fc4df-vs7d6 1/1 Running 0 22h 10.100.230.1 k8s-worker-1 +[root@k8s-master-2 ~]# + + +你看,这个worker-1上不止有MySQL,还有ES data,这是一个吃网络的大户。不过,现在问题并没有指向它。 + +我们在前面看到的是MySQL的响应时间长,所以我们再到worker-1上,接着看全局监控的数据。 + +[root@k8s-worker-1 ~]# top +top - 23:08:21 up 3 days, 11:30, 5 users, load average: 29.90, 28.54, 23.00 +Tasks: 309 total, 1 running, 307 sleeping, 0 stopped, 1 zombie +%Cpu0 : 94.1 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 2.9 si, 2.9 st +%Cpu1 : 94.1 us, 2.9 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 2.9 si, 0.0 st +%Cpu2 : 90.9 us, 3.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 6.1 st +%Cpu3 : 89.7 us, 3.4 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 3.4 si, 3.4 st +%Cpu4 : 87.9 us, 6.1 sy, 0.0 ni, 3.0 id, 0.0 wa, 0.0 hi, 0.0 si, 3.0 st +%Cpu5 : 87.9 us, 9.1 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 3.0 st +KiB Mem : 16265992 total, 1176564 free, 8436112 used, 6653316 buff/cache +KiB Swap: 0 total, 0 free, 0 used. 7422832 avail Mem + + + PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND +21344 27 20 0 8222204 628452 12892 S 331.4 3.9 141:36.72 /opt/rh/rh-mysql57/root/usr/libexec/mysqld --defaults-file=/etc/my.cnf + 5128 techstar 20 0 5917564 1.4g 21576 S 114.3 8.8 233:09.48 /usr/share/elasticsearch/jdk/bin/java -Xshare:auto -Des.networkaddress.cache+ + 5127 techstar 20 0 14.1g 3.5g 25756 S 40.0 22.8 1647:28 /usr/share/elasticsearch/jdk/bin/java -Xshare:auto -Des.networkaddress.cache+ + 1091 root 20 0 1145528 108228 29420 S 25.7 0.7 263:51.49 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock + 1078 root 20 0 2504364 106288 38808 S 14.3 0.7 429:13.57 /usr/bin/kubelet --bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.co+ +17108 root 20 0 164472 2656 1712 R 14.3 0.0 0:00.66 top + + +从上面的数据中,我们也能看到MySQL的进程消耗的CPU比较多,这说明我们现在走的证据链是正确的。既然走到了数据库,那我们主要看什么呢?当然是看MySQL的全局监控了。所以,我打印了MySQL Report,过滤掉一些没问题的数据之后得到如下结果(不然内容就太长了): + +__ Questions ___________________________________________________________ +Total 637.05k 8.0/s + DMS 293.57k 3.7/s %Total: 46.08 + Com_ 235.02k 2.9/s 36.89 +............................. +Slow 20 ms 119.50k 1.5/s 18.76 %DMS: 40.70 Log: +DMS 293.57k 3.7/s 46.08 + SELECT 224.80k 2.8/s 35.29 76.57 + UPDATE 51.86k 0.6/s 8.14 17.66 + INSERT 16.92k 0.2/s 2.66 5.76 + REPLACE 0 0/s 0.00 0.00 + DELETE 0 0/s 0.00 0.00 +............................. + + +__ SELECT and Sort _____________________________________________________ +Scan 137.84k 1.7/s %SELECT: 61.32 +............................. + + +从上面的数据我们可以看到,在Total的部分中,DMS(Data Manipulation Statements ,数据维护语句)占比46.08%。而在DMS中,SELECT占比76.57%。所以,我们要把后续分析的重点放在SELECT语句上。 + +通过Slow这一行,看到慢日志也已经出现,因为我把慢日志阈值设置的比较低,只有20ms,所以,你能看到每秒产生了1.5个慢日志。我之所以把慢日志阈值设的比较低,主要是想把稍微慢一点的SQL都记录下来。不过,在你的应用中,要根据实际的情况来,不要设置过大,也不要过小,不然都是泪。 + +定向分析 + +下面就是看慢日志喽。请你记住,在看MySQL慢日志之前,最好先把日志清一遍,让这个日志只记录压力场景执行时间段内的慢SQL,不然受影响的数据会很多。 + +[root@7dgroup1 gaolou]# pt-query-digest slow-query.log + + +# 7.2s user time, 70ms system time, 36.78M rss, 106.05M vsz +# Current date: Wed Dec 30 23:30:14 2020 +# Hostname: 7dgroup1 +# Files: slow-query.log +# Overall: 36.60k total, 7 unique, 89.06 QPS, 17.17x concurrency _________ +# Time range: 2020-12-30T15:22:00 to 2020-12-30T15:28:51 +# Attribute total min max avg 95% stddev median +# ============ ======= ======= ======= ======= ======= ======= ======= +# Exec time 7055s 20ms 1s 193ms 501ms 160ms 128ms +# Lock time 7s 0 39ms 194us 247us 696us 125us +# Rows sent 35.45k 0 1 0.99 0.99 0.09 0.99 +# Rows examine 2.33G 0 112.76k 66.71k 112.33k 46.50k 112.33k +# Query size 14.26M 6 1016 408.53 592.07 195.17 202.40 + + +# Profile +# Rank Query ID Response time Calls R/Call V/M It +# ==== ============================= =============== ===== ====== ===== == +# 1 0xB8BDB35AD896842FAC41202B... 5744.3322 81.4% 18420 0.3119 0.07 SELECT pms_sku_stock +# 2 0xC71984B4087F304BE41AC8F8... 1309.1841 18.6% 18138 0.0722 0.03 SELECT oms_cart_item +# MISC 0xMISC 1.4979 0.0% 46 0.0326 0.0 <5 ITEMS> + + +# Query 1: 44.82 QPS, 13.98x concurrency, ID 0xB8BDB35AD896842FAC41202BB9C908E8 at byte 6504041 +# This item is included in the report because it matches --limit. +# Scores: V/M = 0.07 +# Time range: 2020-12-30T15:22:00 to 2020-12-30T15:28:51 +# Attribute pct total min max avg 95% stddev median +# ============ === ======= ======= ======= ======= ======= ======= ======= +# Count 50 18420 +# Exec time 81 5744s 76ms 1s 312ms 580ms 148ms 279ms +# Lock time 47 3s 70us 37ms 184us 224us 673us 119us +# Rows sent 50 17.99k 1 1 1 1 0 1 +# Rows examine 85 1.98G 112.76k 112.76k 112.76k 112.76k 0 112.76k +# Query size 26 3.72M 212 212 212 212 0 212 +# String: +# Hosts 10.100.5.54 +# Users reader +# Query_time distribution +# 1us +# 10us +# 100us +# 1ms +# 10ms # +# 100ms ################################################################ +# 1s # +# 10s+ +# Tables +# SHOW TABLE STATUS LIKE 'pms_sku_stock'\G +# SHOW CREATE TABLE `pms_sku_stock`\G +# EXPLAIN /*!50100 PARTITIONS*/ +select + + + id, product_id, sku_code, price, stock, low_stock, pic, sale, promotion_price, lock_stock, + sp_data + + from pms_sku_stock + + + WHERE ( sku_code = '202008270027906' )\G + + +# Query 2: 44.13 QPS, 3.19x concurrency, ID 0xC71984B4087F304BE41AC8F82A88B245 at byte 20901845 +# This item is included in the report because it matches --limit. +# Scores: V/M = 0.03 +# Time range: 2020-12-30T15:22:00 to 2020-12-30T15:28:51 +# Attribute pct total min max avg 95% stddev median +# ============ === ======= ======= ======= ======= ======= ======= ======= +# Count 49 18138 +# Exec time 18 1309s 20ms 419ms 72ms 148ms 43ms 59ms +# Lock time 52 4s 76us 39ms 205us 260us 719us 138us +# Rows sent 49 17.45k 0 1 0.99 0.99 0.12 0.99 +# Rows examine 14 356.31M 19.96k 20.22k 20.12k 19.40k 0 19.40k +# Query size 73 10.51M 604 610 607.81 592.07 0 592.07 +# String: +# Hosts 10.100.5.54 +# Users reader +# Query_time distribution +# 1us +# 10us +# 100us +# 1ms +# 10ms ################################################################ +# 100ms ################## +# 1s +# 10s+ +# Tables +# SHOW TABLE STATUS LIKE 'oms_cart_item'\G +# SHOW CREATE TABLE `oms_cart_item`\G +# EXPLAIN /*!50100 PARTITIONS*/ +select + + + id, product_id, product_sku_id, member_id, quantity, price, product_pic, product_name, + product_sub_title, product_sku_code, member_nickname, create_date, modify_date, delete_status, + product_category_id, product_brand, product_sn, product_attr + from oms_cart_item + WHERE ( member_id = 381920 + and product_id = 317 + and delete_status = 0 + and product_sku_id = 317 )\G + + +从上面的数据来看,我们的优化方向比较简单明了:占用总时间最长的两个SQL需要收拾,其中,一个占用了总时间的81.4%,另一个占用了18.6%。 + +我们先来看最慢的那个SQL: + +select + id, product_id, sku_code, price, stock, low_stock, pic, sale, promotion_price, lock_stock, + sp_data + from pms_sku_stock + WHERE ( sku_code = '202008270027906' )\G + + +要想知道一个语句哪里慢,就得来看一下执行计划: + + + +在执行计划中,type这一列的参数值为ALL,说明这个SQL没有用到索引。你想想,一个有where条件的语句,又没有用到索引,那它上方的索引到底合不合理呢?我们不妨检查一下这个索引: + + + +通过检查索引,我们看到只有一个ID列,也就是一个主键索引,并没有where条件中的sku_code列。所以,我们先给sku_code加一个索引来实现精准查询,这样就不用扫描整表的数据了: + +ALTER TABLE pms_sku_stock ADD INDEX sku_code_index (sku_code); + + +修改之后,我们再来看一下此时的执行计划: + + + +现在,type列的参数值变为了ref,说明where条件确实走了索引了。那我们再把场景执行起来,看看效果: + + + +从结果来看,TPS从50增加到了150以上。响应时间也从750ms左右降到250ms以下。效果显著。 + +收拾完了第一个SQL后,我们再来收拾另一个SQL。同样地,我们先看它的执行计划: + + + +type列的参数值为ALL,表明where条件没有使用索引。但是,第二个语句用了好几个where条件,所以,我们直接加一个组合索引,让where条件可以走到索引这里: + +ALTER TABLE oms_cart_item ADD INDEX mix_index (member_id,product_id,product_sku_id); + + +加了组合索引后,这个SQL的执行计划如下: + + + +还是一样,我们再次把场景跑起来,看看优化了这两个最慢的SQL之后,效果如何。 + +优化效果 + +优化效果如下: + + + +优化前后的对比图如下: + + + +建议你在写报告的时候,画这种对比图,用它来说明优化效果是非常直接明显的。 + +分析的第二阶段 + +现在我们就要来分析错误了,反正也忽悠不过去。 + +压力数据 + +下面是对应的错误图,我把图截多一点,可以看到趋势如下: + + + +你看,TPS中有对的,也有错的,并且TPS越高的时候,错误率也越高。这一点很重要,希望你能记住。 + +紧接着,我们来拆分响应时间。 + +拆分响应时间 + +先设置skywalking的时间段: + + + +请你注意,在看性能计数器的时候,每一个工具上的时间窗口一定要对应上。 + + +User - Gateway: + + + + + +Gateway - Cart: + + +- + + + +Cart - Member: + + + + + +Cart - MySQL: + + + + + +Member - MySQL: + + + + +罗列了一堆信息之后……并没有什么发现。 + +你可能会奇怪,为什么说没有发现呢,Cart上的响应时间不是比较长吗?这里你就要注意了,我们现在分析的问题是错误,而不是响应时间,所以时间长就长呗。在分析的过程中,你一定要时刻记得自己查的是什么问题,不要走到半路就走岔了,那样会陷入混乱的状态。 + +全局分析 + +通常情况下,我们的全局分析都是从资源开始的对吧,也就是从性能分析决策树中一层层查下去。对应我们第4节课讲的内容,你可以把所有的第一层计数器查一遍。 + +而在我们的这个问题的分析中,其实不用那么麻烦,因为在前面看到压力数据的时候,已经看到了大量的报错了,要想分析错误,肯定得先知道错误在哪,所以,这里我们直接查日志相关的内容就可以。查到日志的时候,我们看到下面这些错误信息: + +2020-12-30 23:44:06.754 ERROR 1 --- [io-8086-exec-41] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.dao.DeadlockLoserDataAccessException: +### Error updating database. Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction +### The error may involve com.dunshan.mall.mapper.OmsCartItemMapper.updateByPrimaryKey-Inline +### The error occurred while setting parameters +### SQL: update oms_cart_item set product_id = ?, product_sku_id = ?, member_id = ?, quantity = ?, price = ?, product_pic = ?, product_name = ?, product_sub_title = ?, product_sku_code = ?, member_nickname = ?, create_date = ?, modify_date = ?, delete_status = ?, product_category_id = ?, product_brand = ?, product_sn = ?, product_attr = ? where id = ? +### Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction +; Deadlock found when trying to get lock; try restarting transaction; nested exception is com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction] with root cause +................................... + + +这个错误已经给了我们明确的指向:死锁。可是为什么会死锁呢? + +在性能分析中,你要记得,死锁其实是相对容易分析的内容。有争用才有锁,而死锁,就是说锁被争得死死的。 + +下面我们开始定向分析为什么会产生锁。 + +定向分析 + +首先,我们找到商品加入购物车业务对应的代码: + +/** + * 增加购物车 + * @param productSkuCode 库存商品编号 + * @param quantity 商品数量 + * @return + */ + @Override + public int addCart(String productSkuCode, Integer quantity) { + ......................................... + OmsCartItem existCartItem = getCartItem(cartItem); + if (existCartItem == null) { + cartItem.setCreateDate(new Date()); + count = cartItemMapper.insert(cartItem); + } else { + cartItem.setModifyDate(new Date()); + existCartItem.setQuantity(existCartItem.getQuantity() + cartItem.getQuantity()); + count = cartItemMapper.updateByPrimaryKey(existCartItem); + } + + + return count; + } + + +引用这段代码的事务如下: + +@Transactional +int addCart(String productSkuCode, Integer quantity); + + +根据上面的关系,对于商品加入购物车来说,什么能引起死锁呢?你看,在代码中有一个update,它对应的也就是前面日志中的update语句。所以,要是发生死锁的话,那指定就是ID冲突了,而这个ID对应的就是会员ID。也就是说,有多个线程同时想更新同一个会员的购物车,这怎么能行! + +既然是会员ID冲突了,那是谁给的会员信息呢?想都不用想,这个会员信息肯定是从脚本中传过来的呀,所以我们要查查脚本。 + +对应的脚本如下: + + + +你看,这里有一个productSkuCode参数,共用了1000行数据量。 + + + +上面的图对应的JMeter脚本是这样的: + + + +我们来看JMeter脚本中的这三个参数: + +quotedData: false +recycle: true +stopThread: false + + +这意味着,我们所有的线程都在共用这1000条数据,并且在不断循环。这会导致数据使用重复,也就是说,如果有两个以上的线程用到了相同的用户数据,就会更新同一个购物车,于是产生冲突报错。 + +我们现在把上面三个参数改一下: + +quotedData: true +recycle: false +stopThread: true + + +这样就保证了每个线程可以分到不同的数据。 + +可是,另一个问题来了:我们做这样处理的话,1000条数据是不够用的,怎么办呢?那我们就只有把用户数据加大,等生成更多的Token之后,我们再来执行场景。 + +通过一晚上的造数,时间来到了第二天。 + +优化效果 + +于是,我们得到了如下结果: + + + +从数据上来看,报错没有了,这是一个合理的结果。 + +总结 + +现在,我们总结一下这节课。 + +“哎,哎,你先别总结呀,问题都没解决完,你看这不是还有TPS掉下来的情况吗?” + +“年轻人,别捉急,饭都得一口一口吃,问题自然要一个一个解决了。这个问题,我会放在后面的课程中解决。” + +在这节课中,我们从TPS不高开始,一直分析到了具体的SQL,看似是两个简单的索引就搞定的事情,逻辑也并不复杂,但是,这个分析思路非常重要。 + +对于第二个问题,我们从错误数据查到了日志中出现的死锁信息,这一点大部分人应该都可以做得到。只不过,能立即想到参数冲突的,就是有经验的人了。 + +此外,这里还有一个重点就是,参数化数据一定要符合真实场景!高老师已经反复强调很多遍了,希望你能记得住。 + +课后作业 + +最后,我给你留两道题,请你思考一下: + + +除了用本节课中的手段,你还有什么方法可以快速定位到SQL语句慢的问题? +你能画出在第二阶段分析中的逻辑吗? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果你读完这篇文章有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/17查询购物车:为什么铺底参数一定要符合真实业务特性?.md b/专栏/高楼的性能工程实战课/17查询购物车:为什么铺底参数一定要符合真实业务特性?.md new file mode 100644 index 0000000..0dd5fe3 --- /dev/null +++ b/专栏/高楼的性能工程实战课/17查询购物车:为什么铺底参数一定要符合真实业务特性?.md @@ -0,0 +1,530 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 17 查询购物车:为什么铺底参数一定要符合真实业务特性? + 你好,我是高楼。 + +今天我们来看看查询购物车接口。 + +到现在为止,这是我们分析的第六个接口了。不过,我希望你能明白,我们分析每个接口,并不是为了搞清楚这个接口本身的逻辑,而是通过不同接口的基准测试,来分析不同的性能问题,争取给你带来更多的分析案例。 + +现在很多人在性能场景执行过程中,仍然会问出“当铺底数据不符合生产环境时,该怎么办”这样的疑问,其实答案也挺简单,那就是模拟不出生产环境中的问题。 + +所以,在这节课中,你将看到当铺底数据不合理时,会对TPS产生什么样具体的影响。由此,你会进一步理解为什么我一直在跟你强调铺底数据要符合生产环境逻辑。 + +此外,我们还会分析另一个问题,这个问题可能会让你看着比较郁闷,你会发现我们分析了很久,逻辑看似非常合理,但是结果并不如人意。面对这样的情况,那我们该怎么处理呢?这里留个悬念,我们直接开始今天的分析。 + +压力数据 + +对于查询购物车这个接口,还是一样,我们先来看第一次运行的性能场景结果。这是一个一开始就足以让人心碎的性能数据: + + + +你看,线程数在增加的过程中,TPS只达到40,而响应时间从一开始就不断地增加。 + +这可怎么办呢?根据我们RESAR性能分析逻辑,第一步仍然是看架构图,接着是拆分响应时间。因为响应时间在不断增加,所以我们想要拆分响应时间非常容易。 + +架构图 + +在拆分响应时间之前,我们看一下架构图。在这一步,你只需要把架构图记个大概就行了。因为后面还要反复回来看多次。 + + + +第一阶段分析 + +拆分响应时间 + +我们反反复复在讲,做性能分析的时候,首先就是拆分时间。 + +别人在问我问题的时候,经常会这样描述:TPS不高,响应时间长,瓶颈在哪呢?一看到这种问题,我通常会反问:响应时间长在哪呢?然后,经典的对话结束语就出现了——我不知道呀。我也很想帮助对方解决问题,但是,对于这样的描述,我根本无从下手。 + +一个做性能的人,怎么能只描述响应时间长呢?你至少要告诉别人慢在哪里。这就是为什么我一直在强调要画架构图。因为有了图,才有拆分时间的手段,这样一来,我们自然就不会盲目,除非你啥都没有。 + +在拆分时间的时候,你还要注意一点,要找准时间段。根据我的经验,一般是看响应时间的趋势,如果一直都长的话,倒是简单,看哪一段响应时间都行。要是有的时候长,有的时候短,那你就要注意了,在拆分响应时间的时候,要注意把监控工具中的时间段选择好。 + +在这里,我们选择SkyWalking时间段:2021-01-02 13:53:00 - 2021-01-02 13:54:00。具体拆分时间如下: + + +User - Gateway: + + + + + +Gateway: + + + + + +Gateway - Cart: + + + + + + + +Cart: + + + + + +Cart - MySQL: + + + + +通过上面抓取的数据,你明显可以看到,是购物车服务Cart那一段的响应时间长。 + +我们要注意,有些数据抓取工具由于工具本身的问题,会存在不小的数据偏差,比如说对于上面的SkyWalking时间段,我们看到Gateway - Cart之间的服务端平均响应时间是829.25。但是,在Cart上却是984.50。同样的一段时间,这里就出现了一些偏差。 + +在每一个监控工具上,都或多或少存在性能数据偏差,就比如docker stats,我简直是不想看。所以,我们有时候要结合多个工具来对比数据。 + +定向监控分析 + +拆分完响应时间后,我们不再从全局分析开始,而是直接跳到了定向监控。因为对于查询购物车这个接口,我们已经知道Cart服务是慢的,所以,我们就直接进去查看对应的慢的方法在哪里。 + +这个接口的调用方法如下所示: + +/** + * 根据会员id查询购物车数据 + * + * @param memberId 会员id + * @return + */ +@Override +public List list(Long memberId) { + if (memberId == null) { + return null; + } + OmsCartItemExample example = new OmsCartItemExample(); + example.createCriteria().andDeleteStatusEqualTo(0).andMemberIdEqualTo(memberId); + return cartItemMapper.selectByExample(example); +} + + +通过上面的代码,我们知道了方法名,那我们直接用Arthas来Trace这个接口就好了,命令如下: + +trace com.dunshan.mall.cart.service.imp.CartItemServiceImpl list -v -n 5 --skipJDKMethod false '1==1' + + +于是,我们得到了如下的信息: + +[arthas@1]$ trace com.dunshan.mall.cart.service.imp.CartItemServiceImpl list -v -n 5 --skipJDKMethod false '1==1' +Condition express: 1==1 , result: true +`---ts=2021-01-02 14:59:53;thread_name=http-nio-8086-exec-556;id=10808;is_daemon=true;priority=5;TCCL=org.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader@18c26588 + `---[999.018045ms] com.dunshan.mall.cart.service.imp.CartItemServiceImpl$$EnhancerBySpringCGLIB$$e110d1ef:list() + `---[998.970849ms] org.springframework.cglib.proxy.MethodInterceptor:intercept() #57 + + +Condition express: 1==1 , result: true +`---ts=2021-01-02 14:59:54;thread_name=http-nio-8086-exec-513;id=107d3;is_daemon=true;priority=5;TCCL=org.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader@18c26588 + `---[1095.593933ms] com.dunshan.mall.cart.service.imp.CartItemServiceImpl$$EnhancerBySpringCGLIB$$e110d1ef:list() + `---[1095.502983ms] org.springframework.cglib.proxy.MethodInterceptor:intercept() #57 + + +Condition express: 1==1 , result: true +`---ts=2021-01-02 14:59:53;thread_name=http-nio-8086-exec-505;id=1078b;is_daemon=true;priority=5;TCCL=org.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader@18c26588 + `---[2059.097767ms] com.dunshan.mall.cart.service.imp.CartItemServiceImpl$$EnhancerBySpringCGLIB$$e110d1ef:list() + `---[2059.013275ms] org.springframework.cglib.proxy.MethodInterceptor:intercept() #57 + + +Condition express: 1==1 , result: true +`---ts=2021-01-02 14:59:54;thread_name=http-nio-8086-exec-541;id=107f6;is_daemon=true;priority=5;TCCL=org.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader@18c26588 + `---[1499.559298ms] com.dunshan.mall.cart.service.imp.CartItemServiceImpl$$EnhancerBySpringCGLIB$$e110d1ef:list() + `---[1499.498896ms] org.springframework.cglib.proxy.MethodInterceptor:intercept() # + +通过上面的数据可以看到list()的响应时间确实是长了,但是这个接口并不复杂,就是一个select语句而已。对应的select语句的Mapper内容如下: + + + + +这个Mapper对应到数据库中,具体的SQL就是: + +SELECT id, product_id, product_sku_id, member_id, quantity, price, product_pic, product_name, product_sub_title, product_sku_code, member_nickname, create_date, modify_date, delete_status, product_category_id, product_brand, product_sn, product_attr FROM oms_cart_item WHERE ( delete_status = 0 AND member_id = 597427 ) + + +既然是一个select语句消耗的时间长,那我们就到数据库里,根据相应的SQL来看对应表的数据直方图。命令如下: + +select member_id,count(*) from oms_cart_item_202101021530 GROUP BY 1 ORDER BY 2 DESC; + + +结果如下,我们截取了直方图的部分数据: + + + +从上述数据库中的数据来看,一个会员ID的下面已经加了不少数据。虽然select是通过会员ID查的,但是没做分页处理。这是最简单直接的SQL问题了,分析过程也非常简单。当我们一看到SQL时间长的时候,就要查看一下执行计划: + + + +既然上面的type值是ALL,说明走的是全表扫描,那我们就要根据SQL中的where条件来确定一下要创建什么索引;如果where条件中的查询结果是多条结果,并且数据较多,那就需要做分页。分析到这里,其实也比较容易想到对应的解决方案,有两个动作要做: + + +创建索引:创建索引是为了查询的时候可以精准查询。 +做分页:是为了避免返回到前端的数据太多。 + + +优化效果 + +我们虽然讲的是“优化效果”,但,准确来说只是“验证效果”。因为上面的两个动作都是为了提高SQL的查询效果,确切来说就为了减少查询出来的数据。那我们现在就直接把数据给降下来,来验证我们的判断是不是正确。 + +为了验证我们的分析过程是正确的,这里我先直接把表给TRUNCATE掉,先看看响应时间能不能上来。如果能上来,那就是这里的问题了。 + +可如果不是呢?那我们只能回到角落默默流泪了。这么简单的问题都找不到,我不是一个合格的性能分析人员。 + +不管怎么说,来,我们看下结果: + + + +可以看到,TPS一下子上升了很多,在场景不间断的情况下,这个比对如此喜人。看来,我还能继续干这一行。 + +不过,我们的分析到这里并没有结束,屋漏偏逢连夜雨,我在接着做压力的过程中,又出现了状况,这让我们不得不进入第二个阶段的分析。 + +第二阶段分析 + +到底又出现了什么问题呢?具体情况如下: + + + +What? 那是TPS曲线吗?那是掉下来了吗?掉的还这么多吗?同样是场景不间断啊。我的职业生涯难道要就此断送了吗? + +这个问题有点复杂。但是从响应时间曲线上看,明显是响应时间增加了,TPS下来了。既然这样,仍然走我们拆分响应时间的思路就好了,这里不再赘述。 + +通过拆分时间,我们知道响应时间长的问题出在了Gateway上。下面我们就根据RESAR性能分析逻辑,老老实实开始分析。 + +全局监控分析 + +我们从系统级的资源上可以明显看到,所有的worker节点都无压力。 + + + +我们再从Pod角度来看一下: + + + +你看,有些Pod消耗的CPU已经达到了100%。我把所有的Pod排个序,结果如下: + + + +虽然我们看到了像node_exporter、ES相关的Pod资源都用得不低,但是这些CPU使用率高的节点Pod的资源也都限制了。同时,你要注意,这个资源占用率高的Pod中并没有我们的应用节点,也就是说我们应用节点的CPU资源并没有用完。 + +我本来想去看一下在这段时间内,应用所在的worker上的内存消耗具体是怎样的。但是,在这段时间内却没了数据: + + + +你看,中间的数据已经断掉了,node_exporter已经不传数了。没有办法,我们只有放弃看 worker上的内存消耗了。 + +既然如此,那我们先查一下Gateway在哪个worker上,同时也来看一下这个worker上有多少Pod。走这一步是因为在整个Kubernetes中,所有的namespace都用worker主机的资源。所以,从资源使用的角度来看,我们要考虑到所有命名空间中的Pod。 + +所有namespace在应用节点上的所有Pod如下: + +- 先查询gateway所在的worker节点名 + + [root@k8s-master-2 ~]# kubectl get pods --all-namespaces -o wide | grep gateway + default gateway-mall-gateway-6567c8b49c-pc7rf 1/1 Running 0 15h 10.100.140.2 k8s-worker-2 + +- 再查询对应worker上的所有POD +[root@k8s-master-2 ~]# kubectl get pods --all-namespaces -o wide | grep k8s-worker-2 +default elasticsearch-client-1 1/1 Running 4 20d 10.100.140.28 k8s-worker-2 +default elasticsearch-data-2 1/1 Running 0 4d2h 10.100.140.35 k8s-worker-2 +default elasticsearch-master-2 1/1 Running 4 20d 10.100.140.30 k8s-worker-2 +default gateway-mall-gateway-6567c8b49c-pc7rf 1/1 Running 0 15h 10.100.140.2 k8s-worker-2 +kube-system calico-node-rlhcc 1/1 Running 0 2d5h 172.16.106.149 k8s-worker-2 +kube-system coredns-59c898cd69-sfd9w 1/1 Running 4 36d 10.100.140.31 k8s-worker-2 +kube-system kube-proxy-l8xf9 1/1 Running 6 36d 172.16.106.149 k8s-worker-2 +monitoring node-exporter-mjsmp 2/2 Running 0 4d17h 172.16.106.149 k8s-worker-2 +nginx-ingress nginx-ingress-nbhqc 1/1 Running 0 5d19h 10.100.140.34 k8s-worker-2 +[root@k8s-master-2 ~]# + + +从上面的结果可以看到,我们的worker节点上有9个Pod。 + +不过我们一开始看全局资源信息的时候,并没有发现整个worker节点的资源使用率很高。这是因为我们已经在Pod里限制了资源。所以我们列一下每个Pod的资源限制: + + + +对于那些其他资源占用不高的Pod,我们就不看了。 + +既然资源有限制,那我们还要把目光转回到Gateway上面来。 + +定向监控分析 + +通过查看链路时间,我们也能知道是Gateway上消耗的时间较长: + + + +但是,这个sendRequest是干嘛的?不知道。 + +那我们就做一个试验,看看跳过Gateway之后的TPS是多少。: + + + +可见,走Gateway,TPS只能有400多;不走Gateway,TPS能达到800多。所以,问题确实出在了Gateway上。 + +看到这里,有一个环节我们是缺失的,那就是查看Kubernetes容器里的Java进程的健康状态。因为我们在前面查了worker,也查了worker上的Pod,所以现在就到了第三层,也就是Pod中的Java应用。 + +对此,你也不用有负担,你想想对于一个Java应用来说,能有个啥?无非就是堆、栈一顿看。来,我们打印个Gateway的栈看一下。 + + + +从栈上,啥也没看出来,整个状态似乎都挺合理的。 注意,在这里我不是只看一个截图哦,我已经把整个栈都撸了一遍。由于CPU也不高,我们在分析栈的时候,主要看一下有没有锁等待。从上图可以看到,并没有锁,等待也都合理。 + +看完栈之后,接下来该看堆了。我们得想尽办法,把Kubernetes的Java进程堆拿出来看看: + + + +看到没!如此规则的关联关系:TPS和Gateway的GC趋势是完全一致的。 + +不过,这样看还是不够具体,我们还需要更细的数据。所以,我们进去看一下GC状态: + +[root@gateway-mall-gateway-6567c8b49c-pc7rf /]# jstat -gcutil 1 1000 1000 + S0 S1 E O M CCS YGC YGCT FGC FGCT GCT + 0.00 55.45 45.33 52.96 94.74 92.77 38427 1953.428 94 113.940 2067.368 + 57.16 0.00 26.86 53.24 94.74 92.77 38428 1954.006 94 113.940 2067.946 + 0.00 54.30 15.07 53.65 94.74 92.77 38429 1954.110 94 113.940 2068.050 + 39.28 0.00 18.39 53.84 94.74 92.77 38430 1954.495 94 113.940 2068.435 + 39.28 0.00 81.36 53.84 94.74 92.77 38430 1954.495 94 113.940 2068.435 + 0.00 26.13 68.79 53.84 94.74 92.77 38431 1954.597 94 113.940 2068.537 + 39.18 0.00 59.75 53.84 94.74 92.77 38432 1954.683 94 113.940 2068.624 + 0.00 24.70 76.28 53.84 94.74 92.77 38433 1954.794 94 113.940 2068.734 + + +你看,一次YGC大概需要100ms,一秒一次YGC,这样YGC就占了10%左右,这个时间有点多了。 + +既然YGC消耗CPU较高,那我们就考虑优化Java参数。先来看一下Java参数: + +[root@gateway-mall-gateway-6567c8b49c-pc7rf /]# jinfo -flags 1 +Attaching to process ID 1, please wait... +Debugger attached successfully. +Server compiler detected. +JVM version is 25.242-b08 +Non-default VM flags: -XX:CICompilerCount=2 -XX:InitialHeapSize=262144000 -XX:+ManagementServer -XX:MaxHeapSize=4164943872 -XX:MaxNewSize=1388314624 -XX:MinHeapDeltaBytes=196608 -XX:NewSize=87359488 -XX:OldSize=174784512 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops +Command line: -Dapp.id=svc-mall-gateway -javaagent:/opt/skywalking/agent/skywalking-agent.jar -Dskywalking.agent.service_name=svc-mall-gateway -Dskywalking.collector.backend_service=skywalking-oap:11800 -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.port=1100 -Dcom.sun.management.jmxremote.rmi.port=1100 -Djava.rmi.server.hostname=localhost -Dspring.profiles.active=prod -Djava.security.egd=file:/dev/./urandom +[root@gateway-mall-gateway-6567c8b49c-pc7rf /]# + + +从上面的参数中就可以看到,我在Kubernetes的Java进程中并没有配置GC回收相关的参数。所以,这里我们加上相关的参数。 + +在下面的参数中,我加了PrintGC相关的参数以及ParNew参数: + +[root@gateway-mall-gateway-6c6f486786-mnd6j /]# jinfo -flags 1 +Attaching to process ID 1, please wait... +Debugger attached successfully. +Server compiler detected. +JVM version is 25.261-b12 +Non-default VM flags: -XX:CICompilerCount=2 -XX:CompressedClassSpaceSize=1065353216 -XX:+HeapDumpOnOutOfMemoryError -XX:InitialHeapSize=2147483648 -XX:+ManagementServer -XX:MaxHeapSize=2147483648 -XX:MaxMetaspaceSize=1073741824 -XX:MaxNewSize=1073741824 -XX:MetaspaceSize=1073741824 -XX:MinHeapDeltaBytes=196608 -XX:NewSize=1073741824 -XX:OldSize=1073741824 -XX:ParallelGCThreads=6 -XX:+PrintGC -XX:+PrintGCApplicationConcurrentTime -XX:+PrintGCApplicationStoppedTime -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintTenuringDistribution -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParNewGC +Command line: -Dapp.id=svc-mall-gateway -javaagent:/opt/skywalking/agent/skywalking-agent.jar -Dskywalking.agent.service_name=svc-mall-gateway -Dskywalking.collector.backend_service=skywalking-oap:11800 -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.port=1100 -Dcom.sun.management.jmxremote.rmi.port=1100 -Djava.rmi.server.hostname=localhost -Xms2g -Xmx2g -XX:MetaspaceSize=1g -XX:MaxMetaspaceSize=1g -Xmn1g -XX:+UseParNewGC -XX:ParallelGCThreads=6 -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime -XX:+PrintGCDetails -Xloggc:gc.log -Dspring.profiles.active=prod -Djava.security.egd=file:/dev/./urandom +[root@gateway-mall-gateway-6c6f486786-mnd6j /]# + + +本来指望ParNew能有啥用,然而并没有什么用。 + +既然加参数不是能很快见效的,那我们就得看一下YGC的时候回收了什么,然后再来决定从哪里下手收拾Java进程内存的消耗问题。所以,我们打印一下jmap histo信息,来看一下对象消耗内存的变化,如下所示: + +[root@gateway-mall-gateway-6c6f486786-mnd6j /]# jmap -histo 1 | head -20 + + + num #instances #bytes class name +---------------------------------------------- + 1: 2010270 124874960 [C + 2: 787127 91014984 [I + 3: 601333 42467920 [Ljava.lang.Object; + 4: 1534551 36829224 java.lang.String + 5: 420603 31107504 [B + 6: 21891 21972896 [Ljava.util.concurrent.ConcurrentHashMap$Node; + 7: 186170 11914880 java.util.regex.Matcher + 8: 228807 10982736 java.util.StringTokenizer + 9: 291025 9312800 java.util.concurrent.ConcurrentHashMap$Node + 10: 274253 8804936 [Ljava.lang.String; + 11: 179524 8617152 org.springframework.web.util.pattern.PathPattern$MatchingContext + 12: 210473 8418920 java.util.LinkedHashMap$Entry + 13: 154562 6182480 io.netty.handler.codec.DefaultHeaders$HeaderEntry + 14: 191349 6123168 java.util.LinkedList + 15: 126218 6058464 java.util.TreeMap + 16: 68528 6030464 java.lang.reflect.Method + 17: 98411 5363408 [Ljava.util.HashMap$Node; +[root@gateway-mall-gateway-6c6f486786-mnd6j /]# + + +在这里,我们需要把这个命令多执行几次,看看对象消耗内存的变化。前面我们看到YGC过于频繁,但是从内存上来看,对象的内存回收得挺好。 + +所以,对于这种YGC很高,但从对象内存的消耗又看不出什么有效信息的问题,只有一种可能,那就是对象创建得快,销毁也快。那么,我们只有一个地方可以准确查找对象内存的消耗了,那就是对象的delta。我们连上JvisualVM,看下内存对象delta变量: + + + +(注:这张图上的字之所以这么小,是因为我连的是远程Windows桌面,分辨率不高,实在没有办法。不过,你要是仔细看的话,还是能看到最上面那个HashMap。) + +我比较喜欢用这种视图来看delta值。从这里可以看到,增加和销毁都很快。 + +在前面我们加了打印GC log的参数,所以我们把GC log拿出来分析一下,得到结果如下: + + + + + + + + + +从上面的分析来看,主要是YGC在消耗响应时间。这与我们前面的分析吻合,但是我们仍旧没有找到具体的问题点。 + +在这个问题的分析过程中,我不断在做应用的修改、重启等动作。结果,没想到性能问题没解决,又遇到了两个其他问题,特地记录在这里。 + +之所以记录这样的问题,是想告诉你:在我们的分析过程中,什么样的问题都有可能存在。而我们虽说是做性能分析的人,但也不是只分析性能问题,而是见到问题就要去解决,要不然,你就走不下去。 + +支线问题一 + +我在查找宿主机日志时发现如下信息: + +[3594300.447892] ACPI Exception: AE_AML_BUFFER_LIMIT, Evaluating _PMM (20130517/power_meter-339) +[3594360.439864] ACPI Error: SMBus/IPMI/GenericSerialBus write requires Buffer of length 66, found length 32 (20130517/exfield-389) +[3594360.439874] ACPI Error: Method parse/execution failed [\_SB_.PMI0._PMM] (Node ffff8801749b05f0), AE_AML_BUFFER_LIMIT (20130517/psparse-536) + + +从错误信息来看,这是一个ACPI缓存区大小的问题。这个缓存大小在BIOS和内核之间没有协商一致,也就是说请求的缓存区大小是66字节,而给的却是32字节。所以,电源监控的管理模块就报了异常。 + +这是缺少内核模块引起的,因为这个内核模块在我所用的这个内核版本中不会自动更新。对应的解决方法倒也简单: + +echo "blacklist acpi_power_meter" >> /etc/modprobe.d/hwmon.conf +modprobe ipmi_si +modprobe acpi_ipmi + + +其中,第一条命令是为了不让这个错误再次出现。当然了,这不是解决问题,只是不想看到这个报错而心里烦燥。后面两条命令是手动加载模块,但前提是你要更新内核版本。 + +支线问题二 + +再回到我们分析的主线上,前面提到一个Java的YGC消耗的CPU比较高,但是业务逻辑又没有什么问题。所以,我尝试换一个最简单的Demo程序,先来测试一下整个集体是不是正常的。这个Demo程序没有任何业务逻辑,只返回247B的示例程序。 + +我简单说明一下,我之所以把这个测试过程放在这个支线问题中来描述,是想让我的行为更加有条理。 + + + +在这个测试过程中,我执行了两次。上图的前半部分走了Ingress,后面没有走Ingress,可是后面TPS并没有掉下来。这时,问题就基本清楚了。 + +我这里列个表格梳理一下到现在看到的信息,理理思路。 + + + +从以上数据可以判断出,TPS掉下来和Ingress有绝对的关系。那我们就来看看Ingress的日志: + +root@nginx-ingress-m9htx:/var/log/nginx# ls -lrt +total 0 +lrwxrwxrwx 1 root root 12 Sep 10 2019 stream-access.log -> /proc/1/fd/1 +lrwxrwxrwx 1 root root 12 Sep 10 2019 error.log -> /proc/1/fd/2 +lrwxrwxrwx 1 root root 12 Sep 10 2019 access.log -> /proc/1/fd/1 +root@nginx-ingress-m9htx:/proc/1/fd# ls -lrt +total 0 +lrwx------ 1 root root 64 Jan 7 18:00 7 -> 'socket:[211552647]' +lrwx------ 1 root root 64 Jan 7 18:00 4 -> 'anon_inode:[eventpoll]' +lrwx------ 1 root root 64 Jan 7 18:00 3 -> 'socket:[211552615]' +l-wx------ 1 root root 64 Jan 7 18:00 2 -> 'pipe:[211548854]' +l-wx------ 1 root root 64 Jan 7 18:00 1 -> 'pipe:[211548853]' +lrwx------ 1 root root 64 Jan 7 18:00 0 -> /dev/null +root@nginx-ingress-m9htx:/proc/1/fd# find ./ -inum 212815739 +root@nginx-ingress-m9htx:/proc/1/fd# find ./ -inum 212815740 + + +悲怆的感觉!你看,日志直接重定向到标准输出和标准错误了,而标准输出和标准错误默认都是屏幕。那我们就到Kubernetes管理工具中去追踪日志。可是,结果是啥也没有。唉,这可怎么办呢? + +从下面这张图我们也可以看到,当压力经过这个Ingress时,报错是必然的,压力越大,报错越多。 + + + +可是分析到这里,我们再没有其他可以分析的日志了。没什么办法,只能查一下Ingress的版本了,结果发现,当前的Ingress已经有了新的版本。 + +为了避免去踩Ingress本身存在的一些坑,我把它的版本从1.5.5换到1.9.1之后,得到如下结果: + + + +你看图中没有报错了,看来那些错误是Ingress版本导致的。 + +然而,即便如此,我们还是没有解决TPS会掉的问题。你可能会说,上面这张图里的TPS不是没有掉吗?其实,这只是假象。在上面的场景中,我们只是为了验证Ingress的问题,所以,执行时间并不长。 + +请你注意,我们到这里并没有解决前面所说的TPS会掉的问题。应该说,我们这里可能有两个问题,一个是Ingress,而另一个可能是在其他地方,但是我们还没有去验证。因此,我们要回到主线上,继续来分析它。 + +回到主线 + +经过一翻折腾,你是不是感觉脑袋已经晕了?当我们被一些技术细节深深拖进去的时候,一定要保持清醒。 + +根据我的经验,这个时候我们可以在纸上画一下架构图。并不是说前面已经有架构图,我们就不用画了。画架构图是为了帮我们梳理思路。并且我们还要画得再细一点: + + + +经过梳理,我采用分段测法来判断问题与哪一层相关:因为Cart服务需要通过外部调用走网关,那我在这里直接调用Cart服务,不走网关。并且我也跳过Ingress,直接用NodePort来提供服务,看看TPS有没有调下来。 + +首先,我直接从cart服务的NodePort压进去,得到这样的结果: + + + +也就是说,Cart服务本身就会导致TPS降下来,看起来也并不规律。 + +那我们就修改Tomcat参数,把线程数、连接数调大,再来一次。你可能奇怪,为什么要这样调呢?这是因为在查看应用线程健康状态的过程中,我注意到Spring Boot里的Tomcat线程很忙。 + +在我这样反复验证了几次之后,发现TPS并没有掉下去。 + +为了进一步验证TPS和上面的线程数、连接数等参数有关,我又特意把配置改回去,再看是不是Tomcat参数的问题。 + +结果,TPS掉下去的情况没有复现! + + + +气得我不得不吃份麻辣烫发泄一下。本来我已经看到了TPS掉下来和GC有关。并且,我们在GC中经过一顿分析发现,Servlet的hashmap$node在快速地创建和回收,说明YGC消耗资源多和压力大小有关,所以调了Tomcat相关的参数。可是,现在在同样的压力下,问题竟然不能复现,也真是醉了。 + +像这种随机的问题是比较难整的。不知道TPS稳定的假象是不是和中间有过重启有关。话说重启大法,在技术领域中真是绝对的大招。 + +既然这个问题没有复现,现场也没有了,我们也只能放弃。 + +虽然针对这个问题,我们从前到后的分析逻辑都非常合理,但是仍然没有找到问题点在哪里。如果它是一个随机的问题,那就是我们没有在合适的时机抓到问题的原因。 + +对于一个项目来说,如果出现的随机问题对业务造成的影响是不能接受的,那我们就必须花大精力去解决。如果影响不大,那也可以先放一放。但是每一个问题都有出现的必然性,也就是说,那些看似随机的问题,其实在技术中都有着绝对的必然性。 + +那这个问题到底是什么呢?在这里,我先留一个悬念,因为再继续分析下去,我们这节课就太长了,你看着也很累。下节课我们接着分析。 + +总结 + +在这节课中,我们讲了两个阶段的性能分析。 + +第一个阶段比较简单,就是一个查询的问题。对于查询来说,在实时交易的过程中,最好能够精准查找。如果出现范围查询,那就必须要有分页。 + +不过,如果是大范围的查询,那不仅会对网络造成压力,同时还会对应用、数据库等各层都产生非常明显的压力。所以,在出现范围查询时,我们必须做好技术选型。当业务必须做这样的范围查询时,你可以考虑换组件,像大数据这样的思路就可以用起来了。 + +第二个阶段有点麻烦,虽然我们花了很多时间精力,但是到最后没有找到根本原因。不过,我们分析的方向和思路都是没有问题的。 + +对于这种看似很随机的问题,在实际的项目中也经常出现。我们分析到最后可能会发现这是一个非常简单的问题,让人气得直跺脚。至于这个问题的根本原因是什么,我们下节课再做说明。 + +无论如何,在这节课中,我们仍然把分析的逻辑描述完整了,希望能给到你一些完整的思路。 + +课后作业 + +最后,请你思考一下: + + +在实时交易中,如何快速判断数据库的数据量所引发的性能问题?定向分析的证据链是什么? +如何从CPU使用高定位到GC效率引发的性能问题? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果你读完这篇文章有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下这节课再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/18购物车信息确定订单:为什么动态参数化逻辑非常重要?.md b/专栏/高楼的性能工程实战课/18购物车信息确定订单:为什么动态参数化逻辑非常重要?.md new file mode 100644 index 0000000..8949e2a --- /dev/null +++ b/专栏/高楼的性能工程实战课/18购物车信息确定订单:为什么动态参数化逻辑非常重要?.md @@ -0,0 +1,461 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 18 购物车信息确定订单:为什么动态参数化逻辑非常重要? + 你好,我是高楼。 + +我们今天来看一下购物车信息确定订单这个接口的性能怎么样,有哪些需要优化的地方。 + +在这节课中,我将给你展示如何进行方法级的跟踪,来判断参数的问题。而这个参数,并不是我们这个接口直接用到的,它有不同的使用层次。 + +直接的参数化我们都能理解,对吧。但是当一个参数产生新的数据,而新的数据又会在后续的动作中用到时,你就得注意了,因为我们有可能在第一层数据中没有发现问题,但是在后续的动作中会遇到问题。所以,我们一定要关注参数化的变化,也就是动态的参数化的数据。 + +此外,在这节课中,我还将带你一起来看看在应用有多个节点的情况下,某个节点消耗资源过多导致的复杂问题该怎么处理。 + +话不多说,我们开始今天的分析吧! + +场景运行数据 + +对于购物车信息确定订单这个接口,我们第一次运行的性能场景结果如下: + + + +在图中,响应时间随着压力的增加而增加,而TPS只到了160多,还是有点低了,我们现在就得考虑把TPS提升。 + +注意,这是一个典型的TPS不高,响应时间不断增加的性能问题。 + +按照RESAR性能分析逻辑,我们看一下这个接口的架构图。 + +看架构图 + + + +可以看到,这个接口涉及到的服务比较多,架构图也比之前其他接口的要复杂一些。 + +紧接着,我们就来拆分响应时间。 + +拆分响应时间 + + +Gateway: + + + + + +Order: + + + + + +Member: + + + + + +Cart: + + + + + +Portal: + + + + +从上面的时间拆分来看,Cart消耗了最长的时间。所以,我们先分析Cart。 + +我们再顺手点一下Cart和MySQL之间的时间消耗,看看是什么情况: + + + +这个Cart和MySQL之间的时间看起来不长,那我们就不用考虑数据库的SQL时间消耗了。 + +接下来,我们就来分析响应时间长的Cart服务。 + +第一阶段 + +全局分析 + +按照惯例,我们来看一下worker层面的资源消耗情况: + + + +从上图来看,worker-3上消耗的资源较多。那我们就来查看一下worker-3上有什么服务。 + +[root@k8s-master-2 ~]# kubectl get pods -o wide | grep k8s-worker-3 +cloud-nacos-registry-685b8957d7-vskb6 1/1 Running 0 2d11h 10.100.69.199 k8s-worker-3 +cloud-redis-7f7db7f45c-t5g46 2/2 Running 0 2d8h 10.100.69.196 k8s-worker-3 +elasticsearch-master-2 1/1 Running 0 3h28m 10.100.69.209 k8s-worker-3 +svc-mall-cart-558d787dc7-g6qgh 1/1 Running 0 2d11h 10.100.69.201 k8s-worker-3 +svc-mall-order-fbfd8b57c-kbczh 1/1 Running 0 2d11h 10.100.69.202 k8s-worker-3 +svc-mall-portal-846d9994f8-m7jbq 1/1 Running 0 38h 10.100.69.207 k8s-worker-3 +svc-mall-search-c9c8bc847-h7sgv 1/1 Running 0 161m 10.100.69.210 k8s-worker-3 +[root@k8s-master-2 ~]# + + +可以看到,worker-3上有8个服务,哪个服务消耗的资源最多呢?现在我们进入worker-3,查看下top: + +[root@k8s-worker-3 ~]# top +top - 01:51:35 up 2 days, 12:18, 2 users, load average: 19.48, 18.40, 17.07 +Tasks: 319 total, 1 running, 318 sleeping, 0 stopped, 0 zombie +%Cpu0 : 68.6 us, 6.4 sy, 0.0 ni, 19.9 id, 0.0 wa, 0.0 hi, 5.1 si, 0.0 st +%Cpu1 : 66.7 us, 5.8 sy, 0.0 ni, 22.8 id, 0.0 wa, 0.0 hi, 4.8 si, 0.0 st +%Cpu2 : 66.4 us, 6.1 sy, 0.0 ni, 22.7 id, 0.0 wa, 0.0 hi, 4.7 si, 0.0 st +%Cpu3 : 65.7 us, 5.4 sy, 0.0 ni, 23.6 id, 0.0 wa, 0.0 hi, 5.4 si, 0.0 st +%Cpu4 : 66.6 us, 5.7 sy, 0.0 ni, 22.0 id, 0.0 wa, 0.0 hi, 5.7 si, 0.0 st +%Cpu5 : 67.6 us, 5.8 sy, 0.0 ni, 22.5 id, 0.0 wa, 0.0 hi, 4.1 si, 0.0 st +KiB Mem : 16265992 total, 2525940 free, 7015104 used, 6724948 buff/cache +KiB Swap: 0 total, 0 free, 0 used. 8848464 avail Mem + + + PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND +32216 root 20 0 8878548 658820 16980 S 280.5 4.1 375:31.82 java -Dapp.id=svc-mall-cart -javaagent:/opt/skywalking/agent/skywalking-agen+ +32589 root 20 0 8839408 589196 15892 S 84.1 3.6 171:16.88 java -Dapp.id=svc-mall-order -javaagent:/opt/skywalking/agent/skywalking-age+ +24119 root 20 0 8798548 549804 15892 S 65.9 3.4 115:52.74 java -Dapp.id=svc-mall-portal -javaagent:/opt/skywalking/agent/skywalking-ag+ + 1089 root 20 0 2438956 105708 37776 S 6.3 0.6 248:21.71 /usr/bin/kubelet --bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.co+ + 5470 root 20 0 1154816 14992 1816 S 3.6 0.1 20:15.93 redis-server 0.0.0.0:6379 + + +从以上数据来看,的确是Cart服务消耗的CPU比较高。不过,它还没有把6个CPU都用完,这一点我们要记一下。 + +下面开始定向分析。 + +定向分析 + +既然Cart服务消耗的CPU多,那我们当然要看一下Cart中的线程都在干啥。 + + + +这些线程状态基本都在绿色的Runnable状态,看起来都比较繁忙,有可能是因为线程数配置的太低了,我们查看下配置: + +server: + port: 8086 + tomcat: + accept-count: 1000 + threads: + max: 20 + min-spare: 5 + max-connections: 500 + + +知道了Spring Boot内置的Tomcat线程数配置,我们拆分一下在Cart上正在执行的方法,看看我们的定位方法是不是合理: + + + +看这张图的时候,你要注意消耗时间长的位置,也就是图中右侧线段比较长的地方。这里面有两个环节的问题: + + +MySQL的执行时间长。你要注意哦,虽然这里的MySQL/JDBI/PreparedStatement/execute并没有消耗很长的时间,但是它的下一步Balance/Promotion/Cart/CartPromotion消耗的时间是长的; +Promotionnew方法本身的时间长。 + + +由于慢的节点和MySQL有关,我们创建一个,mysqlreport来看MySQL整体的监控数据: + +__ Connections _________________________________________________________ +Max used 152 of 151 %Max: 100.66 +Total 540 0.0/s + + +原来是连接用完了!我们赶紧改一下,从151改到500。 + +不过,重测之后响应时间还是没有变化,那我们就只能接着跟踪Cart上的方法了。 + +方法级跟踪 + +于是,我们不得不来到方法级跟踪, 看一下我们关注的方法Promotionnew慢在哪里。 + +由上面那张调用视图,我们可以编写下面这样的跟踪语句: + +trace -E com.dunshan.mall.cart.controller.CartItemController listPromotionnew -n 5 -v –skipJDKMethod false ‘1==1’ + +然后得到了下面这个结果: + +`---ts=2021-01-16 15:08:58;thread_name=http-nio-8086-exec-34;id=f8;is_daemon=true;priority=5;TCCL=org.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader@56887c8f + `---[97.827186ms] com.dunshan.mall.cart.service.imp.CartItemServiceImpl$$EnhancerBySpringCGLIB$$ac8f5a97:listPromotion() + `---[97.750962ms] org.springframework.cglib.proxy.MethodInterceptor:intercept() #57 + `---[97.557484ms] com.dunshan.mall.cart.service.imp.CartItemServiceImpl:listPromotion() + +---[72.273747ms] com.dunshan.mall.cart.service.imp.CartItemServiceImpl:list() #166 + +---[0.003516ms] cn.hutool.core.collection.CollUtil:isNotEmpty() #172 + +---[0.004207ms] java.util.List:stream() #173 + +---[0.003893ms] java.util.stream.Stream:filter() #57 + +---[0.003018ms] java.util.stream.Collectors:toList() #57 + +---[0.060052ms] java.util.stream.Stream:collect() #57 + +---[0.002017ms] java.util.ArrayList:() #177 + +---[0.003013ms] org.springframework.util.CollectionUtils:isEmpty() #179 + `---[25.152532ms] com.dunshan.mall.cart.feign.CartPromotionService:calcCartPromotion() #181 + + +可以看到,在我们跟踪的方法com.dunshan.mall.cart.service.imp.CartItemServiceImpl:listPromotion()中,有两处listPromotion和calcCartPromotion时间消耗较大,分别是: + + +com.dunshan.mall.cart.service.imp.CartItemServiceImpl:list() +com.dunshan.mall.cart.feign.CartPromotionService:calcCartPromotion() + + +跟踪List函数 + +我们在Arthas中执行trace跟踪语句如下: + +trace com.dunshan.mall.cart.service.imp.CartItemServiceImpl list -v -n 5 --skipJDKMethod false '1==1' + + +然后得到这样的结果: + +`---ts=2021-01-16 15:19:45;thread_name=http-nio-8086-exec-65;id=23ce;is_daemon=true;priority=5;TCCL=org.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader@56887c8f + `---[70.158793ms] com.dunshan.mall.cart.service.imp.CartItemServiceImpl:list() + +---[0.003501ms] com.dunshan.mall.model.OmsCartItemExample:() #150 + +---[0.002642ms] com.dunshan.mall.model.OmsCartItemExample:createCriteria() #151 + +---[0.002932ms] com.dunshan.mall.model.OmsCartItemExample$Criteria:andDeleteStatusEqualTo() #57 + +---[0.00304ms] com.dunshan.mall.model.OmsCartItemExample$Criteria:andMemberIdEqualTo() #57 + `---[70.078976ms] com.dunshan.mall.mapper.OmsCartItemMapper:selectByExample() #152 + + +在一阵无聊的trace之后,看到一个select语句消耗时间较长,这个select语句是: + +select id, product_id, product_sku_id, member_id, quantity, price, product_pic, product_name, product_sub_title, product_sku_code, member_nickname, create_date, modify_date, delete_status, product_category_id, product_brand, product_sn, product_attr from oms_cart_item WHERE ( delete_status = ? and member_id = ? ) + + +一个简单的select语句,怎么会耗时这么久呢?我们先不管为什么会这样,先来看看这个oms_cart_item的数据有多少。我连上数据库后一查,发现在oms_cart_item里面有10612条数据,这个数据量并不大。 + +此外,我还查看了一下索引,也是有的,并且执行计划也走到了索引这里。那为什么会慢呢?到这里,我们得考虑一下是不是和数据量有关了。所以,我们来看看这个select语句究竟查出了多少条数据。 + +在我补全SQL后一查,发现一个member_id对应500多条记录,这是一个人一次买了500个东西? + +既然在购物车信息里,同一个人有这么多记录,那一定是在商品加入购物车时加进去的。而要往一个人的购物车里加东西,显然是在性能脚本执行的过程中添加,因为每个用户的购物车一开始都是空的。所以,我们要去查一下商品加入购物车的脚本是怎么回事。 + +商品加入购物车的脚本很简单,就是一个post,加上商品ID,在HTTP协议的请求头里面有一个Token来标识是哪个用户。 + +在这里,我们要查的就是token有没有用重,JMeter中的Token参数化配置如下: + + + +看起来挺好,我们在这里设计了不重用数据,所以在设置上Token不会被重用。那么,只有一种可能,就是Token重了。 + + + +在随机检查了几条Token之后,我发现有大量的Token重复。这也就解释了,为什么我们会在一个人的购物车里看到那么多商品数据。 + +可是,这个逻辑就有问题了。你想想,我们设置了参数化中数据不重复使用,但实际上确实有大量的Token被重用,这就说明Token的参数化文件本身就重复了。 + +那怎么办呢?我们只有把所有的Token全部清掉,让Token数据在商品加入购物车的时候不会被重用,以此来避免在一个人的购物车中加入太多商品的情况。 + +接着怎么办?只有一招了,就是把所有的数据都清掉,然后用所有的用户创建合理的购物车数据。于是,我在这里又花了几个小时,造出了130多万数据。现在,我们再回归一下场景: + + + +你看,TPS增加到了300! + +本来这是一个美好到可以喝下午茶的结果,然而……天不随人愿,我在场景持续执行的过程中,又发现了问题,这让我们不得不开启第二阶段的分析。 + +第二阶段 + +场景运行数据 + +是什么问题呢?我在压力运行的数据中,竟然看到了这种TPS曲线: + + + +你看,TPS相当规律地往下掉,不仅会掉下去,而且还会恢复回去,形成一个明显的锯齿状,并且这锯齿还挺大。 + +怎么办?根据高老师的思路,现在我们就得按照RESAR性能分析逻辑来收拾这个问题了。我们在前面已经看过架构图,所以,现在直接来拆分响应时间。 + +拆分响应时间 + + +Gateway: + + + + + +Order: + + + + + +Cart: + + + + + +Portal: + + + + +从上面的数据来看,似乎每一层都有时间消耗,性能都不怎么样。 + +全局分析 + +那我们就查看下当前的全局监控数据,可以看到worker-3上的CPU消耗最多: + + + +因此,我们来查一下worker-3上有哪些Pod: + +[root@k8s-master-3 ~]# kubectl get pods -o wide | grep k8s-worker-3 +cloud-nacos-registry-685b8957d7-vskb6 1/1 Running 0 3d7h 10.100.69.199 k8s-worker-3 +cloud-redis-7f7db7f45c-t5g46 2/2 Running 1 3d4h 10.100.69.196 k8s-worker-3 +elasticsearch-master-2 1/1 Running 0 23h 10.100.69.209 k8s-worker-3 +svc-mall-cart-79c667bf56-j76h6 1/1 Running 0 20h 10.100.69.213 k8s-worker-3 +svc-mall-order-fbfd8b57c-kbczh 1/1 Running 0 3d7h 10.100.69.202 k8s-worker-3 +svc-mall-portal-846d9994f8-m7jbq 1/1 Running 0 2d10h 10.100.69.207 k8s-worker-3 +svc-mall-search-c9c8bc847-h7sgv 1/1 Running 0 23h 10.100.69.210 k8s-worker-3 +[root@k8s-master-3 ~]# + + +居然有这么多服务都在worker-3上。 + +我们现在登录到worker-3上,看一下top资源。其实,我在这里主要想看的是process table,因为我想先确定一下哪个服务消耗的资源最高,然后再决定收拾哪个服务。 + +[root@k8s-worker-3 ~]# top +top - 22:13:01 up 3 days, 8:39, 3 users, load average: 40.34, 30.03, 18.02 +Tasks: 326 total, 6 running, 320 sleeping, 0 stopped, 0 zombie +%Cpu0 : 74.5 us, 13.4 sy, 0.0 ni, 7.7 id, 0.0 wa, 0.0 hi, 4.4 si, 0.0 st +%Cpu1 : 66.3 us, 12.1 sy, 0.0 ni, 16.5 id, 0.0 wa, 0.0 hi, 4.7 si, 0.3 st +%Cpu2 : 49.7 us, 32.4 sy, 0.0 ni, 14.9 id, 0.0 wa, 0.0 hi, 2.7 si, 0.3 st +%Cpu3 : 73.2 us, 9.7 sy, 0.0 ni, 12.4 id, 0.0 wa, 0.0 hi, 4.7 si, 0.0 st +%Cpu4 : 76.4 us, 10.5 sy, 0.0 ni, 8.8 id, 0.0 wa, 0.0 hi, 4.1 si, 0.3 st +%Cpu5 : 62.4 us, 16.4 sy, 0.0 ni, 16.1 id, 0.0 wa, 0.0 hi, 4.7 si, 0.3 st +KiB Mem : 16265992 total, 211212 free, 9204800 used, 6849980 buff/cache +KiB Swap: 0 total, 0 free, 0 used. 6650068 avail Mem + + + PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND +32485 root 20 0 8895760 700564 16896 S 101.6 4.3 723:03.52 java -Dapp.id=svc-mall-cart -javaagent:/opt/skywalking/agent/skywalking-agent.jar -Dskywalking.agent.service_name=svc-ma+ +32589 root 20 0 8845576 778684 15896 S 93.6 4.8 427:04.44 java -Dapp.id=svc-mall-order -javaagent:/opt/skywalking/agent/skywalking-agent.jar -Dskywalking.agent.service_name=svc-m+ +24119 root 20 0 8825208 600956 15888 S 67.9 3.7 262:00.83 java -Dapp.id=svc-mall-portal -javaagent:/opt/skywalking/agent/skywalking-agent.jar -Dskywalking.agent.service_name=svc-+ +............ + + +在上述top资源中,我们主要来看几个吃CPU的大户。不难发现,Cart/Order/Portal这三个服务在购物车信息确定订单的业务中都有用到,并且都在同一个worker上。同时,我们也可以看到,在这个6C的worker中,现在的CPU队列已经达到40了。 + +定向分析 + +从系统上来看,CPU队列长的问题主要是由于资源的争用,因为线程在不断地唤醒,通过start_thread就能看出来: + + + +现在我们要做的就是把线程降下去。 + +怎么降呢?有两种手段: + + +把服务移走,先一个个收拾,分而治之。 +把服务的线程减少。 + + +这两种方式都是能减少资源争用,但是会带来不同的影响。其中,第一种手段比较合理,只是会消耗更多的整体资源;第二种手段虽然会减少争用,但是会导致响应时间增加。 + +我这么跟你一讲,你可能已经发现了,这两种手段都不能解释TPS不稳定的问题。那为什么TPS一会儿掉下去,一会儿又恢复呢?现在我们还不知道答案,不过基于我们“全局-定向”的分析思路,我们先看一下worker-3的资源消耗: + + + +在同一时间段,我也查看了同一台物理机上的其他worker的资源消耗情况,发现worker-8的资源消耗有点不太正常。请你注意,我此时的查看逻辑,仍然依据的是第3讲中描述的逻辑,以及对应第4讲中的性能分析决策树。希望你不要觉得这里有跳跃,实际上我们还是在全局监控的第一层计数器上。 + +我们具体来看一下worker-8的资源消耗: + + + +再来看一下压力场景的执行数据: + + + +从上面worker-8的资源使用率来看,确实有很高的时候。考虑到同一物理机上资源的争用问题,我们现在把cart移到Worker-7上去,把order移到worker-9上去,再来看TPS: + + + +花花绿绿,起起伏伏,真是好看……我们先不管这样做有没有增加TPS,光看这个趋势,就足以让人心碎了。既然结果还是这样,那我们就用老套路,继续拆分时间来看看。 + + +Gateway: + + + + + +Order: + + + + + +Cart: + + + + + +Portal: + + + + + +Member: + + + + +从上面的时间来看,Gateway消耗的时间比较长,这就奇怪了,这都换了到了Gateway服务上有问题了。所以,我们到Gateway机器上看一下到底有哪些服务: + + + +呀呀呀,那个占第一位的是个啥?原来是ES的一个进程,这个进程消耗了多个CPU。看到这,我想起来前几天为了增加ES的能力,我们给ES data和ES client增加过CPU。当时考虑到它们都是吃CPU的大户,只能用一个CPU实在太委屈它们了,所以增加了CPU数量,想让它们的性能好一些。 + +可是没想到,ES data和ES client对应用的影响有这么大。 + +我当时改ES的CPU,是因为我们架构中的一个搜索服务用到了它,而当时的CPU给的是一个C,这导致Search服务的TPS很低。关于这一点,我在第15讲中有描述,你如果不清楚,可以再看看。 + +同时,我们还要注意一点,ES data和ES client都不是单一的节点,而是有几个节点。由此产生的问题就是,任意一个ES节点出现资源消耗过多的时候,都会影响到它所在的worker机器资源,进而影响到这个ES节点所在的整个物理机。 + +既然ES的进程消耗资源占居首位,那我们该怎么办呢?为了验证问题,我先把ES给停掉,看看TPS能不能上来,如果能上来,我们再考虑怎么限制ES的资源。 + +停了ES之后,TPS如下图所示: + + + +看到没有?TPS增加了一倍,并且也没有掉下来!非常理想! + +所以,接下来,我们就要考虑把ES限制到固定的worker上,让它不影响现在的应用。 + +总结 + +在这节课中,我们有两个阶段的分析。 + +在第一个阶段中,我们定位了数据问题。对于性能来说,数据是非常重要的基础资源,而数据的合理性直接影响了测试的结果。 + +经常有初入性能行业的人讨论:性能脚本中的数据到底应该用多少?我觉得这是一个非常明确的问题,在所有的性能场景中,使用的资源都应该按真实发生的业务逻辑来确定,有且只有这样,才能保证结果是有效的。 + +在第二阶段中,我们定位了一个有意思的问题,而这个问题的复杂性在于整体的架构。因为我们是用KVM、Kubernetes和Docker作为基础设施的,而我们选择的应用原本也不是真正的微服务,是对一个开源系统架构做了更改,把它改成了真正的微服务。 + +在这样的环境中,如果一个应用有问题,那在修改重启的时候,应用会被Kubernetes调度到哪个节点上,是不确定的。也正是出于这样的原因,我们一会儿看到这里有问题,一会儿看到那里有问题,定位的逻辑全都对,但是就是层面不对。这也是上节课中随机问题出现的原因。 + +所以,根据我们在第4讲中提到的性能分析决策树,我们仍然需要有全局监控、定向监控的思路,并且还要找到计数器的相关性。这样一来,当看到相关计数器有问题的时候,我们就能知道它们之间的关联关系了。 + +希望你在这节课中,能把性能分析的逻辑记在心中。 + +课后作业 + +最后,请你思考一下: + + +性能脚本中的参数应该如何设计? +如何定位TPS会掉下来的情况?大概描述一下你的思路。 + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果你读完这篇文章有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下这节课再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/19生成订单信息之一:应用JDBC池优化和内存溢出分析.md b/专栏/高楼的性能工程实战课/19生成订单信息之一:应用JDBC池优化和内存溢出分析.md new file mode 100644 index 0000000..d9c501f --- /dev/null +++ b/专栏/高楼的性能工程实战课/19生成订单信息之一:应用JDBC池优化和内存溢出分析.md @@ -0,0 +1,417 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 19 生成订单信息之一:应用JDBC池优化和内存溢出分析 + 你好,我是高楼。 + +在这节课中,我们来看一下生成订单接口的基准场景是什么结果。 + +你将看到一些重复的问题,比如SQL的问题定位,虽然具体的问题不同,但我们的分析逻辑没有区别,我会简单带过。同时,你也会看到一些新的问题,比如JDBC池增加之后,由于数据量过大导致JVM内存被消耗光;批量业务和实时业务共存导致的锁问题等。这节课,我们重点来看看这样的问题如何进一步优化。 + +话不多说,开整! + +场景运行数据 + +对于生成订单接口,我们第一次试执行性能场景的结果如下: + + + +从场景执行的结果来看。40个压力线程只跑出来50多的TPS,响应时间也蹭蹭蹭地跑了近700ms。这显然是一个非常慢的接口了。 + +从这样的接口来看,我们选择这个项目算是选择对了,因为到处都是性能问题。 + +下面我们就来一步步分析一下。 + +架构图 + +前面我们做过多次描述,画架构图是为了知道分析的路径。所以按照惯例,我们仍然把架构图列在这里。 + + + +由于这个接口比较复杂,架构图看起来有点乱,我又整了一个简化版本: + + + +Order服务是这个接口的核心,因此,你可以看到我把Order相关的服务都筛选了出来,这样我们就能很清楚地知道它连接了哪些东西。 + +下面我们来拆分响应时间。 + +拆分响应时间 + +因为在场景运行的时候,我们看到响应时间比较长,所以我们用APM工具来拆分一下: + + +Gateway : + + + + +从上图我们就可以看到Gateway上的时间在700ms左右,这与前面的场景数据是可以对上的。 + +我说明一下,这张小图的采样间隔是分钟,因此,你可能会发现这个曲线和压力工具给出的TPS曲线,在一些细节上对应不起来。不过这没关系,我们更应该关注整体的趋势。 + + +Order: + + + + +我们前面提到,Order是生产订单信息这个接口的重点,并且它的业务逻辑也非常复杂,因此,我们要多关注这个服务。 + +从数据上来看,Order的是时间消耗在350毫秒左右,占到整个响应时间的一半。这是我们着重要分析的,而Gateway的转发能力也是要考虑的问题点,只是Gateway上没有逻辑,只做转发,如果是因为数据量大而导致的Gateway转发慢,那我们解决了Order的问题之后,Gateway的问题也就会被解决。所以,我们先分析Order的问题。 + +所以,我们现在就来分析一下。 + +第一阶段 + +全局监控分析 + +我们先看全局监控: + + + +一眼扫过去,啥也没有。既没有明显的CPU资源消耗,也没有明显的网络资源、IO资源瓶颈。 + +遇到这种情况,我们一定要留意整个链路上有限制的点。什么是有限制的点?比如说,各种池(连接池、等)、栈中的锁、数据库连接、还有数据库的锁之类。其实,总结下来就是一个关键词:阻塞。 + +我们只要分析出阻塞的点,就能把链路扩宽,进而把资源都用起来。 + +当然,也有可能在你分析了一圈之后,发现没有任何有阻塞的点,可是资源就是用不上去。这种情况只有一种可能,那就是你分析得还不够细致。因为可能存在阻塞的地方实在太多了,我们只能一步步拆解。 + +定向监控分析 + +正所谓“心中常备决策树,让你分析不迷路”。到了定向监控分析这里,按照第4讲中强调的性能分析决策树,我们先来分析Order服务: + + + +在我分析Order的线程栈信息时,发现在Order的栈中,有大量这样的内容: + +"http-nio-8086-exec-421" Id=138560 WAITING on java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject@a268a48 + at sun.misc.Unsafe.park(Native Method) + - waiting on java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject@a268a48 + at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039) + at com.alibaba.druid.pool.DruidDataSource.takeLast(DruidDataSource.java:1899) + at com.alibaba.druid.pool.DruidDataSource.getConnectionInternal(DruidDataSource.java:1460) + at com.alibaba.druid.pool.DruidDataSource.getConnectionDirect(DruidDataSource.java:1255) + at com.alibaba.druid.filter.FilterChainImpl.dataSource_connect(FilterChainImpl.java:5007) + at com.alibaba.druid.filter.stat.StatFilter.dataSource_getConnection(StatFilter.java:680) + at com.alibaba.druid.filter.FilterChainImpl.dataSource_connect(FilterChainImpl.java:5003) + at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:1233) + at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:1225) + at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:90) + .......................... + at com.dunshan.mall.order.service.impl.PortalOrderServiceImpl$$EnhancerBySpringCGLIB$$f64f6aa2.generateOrder() + at com.dunshan.mall.order.controller.PortalOrderController.generateOrder$original$hak2sOst(PortalOrderController.java:48) + at com.dunshan.mall.order.controller.PortalOrderController.generateOrder$original$hak2sOst$accessor$NTnIbuo7(PortalOrderController.java) + at com.dunshan.mall.order.controller.PortalOrderController$auxiliary$MTWkGopH.call(Unknown Source) + .......................... + at com.dunshan.mall.order.controller.PortalOrderController.generateOrder(PortalOrderController.java) + .......................... + + +你看,栈信息中有很多getConnection,这明显是Order服务在等数据库连接池。所以,我们要做的就是把JDBC池加大: + +原配置: + initial-size: 5 #连接池初始化大小 + min-idle: 10 #最小空闲连接数 + max-active: 20 #最大连接数 + + +修改为: + initial-size: 20 #连接池初始化大小 + min-idle: 10 #最小空闲连接数 + max-active: 40 #最大连接数 + + +你可以看到,我在这里并没有把JDBC池一次性修改得太大,主要是因为我不想为了维护连接池而产生过多的CPU消耗。我也建议你在增加资源池的时候,先一点点增加,看看有没有效果,等有了效果后再接着增加。 + +修改JDBC池后,我们再来看一下压力场景的执行数据: + + + +从数据上看,TPS有上升的趋势,并且一度达到了150以上。可是紧接着,TPS就掉下来了,这个时候的响应时间倒是没有明显增加。而且你看,TPS不仅掉下来了,而且还断断续续的,极为不稳定。 + +此外,我们还可以发现,在后续的压力中不仅有错误信息产生,响应时间也在上升。与此同时,我查看了全局监控的资源,并没有发现太大的资源消耗。既然有错误产生,没二话,我们只能整它! + +第二阶段 + +全局监控分析 + +因为我们在前面修改了Order的JDBC池,所以在出现新的问题之后,我们先来看一下Order服务的健康状态。在查看Order服务的top时,看到如下信息: + +top - 01:28:17 up 19 days, 11:54, 3 users, load average: 1.14, 1.73, 2.27 +Tasks: 316 total, 1 running, 315 sleeping, 0 stopped, 0 zombie +%Cpu0 :100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st +%Cpu1 : 3.0 us, 2.7 sy, 0.0 ni, 93.6 id, 0.0 wa, 0.0 hi, 0.3 si, 0.3 st +%Cpu2 : 3.4 us, 3.4 sy, 0.0 ni, 93.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st +%Cpu3 : 3.7 us, 2.8 sy, 0.0 ni, 93.5 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st +%Cpu4 : 3.6 us, 2.1 sy, 0.0 ni, 93.6 id, 0.0 wa, 0.0 hi, 0.3 si, 0.3 st +%Cpu5 : 2.8 us, 1.8 sy, 0.0 ni, 95.4 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st +KiB Mem : 16265992 total, 2229060 free, 9794944 used, 4241988 buff/cache +KiB Swap: 0 total, 0 free, 0 used. 6052732 avail Mem + + + PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND +29349 root 20 0 8836040 4.3g 16828 S 99.7 27.4 20:51.90 java + 1089 root 20 0 2574864 98144 23788 S 6.6 0.6 2066:38 kubelet + + +悲催的数据还是来了,你看,有一个us cpu达到了100%!这是啥情况? + +进入到容器中,我通过 top -Hp和jstack -l 1 两个命令查看进程后发现,原来是VM Thread线程占用了CPU,这个线程是做垃圾回收(GC)的。 既然如此,那我们就来看一下内存的回收状态,查看jstat如下: + +[root@svc-mall-order-7fbdd7b85f-ks828 /]# jstat -gcutil 1 1s + S0 S1 E O M CCS YGC YGCT FGC FGCT GCT + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 93 652.664 681.486 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 93 652.664 681.486 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 93 652.664 681.486 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 93 652.664 681.486 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 93 652.664 681.486 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 93 652.664 681.486 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 93 652.664 681.486 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 94 659.863 688.685 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 94 659.863 688.685 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 94 659.863 688.685 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 94 659.863 688.685 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 94 659.863 688.685 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 94 659.863 688.685 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 94 659.863 688.685 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 95 667.472 696.294 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 95 667.472 696.294 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 95 667.472 696.294 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 95 667.472 696.294 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 95 667.472 696.294 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 95 667.472 696.294 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 95 667.472 696.294 + 0.00 100.00 100.00 100.00 94.86 93.15 168 28.822 95 667.472 696.294 + 0.00 100.00 100.00 100.00 94.85 93.14 168 28.822 96 674.816 703.638 + 0.00 100.00 100.00 100.00 94.85 93.14 168 28.822 96 674.816 703.638 + 0.00 100.00 100.00 100.00 94.85 93.14 168 28.822 96 674.816 703.638 + 0.00 100.00 100.00 100.00 94.85 93.14 168 28.822 96 674.816 703.638 + 0.00 100.00 100.00 100.00 94.85 93.14 168 28.822 96 674.816 703.638 + 0.00 100.00 100.00 100.00 94.85 93.14 168 28.822 96 674.816 703.638 + 0.00 100.00 100.00 100.00 94.85 93.14 168 28.822 96 674.816 703.638 + + +从上面的数据来看,FullGC在不断出现,但是又回收不了内存,这个问题就严重了。 + +你要注意,对于这种情况,我们正常的判断逻辑应该是:一个实时的业务系统就算是有FullGC,也应该是每次都回收到正常的状态。如果HEAP内存确实不够用,那我们可以增加。但是如果HEAP一直在减少,直到FullGC也回收不了,那就有问题了。 + +因此,对于这样的问题,我们要做两方面的分析: + + +内存确实在被使用,所以,FullGC回收不了。 +内存有泄露,并且已经泄露完,所以,FullGC无法回收。 + + +那么接下来,我们在做定向监控分析时就要从这两个角度来思考。 + +定向监控分析 + +既然内存已经满了,我们就执行一下jmap -histo:live 1|head -n 50,来看看占比相对较多的内存是什么: + +[root@svc-mall-order-7fbdd7b85f-ks828 /]# jmap -histo:live 1|head -n 50 + + + num #instances #bytes class name +---------------------------------------------- + 1: 74925020 2066475600 [B + 2: 2675397 513676056 [[B + 3: 2675385 85612320 com.mysql.cj.protocol.a.result.ByteArrayRow + 4: 2675386 42806176 com.mysql.cj.protocol.a.MysqlTextValueDecoder + 5: 246997 27488016 [C + 6: 80322 16243408 [Ljava.lang.Object; + 7: 14898 7514784 [Ljava.util.HashMap$Node; + 8: 246103 5906472 java.lang.String + 9: 109732 3511424 java.util.concurrent.ConcurrentHashMap$Node + 10: 37979 3342152 java.lang.reflect.Method + 11: 24282 2668712 java.lang.Class + 12: 55296 2654208 java.util.HashMap + 13: 15623 2489384 [I + 14: 81370 1952880 java.util.ArrayList + 15: 50199 1204776 org.apache.skywalking.apm.agent.core.context.util.TagValuePair + 16: 36548 1169536 java.util.HashMap$Node + 17: 566 1161296 [Ljava.util.concurrent.ConcurrentHashMap$Node; + 18: 28143 1125720 java.util.LinkedHashMap$Entry + 19: 13664 1093120 org.apache.skywalking.apm.agent.core.context.trace.ExitSpan + 20: 23071 922840 com.sun.org.apache.xerces.internal.dom.DeferredTextImpl + 21: 35578 853872 java.util.LinkedList$Node + 22: 15038 842128 java.util.LinkedHashMap + 23: 52368 837888 java.lang.Object + 24: 17779 711160 com.sun.org.apache.xerces.internal.dom.DeferredAttrImpl + 25: 11260 630560 com.sun.org.apache.xerces.internal.dom.DeferredElementImpl + 26: 18743 599776 java.util.LinkedList + 27: 26100 598888 [Ljava.lang.Class; + 28: 22713 545112 org.springframework.core.MethodClassKey + 29: 712 532384 [J + 30: 6840 492480 org.apache.skywalking.apm.agent.core.context.trace.LocalSpan + 31: 6043 483440 org.apache.skywalking.apm.dependencies.net.bytebuddy.pool.TypePool$Default$LazyTypeDescription$MethodToken + 32: 7347 352656 org.aspectj.weaver.reflect.ShadowMatchImpl + 33: 6195 297360 org.springframework.core.ResolvableType + 34: 6249 271152 [Ljava.lang.String; + 35: 11260 270240 com.sun.org.apache.xerces.internal.dom.AttributeMap + 36: 3234 258720 java.lang.reflect.Constructor + 37: 390 255840 org.apache.skywalking.apm.dependencies.io.netty.util.internal.shaded.org.jctools.queues.MpscArrayQueue + 38: 7347 235104 org.aspectj.weaver.patterns.ExposedState + 39: 5707 228280 java.lang.ref.SoftReference + 40: 3009 216648 org.apache.skywalking.apm.agent.core.context.TracingContext + 41: 13302 212832 org.apache.ibatis.scripting.xmltags.StaticTextSqlNode + 42: 8477 203448 org.apache.skywalking.apm.dependencies.net.bytebuddy.pool.TypePool$Default$LazyTypeDescription$MethodToken$ParameterToken + 43: 5068 162176 java.util.concurrent.locks.ReentrantLock$NonfairSync + 44: 2995 143760 org.apache.skywalking.apm.agent.core.context.trace.TraceSegmentRef + 45: 2426 135856 java.lang.invoke.MemberName + 46: 3262 130480 java.util.WeakHashMap$Entry + 47: 1630 130400 org.apache.skywalking.apm.agent.core.context.trace.EntrySpan +[root@svc-mall-order-7fbdd7b85f-ks828 /]# + + +在分析内存时,我们可以过滤掉java自己的对象,只看和业务相关的对象。从上面的第3、4条可以看出,com.mysql.cj.protocol和SQL相关,那我们就到innodb_trx表中去查一下,看看有没有执行时间比较长的SQL。 + +在查询过程中,我们看到了这样一条SQL: + +select id, member_id, coupon_id, order_sn, create_time, member_username, total_amount pay_amount, freight_amount, promotion_amount, integration_amount, coupon_amount discount_amount, pay_type, source_type, status, order_type, delivery_company, delivery_sn auto_confirm_day, integration, growth, promotion_info, bill_type, bill_header, bill_content bill_receiver_phone, bill_receiver_email, receiver_name, receiver_phone, receiver_post_code receiver_province, receiver_city, receiver_region, receiver_detail_address, note, confirm_status delete_status, use_integration, payment_time, delivery_time, receive_time, comment_time modify_time from oms_order WHERE ( id = 0 and status = 0 and delete_status = 0 ) + + +进而我又查询了这个语句,发现涉及到的数据有4358761条,这显然是代码写的有问题。那我们就去查看一下在代码中,哪里调用了这个SQL。 + +通过查看代码,看到如下逻辑: + +example.createCriteria().andIdEqualTo(orderId).andStatusEqualTo(0).andDeleteStatusEqualTo(0); +List cancelOrderList = orderMapper.selectByExample(example); + + +这段代码对应的select语句是: + + + + +这是一个典型的语句没过滤的问题。像这样的开发项目,也最多就是做个Demo用。要是在真实的线上项目中,早就不知道伤害了多少人。 + +我们在这里直接修改代码加上limit,不让它一次性查询出所有的数据。 + +然后,我们看一下优化效果: + + + +你看,没有出现TPS断裂的情况了,优化效果还是有的,说明那条SQL语句不会再查出太多数据把内存给占满了。 + +不过,TPS值并没有增加多少,所以我们必须做第三阶段的分析。 + +第三阶段 + +这次我们不从全局监控数据来看了,有了前面的经验,我们直接来做定向监控分析。 + +定向监控分析 + +因为我们在前面改了SQL,所以在执行SQL之后,我们要去查一下innodb_trx表,看看还有没有慢的SQL。 结果,看到了如下SQL: + + + +把这个SQL拿出来,看看它的执行计划: + + + +又是一个典型的全表扫描,并且是由一个update使用的。看到这里,你是不是有种想把开发拉出去祭旗的冲动? + +由于生成订单信息是一个复杂的接口,我们不急着收拾这个SQL,先把slow log全都拿出来分析一遍。 + +请你注意,有时候项目执行的场景多了,数据相互之间的影响就会很大,容易导致我们分析的方向不准确。所以,我们最好把slow log都清一遍。反正我通常都会这么干,因为我不想看到乱七八糟的数据。 + +在清理完慢SQL、重新执行场景之后,我又把slow log拿出来,用pt-digest-query分析了一遍(关于这一点,我们在第16讲中讲过,如果你不记得的话,建议你再回顾一下),看到如下的数据: + +# Profile +# Rank Query ID Response time Calls R/Call V/M I +# ==== ============================ =============== ===== ======== ===== = +# 1 0x2D9130DB1449730048AA1B5... 1233.4054 70.5% 3 411.1351 2.73 UPDATE oms_order +# 2 0x68BC6C5F4E7FFFC7D17693A... 166.3178 9.5% 2677 0.0621 0.60 INSERT oms_order +# 3 0xB86E9CC7B0BA539BD447915... 91.3860 5.2% 1579 0.0579 0.01 SELECT ums_member +# 4 0x3135E50F729D62260977E0D... 61.9424 3.5% 4 15.4856 0.30 SELECT oms_order +# 5 0xAE72367CD45AD907195B3A2... 59.6041 3.4% 3 19.8680 0.13 SELECT oms_order +# 6 0x695C8FFDF15096AAE9DBFE2... 49.1613 2.8% 1237 0.0397 0.01 SELECT ums_member_receive_address +# 7 0xD732B16862C1BC710680BB9... 25.5382 1.5% 471 0.0542 0.01 SELECT oms_cart_item +# MISC 0xMISC 63.2937 3.6% 1795 0.0353 0.0 <9 ITEMS> + + +通过上面的Profile信息我们看到,第一个语句消耗了总时间的70.5%,第二个语句消耗了总时间的9.5%。我们说要解决性能问题,其实解决的就是这种消耗时间长的语句。而后面的SQL执行时间短,我们可以暂时不管。 + +通常在这种情况下,你可以只解决第一个语句,然后再回归测试看看效果,再来决定是否解决第二个问题。我先把这两个完整的SQL语句列在这里: + +1. UPDATE oms_order SET member_id = 260869, order_sn = '202102030100205526', create_time = '2021-02-03 01:05:56.0', member_username = '7dcmppdtest15176472465', total_amount = 0.00, pay_amount = 0.00, freight_amount = 0.00, promotion_amount = 0.00, integration_amount = 0.00, coupon_amount = 0.00, discount_amount = 0.00, pay_type = 0, source_type = 1, STATUS = 4, order_type = 0, auto_confirm_day = 15, integration = 0, growth = 0, promotion_info = '', receiver_name = '6mtf3', receiver_phone = '18551479920', receiver_post_code = '66343', receiver_province = '北京', receiver_city = '7dGruop性能实战', receiver_region = '7dGruop性能实战区', receiver_detail_address = '3d16z吉地12号', confirm_status = 0, delete_status = 0 WHERE id = 0; + + +2. insert into oms_order (member_id, coupon_id, order_sn, create_time, member_username, total_amount, pay_amount, freight_amount, promotion_amount, integration_amount, coupon_amount, discount_amount, pay_type, source_type, status, order_type, delivery_company, delivery_sn, auto_confirm_day, integration, growth, promotion_info, bill_type, bill_header, bill_content, bill_receiver_phone, bill_receiver_email, receiver_name, receiver_phone, receiver_post_code, receiver_province, receiver_city, receiver_region, receiver_detail_address, note, confirm_status, delete_status, use_integration, payment_time, delivery_time, receive_time, comment_time, modify_time)values (391265, null, '202102030100181755', '2021-02-03 01:01:03.741', '7dcmpdtest17793405657', 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, null, null, 15, 0, 0, '', null, null, null, null, null, 'belod', '15618648303', '93253', '北京', '7dGruop性能实战', '7dGruop性能实战区', 'hc9r1吉地12号', null, 0, 0, null, null, null, null, null, null); + + +我们先来看第一个语句。这个update语句虽然被调用的次数不多,但是特别慢。这显然不应该是实时接口在调用,那我们就要查一下到底是什么业务调用了这个语句。你看,在这个语句中,update更新的是where条件中ID为0的数据,这看上去就是一个批量业务。 + +我们再来看第二个语句。第二个insert语句调用次数多,应该是实时交易的SQL。通常,我们会通过批量插入数据来优化insert,所以,就需要调整bulk_insert_buffer_size参数(默认是8M)来实现这一点。因为bulk_insert_buffer_size就是在批量插入数据时提高效率的。我去查询了一下这个参数,确实没有优化过,还是默认值。 + +这里你要注意一点,在生产环境中,因为Order表中要加索引,所以在架构设计时也最好是主从分离,让update、insert和select不会相互影响。 + +分析完这两个SQL语句,我们先来查找第一个SQL的来源。通过查找代码,可以看到这里调用了该语句: + +orderMapper.updateByPrimaryKeySelective(cancelOrder); + + +但是,请注意,这个updateByPrimaryKeySelective方法是批量任务中的,而批量任务应该和实时交易分开才是。如果你是作为性能团队的人给架构或开发提优化建议,那你可以这样给建议: + + +读写分离; + +批量业务和实时业务分离。 + + +在这里,我先把这个批量业务给分离开,并且也不去调用它。但是,在真实的业务逻辑中,你可不能这么干。我之所以这么做,是为了看后续性能优化的效果和方向。 + +做了上述修改之后,TPS如下: + + + +从效果上来看,TPS能达到300左右了,响应时间看起来也稳定了。我们终于可以进入正常的性能分析阶段了,哈哈。 + +不过,到这里,我们的工作并没有结束,从上图来看,TPS在300左右,根据我们的整体系统资源来考虑,这个TPS还是偏低的,所以这个接口显然还有优化的空间。所以,在下节课中,我们接着来唠。 + +总结 + +在这节课中,我们做了三个阶段的分析优化。 + +在第一阶段中,我们修改了JDBC池,虽然TPS有上升的趋势,但是,新问题也同样出现了:TPS非常不稳定,还有断断续续的情况。 + +在第二阶段中,我们分析了内存溢出的问题,定位出了原因并优化了内存问题。虽然我们在TPS曲线上明显看到了优化的效果,但仍然没有达到理想的程度。 + +在第三阶段中,我们分析定位了SQL的问题,这是非常合乎逻辑的。因为我们在第二阶段中修改了SQL,所以到了第三阶段,就要直接到数据库中做相应的定位。从结果上看,效果不错,TPS已经有明显正常的趋势了。不过,你要注意的是,当批量业务和实时业务同时出现在同一个数据库中,并且是对同样的表进行操作,这时,你就得考虑一下架构设计是否合理了。 + +总之,在这节课中你可以看到,当SQL查询出来的数据到了应用内存的时候,导致了内存的增加。而应用内存的增加也增加了GC的次数,进而消耗了更多的CPU资源。 + +课后作业 + +最后,请你思考两个问题: + + +为什么JDK中看到VM Thread线程消耗CPU高,会去查看内存消耗是否合理? +在MySQL中分析SQL问题时为什么要先查询innodb_trx表? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果你读完这篇文章有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/20生成订单信息之二:业务逻辑复杂,怎么做性能优化?.md b/专栏/高楼的性能工程实战课/20生成订单信息之二:业务逻辑复杂,怎么做性能优化?.md new file mode 100644 index 0000000..cb9be72 --- /dev/null +++ b/专栏/高楼的性能工程实战课/20生成订单信息之二:业务逻辑复杂,怎么做性能优化?.md @@ -0,0 +1,437 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 20 生成订单信息之二:业务逻辑复杂,怎么做性能优化? + 你好,我是高楼。 + +在上节课中,我们针对生成订单信息这个接口做了三个阶段的分析定位和优化动作,让TPS变得正常了一些。不过,系统资源并没有完全用起来,这个接口显然还有优化的空间。因为高老师说过很多遍,在性能优化的过程中,我们要把资源都用起来。 + +关于“把资源用起来”这一理论,我希望你能明白的是,我们在性能环境中做优化,把资源用起来是为了看系统的最大容量在哪里。这并不意味着,你可以在生产环境中让硬件使用到这种程度。 + +对于一个不可控的系统容量来说,资源使用率高,极有可能导致各种问题出现。所以,安全稳妥起见,很多生产环境的资源利用率都是非常低的,倘若用得超过了20%,运维都得半夜惊出一身冷汗。 + +而我们在性能环境中的测试结果,要想给生产环境配置一个比较明确并且可以借鉴的结论,就必须先去分析生产的业务容量,然后再来确定当生产业务容量达到峰值的时候,相应的硬件资源用到多少比较合理。 + +不过,在我们的优化环境中,我们可以通过把一个系统用起来,来判断软件的容量能力。所以,我们接着上节课的内容,再进入到第四阶段。你将看到在业务逻辑复杂的情况下,我们该怎么做优化。 + +闲言少叙,直接开整。 + +第四阶段 + +在解决了前面三个不正经的问题之后,我们现在可以正常分析时间消耗到哪去了,只要解决了快慢的问题,我们才能进而解决资源没有用起来的问题。所以,我们先来拆分响应时间,同样,我们也不做全局监控分析,因为…哥嫌累。 + +拆分响应时间 + +之前很多次我们都在用APM来拆分响应时间,感觉没什么新意,这次我用日志来拆分一下时间。 + + +Gateway: + + +10.100.79.93 - - [04/Feb/2021:00:13:17 +0800] "POST /mall-order/order/generateOrder HTTP/1.1" 200 726 8201 151 ms +10.100.79.93 - - [04/Feb/2021:00:13:17 +0800] "POST /mall-order/order/generateOrder HTTP/1.1" 200 726 8201 147 ms +10.100.79.93 - - [04/Feb/2021:00:13:17 +0800] "POST /mall-order/order/generateOrder HTTP/1.1" 200 726 8201 141 ms +10.100.79.93 - - [04/Feb/2021:00:13:17 +0800] "POST /mall-order/order/generateOrder HTTP/1.1" 200 726 8201 122 ms +10.100.79.93 - - [04/Feb/2021:00:13:17 +0800] "POST /mall-order/order/generateOrder HTTP/1.1" 200 726 8201 125 ms +10.100.79.93 - - [04/Feb/2021:00:13:17 +0800] "POST /mall-order/order/generateOrder HTTP/1.1" 200 726 8201 150 ms +10.100.79.93 - - [04/Feb/2021:00:13:17 +0800] "POST /mall-order/order/generateOrder HTTP/1.1" 200 726 8201 177 ms + + + + +Order: + + +10.100.79.106 - - [04/Feb/2021:00:13:31 +0800] "POST /order/generateOrder HTTP/1.1" 200 738 "-" "Apache-HttpClient/4.5.12 (Java/1.8.0_261)" 72 ms 72 ms +10.100.79.106 - - [04/Feb/2021:00:13:31 +0800] "POST /order/generateOrder HTTP/1.1" 200 738 "-" "Apache-HttpClient/4.5.12 (Java/1.8.0_261)" 94 ms 93 ms +10.100.79.106 - - [04/Feb/2021:00:13:31 +0800] "POST /order/generateOrder HTTP/1.1" 200 738 "-" "Apache-HttpClient/4.5.12 (Java/1.8.0_261)" 76 ms 76 ms +10.100.79.106 - - [04/Feb/2021:00:13:31 +0800] "POST /order/generateOrder HTTP/1.1" 200 738 "-" "Apache-HttpClient/4.5.12 (Java/1.8.0_261)" 95 ms 95 ms +10.100.79.106 - - [04/Feb/2021:00:13:31 +0800] "POST /order/generateOrder HTTP/1.1" 200 738 "-" "Apache-HttpClient/4.5.12 (Java/1.8.0_261)" 90 ms 90 ms + + +我们先不用看后面的服务,因为从这个接口往后就直接到数据库了,我们先来看一下应用本身有没有问题。 + +为了让你看得清楚一点,这里我只截取了部分数据,但并不是说我们只看这些就够了。在项目中的话,你可以通过写脚本或其他的方式自己做响应时间的统计。 + +从上面的信息可以看到,这个接口的整个响应时间是150ms左右,而在order服务上就消耗了90毫秒。所以,下面我们要分析:为什么在order上会消耗这么久的时间。 + +定向监控分析 + +要想知道Order服务的时间消耗,那显然,我们得知道Order应用中的线程都在做什么动作,所以我们先直接来分析Order的栈。 + +通过Spring Boot Admin,我们可以查看到线程的整体状态: + + + +你看,线程确实比较繁忙。至于这些线程在做什么,我们通过栈的内容可以知道,然后再进一步确定优化的方向。 + +但是,由于系统资源还没有用到上限,我们得先调整一下Tomcat的线程数,把它加大一些,争取让Order应用把硬件资源用起来。 + +原值: +max: 20 +修改为: +max: 100 + + +我们看一下调整后的结果: + + + +没想到,性能更差了……这乱七八糟的曲线和想像中的优美曲线完全不一致呀! + +事实证明,偷懒是绕不过去坑的,我们只有再次查看响应时间消耗到了哪里。 + +于是,通过用各个服务的日志拆分响应时间,我发现在Member服务上有这样的日志(为了让你看清楚点,我截取了一些时间消耗比较大的日志,请注意一下哦,这是我们改了Order的Tomcat线程池之后的结果): + +10.100.69.248 - - [04/Feb/2021:00:37:15 +0800] "GET /sso/feign/info HTTP/1.1" 200 814 "-" "okhttp/3.14.8" 2348 ms 2348 ms +10.100.69.248 - - [04/Feb/2021:00:37:17 +0800] "GET /sso/feign/info HTTP/1.1" 200 816 "-" "okhttp/3.14.8" 4155 ms 4155 ms +10.100.69.248 - - [04/Feb/2021:00:37:17 +0800] "GET /sso/feign/info HTTP/1.1" 200 817 "-" "okhttp/3.14.8" 4968 ms 1813 ms +10.100.69.248 - - [04/Feb/2021:00:37:15 +0800] "GET /sso/feign/info HTTP/1.1" 200 810 "-" "okhttp/3.14.8" 2333 ms 2333 ms +10.100.69.248 - - [04/Feb/2021:00:37:17 +0800] "GET /sso/feign/info HTTP/1.1" 200 815 "-" "okhttp/3.14.8" 5206 ms 4970 ms +10.100.69.248 - - [04/Feb/2021:00:37:20 +0800] "GET /sso/feign/info HTTP/1.1" 200 818 "-" "okhttp/3.14.8" 6362 ms 6362 ms +10.100.69.248 - - [04/Feb/2021:00:37:20 +0800] "GET /sso/feign/info HTTP/1.1" 200 818 "-" "okhttp/3.14.8" 6710 ms 6710 ms +10.100.69.248 - - [04/Feb/2021:00:37:20 +0800] "GET /sso/feign/info HTTP/1.1" 200 817 "-" "okhttp/3.14.8" 6696 ms 6587 ms +10.100.69.248 - - [04/Feb/2021:00:37:21 +0800] "GET /sso/feign/info HTTP/1.1" 200 813 "-" "okhttp/3.14.8" 7987 ms 7976 ms +10.100.69.248 - - [04/Feb/2021:00:37:22 +0800] "GET /sso/feign/info HTTP/1.1" 200 814 "-" "okhttp/3.14.8" 8784 ms 8784 ms +10.100.69.248 - - [04/Feb/2021:00:37:22 +0800] "GET /sso/feign/info HTTP/1.1" 200 817 "-" "okhttp/3.14.8" 9100 ms 8764 ms +10.100.69.248 - - [04/Feb/2021:00:37:22 +0800] "GET /sso/feign/info HTTP/1.1" 200 834 "-" "okhttp/3.14.8" 9126 ms 9013 ms +10.100.69.248 - - [04/Feb/2021:00:37:22 +0800] "GET /sso/feign/info HTTP/1.1" 200 817 "-" "okhttp/3.14.8" 9058 ms 9058 ms +10.100.69.248 - - [04/Feb/2021:00:37:23 +0800] "GET /sso/feign/info HTTP/1.1" 200 820 "-" "okhttp/3.14.8" 9056 ms 9056 ms + + +显然,这个Member服务的响应时间太长了。而在生成订单信息这个接口中,也确实调用了Member服务,因为要使用Token嘛。既然是Order的Tomcat线程池加大了,导致Member服务响应如此之慢,那我们就有理由作出判断:Order之所以消耗时间长,是因为Member服务不能提供Order请求时的快速响应。通俗点讲,就是Member的性能差。 + +要想分析Member性能为什么差,我们其实可以直接到Member上打印栈信息来看看,这是高老师有时候偷懒的做法。 + +而我们一直在讲,完整的分析逻辑应该是先看全局监控数据,再看定向监控数据。所以,高老师在这里,勤快一点。我们通过全局监控数据来看看整体的资源消耗: + + + +worker-8的CPU资源居然用到了这么高!这说明我们在前面增加Order的Tomcat线程数是有价值的。现在,瓶颈点到了另一个地方,也就是我们的Member服务。 + +既然worker-8的资源使用率高,那我们就来看看它上面有什么Pod,不难看出Member就在worker8上: + +[root@k8s-master-2 ~]# kubectl get pods -o wide | grep k8s-worker-8 +elasticsearch-client-0 1/1 Running 0 38h 10.100.231.233 k8s-worker-8 +monitor-mall-monitor-d8bb58fcb-kfbcj 1/1 Running 0 23d 10.100.231.242 k8s-worker-8 +skywalking-oap-855f96b777-5nxll 1/1 Running 6 37h 10.100.231.235 k8s-worker-8 +skywalking-oap-855f96b777-6b7jd 1/1 Running 5 37h 10.100.231.234 k8s-worker-8 +svc-mall-admin-75ff7dcc9b-8gtr5 1/1 Running 0 17d 10.100.231.208 k8s-worker-8 +svc-mall-demo-5584dbdc96-fskg9 1/1 Running 0 17d 10.100.231.207 k8s-worker-8 +svc-mall-member-5fc984b57c-bk2fd 1/1 Running 0 12d 10.100.231.231 k8s-worker-8 +[root@k8s-master-2 ~]# + + +同时,我们还能发现,这个节点上有不少服务,而这些服务都是比较吃CPU的,并且在压力过程中,还出现了sy cpu消耗很高的情况,我截两个瞬间的数据给你看看,一个是sy cpu高的情况,一个是us cpu高的情况,具体如下所示: + +- sys cpu高的情况 +[root@k8s-worker-8 ~]# top +top - 00:38:51 up 28 days, 4:27, 3 users, load average: 78.07, 62.23, 39.14 +Tasks: 275 total, 17 running, 257 sleeping, 1 stopped, 0 zombie +%Cpu0 : 4.2 us, 95.4 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.4 st +%Cpu1 : 1.8 us, 98.2 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st +%Cpu2 : 2.1 us, 97.9 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st +%Cpu3 : 1.0 us, 99.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st +KiB Mem : 16266296 total, 1819300 free, 7642004 used, 6804992 buff/cache +KiB Swap: 0 total, 0 free, 0 used. 8086580 avail Mem + + + PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND +12902 root 20 0 1410452 32280 17744 S 48.1 0.2 751:39.59 calico-node -felix + 9 root 20 0 0 0 0 R 34.8 0.0 131:14.01 [rcu_sched] + 3668 techstar 20 0 4816688 1.3g 23056 S 33.9 8.5 111:17.12 /usr/share/elasticsearch/jdk/bin/java -Xshare:auto -Des.networkaddress.cache.ttl=60 -Des.networkaddress+ +26105 root 20 0 119604 6344 2704 R 25.8 0.0 0:02.36 runc --root /var/run/docker/runtime-runc/moby --log /run/containerd/io.containerd.runtime.v1.linux/moby+ +26163 root 20 0 19368 880 636 R 25.2 0.0 0:00.95 iptables-legacy-save -t nat +26150 root 20 0 18740 3136 1684 R 21.6 0.0 0:01.18 runc init +26086 root 20 0 18744 5756 2376 R 20.3 0.0 0:03.10 runc --root /var/run/docker/runtime-runc/moby --log /run/containerd/io.containerd.runtime.v1.linux/moby+ + 410 root 20 0 0 0 0 S 19.4 0.0 42:42.56 [xfsaild/dm-1] + 14 root 20 0 0 0 0 S 14.8 0.0 54:28.76 [ksoftirqd/1] + 6 root 20 0 0 0 0 S 14.2 0.0 50:58.94 [ksoftirqd/0] +26158 root 20 0 18740 1548 936 R 14.2 0.0 0:00.90 runc --version +31715 nfsnobo+ 20 0 129972 19856 9564 S 11.3 0.1 12:41.98 ./kube-rbac-proxy --logtostderr --secure-listen-address=[172.16.106.56]:9100 --tls-cipher-suites=TLS_EC+ +10296 root 20 0 3402116 113200 39320 S 10.3 0.7 2936:50 /usr/bin/kubelet --bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf --kubeconfig=/etc/kubern+ + 22 root rt 0 0 0 0 S 8.7 0.0 3:18.08 [watchdog/3] +26162 root 20 0 139592 2792 2508 R 8.4 0.0 0:00.39 /opt/cni/bin/calico + 6843 root 20 0 965824 110244 30364 S 7.7 0.7 1544:20 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock + 24 root 20 0 0 0 0 S 7.4 0.0 49:03.89 [ksoftirqd/3] + 3636 techstar 20 0 4368 364 280 S 6.8 0.0 0:12.19 /tini -- /usr/local/bin/docker-entrypoint.sh eswrapper +26159 root 20 0 18740 760 552 R 6.5 0.0 0:00.28 runc --version + 1755 root 20 0 411108 5836 4416 S 4.8 0.0 35:39.97 /usr/libexec/packagekitd + + +- us cpu高的情况 +[root@k8s-worker-8 ~]# top +top - 00:43:01 up 28 days, 4:31, 3 users, load average: 72.51, 68.20, 47.01 +Tasks: 263 total, 2 running, 260 sleeping, 1 stopped, 0 zombie +%Cpu0 : 77.2 us, 15.7 sy, 0.0 ni, 2.2 id, 0.0 wa, 0.0 hi, 4.8 si, 0.0 st +%Cpu1 : 77.0 us, 15.7 sy, 0.0 ni, 2.3 id, 0.0 wa, 0.0 hi, 5.0 si, 0.0 st +%Cpu2 : 70.3 us, 20.9 sy, 0.0 ni, 2.9 id, 0.0 wa, 0.0 hi, 5.9 si, 0.0 st +%Cpu3 : 76.6 us, 12.2 sy, 0.0 ni, 5.1 id, 0.0 wa, 0.0 hi, 6.1 si, 0.0 st +KiB Mem : 16266296 total, 1996620 free, 7426512 used, 6843164 buff/cache +KiB Swap: 0 total, 0 free, 0 used. 8302092 avail Mem + + + PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND +20072 root 20 0 7944892 689352 15924 S 137.1 4.2 3127:04 java -Dapp.id=svc-mall-member -javaagent:/opt/skywalking/agent/skywalking-agent.jar -Dskywalking.agent.+ +29493 root 20 0 3532496 248960 17408 S 98.3 1.5 0:06.70 java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -Dmode=no-init -Xmx2g -Xms2g -cl+ +28697 root 20 0 3711520 1.0g 18760 S 61.6 6.7 124:41.08 java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -Dmode=no-init -Xmx2g -Xms2g -cl+ +25885 root 20 0 3716560 1.2g 18908 S 59.3 7.6 183:12.97 java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -Dmode=no-init -Xmx2g -Xms2g -cl+ + 6843 root 20 0 965824 109568 30364 S 7.6 0.7 1544:49 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock + 3668 techstar 20 0 4816688 1.3g 23056 S 6.6 8.5 111:58.56 /usr/share/elasticsearch/jdk/bin/java -Xshare:auto -Des.networkaddress.cache.ttl=60 -Des.networkaddress+ +10296 root 20 0 3402372 111692 39320 S 6.6 0.7 2937:43 /usr/bin/kubelet --bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf --kubeconfig=/etc/kubern+ + 18 root rt 0 0 0 0 S 5.0 0.0 5:57.54 [migration/2] + 6 root 20 0 0 0 0 S 2.6 0.0 51:21.52 [ksoftirqd/0] + 410 root 20 0 0 0 0 D 2.6 0.0 43:08.23 [xfsaild/dm-1] +28310 root 20 0 7807048 565740 15924 S 2.6 3.5 1036:53 java -Dapp.id=svc-mall-admin -javaagent:/opt/skywalking/agent/skywalking-agent.jar -Dskywalking.agent.s+ +29741 root 20 0 7749608 540376 15848 S 2.3 3.3 304:41.47 java -Dapp.id=svc-mall-monitor -javaagent:/opt/skywalking/agent/skywalking-agent.jar -Dskywalking.agent+ +12902 root 20 0 1410452 30368 17744 S 2.0 0.2 752:30.32 calico-node -felix +16712 root 0 -20 0 0 0 S 2.0 0.0 1:56.16 [kworker/2:0H] + 6381 root 20 0 7782400 491476 15928 S 1.7 3.0 441:08.96 java -Dapp.id=svc-mall-demo - + + +从sy cpu高的top数据来看,这个节点显然在不断地调度系统资源,通过top中的rcu_sched/softirq等进程就可以知道,这种情况显然是因为Kubernetes在这个节点上过多地安排了任务。所以,我先把Member服务移到另一个worker上,然后看到TPS如下: + + + +你看,TPS增加到400多了,也就是说我们的方向是对的。 + +那为什么我们之前修改Order服务的Tomcat线程数没有看到效果呢?这是因为压力已经到了Member服务上,这让Member服务所在的worker节点资源使用率增加,导致Member服务无法正常响应请求。因此,整个TPS看起来没有什么优化效果。现在,我们移走了Member服务,看到效果明显增加,这说明我们的方向还在正确的道路上。 + +我们再回来看一下整体的资源监控: + + + +现在,没有一个worker的资源用满或者接近用满,完全不符合我们“把资源都用起来”的目标,这显然是不可接受的。 + +在长时间的压力中,我们发现资源怎么也用不上去。而且在上节课第三阶段的最后一个图中,你也能清楚地看到这一点。 + +到这里为止,我们查看了一次次的性能分析决策树,也调整了一些参数,比如Spring Boot中的Tomcat连接池、JDBC池、Jedis池、MQ池等,调整之后TPS似乎有增加的趋势,但是非常不明显。所以,我们只能开始新一轮的定位。 + +第五阶段 + +定位时间消耗 + +在上一个阶段的分析中,我们用日志拆分了响应时间,是想让你看到我们用不同的手段都可以把响应时间拆出来。这也是我一直强调的:你不要在意用什么手段,而要在意你想要的是什么。 + +在这一阶段中,我们再换一个思路:跟踪方法的执行过程来判断时间消耗。我想让你看到:在优化过程中,唯有思路不变,手段任你选择。 + +这个方法和我们用日志拆分时间的逻辑其实是一样的。我们可以直接用Arthas来定位方法的时间消耗。请你记住,除了Arthas之外,还有很多其他工具也是可以的,比如JvisualVM/JMC/BTrace等。 + +我们已经知道接口方法是com.dunshan.mall.order.service.impl.PortalOrderServiceImpl中的generateOrder,所以,我们直接trace(跟踪)它就可以了。 + +你要注意,在这一步中,我们需要反复trace多次,这是为了保证判断方向的正确性。不得不承认,这是一种耗时又枯燥的工作,有一种数羊睡觉的感觉。不过,有的人能数睡着,有的人却是越数越兴奋。 + +现在,我们来看一下反复trace后的结果。由于跟踪的栈实在太长了,我把多次跟踪的内容做了简化,其中重要的部分如下所示: + ++---[91.314104ms] com.dunshan.mall.order.feign.MemberService:getCurrentMember() #150 +.................... ++---[189.777528ms] com.dunshan.mall.order.feign.CartItemService:listPromotionnew() #154 +.................... ++---[47.300765ms] com.dunshan.mall.order.service.impl.PortalOrderServiceImpl:sendDelayMessageCancelOrder() #316 + + +为什么说这几个方法重要呢?这里我要说明一下,对于跟踪的内容,我们主要判断的是:消耗时间的方法是不是固定的。如果时间不是消耗在了固定的方法上,那就有些麻烦了,因为这说明不是方法本身的问题,而是其他的资源影响了方法的执行时间;如果时间一直消耗在了固定的方法上,就比较容易了,我们只要接着去跟踪这个方法就好了。 + +而我反复跟踪了多次之后,总是发现上面几个方法都比较消耗时间。既然已经知道了方法的时间消耗,那全局监控已经救不了我们了,只有在定向监控中来分析了。 + +定向监控分析 + +我先说明一下,根据我们的分析思路,我在定向监控分析之前,反复分析了全局监控计数器,没觉得有什么资源使用上的问题。并且从压力工具到数据库,我也没发现有什么阻塞点,整条大路都很宽敞。 + +但是,上面我们也确实看到了响应时间消耗在了几个具体的方法上,并且这几个方法并不是一直都消耗这么长的时间,而是有快有慢。 + +经过反复确认后,我觉得有必要来看一下业务逻辑了。因为对于一个复杂的业务来说,如果业务代码逻辑太长,那我们不管怎么优化,都不会有什么效果,最后只能按照扩容的思路来加机器了。 + +不过,在我的逻辑中,即便是加机器,我们也要给出加机器的逻辑。如果业务可优化,那我们更要尽力一试。因为从成本上来说,优化代码是一个更优的选择。 + +在这里,我多说几句闲话。我看到过很多企业连一些简单的优化都没有做,就从寻找心理安全感的角度去增加机器,结果耗费了大量的成本,这是非常不理智的。从技术的角度来说,花不多的时间成本就可以节省大量的资源成本,这显然是很划算的。可是,受一些社会不良思维的误导,有些企业就觉得只要能通过加机器解决的问题,都不是啥大问题。 + +对于这种思路,我们就得放到成本上来算一算了。大部分打工人可能会觉得,反正用的又不是自己的钱,管老板花多少钱加机器干嘛?没意义。但是,从节能减排的全球大局观来看,一个该做的优化没有做,不仅浪费公司的成本,还一点儿都不环保!呃…好像扯的有点远了。 + +我们回到正题,既然我们想优化业务,就得先知道业务的调用逻辑是个啥样子。所以我们打开idea,找到generateOrder方法,然后把sequence diagram(idea的一个插件)打开,就看到了这样一张很长的业务逻辑图: + + + +如果你看不懂这张图,也没有关系。我在这里给你大致描述一下这张图里有什么东西: + + +获取用户名; +获取购物车列表; +获取促销活动信息; +判断库存; +判断优惠券; +判断积分; +计算金额; +转订单并插库; +获取地址信息; +计算赠送积分和成长值 +插入订单表; +更新优惠券状态; +扣积分; +删除购物车商品; +发送取消订单消息; +返回结果; + + +是不是有种很复杂的感觉?通过我大概列出来的这些内容,你就能知道下订单这个动作有多长了。对这样的复杂接口,如果业务逻辑要求必须是这样的,那我们在接口上就没有什么优化空间了。在前面,我们已经把TPS优化到了400多,在这样的硬件机器上,也基本上就这样了。 + +在这节课中,我们不是要去设计一个下订单的业务逻辑,因为每个企业的下订单逻辑,都会有不同的业务限制。做为性能工程师,我们没有对业务逻辑的设计说改就改的权利,因为修改业务逻辑需要所有的相关人员一起商讨确定。不过,我们可以通过分析的结果给出优化的建议。 + +在这里,我把优惠券、积分、发送延时取消订单信息的步骤都从下订单的步骤中删掉。有人可能会问这样改合适吗?我强调一下,不是我要这样改业务逻辑,而是想看看这样改了之后,TPS有没有增加。如果增加了,就说明我们的方向是对的,也就是说,这个业务逻辑需要再和各方商量一下,重新设计。 + +我们来看修改之后的TPS图: + + + +可以看到,这样的修改确实有效果,那我们后续的优化建议就比较清晰了。如果你在项目中遇到这样的接口,优化建议就可以这样来提: + + +分库分表; +利用缓存; +异步处理非关键步骤; +大接口拆成小接口。 + + +但是,建议终归是建议,通常在一个企业中,对于这样的接口,技术团队会根据具体的业务逻辑做长时间的技术分析,来判断如何实现。如果确实没办法在技术上做优化,那就只能上最后一招:扩容!这个扩容就不再是扩某一段了,而是一整条链路上涉及到的服务。 + +还有一点,在一个业务链路中,每个企业通常都是根据发展的速度做相应的技术沉淀。如果技术团队太追潮流了,学习成本大,不见得是好事;如果太陈旧了,维护的成本大,也不见得是好事。因此,我们只有根据实际的业务发展不断地演进业务流程和技术实现,才是正道。 + +我们优化到这里,看似就可以结束收工了,但是并没有,因为天不随人愿的定律从来都没有被打破过,我们只得来到第六个阶段。 + +第六阶段 + +定位TPS会降下来的问题 + +具体是什么原因呢?我在接着压的时候,又出现了这样的问题: + + + +你看,TPS掉呀掉的,心都碎了……虽说在每个项目的优化过程中,都会出现各种意外的事情,但是,这个接口的意外也太多了点。没办法,我们接着查吧。 + +仍然是按照高老师强调的性能分析决策树(如果你不清楚,可以再看看第4讲),我们一个个计数器看过去,最后在mysqlreport中看到了下面这些数据: + +__ InnoDB Lock _________________________________________________________ +Waits 889 0.1/s +Current 77 +Time acquiring + Total 36683515 ms + Average 41263 ms + Max 51977 ms + + +显然当前的锁有点多,并且这锁的时间还挺长。要想查锁,就得先知道当前正在运行的是什么样的事务,所以我们就去查一下innodb_trx表,因为MySQL在这个表中会记录所有正在执行的事务。在数据库中,我们发现了大量的lock_wait(锁等待): + + + +既然有锁等待,那我们自然要查一下锁关系,看看是什么在等待锁。在看具体的锁关系之前,我们也来查看一下应用日志。 + +为什么要看应用日志呢?因为对于数据库来说,锁是为了保护数据的一致性,而产生锁的事务自然是从应用中来的。按照这样的逻辑,我们在MySQL中和在应用中看到的事务SQL,应该是对应的。而现在我们只看到了MySQL中的锁,还不知道在应用中会是什么样子,所以,我们要看一下应用的日志。 + +这里温馨提醒一句:**在这个时候,*我们*还*需要注意,不要用*重压力工具中的某些具有唯一性的参数化数据**。因为当参数化数据用重了,在数据库中执行update语句也照样会出现锁。 + +在查看了应用日志之后,我们看到如下信息: + +[2021-02-06 00:46:59.059] [org.apache.juli.logging.DirectJDKLog] [http-nio-8086-exec-72] [175] [ERROR] Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.dao.CannotAcquireLockException: +### Error updating database. Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction +### The error may involve com.dunshan.mall.mapper.OmsOrderMapper.insert-Inline +### The error occurred while setting parameters +### SQL: insert into oms_order (member_id, coupon_id, order_sn, create_time, member_username, total_amount, pay_amount, freight_amount, promotion_amount, integration_amount, coupon_amount, discount_amount, pay_type, source_type, status, order_type, delivery_company, delivery_sn, auto_confirm_day, integration, growth, promotion_info, bill_type, bill_header, bill_content, bill_receiver_phone, bill_receiver_email, receiver_name, receiver_phone, receiver_post_code, receiver_province, receiver_city, receiver_region, receiver_detail_address, note, confirm_status, delete_status, use_integration, payment_time, delivery_time, receive_time, comment_time, modify_time) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +### Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction +; Lock wait timeout exceeded; try restarting transaction; nested exception is com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction] with root cause +com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction + + +你看,连一个insert都会报lock_wait,这显然是出现表级锁了。因为insert本身是不会出现表级锁的,所以应该还有其他的信息。我们接着看日志,果然,又看到如下信息: + +[2021-02-06 01:00:51.051] [org.springframework.scheduling.support.TaskUtils$LoggingErrorHandler] [scheduling-1] [95] [ERROR] Unexpected error occurred in scheduled task +org.springframework.dao.CannotAcquireLockException: +### Error updating database. Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction +### The error may involve defaultParameterMap +### The error occurred while setting parameters +### SQL: update oms_order set status=? where id in ( ? ) +### Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction +; Lock wait timeout exceeded; try restarting transaction; nested exception is com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction + + +你看,其中有update语句,这样一来,逻辑就成立了:我们知道,update是会锁数据的,但是,MySQL用的是InnoDB的引擎。如果update的条件是精确查找,那就应该不会出现表级锁。 + +可是,如果update的范围比较大,就会有问题了,因为这会导致insert语句被阻塞。过一会儿之后,你就会看到如下内容: + + + +我们看到,所有的insert都在LOCK WAIT状态了,这就是表级锁对insert产生的影响。如果你再查一下锁和锁等待的话,就会看到如下信息: + + + +不难看出,lock_mode这一列的值全是X,意思是X锁。我们知道,排他锁(X锁),又叫写锁。图中的锁类型(lock_type)全是RECORD,锁住的是索引,并且索引是GEN_CLUST_INDEX,说明这个锁等待是因为innodb创建的隐藏的聚集索引。 + +当一个SQL没有走任何索引时,就会在每一条聚集索引后面加X锁,这和表级锁的现象是一样的,只是在原理上有所不同而已。为了方便描述,我们仍然用“表级锁”来描述。 + +要查锁,就得看看是谁持有锁。经过仔细查看上面的INNODB_LOCK_WAIT后,我们确定了这个锁对应的事务ID是157710723,它对应的SQL是: + +update oms_order set status=4 where id in ( 0 ); + + +我们去代码中查一下这段update代码: + +/** + * 批量修改订单状态 + */ +int updateOrderStatus(@Param("ids") List ids,@Param("status") Integer status); + + +原来这是一个批量任务的调用,具体逻辑如下: + + + +这个批量任务的问题在于,在一个订单表中做批量更新的操作,并且这个批量查询的内容还挺多。因为上面的ID是0,表示订单是未支付的,而未支付的订单在这个表中有很多,所以,在更新时做大范围的查找,会进而导致表级锁的出现。 + +这个批量任务的设计明显有问题。你想想,要在订单表中做更新这么大的动作,那也应该是做精准更新,而不是范围更新。其实对于订单的更新逻辑,我们可以选择其他的实现方式。 + +锁的原因找到了,我们现在要把范围更新改为非常精准的更新,让它不产生表级锁。修改之后,重新执行场景的结果如下: + + + +从优化效果来看,TPS已经达到700以上了。对这样一个复杂的接口来说,这已经非常不错了。 + +其实,这个订单业务还有很多的优化空间,比如说: + + +异步生成订单序列号,然后存放到Redis里,随用随取。 +批量业务需要重新设计。 +读写分离之后,对业务代码也做相应更新。 +…… + + +由于订单逻辑是电商中的非常复杂的一步,我就不再展开说了,因为再说就超出了性能的范畴。 + +总结 + +在这个接口中,我们遇到了好几个问题。先抛开问题和复杂度不说,我想表达的是,在性能优化过程中,问题是像洋葱一样一个个剥开的。虽然有可能一个优化动作就可以产生很好的效果,但是我们一定不要着急,要慢慢分析一个个问题。 + +回顾一下我们对这个接口的所有分析优化过程。在第一阶段中,我们修改线程池产生了效果,但也出现了新问题;在第二阶段中,我们解决了查询大量数据导致内存被耗光的问题;在第三阶段,我们解决了索引的问题;在第四阶段中,我们重新调配了资源,让系统的调度更加合理。 + +在第五阶段中,我们定位了方法的时间消耗问题,这一步你要注意,一定要在分析了业务逻辑之后再做相应的优化,不要因一味追求性能的优化效果而纠结。 + +在第六阶段中,我们定位了批量任务设计不合理的问题。在正经的批量任务中,批量产生的表级锁和insert的功能点,一定要分开。 + +总之,在分析的过程中,我们不要界定问题的边界,遇到什么问题就解决什么问题,不急不燥,不卑不亢。 + +课后作业 + +最后,请你思考两个问题: + + +如何快速定位内存被消耗光的情况? +如何快速定位业务逻辑导致的TPS上不去、资源也用不上的情况? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果你读完这篇文章有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/21支付前查询订单列表:如何分析优化一个固定的技术组件?.md b/专栏/高楼的性能工程实战课/21支付前查询订单列表:如何分析优化一个固定的技术组件?.md new file mode 100644 index 0000000..f586936 --- /dev/null +++ b/专栏/高楼的性能工程实战课/21支付前查询订单列表:如何分析优化一个固定的技术组件?.md @@ -0,0 +1,302 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 21 支付前查询订单列表:如何分析优化一个固定的技术组件? + 今天我们来分析支付前查询订单列表接口。 + +在这节课中,我将带你来看一下对于一个固定的技术组件,分析优化思路应该是怎样的,也就是说组件不是我们开发的,但是又要分析优化它,我们该怎么办? + +此外,我们还会遇到一个问题,就是当数据库的CPU并没有全部用完,而是只用了几颗的时候,我们应该如何具体定向?对此,我们将用到查看数据库本身线程栈的方法,这和前面直接看trx表有所不同。 + +下面,我们一起进入今天的内容。 + +场景运行数据 + +对于支付前查询订单列表接口,我们先来看第一次运行的性能场景结果: + + + +从运行的场景数据来看,这个接口的TPS一开始还是挺高的,达到了800多。但是,响应时间也增加了,瓶颈已经出现。我们只要知道瓶颈在哪,就能知道这个接口有没有优化空间。 + +根据高老师的分析逻辑,在正式分析之前,我们看一下架构图。 + +架构图 + + + +这张架构图是非常清楚的,可以看到,当前接口的逻辑为:Gateway - Order - Member,其中也使用到了MySQL和Redis。 + +下面我们来看看,响应时间消耗到哪里去了。 + +拆分响应时间 + + +Gateway:- + + +Order:- + + +Member:- + + + +从响应时间的分布来看,Gateway(网关)上消耗的时间要长一些。所以,我们接下来得从Gateway下手,分析一下到底是哪里消耗了时间。 + +第一阶段 + +全局监控分析 + +按照“先看全局监控,后看定向监控”的逻辑,我们先看这个接口的全局监控: + + + +由于Gateway消耗的响应时间长,我们看过全局监控视图之后,要判断出Gateway在哪个worker上: + +[root@k8s-master-2 ~]# kubectl get pods -o wide | grep gateway +gateway-mall-gateway-757659dbc9-tdwnm 1/1 Running 0 3d16h 10.100.79.96 k8s-worker-4 +[root@k8s-master-2 ~]# + + +这个Gateway服务在worker-4上,同时,在全局监控图上可以看到,虽然Gateway只消耗了70%的CPU,但它还是消耗了最多的响应时间。既然这样,我们就要关注一下Gateway的线程状态,看看它在处理什么。 + +定向监控分析 + +在做定向监控时,我们先来看一下线程的CPU消耗: + + + +通过上图可以看到,在Gateway中有两类重要的工作线程,分别是reactor-http-epoll和boundedElastic。 + +在官方的说明中提到,reactor-http-epoll线程的设置最好与CPU个数一致。我们当前的reactor-http-epoll线程是4个,而这个worker有6C,所以还能增加两个,增加多了意义也不大。至于boundedElastic,它是有边界的弹性线程池,默认为CPU核x10,也没啥可优化的。 + +我们再持续看一会儿Gateway服务中的线程所消耗的时间比例,看一下方法级的时间消耗有没有异常的情况,也就是比例非常高的,如下图所示: + + + +你看,当前的执行方法也都没啥异常的。 + +现在我们就把线程增加到6个,看能不能把CPU用高一点。如果CPU用多了之后,仍然是Gateway消耗的时间长,那我们就只有再继续加CPU了。 + +请你注意,在性能项目中,不要轻易给出加CPU这样的建议。一定要在你分析了逻辑之后,确定没有其他优化空间了,再给这样的建议。 + +优化效果 + +我们来看一下优化效果: + + + +通过回归测试,我们看到TPS有一点增加,只是在图的后半段(由于在测试过程中,Gateway重启过,前面的TPS就当是预热了)增加的并不明显,大概有50多TPS的样子。不过,也算是有了效果。 + +我们优化到这里并没有结束,因为在查看各个Worker的过程中,我还发现一个奇怪的现象,那就是数据库里有两个CPU的使用率非常高。下面我们来扒一扒。 + +第二阶段 + +全局监控分析 + +因为前面优化的效果并不怎么样,所以我们要重新开始分析。让我们从全局监控开始: + + + +看起来倒是没啥,数据库所在的worker-1也不见有什么大的资源消耗。 + +请你注意,我在文章中经常用这个界面来看全局监控的数据。但这并不是说,我只看这个界面。当我在这个界面中看不到明显的问题点时,我也会去看一些命令,像top/vmstat等,这和我一直说的全局监控的完整计数器有关。因此,你的脑袋里要有全局监控计数器的视图,然后才能真正看全第一层的计数器。 + +我们再来看数据库所在的worker上的top数据,发现了这样的现象: + +bash-4.2$ top +top - 09:57:43 up 3 days, 17:54, 0 users, load average: 4.40, 3.57, 3.11 +Tasks: 11 total, 1 running, 9 sleeping, 1 stopped, 0 zombie +%Cpu0 : 8.0 us, 4.7 sy, 0.0 ni, 84.3 id, 0.0 wa, 0.0 hi, 2.2 si, 0.7 st +%Cpu1 :100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st +%Cpu2 : 6.5 us, 4.4 sy, 0.0 ni, 85.5 id, 0.0 wa, 0.0 hi, 2.2 si, 1.5 st +%Cpu3 : 7.8 us, 5.7 sy, 0.0 ni, 83.7 id, 0.0 wa, 0.0 hi, 2.1 si, 0.7 st +%Cpu4 : 96.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 4.0 si, 0.0 st +%Cpu5 : 7.0 us, 4.0 sy, 0.0 ni, 84.9 id, 0.0 wa, 0.0 hi, 2.6 si, 1.5 st +KiB Mem : 16265992 total, 1203032 free, 6695156 used, 8367804 buff/cache +KiB Swap: 0 total, 0 free, 0 used. 9050344 avail Mem + + + PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND + 1 mysql 20 0 8272536 4.7g 13196 S 248.8 30.5 6184:36 mysqld + + +可以非常明显地看到,有两个CPU的使用率高,那我们就来定向分析下数据库。 + +在此之前,我们不妨心中默念10遍“只要思路不乱,任何问题都是一盘菜”,因为保持思路清晰非常重要。 + +定向监控分析 + +我们要定向分析数据库,可是在数据库上又不是所有的CPU使用率都高,所以,我们要来看一下数据库线程到底在做什么动作。有了上面的进程信息之后,我们再深入到线程级: + +bash-4.2$ top -Hp 1 +top - 09:56:40 up 3 days, 17:53, 0 users, load average: 3.05, 3.30, 3.01 +Threads: 92 total, 2 running, 90 sleeping, 0 stopped, 0 zombie +%Cpu0 : 5.4 us, 2.9 sy, 0.0 ni, 89.2 id, 0.0 wa, 0.0 hi, 2.2 si, 0.4 st +%Cpu1 : 99.7 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.3 st +%Cpu2 : 5.4 us, 3.2 sy, 0.0 ni, 88.2 id, 0.0 wa, 0.0 hi, 2.5 si, 0.7 st +%Cpu3 : 6.3 us, 4.2 sy, 0.0 ni, 87.0 id, 0.0 wa, 0.0 hi, 2.1 si, 0.4 st +%Cpu4 : 96.3 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 3.7 si, 0.0 st +%Cpu5 : 4.0 us, 2.5 sy, 0.0 ni, 91.0 id, 0.0 wa, 0.0 hi, 1.8 si, 0.7 st +KiB Mem : 16265992 total, 1205356 free, 6692736 used, 8367900 buff/cache +KiB Swap: 0 total, 0 free, 0 used. 9052664 avail Mem + + + PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND + 311 mysql 20 0 8272536 4.7g 13196 R 99.9 30.5 18:20.34 mysqld + 241 mysql 20 0 8272536 4.7g 13196 R 99.7 30.5 1906:40 mysqld + 291 mysql 20 0 8272536 4.7g 13196 S 3.3 30.5 15:49.21 mysqld + 319 mysql 20 0 8272536 4.7g 13196 S 3.0 30.5 11:50.34 mysqld + 355 mysql 20 0 8272536 4.7g 13196 S 3.0 30.5 13:01.53 mysqld + 265 mysql 20 0 8272536 4.7g 13196 S 2.7 30.5 18:17.48 mysqld + 307 mysql 20 0 8272536 4.7g 13196 S 2.7 30.5 16:47.77 mysqld + 328 mysql 20 0 8272536 4.7g 13196 S 2.7 30.5 15:34.92 mysqld + 335 mysql 20 0 8272536 4.7g 13196 S 2.7 30.5 8:55.38 mysqld + 316 mysql 20 0 8272536 4.7g 13196 S 2.3 30.5 14:38.68 mysqld + 350 mysql 20 0 8272536 4.7g 13196 S 2.3 30.5 10:37.94 mysqld + 233 mysql 20 0 8272536 4.7g 13196 S 2.0 30.5 14:19.32 mysqld + 279 mysql 20 0 8272536 4.7g 13196 S 2.0 30.5 19:51.80 mysqld + 318 mysql 20 0 8272536 4.7g 13196 S 2.0 30.5 11:34.62 mysqld + 331 mysql 20 0 8272536 4.7g 13196 S 2.0 30.5 11:46.94 mysqld + 375 mysql 20 0 8272536 4.7g 13196 S 2.0 30.5 1:29.22 mysqld + 300 mysql 20 0 8272536 4.7g 13196 S 1.7 30.5 17:45.26 mysqld + 380 mysql 20 0 8272536 4.7g 13196 S 1.7 30.5 1:24.32 mysqld + + +你看,只有两个MySQL的线程在使用CPU。到了这一步,你可能会想,接下来去查SQL!虽然可能就是SQL的问题,但我还是建议你找到相应的证据。 + +由于MySQL是用C语言写的,那我们就用gstack(这是一个装了GDB之后就会有的命令)打印一下这两个MySQL的栈看看具体的函数。我们把那两个PID(311、241)的栈拿出来之后,看到如下信息: + +Thread 59 (Thread 0x7f1d60174700 (LWP 241)): +#0 0x000055a431fefea9 in JOIN_CACHE::read_record_field(st_cache_field*, bool) () +#1 0x000055a431ff01ca in JOIN_CACHE::read_some_record_fields() () +#2 0x000055a431ff070f in JOIN_CACHE::get_record() () +#3 0x000055a431ff2a92 in JOIN_CACHE_BNL::join_matching_records(bool) () +#4 0x000055a431ff18f0 in JOIN_CACHE::join_records(bool) () +#5 0x000055a431e397c0 in evaluate_join_record(JOIN*, QEP_TAB*) () +#6 0x000055a431e3f1a5 in sub_select(JOIN*, QEP_TAB*, bool) () +#7 0x000055a431e37a90 in JOIN::exec() () +#8 0x000055a431eaa0ba in handle_query(THD*, LEX*, Query_result*, unsigned long long, unsigned long long) () +#9 0x000055a43194760d in execute_sqlcom_select(THD*, TABLE_LIST*) () +#10 0x000055a431e6accf in mysql_execute_command(THD*, bool) () +#11 0x000055a431e6d455 in mysql_parse(THD*, Parser_state*) () +#12 0x000055a431e6e3b6 in dispatch_command(THD*, COM_DATA const*, enum_server_command) () +#13 0x000055a431e6fc00 in do_command(THD*) () +#14 0x000055a431f33938 in handle_connection () +#15 0x000055a4320e66d4 in pfs_spawn_thread () +#16 0x00007f1e8f1fcdd5 in start_thread () from /lib64/libpthread.so.0 +#17 0x00007f1e8d3cc02d in clone () from /lib64/libc.so.6 +Thread 41 (Thread 0x7f1d585e0700 (LWP 311)): +#0 0x000055a4319dbe44 in Item_field::val_int() () +#1 0x000055a4319fb839 in Arg_comparator::compare_int_signed() () +#2 0x000055a4319fbd9b in Item_func_eq::val_int() () +#3 0x000055a431ff24ab in JOIN_CACHE::check_match(unsigned char*) () +#4 0x000055a431ff26ec in JOIN_CACHE::generate_full_extensions(unsigned char*) () +#5 0x000055a431ff2ab4 in JOIN_CACHE_BNL::join_matching_records(bool) () +#6 0x000055a431ff18f0 in JOIN_CACHE::join_records(bool) () +#7 0x000055a431e397c0 in evaluate_join_record(JOIN*, QEP_TAB*) () +#8 0x000055a431e3f1a5 in sub_select(JOIN*, QEP_TAB*, bool) () +#9 0x000055a431e37a90 in JOIN::exec() () +#10 0x000055a431eaa0ba in handle_query(THD*, LEX*, Query_result*, unsigned long long, unsigned long long) () +#11 0x000055a43194760d in execute_sqlcom_select(THD*, TABLE_LIST*) () +#12 0x000055a431e6accf in mysql_execute_command(THD*, bool) () +#13 0x000055a431e6d455 in mysql_parse(THD*, Parser_state*) () +#14 0x000055a431e6e3b6 in dispatch_command(THD*, COM_DATA const*, enum_server_command) () +#15 0x000055a431e6fc00 in do_command(THD*) () +#16 0x000055a431f33938 in handle_connection () +#17 0x000055a4320e66d4 in pfs_spawn_thread () +#18 0x00007f1e8f1fcdd5 in start_thread () from /lib64/libpthread.so.0 +#19 0x00007f1e8d3cc02d in clone () from /lib64/libc.so.6 + + +很明显,是两个execute_sqlcom_select函数,也就是两个select语句。我们接着往上看栈,还可以看到是JOIN函数。既然是select语句中的JOIN,那我们直接去找SQL语句就好了。 + +因此,我们直接去查innodb_trx表,看看正在执行SQL有没有消耗时间长的。你也许会执行show processlist之类的命令,但是为了看全SQL,我还是建议你直接查trx表。由于我们使用的thread_handling是默认的one-thread-per-connection,操作系统的线程和mysql里的线程都是一一对应的。所以,我们在这里直接查trx表不会有什么误判。 + +通过查找innodb_trx表,我们看到了这样两个SQL消耗时间较长,列在这里: + +-- sql1 +SELECT + count(*) +FROM + oms_order o +LEFT JOIN oms_order_item ot ON o.id = ot.order_id +WHERE + o. STATUS = 0 +AND o.create_time < date_add(NOW(), INTERVAL - 120 MINUTE) +LIMIT 0, + 1000 + + +-- sql2: +SELECT + o.id, + o.order_sn, + o.coupon_id, + o.integration, + o.member_id, + o.use_integration, + ot.id ot_id, + ot.product_name ot_product_name, + ot.product_sku_id ot_product_sku_id, + ot.product_sku_code ot_product_sku_code, + ot.product_quantity ot_product_quantity +FROM + oms_order o +LEFT JOIN oms_order_item ot ON o.id = ot.order_id +WHERE + o. STATUS = 0 +AND o.create_time < date_add(NOW(), INTERVAL - 120 MINUTE) + + +我们提到多次,要想看SQL慢,就得看SQL对应的执行计划(在MySQL中,如果执行计划看得不清楚,还可以看Profile信息)。这两个SQL对应的执行计划如下: + + + + + +依然是常见的全表扫描。看到这里,你是不是有一种索然无味的感觉?但是,我们还是需要知道这两个语句为什么会产生。 + +其实,支付前查询订单列表这个接口并没有用到这两个SQL。于是,我到代码中看了一下这两个SQL的生成过程,反向查找到如下代码: + + @Scheduled(cron = "0 0/20 * ? * ?") + private void cancelTimeOutOrder(){ + Integer count = portalOrderService.cancelTimeOutOrder(); + LOGGER.info("取消订单释放锁定库存:{}",count); + } + + +很显然,这是一个定时计划,每20分钟执行一次。到这里,问题就很清楚了,原来是定时任务调用了这两个批量的查询语句,导致了两个CPU使用率达到100%,并且也持续了一段时间。 + +像这样的定时任务,我们要格外关注一下,注意把它和实时业务分开部署和处理,减少批量业务对实时业务的资源争用。如果放在一起处理,那就要控制好要批量查询的数据量级,让SQL的查询变得合理。 + +由于数据库可用的CPU比较多,这个定时任务对我们的TPS并没有产生什么明显的影响,在这里我们不用做什么处理,以后注意分开就好了。 + +总结 + +在这节课中,虽然我们的优化并没有让TPS明显增加,但是因为分析的技术细节不一样,我也非常完整地记录了整个分析过程。 + +在第一阶段的分析中,我们运用的还是之前提到的分析思路。不同点在于,对于一个非常成熟的固定组件,我们要想优化它,就要去了解它的架构,找到它的相关性能参数。因为在实际的性能项目中,面对这样的组件,我们往往没有时间去纠结内部的实现,需要非常快速地作出判断。如果时间允许,你倒是可以慢慢折腾。 + +其实理解一个技术组件的原理,并没有想像中的那么高不可攀、深不可测,只要耐心看下去,你总会成长。 + +在第二阶段的分析中,我们由某几个CPU高的现象分析到了具体的SQL问题。这个过程虽然简单,但是从这个问题上,我们可以看出这个系统还有很多的优化空间,比如说主从分离、定时任务拆为单独的服务等等。不过,在我们的性能分析中,重点仍然是我跟你一直灌输的分析思路,希望你记在心里了。 + +课后作业 + +最后,我给你留两道题,请你思考一下: + + +为什么要看全部的全局监控计数器? +单CPU高时,如何定位具体的问题点?你有什么思路? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果你读完这篇文章有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/22支付订单信息:如何高效解决for循环产生的内存溢出?.md b/专栏/高楼的性能工程实战课/22支付订单信息:如何高效解决for循环产生的内存溢出?.md new file mode 100644 index 0000000..40a81b2 --- /dev/null +++ b/专栏/高楼的性能工程实战课/22支付订单信息:如何高效解决for循环产生的内存溢出?.md @@ -0,0 +1,396 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 22 支付订单信息:如何高效解决for循环产生的内存溢出? + 你好,我是高楼。 + +今天,我们来优化支付订单接口。通过这个接口,我们来看看怎么高效解决for循环产生的内存溢出问题。 + +对于JVM内存溢出或泄露来说,通常性能人员都能定位到一个应用hang住了。但是,要想进一步判断出应用hang住的原因,并没有那么容易做到。因为内存大时做堆Dump比较费时,更重要的一点是,要想把堆里面的对象和栈关联起来是需要足够的经验和时间的。这也是其中的难点之一。 + +这节课我就带你来看看怎么解决这个难点。 + +不过,在此之前,我们会先处理一个熟悉的问题,就是数据库表加索引。很显然,我们在测试这个接口时又遇到它了。虽然我在第16讲中给你重点讲过这个问题,但是这一次的每秒全表扫描比之前要高得多。通过这次的讲解,我希望你能明白只要存在全表扫描,CPU消耗很快就会达到100%。同时,也希望你能借此看清楚全表扫描对CPU消耗的影响。 + +场景运行数据 + +首先,我们来运行一下场景: + + + +这是一个典型的TPS太低、响应时间不断上升的性能瓶颈,对于这种瓶颈的分析逻辑,我在前面的课程里已经写过很多次了,相信你已经掌握。下面我们来看一下具体的问题是什么。 + +架构图 + + + +这个接口的链路比较简单:User - Gateway - Order - MySQL,我们大概记在脑子里就好。 + +第一阶段 + +在这里我就不拆分时间了,我们直接来看全局监控。因为第一阶段的问题相对来说比较简单,只是性能瓶颈的表现形式和之前不太一样。 + +全局监控分析 + +全局监控的数据如下: + + + +看到这张图,你是不是有一种终于见到典型性能瓶颈的感觉?CPU使用率这么高!那还不简单?打栈看代码呀! + +不过,我们得先查一下是什么东西导致k8s-worker-1的CPU使用率这么高的。这个worker上运行的服务如下: + +[root@k8s-master-2 ~]# kubectl get pods -o wide | grep worker-1 +mysql-min-6685c9ff76-4m5xr 1/1 Running 0 4d23h 10.100.230.14 k8s-worker-1 +skywalking-es-init-ls7j5 0/1 Completed 0 4d11h 10.100.230.18 k8s-worker-1 +[root@k8s-master-2 ~]# + + +可以看到,有两个服务在这个worker上跑着,一个是初始化容器,另一个是MySQL。初始化容器已经是完成的状态,那CPU使用率高肯定是因为MySQL了。因此,我们就进到容器中,执行下top,看看资源消耗到什么程度。 + + + +什么情况?CPU使用率这么高吗?既然这样,我们就得来查一下MySQL的全局监控了。 + +在这里,查看全局监控其实是分析MySQL的必要步骤。如果直接查看trx表,中间其实是有些跳跃的,因为查看MySQL的全局监控数据,才是承上启下的一步。我现在把这个过程写全一些,以免你产生困惑。 + +于是,我们执行一个mysqlreport命令,看看mysql的全局监控数据是怎样的。这里我截取了其中的一些重要信息: + +__ Questions ___________________________________________________________ +Total 307.93M 912.0/s + +Unknown 201.91M 598.0/s %Total: 65.57 + DMS 43.20M 128.0/s 14.03 + Com_ 32.90M 97.5/s 10.69 + QC Hits 29.91M 88.6/s 9.71 + COM_QUIT 389 0.0/s 0.00 +Slow 20 ms 273.66k 0.8/s 0.09 %DMS: 0.63 Log: +DMS 43.20M 128.0/s 14.03 + SELECT 32.39M 95.9/s 10.52 74.98 + INSERT 10.64M 31.5/s 3.46 24.63 + UPDATE 170.15k 0.5/s 0.06 0.39 + REPLACE 0 0/s 0.00 0.00 + DELETE 0 0/s 0.00 0.00 +Com_ 32.90M 97.5/s 10.69 + set_option 21.98M 65.1/s 7.14 + commit 10.70M 31.7/s 3.48 + admin_comma 137.68k 0.4/s 0.04 + + +__ SELECT and Sort _____________________________________________________ +Scan 20.04M 59.4/s %SELECT: 61.88 +Range 0 0/s 0.00 +Full join 32 0.0/s 0.00 +Range check 0 0/s 0.00 +Full rng join 0 0/s 0.00 +Sort scan 120 0.0/s +Sort range 2.41k 0.0/s +Sort mrg pass 0 0/s + + +你看,DMS中的select占比比较大。其实,如果只是select的占比比较大的话,倒不是什么大事,关键是在下面的数据中还有一个Scan(全表扫描),而全表扫描是典型的性能问题点。 + +看到这里,我想你应该非常清楚我接下来的套路了吧,就是找SQL,看执行计划,然后确定优化方案。如果你不太清楚,可以再看一下第16讲或第20讲,其中都有描述。 + +定向监控分析 + +于是,我们现在进入到了定向监控分析阶段。通过查看innodb_trx表,我们看到了SQL中执行慢的语句,它的执行计划如下: + + + +这是很典型的全表扫描,虽然数据量并不大,但是我们也要添加索引。添加索引的语句如下: + + + +这里你要注意一下,在创建索引的时候,如果数据量太大,创建索引可能会卡住很长时间,这要取决于机器单CPU的能力。 + +优化效果 + +添加索引之后,我们直接来看一下优化效果: + + + +你看,TPS要上千了! + +其实,对于SQL的优化,如果我们只是加一个索引,那就算是非常简单的步骤了,并且效果也会非常好,TPS增加上千倍、甚至上万倍都有可能。可是,如果优化涉及到了业务逻辑,那就麻烦一些了。 + +如果你觉得这节课只是为了给你讲一个加索引的案例,那你就有些单纯了。下面,我们来看一个复杂的问题。 + +第二阶段 + +在我接着执行压力的时候,看到了一个非常有意思的情况,我们来一起折腾折腾! + +场景运行数据 + + + +你看这张图,在压力持续大概6分钟之后,TPS不稳定也就算了,居然还掉下来了!你掉下来也就算了,居然还断开了!你断开了也就算了,响应时间居然也不增加! + +这可怎么分析呢?想要拆分时间都没有一个适合的理由呀! + +这时候,就得用上哥的性能分析决策树了。我们把相关的全局监控计数器都看一看,一层层查下去,还好这个接口的逻辑链路也不怎么长。 + +全局监控分析 + +按照高老师的习惯,我们首先来看全局监控: + + + +从这张图上,我们什么也没看出来。所以,我们接着查性能分析决策树,一层层地看下去。当看到Order的GC健康状态时,我们看到了下面这些数据: + +[root@svc-mall-order-568bd9b79-twhcw /]# jstat -gcutil 1 1s + S0 S1 E O M CCS YGC YGCT FGC FGCT GCT +100.00 0.00 100.00 100.00 95.06 92.82 1182 34.966 495 3279.704 3314.670 +100.00 0.00 100.00 100.00 95.06 92.82 1182 34.966 495 3279.704 3314.670 +100.00 0.00 100.00 100.00 95.06 92.82 1182 34.966 495 3279.704 3314.670 +100.00 0.00 100.00 100.00 95.06 92.82 1182 34.966 495 3279.704 3314.670 + 90.88 0.00 100.00 100.00 95.08 92.82 1182 34.966 495 3286.621 3321.58 +100.00 0.00 100.00 100.00 95.08 92.82 1182 34.966 496 3286.621 3321.586 +100.00 0.00 100.00 100.00 95.08 92.82 1182 34.966 496 3286.621 3321.586 +100.00 0.00 100.00 100.00 95.08 92.82 1182 34.966 496 3286.621 3321.586 +100.00 0.00 100.00 100.00 95.08 92.82 1182 34.966 496 3286.621 3321.586 +100.00 0.00 100.00 100.00 95.08 92.82 1182 34.966 496 3286.621 3321.586 +100.00 0.00 100.00 100.00 95.08 92.82 1182 34.966 496 3286.621 3321.586 +........................... + + +有没有感到绝望?内存泄漏了!年轻代和年老代的内存都用到100%了!即便是FullGC之后,内存也没回收下来,可怜呀。 + +定向监控分析 + +既然是内存被用完了,那我们自然要查一下是什么样的对象把内存用完了。所以,我们进到容器里面,执行jmap -histo 1|more看一眼: + +num #instances #bytes class name +---------------------------------------------- + 1: 49727866 1397691896 [ + 2: 12426103 795269200 [[ + 3: 12426038 397633216 com.mysql.cj.protocol.a.result.ByteArrayRo + 4: 2002596 384498432 com.dunshan.mall.order.domain.OmsOrderDetai + 5: 12426082 198817312 com.mysql.cj.protocol.a.MysqlTextValueDecode + 6: 2070085 182840264 [Ljava.lang.Object + 7: 6008660 144207840 java.lang.Lon + 8: 2207452 132116320 [ + 9: 4072895 97749480 java.util.ArrayLis + 10: 2002690 80107600 org.apache.ibatis.cache.CacheKe + 11: 2039613 65267616 java.util.HashMap$Nod + 12: 2197616 52742784 java.lang.Strin + 13: 14736 23246672 [Ljava.util.HashMap$Node + 14: 36862 3243856 java.lang.reflect.Metho + 15: 97195 3110240 java.util.concurrent.ConcurrentHashMap$Nod + 16: 62224 2986752 java.util.HashMa + 17: 19238 2452264 [ + 18: 21482 2360328 java.lang.Clas + 19: 26958 1078320 java.util.LinkedHashMap$Entr +........................... + + +从中我们似乎找到了问题点。你看,这里面有一个MySQL的result占的内存还挺大,同时,在它的下面我们也看到了OmsOrderDetail类,这个类是用来在数据库中查询订单的详细信息的。 + +从逻辑上来讲,我们看订单的详细信息,实际上是想查询数据库中的信息,进而把查询出来的数据放到应用的内存中。所以,MySQL的result查的数据越多,就会导致应用的JVM内存消耗越大。 + +你也许会想,接下来是不是直接去看OmsOrderDetail的代码就可以了?你可以去看,但是,我们这个案例并没有那么直接。因为我们已经知道代码了,逻辑也梳理清楚了,所以,再去查看代码其实也看不出什么问题来。 + +那为什么JVM内存消耗会高呢?这里我们就要查一下线程在做什么动作了: + +-- top +[root@k8s-worker-3 ~]# docker exec -it 66d3639cf4a8 /bin/bash +[root@svc-mall-order-568bd9b79-twhcw /]# top +top - 16:10:50 up 11 days, 2:37, 0 users, load average: 3.92, 4.77, 3.35 +Tasks: 4 total, 1 running, 3 sleeping, 0 stopped, 0 zombie +%Cpu0 : 46.7 us, 8.6 sy, 0.0 ni, 43.6 id, 0.0 wa, 0.0 hi, 0.7 si, 0.3 st +%Cpu1 : 23.3 us, 9.2 sy, 0.0 ni, 66.1 id, 0.0 wa, 0.0 hi, 1.0 si, 0.3 st +%Cpu2 : 50.0 us, 7.2 sy, 0.3 ni, 41.4 id, 0.0 wa, 0.0 hi, 0.7 si, 0.3 st +%Cpu3 : 46.4 us, 8.5 sy, 0.0 ni, 43.7 id, 0.0 wa, 0.0 hi, 1.0 si, 0.3 st +%Cpu4 : 50.5 us, 8.0 sy, 0.0 ni, 41.5 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st +%Cpu5 : 50.2 us, 3.1 sy, 0.0 ni, 46.1 id, 0.0 wa, 0.0 hi, 0.7 si, 0.0 st +KiB Mem : 16265992 total, 171760 free, 9077080 used, 7017152 buff/cache +KiB Swap: 0 total, 0 free, 0 used. 6676508 avail Mem + + + PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND + 1 root 20 0 8788300 4.2g 13860 S 127.9 27.1 115:17.15 java + 575 root 20 0 11828 1776 1328 S 0.0 0.0 0:00.01 sh + 789 root 20 0 11964 1980 1484 S 0.0 0.0 0:00.02 bash + 802 root 20 0 56232 2012 1432 R 0.0 0.0 0:00.05 to + + +-- top -Hp 1 +top - 16:11:39 up 11 days, 2:38, 0 users, load average: 8.87, 6.09, 3.87 +Threads: 85 total, 1 running, 84 sleeping, 0 stopped, 0 zombie +%Cpu0 : 55.6 us, 7.1 sy, 0.0 ni, 36.6 id, 0.0 wa, 0.0 hi, 0.3 si, 0.3 st +%Cpu1 : 41.3 us, 3.8 sy, 0.0 ni, 54.9 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st +%Cpu2 : 30.4 us, 9.9 sy, 0.0 ni, 59.4 id, 0.0 wa, 0.0 hi, 0.3 si, 0.0 st +%Cpu3 : 60.3 us, 6.7 sy, 0.0 ni, 32.7 id, 0.0 wa, 0.0 hi, 0.3 si, 0.0 st +%Cpu4 : 21.2 us, 9.2 sy, 0.0 ni, 69.6 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st +%Cpu5 : 45.6 us, 10.1 sy, 0.3 ni, 43.6 id, 0.0 wa, 0.0 hi, 0.0 si, 0.3 st +KiB Mem : 16265992 total, 197656 free, 9071444 used, 6996892 buff/cache +KiB Swap: 0 total, 0 free, 0 used. 6681848 avail Mem + + + PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND + 7 root 20 0 8788300 4.2g 13836 R 96.0 27.1 70:13.42 VM Thread + 26 root 20 0 8788300 4.2g 13836 S 0.7 27.1 0:05.70 VM Periodic Tas + + +执行了上面两个命令之后,你有没有注意到只有一个线程在消耗CPU?根据我们前面查看的GC状态,这个线程应该在忙着做FullGC。我们打印栈信息来确认一下,果然是它! + +"VM Thread" os_prio=0 tid=0x00007fb18c0f4800 nid=0x7 runnable + + +到这里,我们下一步该怎么做就非常清晰了,那就是打印一个堆Dump,来看看对象在内存中的消耗比例。 + +所以,我们现在执行下面这个命令,来生成堆Dump。 + +jmap -dump:format=b,file=文件名 pid + + +然后,我们再把生成的堆Dump下载下来,用MAT打开。 + +在我打开堆文件的过程中出现了一个小插曲,这也是你需要留意的地方,那就是如果堆Dump的内存太大的话,我们打开堆Dump就会报这样的错: + + + +这个时候我们就要到MemoryAnalyzer.ini文件中,把JVM的最大值参数-Xmx给调大。-Xmx的默认值是1G,至于要调到多大就要看你想打开多大的文件了。如果-Xmx调整得不够大,还会出现下面这样的错误: + + + +在我们费了九牛二虎之力,终于打开堆文件后,看到这样的信息: + + + +有一段内存居然消耗了2.6G!这个可疑的内存点在上图中也出现了(用MAT或jmap来看对象的内存消耗,是两种不同的手段,你可以自己选择。只是在MAT上,我们可以看到可疑的内存消耗点的提醒,而在jmap中是不会给出这样的提醒的,需要我们自己判断),我们点进去看一眼,看看可疑的内存点里具体的对象是什么: + + + +你看,确实是SQL返回的数据量比较大,在上图的列表中居然有1千多万条记录。我们再把相应的栈展开看看: + + + +看到OmsOrderDetail这个类了没?你可能会想,是支付订单信息这个接口有问题!但是证明还不足,我们要确定OmsOrderDetail是不是在这个接口中调用或生成的,才能判断出是不是这个接口的问题。 + +由于当前使用的接口是paySuccess,我们看一下paySuccess的调用逻辑,发现paySuccess有一个getDetail函数。看到这个“getDetail”,还有其中这个“Detail”字符,你是不是感觉和OmsOrderDetail可以对应上?那我们就来查看一下getDetail对应的代码,看看它和OmsOrderDetail之间是什么关系: + + + +不难发现,getDetail是一个OmsOrderDetail类。这么看来,我们的接口确实用到了OmsOrderDetail类,你是不是有一种抓住元凶的兴奋感?别着急,让人无奈的事情总是会出现的,下面我们看一下这段代码对应的SQL语句是一个什么样的逻辑。 + +通过查看代码,可以看到这个接口中有两个update和一个select,这三个语句分别是: + + + update oms_orde + ........................ + where id = #{id,jdbcType=BIGINT + + update pms_sku_stoc + ........................ + where id = #{id,jdbcType=BIGINT + () #8 + +---[75.263121ms] com.dunshan.mall.order.feign.MemberService:getCurrentMember() #8 + +---[0.006396ms] com.dunshan.mall.model.UmsMember:getId() #9 + +---[0.004322ms] com.dunshan.mall.model.UmsMember:getId() #9 + +---[0.008234ms] java.util.List:toArray() #5 + +---[min=0.006794ms,max=0.012615ms,total=0.019409ms,count=2] org.slf4j.Logger:info() #5 + +---[0.005043ms] com.dunshan.mall.model.UmsMember:getId() #9 + +---[28.805315ms] com.dunshan.mall.order.feign.CartItemService:listPromotionnew() #5 + +---[0.007123ms] com.dunshan.mall.order.domain.ConfirmOrderResult:setCartPromotionItemList() #9 + +---[0.012758ms] com.dunshan.mall.model.UmsMember:getList() #10 + +---[0.011984ms] com.dunshan.mall.order.domain.ConfirmOrderResult:setMemberReceiveAddressList() #5 + +---[0.03736ms] com.alibaba.fastjson.JSON:toJSON() #11 + +---[0.010188ms] com.dunshan.mall.order.domain.OmsCartItemVo:() #12 + +---[0.005661ms] com.dunshan.mall.order.domain.OmsCartItemVo:setCartItemList() #12 + +---[19.225703ms] com.dunshan.mall.order.feign.MemberService:listCart() #12 + +---[0.010474ms] com.dunshan.mall.order.domain.ConfirmOrderResult:setCouponHistoryDetailList() #5 + +---[0.007807ms] com.dunshan.mall.model.UmsMember:getIntegration() #13 + +---[0.009189ms] com.dunshan.mall.order.domain.ConfirmOrderResult:setMemberIntegration() #5 + +---[27.471129ms] com.dunshan.mall.mapper.UmsIntegrationConsumeSettingMapper:selectByPrimaryKey() #13 + +---[0.019764ms] com.dunshan.mall.order.domain.ConfirmOrderResult:setIntegrationConsumeSetting() #13 + +---[0.154893ms] com.dunshan.mall.order.service.impl.PortalOrderServiceImpl:calcCartAmount() #13 + `---[0.013139ms] com.dunshan.mall.order.domain.ConfirmOrderResult:setCalcAmount() #13 + + +你看,这个接口中有一个getCurrentMember方法,它是Member上的一个服务,是用来获取当前用户信息的,而其他服务都会用到这个服务,因为需要Token嘛。 + +从上面的栈信息看,getCurrentMember用了75ms多,这个时间明显是慢了,我们跟踪一下这个方法,看看是哪里慢了: + +Condition express: 1==1 , result: true +`---ts=2021-02-18 19:43:18;thread_name=http-nio-8083-exec-25;id=34bd;is_daemon=true;priority=5;TCCL=org.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader@6cb759d5 + `---[36.139809ms] com.dunshan.mall.member.service.imp.MemberServiceImpl:getCurrentMember( + +---[0.093398ms] javax.servlet.http.HttpServletRequest:getHeader() #18 + +---[0.020236ms] cn.hutool.core.util.StrUtil:isEmpty() #18 + +---[0.147621ms] cn.hutool.json.JSONUtil:toBean() #19 + +---[0.02041ms] com.dunshan.mall.common.domain.UserDto:getId() #19 + `---[35.686266ms] com.dunshan.mall.member.service.MemberCacheService:getMember() #5 + + +这种需要瞬间抓的数据,要反复抓很多遍才能确定。虽然我在这里只展示了一条,但是,我抓的时候可是抓了好多次才得到的。从上面的数据来看,getCurrentMember中使用的getMember方法耗时比较长,达到了35ms多。 + +我们看一下getMember的具体实现: + +@Override +public UmsMember getMember(Long memberId) { + String key = REDIS_DATABASE + ":" + REDIS_KEY_MEMBER + ":" + memberId; + return (UmsMember) redisService.get(key); + + + +这个代码的逻辑很简单:拼接Key信息,然后从Redis里找到相应的Member信息。 + +既然getMember函数是从Redis里获取数据,那我们就到Redis里检查一下slowlog: + +127.0.0.1:6379> slowlog get +1) 1) (integer) 5 + 1) (integer) 1613647620 + 2) (integer) 30577 + 3) 1) "GET" + 1) "mall:ums:member:2070064" + 4) "10.100.140.46:53152" + 5) "" +2) 1) (integer) 4 + 1) (integer) 1613647541 + 2) (integer) 32878 + 3) 1) "GET" + 1) "mall:ums:member:955622" + 4) "10.100.140.46:53152" + 5) "" +........................ + + +你看,确实是get命令慢了,看时间都超过了10ms(slowlog默认设置是10ms以上才记录)。如果这个命令执行的次数不多,倒也没啥。关键是这个命令是验证用户的时候用的,这样的时间是没办法容忍的。 + +为什么这么说呢? + +因为在业务上来看,除了打开首页和查询商品不用它之外,其他的脚本似乎都需要用它。所以,它慢了不是影响一个业务,而是影响一堆业务。 + +然而,正当我们分析到这里,还没来得及做优化的时候,又……出现了新问题。我在接着压的过程中,发现了这样的现象: + + + +你瞧瞧,TPS不稳定就算了,后面居然还全报错了,这也太不合适了吧! + +于是,我开始对报错日志一通查,最后发现了Redis的容器都飘红了,下面是Redis在架构中的状态截图: + + + +这明显是Redis没了呀!这时候我们再去看应用的状态: + + + +满目疮痍呀! + +接着,我们登录到Redis服务所在的worker节点,查看日志: + +[ 7490.807349] redis-server invoked oom-killer: gfp_mask=0xd0, order=0, oom_score_adj=807 +[ 7490.821216] redis-server cpuset=docker-18cc9a81d8a58856ecf5fed45d7db431885b33236e5ad50919297cec453cebe1.scope mems_allowed=0 +[ 7490.826286] CPU: 2 PID: 27225 Comm: redis-server Kdump: loaded Tainted: G ------------ T 3.10.0-1127.el7.x86_64 #1 +[ 7490.832929] Hardware name: Red Hat KVM, BIOS 0.5.1 01/01/2011 +[ 7490.836240] Call Trace: +[ 7490.838006] [] dump_stack+0x19/0x1b +[ 7490.841975] [] dump_header+0x90/0x229 +[ 7490.844690] [] ? ep_poll_callback+0xf8/0x220 +[ 7490.847625] [] oom_kill_process+0x25e/0x3f0 +[ 7490.850515] [] ? cpuset_mems_allowed_intersects+0x21/0x30 +[ 7490.853893] [] mem_cgroup_oom_synchronize+0x546/0x570 +[ 7490.857075] [] ? mem_cgroup_charge_common+0xc0/0xc0 +[ 7490.860348] [] pagefault_out_of_memory+0x14/0x90 +[ 7490.863651] [] mm_fault_error+0x6a/0x157 +[ 7490.865928] [] __do_page_fault+0x491/0x500 +[ 7490.868661] [] trace_do_page_fault+0x56/0x150 +[ 7490.871811] [] do_async_page_fault+0x22/0xf0 +[ 7490.874423] [] async_page_fault+0x28/0x30 +[ 7490.877127] Task in /kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod6e897c3a_8b9f_479b_9f53_33d2898977b0.slice/docker-18cc9a81d8a58856ecf5fed45d7db431885b33236e5ad50919297cec453cebe1.scope killed as a result of limit of /kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod6e897c3a_8b9f_479b_9f53_33d2898977b0.slice/docker-18cc9a81d8a58856ecf5fed45d7db431885b33236e5ad50919297cec453cebe1.scope +[ 7490.893825] memory: usage 3145728kB, limit 3145728kB, failcnt 176035 +[ 7490.896099] memory+swap: usage 3145728kB, limit 3145728kB, failcnt 0 +[ 7490.899137] kmem: usage 0kB, limit 9007199254740988kB, failcnt 0 +[ 7490.902012] Memory cgroup stats for /kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod6e897c3a_8b9f_479b_9f53_33d2898977b0.slice/docker-18cc9a81d8a58856ecf5fed45d7db431885b33236e5ad50919297cec453cebe1.scope: cache:72KB rss:3145656KB rss_huge:0KB mapped_file:0KB swap:0KB inactive_anon:0KB active_anon:3145652KB inactive_file:0KB active_file:20KB unevictable:0KB +[ 7490.962494] [ pid ] uid tgid total_vm rss nr_ptes swapents oom_score_adj name +[ 7490.966577] [27197] 0 27197 596 166 5 0 807 sh +[ 7490.970286] [27225] 0 27225 818112 786623 1550 0 807 redis-server +[ 7490.974006] [28322] 0 28322 999 304 6 0 807 bash +[ 7490.978178] Memory cgroup out of memory: Kill process 27242 (redis-server) score 1808 or sacrifice child +[ 7490.983765] Killed process 27225 (redis-server), UID 0, total-vm:3272448kB, anon-rss:3144732kB, file-rss:1760kB, shmem-rss:0kB + + +原来是worker节点的内存不够用了,而Redis在计算OOM评分时也达到了1808分。于是,操作系统就义无反顾地把Redis给杀了。 + +我们再次把Redis启动之后,观察它的内存消耗,结果如下: + +[root@k8s-worker-4 ~]# pidstat -r -p 5356 1 +Linux 3.10.0-1127.el7.x86_64 (k8s-worker-4) 2021年02月18日 _x86_64_ (6 CPU) + + +19时55分52秒 UID PID minflt/s majflt/s VSZ RSS %MEM Command +19时55分53秒 0 5356 32.00 0.00 3272448 1122152 6.90 redis-server +19时55分54秒 0 5356 27.00 0.00 3272448 1122416 6.90 redis-server +19时55分55秒 0 5356 28.00 0.00 3272448 1122416 6.90 redis-server +19时55分56秒 0 5356 28.00 0.00 3272448 1122680 6.90 redis-server +19时55分57秒 0 5356 21.78 0.00 3272448 1122680 6.90 redis-server +19时55分58秒 0 5356 38.00 0.00 3272448 1122880 6.90 redis-server +19时55分59秒 0 5356 21.00 0.00 3272448 1122880 6.90 redis-server +19时56分00秒 0 5356 25.00 0.00 3272448 1122880 6.90 redis-server + + +我只是截取了Redis没死之前的一小段数据,然后通过RSS(实际使用内存)来不断观察这段数据,发现内存确实会一直往上涨。我又查了一下Redis的配置文件,发现没配置maxmemory。 + +没配置倒是没什么,内存不够就不够了呗,Pod不是还有内存限制吗?但可惜的是,worker上的内存不够了,导致了Redis进程被操作系统杀掉了,这就解释了TPS图中后半段会报错的问题。 + +但是响应时间慢,我们还是得接着分析。我们在前面看到软中断和带宽有关,为了减少服务中断之间的相互影响,待会我把Redis和Gateway两个服务分开。 + +我们都知道,Redis是靠内存来维护数据的,如果只做内存的操作,它倒是会很快。但是Redis还有一块跟内存比较有关的功能,就是持久化。我们现在采用的是AOF持久化策略,并且没有限制AOF的文件大小。 + +这个持久化文件是放到NFS文件服务器上面的,既然是放到文件服务器上,那就需要有足够的磁盘IO能力才可以。因此,我们到nfs服务器上查看一下IO的能力,截取部分数据如下: + +Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util +sda 0.00 0.00 65.00 0.00 6516.00 0.00 200.49 1.85 28.43 28.43 0.00 3.95 25.70 + + +Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util +sda 0.00 0.00 24.00 0.00 384.00 0.00 32.00 0.15 6.46 6.46 0.00 6.46 15.50 + + +Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util +sda 0.00 0.00 8.00 0.00 1124.00 0.00 281.00 0.07 8.38 8.38 0.00 4.00 3.20 + + +.......................... + + +Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util +sda 0.00 0.00 11.00 0.00 556.00 0.00 101.09 0.15 13.55 13.55 0.00 10.36 11.40 + + +Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util +sda 0.00 0.00 4.00 0.00 32.00 0.00 16.00 0.08 19.25 19.25 0.00 15.25 + + +通过svctm(IO响应时间计数器)这个参数可以看到,IO的响应时间也增加了。虽然在sysstat的新版本中已经不建议使用svctm了,但是在我们当前使用的版本中,仍然有这个参数。并且通过它,我们可以看到IO的响应时间确实在增加。 + +为了证明IO的响应时间是和AOF有关,我们先把AOF关掉,设置appendonly no看看效果。如果有效果,那优化方向就非常明确了,我们要做的就是这几个优化动作: + + +把Redis先移到一个网络需求没那么大的Worker上去,观察一下TPS能不能好一点。如果这一步有效果,我们就不用再折腾下一步了; + +如果上一步做完之后没有效果,就再把AOF关掉,再观察TPS。如果AOF关掉后有效果,那我们就得分析下这个应用有没有必要做Redis的持久化了。如果有必要,就得换个快一点的硬盘; + +不管上面两步有用没用,对Redis来说,我们都应该考虑限制内存的大小和AOF文件的大小。 + + +我们看一下把Redis从worker-4移到worker-7上之后的TPS如下: + + + +TPS还是在下降,并且没有一开始的那么高了,之前都能达到1000TPS。这个看似非常正确的优化动作却导致了TPS下降的现象,显然不是我们期望的。 + +现在还不知道问题在哪里,不过,我们一直想达到的目标是降队列。所以,我们先确认下网络队列有没有降下来,再来考虑TPS怎么提升。 + +你看worker-4上的队列,没有recv_Q的值了: + + + +现在我们就得来处理下AOF了,因为我们虽然移开了Redis,但是TPS并没有上升。所以,我们还得看看AOF的影响。 + +关掉AOF之后,TPS如下: + + + +总体资源如下: + + + +看到了没,效果还是有的吧?我们可是得到了1000以上的稳定的TPS曲线。 + +在这个容量场景中,我们完成了四个阶段的分析之后,优化效果还不错。不过,每个性能测试都应该有结论。所以,我们还需要做一个动作,就是接着增加压力,看一下系统的最大容量能达到多少。 + +于是,我们进入第五个阶段的分析。 + +第五阶段分析 + +请你注意,容量场景最重要的变化只有一个,就是增加线程。而跟着线程一起变化的就是参数化的数据量。在这样的增加线程的场景中,我们还要关注的就是资源的均衡使用。因此,在第四阶段的优化之后,我们先来看一下这个场景的结果是个什么样子。 + +场景运行数据 + +场景压力数据如下: + + + +从效果上来看,不错哦,TPS已经达到1700了。 + +全局监控分析 + +全局监控的数据如下: + + + +从上面两张图中可以看到,在我们这样的压力之下,TPS最大能达到1700左右,系统整体资源使用率也不算少了。 + +经过了基准场景和容量场景之后,我们现在就可以下结论了:系统资源在这个最大容量的场景中已经达到了比较高的使用率。 + +你有没有听过性能行业中一直流传的一句话:性能优化是无止境的。所以,我们一定要选择好性能项目结束的关键点。 + +就拿我们这个课程的案例来说,这个系统在技术上已经没有优化的空间了,或者说在技术上优化的成本比较高(比如说要做一些定制的开发和改造)。如果你在这种情况下还想要扩大容量,那么你能做的就是增加节点和硬件资源,把所有的硬件资源全都用完。 + +但是!请所有做性能项目的人注意!我们做性能项目,不是说把系统优化到最好后,就可以在生产环境中按这样的容量来设计整体的生产资源****了!要知道,生产环境中出现问题的代价是非常高的,一般我们都会增加一定的冗余,但是冗余多少就不一而足了。 + +在很多企业中,生产环境里使用的CPU都没有超过20%。为什么会有这么多的冗余呢?在我的经验中,大部分的项目都是根据业务的发展在不断迭代,然后形成了这样的局面。你可以想像一下,这样的生产环境里有多少资源浪费。 + +说到这里,我们不得不说一下怎么评估架构级的容量。因为对于一个固定客户端数的系统来说,很容易判断整体的容量。但是,对非固定客户端数的系统而言,要想抵挡得住突发的业务容量。那就要经过严格的设计了,像缓存、队列、限流、熔断、预热等等这些手段都要上了。 + +对于整体的架构容量设计,在所有的企业中都不是一蹴而就的,都要经过多次的、多年的版本迭代,跟着业务的发展不断演进得到。这就不是一个专栏可以尽述的了。 + +总结 + +从基准场景做完之后,我们来到了容量场景,这是一个非常大的变化。在这个场景中,我们解决了几个问题并最终给出了结论: + +第一个阶段:分析了压力工具参数化的问题,解决了TPS不断降低、响应时间不断上升的问题。 + +第二个阶段:分析了数据库索引,解决了TPS低的问题。 + +第三个阶段:分析了资源争用,解决了多容器跑到一个节点上的问题。 + +第四个阶段:分析了网络争用和Redis的AOF,解决了TPS不稳定的问题。 + +第五个阶段:递增压力,给出最终系统整体容量的结论。 + +在做完这些动作之后,我们终于可以给出比较明确的结论了:TPS能达到1700! + +请你记住,对于一个性能项目来说,没有结论就是在耍流氓。所以,我一直强调,在性能项目中,我们一定要给出最大容量的结论。 + +课后作业 + +这就是今天的全部内容,最后给你留两个思考题吧: + + +为什么性能项目一定要有结论? +当多个性能问题同时出现时,我们怎么判断它们产生的相互影响? +如何判断一个系统已经优化到了最优的状态? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果这节课让你有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/26稳定性场景之一:怎样搞定业务积累量产生的瓶颈问题?.md b/专栏/高楼的性能工程实战课/26稳定性场景之一:怎样搞定业务积累量产生的瓶颈问题?.md new file mode 100644 index 0000000..ac220b9 --- /dev/null +++ b/专栏/高楼的性能工程实战课/26稳定性场景之一:怎样搞定业务积累量产生的瓶颈问题?.md @@ -0,0 +1,368 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 26 稳定性场景之一:怎样搞定业务积累量产生的瓶颈问题? + 你好,我是高楼。 + +根据我们的RESAR性能理论,在执行完基准场景、容量场景之后,接下来就是稳定性场景了。 + +做过性能项目的工程师应该都有一个感觉:在跑稳定性场景之前,内心是战战兢兢的,因为不知道在运行长时间之后,系统会是什么样的表现。 + +并且,还有一个复杂的地方就是,在稳定性场景中,由于运行的时间长,出现问题后,我们分析起来会比较困难,主要有三点原因: + +(1)分析一定要有完整且持续的计数器监控。因为在稳定性场景中,实时查看性能计数器是不现实的,我们不可能一直盯着。而且,问题出现的时间点也不确定。所以,在分析问题时,我们需要完整且持续的计数器监控。 + +(2)累积业务量产生的问题点在整个系统中也是不确定的。 + +(3)你知道,稳定性场景回归比较耗时,在分析优化的过程中,但凡调个参数、改行代码啥的,总是要回归场景的,而把稳定性场景拉起来就需要几个小时。所以,稳定性场景中的优化动作即便看似简单,也会消耗比较长的时间。 + +基于这几点原因,我们在稳定性运行之前,一定要想好监控哪些计数器,避免在稳定性运行过程中遇到问题时,发现没有可用的计数器分析问题,那就悲催了。这是极有可能出现的情况,你要格外注意。 + +根据第9讲中提到的监控逻辑,在执行我们稳定性场景前,我们已经按“组件 - 模块 - 计数器”这样的逻辑罗列了所有需要监控的计数器,并且也用相应的工具去实现了。一切看起来已经万事具备。下面我们来看看在执行稳定性场景时,有哪些要点需要注意? + +稳定性场景的要点 + +在稳定性场景中,有两点是需要你着重关注的:一个是运行时长,另一个是压力量级。 + +1. 运行时长 + +我们在前面提到,容量场景是为了看系统所能承受的最大容量,而稳定性场景主要看的是系统提供长时间服务时的性能稳定性,观察系统在长时间运行过程中出现的累积效应。因此,运行时长就是稳定性场景中非常重要的一个指标了。 + +在每个业务系统中,稳定性运行时长都不是固定的,这取决于业务系统的具体应用场景。 + +对于大部分长年不能宕机的系统来说,它们靠的不是系统中的所有节点都能长年运行,而是架构设计可以在任一节点出现问题之后,将对应的业务承接到其他节点上。而这些架构设计就涉及到了DNS分区、扩展能力、高可用能力等技术。 + +可是,对于我们性能项目来说,即便是长年不宕机的系统,稳定性场景也不可能长年运行。因为如果这样做,就相当于长年运行着另一个生产系统,成本高、维护难,这显然是非常不现实的。 + +这时候,另一个岗位的重要性就体现出来了,那就是:运维。 + +在运维的职责里,就有“处理生产环境中出现的各种问题”这一项,我们俗称背锅侠。运维要做的就是保障系统在各种场景下都要正常运行。不过我想多啰嗦几句,要保证这一点,就不能只靠运维岗的工程师,它需要一个企业中所有技术岗的通力合作。换句话说,运维的职责实际上应该由一个企业的所有技术人员来承担。 + +话说回来,我们知道,运维会制定各种工作内容来保障系统的正常运行,其中,非常重要的一项就是,搭建完善的监控系统,因为你让一个运维眼睛都不眨眼地盯着系统是不现实的。而我们这个课程中提到的全局监控和定向监控,就可以完全覆盖到这种监控系统的要求。 + +为什么要提到运维呢? + +因为稳定性场景的运行时长,不能覆盖长年运行的系统,这就需要运维人员来保障那线上的稳定性状态了。总体来看,运维有两大类工作内容:一类是日常巡检(用手工或自动化的方式,查看系统的健康状态);另一类是运维动作(用手工或自动化的方式,完成归档、日志清理等动作)。 + +有些系统有固定的运维周期,周期按照天、周或者月来计算。而有些系统是没有固定的运维周期的,这就要靠监控系统提供的信息来判断什么时候做运维动作了。在自动化运维比较完善的情况下,有些运维动作就由自动化系统承接了;在自动化运维不完善的情况下,就只能靠人了。 + +不过,不管有没有自动化运维,每个系统都是有运维周期的,像下面这样: + + + +下面我们具体来看看,对于上述两种系统,怎么计算稳定性场景的运行时长。 + + +有固定运维周期的系统 + + +对于有固定运维周期的系统,稳定性场景的运行时长就比较容易定义了。我们先根据生产系统的数据统计,看一下系统在固定的运维周期内,最大的业务容量是多少。 + +假设你根据生产系统统计出,在之前的运维周期中,有1亿的业务容量,而在容量场景中得到的最大TPS有1000。那么,我们就可以通过下面这个公式来计算: +\[ 稳定性运行时长 = 1亿(业务累积量) \\div 1000(TPS) \\div 3600(秒) \\approx 28(小时) \]用这种方式得出的稳定性运行时长,对于有固定运维周期的系统来说已经足够了。 + + +没有固定运维周期的系统 + + +对于没有固定运维周期的系的系统,该怎么办呢?也许有人会说,运行时间只有尽可能长了。但是,“尽可能”也得有一个界限。根据我的经验,我们不能用“尽可能”来判断稳定性场景的运行时长。 + +根据上面的运算公式,TPS来自于容量场景,时间是最大的变量,所以业务累积累是不确定的。现在,我们要做的就是把业务累积量确定下来。 + +我们知道,业务积累量需要根据历史业务的统计数据来做决定。如果你的系统一个月有1000万的业务累积量,同时,稳定性运行的指标是稳定运行三个月(也就是说,即便没有固定的运维周期,我们也得给出一个时间长度):- + + +那么,总业务累积量就是3000万。 + +我们再根据上面的公式来计算就可以了: +\[ 稳定性运行时长 = 3000万(业务累积量) \\div 1000(TPS) \\div 3600(秒) \\approx 8(小时) \]总之,不管是什么样的系统,要想运行稳定性场景,都得确定一个业务累积量。 + +2. 压力量级 + +我们再来看压力量级,这是稳定性场景中必须要确定的另一个前提条件。 + +我们在网上经常能看到这样的说法:稳定性的压力应该用最大TPS的80%来运行。可是,我们来看一下稳定性场景的目标:保障系统的业务累积量。也就是说,我们只要保证这一目标就可以了,至于TPS是多少,并不重要。 + +因此,我们不用考虑80%的问题,直接用最大TPS来运行即可。一个系统如果能在最大TPS的状态下正常运行,才算是真正经受住了考验。 + +你可能会有这样的疑问:当一个系统在最大TPS状态下运行,如果有突增的压力需要更高的TPS怎么办?请你注意,稳定性场景不是为了解决突增的压力峰值而设计的。如果你要考虑突增的业务压力,我建议你增加容量场景来验证。 + +另外,如果我们要对付突增的业务容量,不止要在性能场景中考虑增加容量场景,还要在架构设计时,把相应的限流、熔断、降级等异常保障机制加进来。 + +到这里,我们就把两个重要的稳定性条件讲完了。 + +下面我们具体操作一下,以我们这个课程的电商系统为例,看看稳定性场景该怎么确定。 + +场景运行数据 + +因为这是一个示例系统,所以我们先定一个小目标:稳定运行业务累积量为5000万。 + +对于这个系统,我们在容量场景中得到的最大TPS在1700,但是随着容量场景的不断增加,数据库中的数据量越来越大,TPS也会慢慢降低,因为我并没有做数据库的容量限制和归档等动作。那我们就用容量场景中的相应的压力线程来运行稳定性场景,让我们的理论能在落地时得到印证。根据前面的计算公式,运行时长为: +\[ 稳定性运行时长 = 5000万 \\div 1700(TPS) \\div 3600(秒) \\approx 8.16(小时) \]也就是说我们要运行稳定性场景8个小时多一点。 + +下面我们来看一下具体的运行数据: + + + +从数据上来看,在稳定性场景运行4个多小时的时候,TPS就没了,响应时间又非常高,这明显是出现问题了。 + +这时候的业务积累量为: + + + +总的业务累积量是2900多万,这和我们的预期并不相符。 + +下面我们分析一下到底是怎么回事。 + +全局监控分析 + +按照我们一贯的性能分析逻辑,我们先来查看全局监控数据: + + + +你看,在运行期间,好几个worker的CPU资源都在70%以上,这样的数据中规中矩,还不是我们关注的重点。因为对于稳定性场景来说,资源只要能撑得住就行了。 + +但是,在场景运行数据中,TPS直接就断掉了。在我查看每个主机的资源情况时,在worker-1上看到了这样的数据: + + + +这是数据断掉了呀!那我们就要定向分析这个主机了。 + +定向监控分析 + +定向分析第一阶段 + +根据断掉的时间点,和我们前面使用的监控手段,一层层查(这个步骤就是把我们的项目级全局监控计数器看一遍,在第4讲中已经有了明确的说明,我这里不再赘述了),结果看到了这样的日志信息: + +Feb 20 04:20:41 hp-server kernel: Out of memory: Kill process 7569 (qemu-kvm) score 256 or sacrifice child +Feb 20 04:20:41 hp-server kernel: Killed process 7569 (qemu-kvm), UID 107, total-vm:18283204kB, anon-rss:16804564kB, file-rss:232kB, shmem-rss:16kB +Feb 20 04:20:44 hp-server kernel: br0: port 4(vnet2) entered disabled state +Feb 20 04:20:44 hp-server kernel: device vnet2 left promiscuous mode +Feb 20 04:20:44 hp-server kernel: br0: port 4(vnet2) entered disabled state +Feb 20 04:20:44 hp-server libvirtd: 2021-02-19 20:20:44.706+0000: 1397: error : qemuMonitorIO:718 : 内部错误:End of file from qemu monitor +Feb 20 04:20:44 hp-server libvirtd: 2021-02-19 20:20:44.740+0000: 1397: error : qemuAgentIO:598 : 内部错误:End of file from agent monitor +Feb 20 04:20:45 hp-server systemd-machined: Machine qemu-3-vm-k8s-worker-1 terminated. + + +显然,因为宿主机内存不够,worker-1被直接杀掉了。既然是内存不足,我们肯定要确定一下这个宿主机是为什么内存不足了。 + +我检查了宿主机的overcommit参数。这个参数是确定操作系统是否允许超分内存的。对于Linux来说,内存分配出去,不一定会被用完。所以,对宿主机来说超分可以支持更多的虚拟机。 + +[root@hp-server log]# cat /proc/sys/vm/overcommit_memory +1 + + +我们看到,overcommit的配置是1,那就是允许超分。 + +我在这里简单说明一下,这个参数的几个选项: + + +0,不允许超分。 +1,不管当前的内存状态,都允许分配所有的物理内存。 +2,允许分配的内存超过物理内存+交换空间。 + + +请你注意,允许超分,并不是说允许超用!而我们现在的情况是宿主已经OOM(内存溢出)了,这就说明内存真的已经不够用了。 + +这个逻辑其实挺有意思;Linux虽然允许超分内存,但是当内存真正不够用的时候,即便是收到了超分请求,也得为了保证自己的正常运行而做OOM的判断。也就是说分给你,你不见得能用得起来!这种耍流氓的手段,像不像领导画大饼? + +没办法,我们还是要理智地来分析,看看怎么解决。 + +因为虚拟机是worker-1被杀掉的,我们来看一下worker-1的内存: + + + +从worker-1的资源上来看,如果worker-1是因为内存用得多被杀掉,那应该在12点20分到12点30分之间就被杀掉了,因为上面的内存曲线在12点半左右之后就没有大的波动了。 + +可是,为什么要等到凌晨4点20分呢?这说明worker-1被杀掉,并不是因为worker-1上的内存使用突然增加。而是宿主机上的内存使用变多,进而内存不足,然后在计算了OOM评分之后杀掉了worker-1。那我们就到宿主机上,看看还有哪些虚拟机在运行: + +[root@hp-server log]# virsh list --all + Id 名称 状态 +---------------------------------------------------- + 1 vm-k8s-master-1 running + 2 vm-k8s-master-3 running + 4 vm-k8s-worker-2 running + 5 vm-k8s-worker-3 running + 6 vm-k8s-worker-4 running + 7 vm-k8s-worker-1 running + + +宿主机上总共运行了6个虚拟机,它们在12点半之后的时间里,对应的内存依次如下: + +vm-k8s-worker-2: + + + +vm-k8s-worker-3: + + + +vm-k8s-worker-4: + + + +vm-k8s-master-1: + + + +vm-k8s-master-3: + + + +看到了没有?4点多的时候,在worker-2上有一个内存较大的请求。 + +针对这种情况,如果我们要细细地分析下去,接下来应该分析这个内存请求是从哪来的。但是,在稳定性场景中,要做这样的分析是比较麻烦的。因为这个场景的运行时间长,并且业务众多,不容易拆分时间。因此,我建议你到基准场景中去做分析。 + +现在,我们不能断言这个内存请求不合理,我们要做的是让这个系统稳定运行下去。所以,我们先来解决问题。 + +你可能会有疑问:既然是worker-2请求了内存,为啥要把worker-1杀掉呢?这就需要了解Linux的OOM killer机制了。 + +在OOM killer机制中,不是说谁用的内存大就会杀谁(当然,如果谁用的内存大,被杀的可能性也会比较大),而是会经过计算评分,哪个进程评分高就杀哪个。 + +在每个进程中,都会有三个参数:oom_adj、oom_score、oom_score_adj,系统的评分结果就记录在oom_score中。其他两个是调节参数:oom_adj是一个旧的调节参数,为了系统的兼容性,被保留了下来;oom_score_adj是一个新的调节参数,Linux会根据进程的运行参数来判断调节参数为多少。 + +这里提到的运行参数主要是这几个: + + +运行时长(存活时间越长的进程,越不容易被杀掉) +CPU时间消耗(CPU消耗越大的进程,越容易被干掉) +内存消耗(内存消耗越大的进程,越容易被干掉) + + +这些参数组合在一起,决定了哪个进程要被干掉。 + +而在我们这个场景中是worker-1被干掉了,这就说明worker-1的评分是高的。 + +因为前面有worker-1上的内存消耗也比较大,所以,我们在worker-1、worker-2这两台机器上查一下有多少Pod: + +[root@k8s-master-1 ~]# kubectl get pods -o wide --all-namespaces| grep worker-2 +default cloud-nacos-registry-76845b5cfb-bnj76 1/1 Running 0 9h 10.100.140.8 k8s-worker-2 +default sample-webapp-755fq 0/1 ImagePullBackOff 0 19h 10.100.140.7 k8s-worker-2 +default skywalking-es-init-4w44r 0/1 Completed 0 15h 10.100.140.11 k8s-worker-2 +default skywalking-ui-7d7754576b-nj7sf 1/1 Running 0 9h 10.100.140.14 k8s-worker-2 +default svc-mall-auth-6ccf9fd7c9-qh7j8 1/1 Running 0 151m 10.100.140.21 k8s-worker-2 +default svc-mall-auth-6ccf9fd7c9-sblzx 1/1 Running 0 151m 10.100.140.23 k8s-worker-2 +default svc-mall-member-df566595c-9zq9k 1/1 Running 0 151m 10.100.140.19 k8s-worker-2 +default svc-mall-member-df566595c-dmj67 1/1 Running 0 151m 10.100.140.22 k8s-worker-2 +kube-system calico-node-pwsqt 1/1 Running 8 37d 172.16.106.149 k8s-worker-2 +kube-system kube-proxy-l8xf9 1/1 Running 15 85d 172.16.106.149 k8s-worker-2 +monitoring node-exporter-wcsj7 2/2 Running 18 42d 172.16.106.149 k8s-worker-2 +nginx-ingress nginx-ingress-7jjv2 1/1 Running 0 18h 10.100.140.62 k8s-worker-2 +[root@k8s-master-1 ~]# kubectl get pods -o wide --all-namespaces| grep worker-1 +default mysql-min-c4f8d4599-fxwf4 1/1 Running 0 9h 10.100.230.9 k8s-worker-1 +kube-system calico-node-tmpfl 1/1 Running 8 37d 172.16.106.130 k8s-worker-1 +kube-system kube-proxy-fr22f 1/1 Running 13 85d 172.16.106.130 k8s-worker-1 +monitoring alertmanager-main-0 2/2 Running 0 162m 10.100.230.12 k8s-worker-1 +monitoring node-exporter-222c5 2/2 Running 10 7d 172.16.106.130 k8s-worker-1 +nginx-ingress nginx-ingress-pjrkw 1/1 Running 1 18h 10.100.230.10 k8s-worker-1 +[root@k8s-master-1 ~]# + + +我们进一步查看那些应用经常使用的Pod,看看它们的内存情况如何: + +PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND + 7609 27 20 0 12.4g 7.0g 12896 S 118.9 45.0 167:38.02 /opt/rh/rh-mysql57/root/usr/libexec/mysqld --defaults-file=/etc/my.cnf + + +通过查看worker-1上的进程,我们发现主要是MySQL使用的内存最多,这是吃内存的大户。如果宿主机内存不够,把worker-1杀掉确实是有可能的。 + +下面,我们增加几个临时的监控,把一些重要服务的内存记录一下,比如Gateway、Member、MySQL、Redis等。然后再恢复所有的应用,把场景跑起来,看看是什么样的结果: + + + +运行时长已经快有七个小时了。你可能会奇怪,为什么上一个场景只运行了4个多小时,而现在却能运行7个小时了呢?这是因为worker-1被杀了之后,虚拟机重启了,状态都重置了。 + +而在上次的场景运行之前,我们并没有重启过虚拟机,也就是说前面已经有了一段时间的内存消耗。对于稳定性场景来说,增删改查都是有的,数据量也在不断增加,所以内存会使用得越来越多。 + +这一次运行的累积业务量是3200多万: + + + +但是,问题还是出现了:通过查看宿主机的日志,我看到worker-2又被杀掉了: + +Feb 20 19:42:44 hp-server kernel: Out of memory: Kill process 7603 (qemu-kvm) score 257 or sacrifice child +Feb 20 19:42:44 hp-server kernel: Killed process 7603 (qemu-kvm), UID 107, total-vm:17798976kB, anon-rss:16870472kB, file-rss:0kB, shmem-rss:16kB +Feb 20 19:42:46 hp-server kernel: br0: port 5(vnet3) entered disabled state +Feb 20 19:42:46 hp-server kernel: device vnet3 left promiscuous mode +Feb 20 19:42:46 hp-server kernel: br0: port 5(vnet3) entered disabled state +Feb 20 19:42:46 hp-server systemd-machined: Machine qemu-4-vm-k8s-worker-2 terminated. +Feb 20 19:42:46 hp-server avahi-daemon[953]: Withdrawing address record for fe80::fc54:ff:fe5e:dded on vnet3. +Feb 20 19:42:46 hp-server avahi-daemon[953]: Withdrawing workstation service for vnet3. +[root@hp-server log]# + + +也就是说,在内存不够的情况下,杀掉哪个worker并不是固定的。至少这可以说明,宿主机真的是因为自己的内存不够用而杀掉虚拟机的。这可能就和具体的组件无关了,因为组件的内存消耗是根据运行需求来的,是合理的。 + +为什么做这样的判断呢?因为如果是某个固定的worker被杀掉,那我们可以去监控这个worker上运行的技术组件,看看是哪个组件的内存增加得快,然后进一步判断这个技术组件的内存不断增加的原因。 + +可是现在被杀掉的worker并不是固定的。根据OOM的逻辑,宿主机操作系统在内存不够用的时候才会调用OOM killer。我们前面也提到,overcommit的参数设置是1,也就是说宿主机操作系统允许内存在请求时超分。 + +但是,在宿主机真正使用内存的时候,内存不够用了,进而导致虚拟机被杀掉。这意味着,在宿主机创建KVM虚拟机时,产生了超分但并没有提供足够的可用内存,而在压力持续的过程中,虚拟机又确实需要这些内存。所以,虚拟机不断向宿主机申请内存,可宿主机没有足够的内存,因而触发了OOM killer机制。 + +这样看来,我们就得算一下内存到底超分了多少,看看是不是因为我们配置的超分过大,导致了这个问题。我们把虚拟机的内存列出来看看: + + + +我们计算一下总分配内存: +\[ 总分配内存 = 8 \\times 2 + 16 \\times 4 = 80G \]而宿主机的物理内存只有: + +[root@hp-server log]# cat /proc/meminfo|grep Total +MemTotal: 65675952 kB +SwapTotal: 0 kB +VmallocTotal: 34359738367 kB +CmaTotal: 0 kB +HugePages_Total: 0 +[root@hp-server log]# + + +也就是说宿主机的最大物理内存也只有65G左右。这也难怪,物理内存在真实使用时会不够用。 + +现在我们把虚拟机的内存降下来,让它不会产生超分,配置如下: + + + +总分配内存计算下来就是: +\[ 总分配内存 = 4 \\times 2 + 13 \\times 4 = 60G \]这样就足够用了。 + +不过,根据性能分析中,时间和空间相互转换的原则,这样可能会导致TPS降低。因为在虚拟机的操作系统内存减少时,会更早地出现page faults,也就是页错误(换页时会产生)。不过,如果只是换页,而不是出现OOM,至少不会导致虚拟机被杀掉。 + +我们再把场景跑起来,看看结果: + + + +这个结果看起来不错,虽说TPS有掉下来的时候,但是总体上是稳定的。运行时间也超过了12小时。 + +我们再来看累积业务量: + + + +这次的累积业务量超过了7200万,超过了我们定的5000万的小目标。现在是不是可以欢呼一下了? + +别高兴太早,在下节课中,你会感受到性能项目中的大起大落。 + +总结 + +今天我们讲了稳定性场景的两个要点,分别是运行时长和压力量级。要想把稳定性场景做得有意义,这两点是必备前提条件。 + +同时,你要记住一点,稳定性场景是为了找出业务积累的过程中出现的问题。所以,如果业务积累量不能达到线上的要求,就不能说明稳定性场景做得有意义。 + +此外,在这节课中,我们也分析了物理内存增加的问题。在内存的使用上,特别是在这种Kubernetes+Docker的架构中,资源分配是非常关键的。不要觉得Kubernetes给我们做了很多自动的分配工作,我们就可以喝咖啡了。你会发现,仍然有不少新坑在等着我们。 + +课后作业 + +这就是今天的全部内容,最后给你留两个思考题吧: + + +在你的项目中,怎么将这节课的稳定性理念落地? +在查找稳定性的问题时,如何设计监控策略,才能保证我们可以收集到足够的分析数据?在你的项目中是如何做的? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果这节课让你有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/27稳定性场景之二:怎样搞定磁盘不足产生的瓶颈问题?.md b/专栏/高楼的性能工程实战课/27稳定性场景之二:怎样搞定磁盘不足产生的瓶颈问题?.md new file mode 100644 index 0000000..9a14a2f --- /dev/null +++ b/专栏/高楼的性能工程实战课/27稳定性场景之二:怎样搞定磁盘不足产生的瓶颈问题?.md @@ -0,0 +1,255 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 27 稳定性场景之二:怎样搞定磁盘不足产生的瓶颈问题? + 你好,我是高楼。 + +上节课,我们讲解了稳定性场景的两个要点:运行时长和压力量级,并通过课程的示例系统,带你具体操作了稳定性场景。 + +在定向分析的第一个阶段中,我们分析了虚拟机内存超分导致的操作系统OOM的问题,发现是配置的超分过大导致的。在我们降低了虚拟机的内存之后,稳定性场景的运行时间超过了12个小时,累积业务量达到7200多万,这样的结果已经达到了我们的目标。 + +可是,由于贪心,我并没有停止场景,就在它继续运行的时候,又出现了新问题……因此,我们今天就进入到定向分析的第二阶段,看看还有什么问题在等着我们。 + +定向监控分析 + +定向分析第二阶段 + +当场景继续运行的时候,我看到了这样的数据: + + + +从图中我们可以很明显地看到,在场景持续运行的过程中,TPS掉下来了,响应时间则是蹭蹭往上涨。 + +我们看一下这时候的总业务累积量: + + + +也就是说,多了20多万的业务累积量。 + +见到问题,不分析总是觉得不那么舒服,那我们就来分析一下。 + +还是按照性能分析决策树,我们把计数器一个一个查过去。在我查看MySQL的Pod日志时,发现它一直在被删掉重建: + + + +请注意,我们这是一个示例系统,为了方便重建,我把MySQL放到Pod中了。如果是在真实的环境中,我建议你最好根据生产的实际配置来做数据库的配置。 + +讲到这里,我稍微回应一下行业里的一种声音:数据库不应该放到Kubernetes的Pod中去。我不清楚持这样观点的人,只是在感觉上认为不安全,还是真正遇到了问题。在我的经验中,很多系统对数据库的性能要求其实并不高,业务量也不大。而用容器来管理便于迁移和重建,并且性能上也完全跟得上。所以,在这种场景下用Pod也没有关系。 + +当然,也有一些系统用数据库比较狠,为了保障更高的性能,会在物理机上直接部署。如果你面对的系统,确实需要用到物理机来创建数据库,那就选择物理机。如果Pod可以满足需求,我觉得不用纠结,直接用Pod就可以了。 + +因为MySQL的Pod被删掉重建,而MySQL又位于worker-1中,那我们就来看一下worker-1的操作系统日志: + +Feb 22 00:43:16 k8s-worker-1 kubelet: I0222 00:43:16.085214 1082 image_gc_manager.go:304] [imageGCManager]: Disk usage on image filesystem is at 95% which is over the high threshold (85%). Trying to free 7213867827 bytes down to the low threshold (80%). + + +原来是分配给worker-1的磁盘被用光了,难怪MySQL的Pod一直在被删掉重建。 + +我们检查一下磁盘的配额: + +[root@k8s-worker-1 ~]# df -h +文件系统 容量 已用 可用 已用% 挂载点 +devtmpfs 6.3G 0 6.3G 0% /dev +tmpfs 6.3G 24M 6.3G 1% /dev/shm +tmpfs 6.3G 67M 6.3G 2% /run +tmpfs 6.3G 0 6.3G 0% /sys/fs/cgroup +/dev/mapper/centos-root 47G 45G 2.7G 95% / +/dev/vda1 1014M 304M 711M 30% /boot +tmpfs 1.3G 4.0K 1.3G 1% /run/user/42 +tmpfs 6.3G 12K 6.3G 1% /var/lib/kubelet/pods/9962f8d2-f6bb-4981-a073-dd16bfa9a171/volumes/kubernetes.io~secret/kube-proxy-token-vnxh9 +tmpfs 6.3G 12K 6.3G 1% /var/lib/kubelet/pods/f5872331-14b1-402b-99e0-063834d834fa/volumes/kubernetes.io~secret/calico-node-token-hvs7q +overlay 47G 45G 2.7G 95% /var/lib/docker/overlay2/e61e5b2232592ef9883861d8536f37153617d46735026b49b285c016a47179cf/merged +overlay 47G 45G 2.7G 95% /var/lib/docker/overlay2/4c057d86c1eabb84eddda86f991ca3852042da0647fd5b8c349568e2a0565591/merged +shm 64M 0 64M 0% /var/lib/docker/containers/f1e8c983be46895acc576c1d51b631bd2767aabe908035cff229af0cd6c47ffb/mounts/shm +shm 64M 0 64M 0% /var/lib/docker/containers/c7e44cdfc5faa7f8ad9a08f8b8ce44928a5116ccf912fbc2d8d8871ab00648a5/mounts/shm +overlay 47G 45G 2.7G 95% /var/lib/docker/overlay2/a685a1652586aca165f7f159347bf466dd63f497766762d8738b511c7eca1df3/merged +overlay 47G 45G 2.7G 95% /var/lib/docker/overlay2/b7da3fde04f716a7385c47fe558416b35e471797a97b28dddd79f500c62542f2/merged +tmpfs 1.3G 36K 1.3G 1% /run/user/0 +tmpfs 6.3G 12K 6.3G 1% /var/lib/kubelet/pods/d01f8686-e066-4ebf-951e-e5fe9d39067d/volumes/kubernetes.io~secret/node-exporter-token-wnzc6 +overlay 47G 45G 2.7G 95% /var/lib/docker/overlay2/5215bd987a62316b3ebb7d6b103e991f26fffea4fe3c05aac51feeb44ab099ab/merged +shm 64M 0 64M 0% /var/lib/docker/containers/d0cf9df15ac269475bb9f2aec20c048c8a61b98a993c16f5d6ef4aba2027326a/mounts/shm +overlay 47G 45G 2.7G 95% /var/lib/docker/overlay2/aa5125b01d60b19c75f3f5d018f7bb51e902264580a7f4033e5d2abaaf7cc3f6/merged +overlay 47G 45G 2.7G 95% /var/lib/docker/overlay2/3a7d3d4cddc51410103731e7e8f3fbcddae4d74a116c88582557a79252124c5d/merged +tmpfs 6.3G 12K 6.3G 1% /var/lib/kubelet/pods/34f60184-07e5-40da-b2cb-c0295d560d54/volumes/kubernetes.io~secret/default-token-7s6hb +overlay 47G 45G 2.7G 95% /var/lib/docker/overlay2/940009fca9f57e4b6f6d38bab584d69a2f3ff84153e3f0dfd3c9b9db91fa2b30/merged +shm 64M 0 64M 0% /var/lib/docker/containers/12c6a27bb53a4b0de5556a960d7c394272d11ceb46ac8172bd91f58f762cde14/mounts/shm +overlay 47G 45G 2.7G 95% /var/lib/docker/overlay2/366007a9f82dfb9bd5de4e4cadf184cba122ef2070c096f393b7b9e24ae06a98/merged +tmpfs 6.3G 12K 6.3G 1% /var/lib/kubelet/pods/251e9c86-4f25-42bd-82a0-282d057fe238/volumes/kubernetes.io~secret/nginx-ingress-token-cbpz9 +overlay 47G 45G 2.7G 95% /var/lib/docker/overlay2/1defd5a0004201a0f116f48dd2a21cba16647a3c8fdfde802fb5ea1d3e5591ff/merged +shm 64M 0 64M 0% /var/lib/docker/containers/459bf58b1cafcc9ab673d30b92ae815a093e85593ab01921b9ba6e677e36fe45/mounts/shm +overlay 47G 45G 2.7G 95% /var/lib/docker/overlay2/49197bcd5b63e30abc94315b0083761a4fd25ebf4341d2574697b84e49350d53/merged +[root@k8s-worker-1 ~]# + + +可以看到,磁盘的配置是使用到95%就会驱逐Pod,而现在的磁盘使用量已经到了配置的限额。 + +既然如此,那我们的解决方案也就非常明确了,就是把磁盘再加大一些,我们再扩大100G: + +[root@k8s-worker-1 ~]# df -h +文件系统 容量 已用 可用 已用% 挂载点 +devtmpfs 6.3G 0 6.3G 0% /dev +tmpfs 6.3G 0 6.3G 0% /dev/shm +tmpfs 6.3G 19M 6.3G 1% /run +tmpfs 6.3G 0 6.3G 0% /sys/fs/cgroup +/dev/mapper/centos-root 147G 43G 105G 30% / +/dev/vda1 1014M 304M 711M 30% /boot +tmpfs 1.3G 4.0K 1.3G 1% /run/user/42 +tmpfs 6.3G 12K 6.3G 1% /var/lib/kubelet/pods/d01f8686-e066-4ebf-951e-e5fe9d39067d/volumes/kubernetes.io~secret/node-exporter-token-wnzc6 +tmpfs 6.3G 12K 6.3G 1% /var/lib/kubelet/pods/9962f8d2-f6bb-4981-a073-dd16bfa9a171/volumes/kubernetes.io~secret/kube-proxy-token-vnxh9 +tmpfs 6.3G 12K 6.3G 1% /var/lib/kubelet/pods/251e9c86-4f25-42bd-82a0-282d057fe238/volumes/kubernetes.io~secret/nginx-ingress-token-cbpz9 +tmpfs 6.3G 12K 6.3G 1% /var/lib/kubelet/pods/f5872331-14b1-402b-99e0-063834d834fa/volumes/kubernetes.io~secret/calico-node-token-hvs7q +overlay 147G 43G 105G 30% /var/lib/docker/overlay2/7380ac7d8f83ba37ddae785e5b4cd65ef7f9aa138bfb04f86e3c7f186f54211a/merged +shm 64M 0 64M 0% /var/lib/docker/containers/3c90444a51820f83954c4f32a5bc2d1630762cdf6d3be2c2f897a3f26ee54760/mounts/shm +overlay 147G 43G 105G 30% /var/lib/docker/overlay2/c841e85e88fdcfe9852dcde33849b3e9c5a229e63ee5daea374ddbc572432235/merged +overlay 147G 43G 105G 30% /var/lib/docker/overlay2/147a81e8a50401ec90d55d3d4df3607eb5409ffe10e2c4c876c826aa5d47caf0/merged +shm 64M 0 64M 0% /var/lib/docker/containers/9e2c04b858025523e7b586fe679a429ac49df3711881261cda40b158ad05aebf/mounts/shm +overlay 147G 43G 105G 30% /var/lib/docker/overlay2/83e01c8cda50233088dc70395a14c861ac09ce5e36621f1d8fdd8d3d3e0a7271/merged +shm 64M 0 64M 0% /var/lib/docker/containers/f23362117532f08ff89f937369c3e4d2039d55a9ba51f61e41e62d725b24e3a1/mounts/shm +overlay 147G 43G 105G 30% /var/lib/docker/overlay2/0cfe0dbd0c633e13a42bd3d69bd09ea51ab4354d77a0e6dcf93cabf4c76c3942/merged +overlay 147G 43G 105G 30% /var/lib/docker/overlay2/7b83010457d86cecf3c80ebc34d9db5d26400c624cba33a23f0e9983f7791aef/merged +overlay 147G 43G 105G 30% /var/lib/docker/overlay2/0f31c96b1961d5df194a3710fdc896063a864f4282d7a287b41da27e4d58a456/merged +overlay 147G 43G 105G 30% /var/lib/docker/overlay2/f67a6de6a1b18d4748581230ed7c34c8f16d8f0dd877a168eb12eacf6bf42f05/merged +shm 64M 0 64M 0% /var/lib/docker/containers/e3eb1ea1785e35045213518dd6814edcd361b501748b8e6bdede20c8961062d2/mounts/shm +overlay 147G 43G 105G 30% /var/lib/docker/overlay2/5cec0d1a7723dfcb0e5eaf139f4965a220575557795ad2959ce100aa888dc12b/merged +tmpfs 1.3G 32K 1.3G 1% /run/user/0 +tmpfs 6.3G 12K 6.3G 1% /var/lib/kubelet/pods/704657eb-ea28-4fb0-8aee-c49870e692d3/volumes/kubernetes.io~secret/default-token-7s6hb +overlay 147G 43G 105G 30% /var/lib/docker/overlay2/da4073b560a2ce031fa234624c09757b65eb7b6cfc895186dbf8731e2d279fee/merged +shm 64M 0 64M 0% /var/lib/docker/containers/76a6814a838778049495e9f8b2b93e131d041c8f90e8dea867d3c99fa6ca918b/mounts/shm +tmpfs 6.3G 12K 6.3G 1% /var/lib/kubelet/pods/a73b0bc6-76fc-4e2a-9202-380397399b76/volumes/kubernetes.io~secret/default-token-7s6hb +overlay 147G 43G 105G 30% /var/lib/docker/overlay2/cea9c51e718964cc46824ba51ff631a898402318c19e9603c6d364ac3bed8a27/merged +shm 64M 0 64M 0% /var/lib/docker/containers/d936e646d12f7b8381a36e8a11094d76a0a72d95f84edf3f30c7e8b3981264e0/mounts/shm +overlay 147G 43G 105G 30% /var/lib/docker/overlay2/4c210c659428999d000676fde7f1c952f1f43d68b63b308fa766b0ce41568f06/merged +[root@k8s-worker-1 ~]# + + +这样就完美地解决了MySQL中的Pod一直在被删掉重建的问题,对吧! + +不过,我们高兴得有些早了,正当我把这次的稳定性场景接着跑起来的时候,结果还没出来,又出现了新问题!于是,我们只得来到定向分析的第三阶段。 + +定向分析第三阶段 + +在上一阶段中,我们发现了MySQL的Pod被删掉重建。于是,当场景再次运行起来时,我就直接查看了所有namespace中的Pod状态,结果看到了下面的信息: + + + +不知道什么原因,Order容器一直在被驱逐。 + +通过查看日志,我发现了这样的信息: + + + +DiskPressure!这就是说Order服务所在的worker上的存储被用完了呗。在Order应用的虚拟机中,我通过查看Kubernetes日志,发现了这样的错误信息: + +kubelet: W0222 14:31:35.507991 1084 eviction_manager.go:344] eviction manager: attempting to reclaim ephemeral-storage + + +这是Order应用的存储已经用完了,并且尝试扩展存储。我们接着查Kubernetes日志,看到了“The node was low on resource: ephemeral-storage”的提示。 + +对Order服务来说,不就是写个日志吗?其他的应用方法都是在用CPU,到底什么Order服务会用这么多的磁盘呢?我们查一下日志,结果看到了这样的错误信息: + +[2021-02-25 20:48:22.022] [org.apache.juli.logging.DirectJDKLog] [http-nio-8086-exec-349] [175] [ERROR] Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.jdbc.UncategorizedSQLException: +### Error querying database. Cause: java.sql.SQLException: Index oms_order is corrupted +### The error may exist in URL [jar:file:/app.jar!/BOOT-INF/lib/mall-mbg-1.0-SNAPSHOT.jar!/com/dunshan/mall/mapper/OmsOrderMapper.xml] +### The error may involve com.dunshan.mall.mapper.OmsOrderMapper.selectByExample-Inline +### The error occurred while setting parameters +### SQL: SELECT count(0) FROM oms_order WHERE (delete_status = ? AND member_id = ?) +### Cause: java.sql.SQLException: Index oms_order is corrupted +; uncategorized SQLException; SQL state [HY000]; error code [1712]; Index oms_order is corrupted; nested exception is java.sql.SQLException: Index oms_order is corrupted] with root cause +java.sql.SQLException: Index oms_order is corrupted + + +这样的错误有很多,并且还增加得非常快。难道是Order表坏了? + +在Order服务的日志中,我们可以看到明显是SQL执行报错了,但是我们在上面已经增加了MySQL机器的硬盘,按理说不应该是MySQL的磁盘不够导致的这类报错。所以,我们再来看看MySQL错误日志: + +156 2021-02-25T11:26:35.520327Z 0 [Note] InnoDB: Uncompressed page, stored checksum in field1 2147483648, calculated checksums for field1: crc32 1193184986/3495072576, innodb 846701958, none 3735928559, stored checksum in field2 2147483659, calculated checksums for field2: crc32 1193184986/3495072576, innodb 810726412, none 3735928559, page LSN 0 0, low 4 bytes of LSN at page end 3836608512, page number (if stored to page already) 327680, space id (if created with >= MySQL-4.1.1 and stored already) 0 +157 InnoDB: Page may be a freshly allocated page +158 2021-02-25T11:26:35.520373Z 0 [Note] InnoDB: It is also possible that your operating system has corrupted its own file cache and rebooting your computer removes the error. If the cor rupt page is an index page. You can also try to fix the corruption by dumping, dropping, and reimporting the corrupt table. You can use CHECK TABLE to scan your table for corruption. Please refer to http://dev.mysql.com/doc/refman/5.7/en/forcing-innodb-recovery.html for information about forcing recovery. +159 2021-02-25T11:26:35.520408Z 0 [ERROR] InnoDB: Space id and page no stored in the page, read in are [page id: space=779484, page number=3836674048], should be [page id: space=111, pag e number=150922] +160 2021-02-25T11:26:35.520415Z 0 [ERROR] InnoDB: Database page corruption on disk or a failed file read of page [page id: space=111, page number=150922]. You may have to recover from a backup. +161 2021-02-25T11:26:35.520420Z 0 [Note] InnoDB: Page dump in ascii and hex (16384 bytes): +............. + + +从上面的信息来看,应该是数据库的文件损坏了,从而导致了报错。 + +那我们就尝试一下直接在MySQL中对这个表进行操作,会不会报错。结果得到如下信息: + +[Err] 1877 - Operation cannot be performed. The table 'mall.oms_order' is missing, corrupt or contains bad data. + + +这个错误日志提示说,操作系统的文件有可能出现了问题,建议重启操作系统。既然系统有建议,那咱就重启一下吧。 + +然而,世事无常,处处是坑,我们在重启操作系统时看到了这个界面: + + + +想哭对吧?现在居然连磁盘都找不着了…… + +回想刚才对磁盘的操作,我们在增加磁盘空间时,直接用的是这个命令: + +qumu-img resize imagename +100G + + +我在查看了官方资料之后发现,这个命令虽然能扩容,但是要先关闭虚拟机,不然磁盘会出问题。而我们当时并没有关虚拟机。没办法,我们只有把虚拟机重建一下了。 + +经过一翻折腾,我们再次把数据库启动,等应用都正常连上之后,再次把稳定性场景运行起来,看到了这样的结果: + + + +此时的业务累积量为: + + + +从场景数据来看,TPS有所下降,这是因为我们在压力过程中,所有的数据一直都是累加的。累加得越多,数据库中的操作就会越慢,我们看一下worker-1中,数据库的资源使用量就可以知道: + + + + + +你看,这里面的系统负载和IO吞吐,都因稳定性运行时间越来越长、数据量越来越多受到了影响。我们后续的优化思路也就非常明确了,那就是保证当前表的数据量级,像分库分表、定期归档这样的手段就可以上了,这些操作都是常规操作。 + +在我们当前这个场景中,由于稳定性目标已经达到,就不再接着往下整了。毕竟,性能优化是没有止境的。 + +总结 + +正如我们前面所说,在稳定性场景中,我们要关注的就是“运行时长”和“业务累积量”这两个指标。只要达到了这两个指标,稳定性场景即可结束。 + +在这节课中,我们分析了长时间运行导致的两个问题: + + +磁盘不足的问题 + + +这样的问题在稳定性中也是经常出现的。因此,我们在稳定性场景运行之前,最好预估一下会用到多少磁盘。像日志之类的增长速度,一定要做好循环日志,不要保留太长时间。如果你想保留,那就移到其他地方去。 + + +数据库文件损坏的问题 + + +这是扩展磁盘空间导致的问题。虽然在操作的过程中,磁盘看似成功扩展了,但由于操作的不当导致了数据库文件损坏。这类问题虽然是操作上的问题,但在操作的过程中我们却没有看到任何的错误信息和提醒。 + +另外,我们还需要充分考虑数据库的累积量。我建议你先通过计算来判断,当数据库的数据量增加后,会对应用产生什么样的影响。磁盘要能保证数据库当前表的数据量,以及数据增长过程中的查询性能。 + +在这里,我无法穷举出稳定性中的所有问题,只有通过实际案例给你一个分析的思路,供你借鉴。在你的应用场景中,你可以根据RESAR性能工程理论分析具体的问题。 + +总之,在性能工程中,稳定性场景对系统来说是一个严峻的考验。“合情合理地设计稳定性场景,并且做到符合生产业务场景”是我们必须要达到的目标。 + +课后作业 + +这就是今天的全部内容,最后给你留两个思考题吧: + + +你在稳定性场景中遇到过什么由于业务不断累积导致的问题? +在稳定性场景中,如何保证磁盘使用量不会随场景持续增加,而是保持在一个使用量级? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果这节课让你有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/28如何确定异常场景的范围和设计逻辑?.md b/专栏/高楼的性能工程实战课/28如何确定异常场景的范围和设计逻辑?.md new file mode 100644 index 0000000..9f1ad9f --- /dev/null +++ b/专栏/高楼的性能工程实战课/28如何确定异常场景的范围和设计逻辑?.md @@ -0,0 +1,172 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 28 如何确定异常场景的范围和设计逻辑? + 你好,我是高楼。 + +在性能的领域中,异常场景一直都处在薄弱的环节,大家都觉得异常场景应该做,但是又不知道怎么做才能把异常问题覆盖全面。 + +异常范围之所以比较难确定,是因为有很多问题都被归纳到了“异常”中,比如说高可用、可靠性、灾备等等。当然,也有些企业把这部分归纳到非功能中来做,因此在性能的项目中就不会有异常场景了。 + +在我的RESAR性能工程理论中,异常场景是必须要做的,这主要是因为异常场景需要压力背景。 + +既然要做异常场景,我们具体该怎么做?测试哪些问题才能将异常场景覆盖完整?这就需要我们明确两个关键点:一是异常场景的范围,二是异常场景的设计逻辑。 + +因此,在这节课中,我们就来看看如何确定异常场景的范围和设计逻辑。 + +异常场景的范围 + +在以前的异常场景中,基本上采用的是宕主机、断网络、宕应用这几种测试手段。此外,从主机、网络、应用等视角出发,还会有一些细分操作,比如说: + + +主机:断电、reboot、shutdown等; +网络:ifdown命令关网卡、模拟抖动丢包延时重传等; +应用:kill、stop等。 + + +上述这些操作在当前新的技术架构中仍然有效,只不过现在会有更多的细分操作。因为现在微服务的应用多了之后,又多出了几层,比如虚拟机层、容器层、网关层等等。我在这里画一张图,大概罗列一下异常场景测试的不同角度: + + + +关于做异常场景的范围和时机,有两个话题也一直在争论: + + +异常场景到底应不应该放到性能项目中完成? +异常场景到底包括什么样的内容? + + +对于第一个问题,我是这样考虑的:不管是代码逻辑验证、功能验证、还是性能验证,我们只要模拟出真实的异常场景,都会有异常场景的细分。在当前的测试市场中,有很多企业也确实这样做了,这是一个好现象。而这些异常场景需要在有压力背景的前提下进行,所以它应该放到性能项目中来完成。 + +因为如果把这一类场景放在其他阶段完成,像脚本、参数、监控等这些工作都要重复做。如果还需要不同的团队共同完成,那成本显然会增加。 + +对于第二个问题,你可能会感到奇怪,上面那张图不是已经把异常场景包含的内容全都列出来了吗?这里怎么还要提呢?这主要是因为在技术市场中,有很多不同的声音和视角,一些人觉得在异常场景中也还应该包含高可用、可靠性、可扩展、可伸缩、稳定性等内容。 + +其实,对于这些技术名词,我们很多时候都似懂非懂,感觉自己知道是怎么一回事,但是又抓不住重点。就拿可靠性为例,可靠性在实施的过程中,我们能想到的就是一个系统在一定的时间和条件下无故障地运行。可是,既然如此,那“稳定性”又是什么呢?我们知道,稳定性是指在规定的一定长的时间内系统无故障运行。 + +咦,怎么看起来意思差不多?“可靠性”和“稳定性”到底有啥区别呢?在我看来,稳定性包括在了可靠性之内。 + +我这么一说,你应该就明白了,在当前技术市场中,虽然有很多人提出了不同的视角,但是,如果我们把这些视角对应的落地步骤罗列一下,你就会发现,它们都能落在我刚才讲的这张图里。 + + + +因此,请你记住,在异常场景中,我们只要包含图中的这些内容就足够了。 + +异常场景的设计逻辑 + +从逻辑上来说,异常场景的设计主要分为两步: + + +分析架构:把技术架构中的组件全部列出来,并分析可能产生异常的点。 + +列出异常场景:根据分析的异常点设计对应的场景。 + + +这样的逻辑看起来并不复杂,如果我们只从组件级来考虑,那就可以设计通用的异常场景了。但是,如果从业务逻辑异常的视角来看,就没有通用的异常场景了,我们需要针对不同业务设计不同的异常场景。 + +不过,在性能领域中,大部分人对异常场景没有什么设计套路,都是跟着感觉走的,而且即便是遵循上述这两个步骤设计异常场景,也必然会涉及到一个问题:异常场景覆盖得全不全? + +对于这个问题,我建议你在异常场景的设计逻辑中,参考FMEA失效模型的逻辑,因为FMEA至少是一套有逻辑的设计思路,可以让我们有章可循。 + +FMEA在性能行业中使用率并不高,大家对它基本上处于不明就理的状态。在我深入了解了FMEA之后,觉得它作为一套分析失效模型的方法策略,可以被应用在性能项目的异常场景设计中。因此,如何把FMEA借鉴到异常场景中,是我们接下来要讨论的一个问题。 + +我在这里先给你简单介绍一下FMEA。 + +FMEA是一套做事的逻辑,它最初被用于战斗机的操作系统设计分析中,后来又被广泛应用于航天、汽车、医疗、微电子等领域。 + +FMEA是Failure Mode&Eeffect Criticality Analysis的缩写,中文含义是潜在失效模型或影响的严重等级分析,它又分为DFMEA、PFMEA和FMEA-MSR: + + +DFMEA,也就是Design FMEA,是针对设计阶段的潜在失效模式分析; +PFMEA是指Process FMEA,它针对的是过程; +FMEA-MSR是“FMEA for Monitoring and System Response”的缩写,也就是“监控和系统响应”的意思,它通过分析诊断监控和系统响应(MSR)来维护功能安全。 + + +FMEA的这三个细分采用的逻辑是一致的,只是针对的阶段和关键点不同而已。 + +如今,在IT技术圈中,也不乏有人尝试在软件系统中落地。在FMEA中,最重要的就是下面这样的表格,你在网上也经常能看到。 + + + +我解释一下表格中的“RPN”,它是Risk Priority Number的缩写,意思是风险系数、风险优先级。RPN是“严重度 S”、“频度 O”、“探测度 D”三个的乘积。至于表格中其他的名词,你看了字就能大概理解,我在这里就不多啰嗦了。 + +看到这样的表格,你是不是觉得它很难在IT架构中使用?其实,在异常场景具体落地的时候,我们可以根据自己的理解,把表格做一些变化: + + + +我在表格中加了一个“系统”列,这是因为一些项目有多个系统。当然,你也可以不加这一列,把整个表格命名为某系统的表格。至于其他名词,我只是做了相应的调整,并没有改变原有的表结构。 + +在我们填写这张表格之前,有一点我要说明一下。在FMEA中,严重度、频率、探测度需要各自分级,并且都分为1~10这10个等级。下面我大概列一下这三个方面不同级别的含义。请你注意,我只是描述相对通用的内容,尽量不和业务挂勾。 + + +严重度S- + + +频率O- + + +测试度D- + + + +对于你自己的系统,不一定要完全照搬上面表格中对等级标准的划分,不过,逻辑还是可以借鉴的。 + +现在,我给你举一个异常用例,来看看严重度、频率和探测度这三个角度具体是怎么落地的: + + + +对应着这张表格,你应该知道怎么列出你自己的异常场景了。 + +请你注意,即便你想用FMEA来设计异常场景,我在这节课一开始画的那张图仍然是不可或缺的,因为那是这张表格的输入条件之一。也就是说,在填写这张表格之前,我们一定要清楚在异常场景中测试哪些内容,这一点非常重要。 + +不过,有了这张表格,以及严重度、频率和探测度的10个等级之后,异常场景一下子就变得复杂了。因为PRN有太多的可能性,具体算下来的话,应该有10X10X10=1000种PRN值。 + +如果我们把系统中的全部异常场景都列出来,那就得按PRN的值从上到下挨个执行了。假设,PRN为1000的场景有10个;PRN为900~1000的场景有20个……..这样一个一个数下去,都要吐了对不对? + +记得之前我跟一个IT经验非常丰富的朋友聊天,我们说到写异常用例这个话题。他说,如果让他来设计异常用例,针对一个系统设计出上万甚至更多的用例基本不在话下。然后,我说:“那你设计的这些异常用例,在生产上出现的概率是多少呢?如果系统运行到寿终正寝都没出现这些情况,那要这些用例有什么用呢? + +通过这段对话,你可以思考一个问题:我们是不是非得把自己系统的异常场景弄这么复杂呢? + +当然不是,其实我们可以做简化,比如说把等级减少。我们在前面讲到,在FMEA中,严重度、频率和探测度分别都定义了10级,那对于系统的异常场景,我们定义三四个等级就可以了。如果你要较真,觉得三四个等级不合适的话,那你可以根据自己系统的情况来用这个逻辑,具体怎么用就要看你系统的重要性有多高了。 + +总体来说,FMEA是一套非常完整的逻辑,它的第四版白皮书就有130多页,你要是有兴趣,可以去看看。 + +其实,在FMEA落地到异常场景测试的过程中,套用FMEA并不复杂,复杂的是如何制定S、O、D。因为在具体制定的时候,并不像我在前面列出三个表格那么简单,它需要拿系统的逻辑来进行详细分析。 + +接下来我就得摆摆观点了,请你记住,对任何一套方法论逻辑的落地实施,都不要过度使用,而要注意合理使用。从我接触过的老外的思路和逻辑来看,他们很喜欢弄一些RESEARCH方面的功能,并延伸出一套理念,然后拿着这套理念就可以忽悠一辈子。 + +记得在我带过的团队中有一个老外,是一个年轻小伙,他一直在做缺陷管理员的工作,也就是天天去追Bug的修复进度。有一天,他找我说想离职,我问他:“那你想干什么呢?”他说:“我想做RESEARCH”。我继续问:“那你想研究个啥呢?”他回答说:“我还没想好,但是我想做RESEARCH。”他说的时候,似乎觉得RESEARCH是一个挺高端的事业。我微微一笑说:“行,那你去吧。” + +我讲这一段,不是说FMEA也是没有经过深思熟虑的方法论,而是想说,我们在看待外来的理念时,一定要保持冷静,至于哪里的月亮更圆更亮,取决于时间。对于FMEA在性能中的应用,我们同样也要理智地使用。同时,我们也应该有自己完整的思维能力。 + +因此,我建议你在异常场景设计时,可以参考FMEA中的逻辑,把不适用的部分给清理掉,设计出符合自己系统的失效模型。而我们这节课的描述也只是给你一个思路,因为授人以鱼不如授人以渔,才是我的初衷。 + +上述内容就是我针对异常场景的设计,所做的尽可能全面的描述了。请注意,其关键点不是FMEA,而是上文中的异常场景范围图。 + +可是,叭叭地讲了这么多,如果不落地,实在不符合我的风格。所以,我们还是要有具体的操作实例的。在下节课中,我们就按照这节课一开始画的视角,做几个实际的案例来看一看,异常场景具体该怎么执行。 + +总结 + +针对异常场景,在性能行业中各有各的看法,并且谁都说服不了谁,这就导致每个企业做的异常场景范围都不一样。同时,行业中又有很多关于混沌测试、非功能测试的不同说法。因此,异常场景一直都没有在性能项目中固定下来。 + +而在RESAR性能工程理念中,对于有压力背景的异常场景来说,我觉得由性能人员来完成它,是责无旁贷的。 + +通过这节课,我想告诉你的就是异常场景的范围应该有多大,以及设计的逻辑应该是怎样的。有了这些内容之后,异常场景的覆盖率就会足够全,并且也有章可循了。 + +课后作业 + +最后,我给你留两道题,请你思考一下: + + +你做过哪些异常场景设计?说说你的设计思路。 +你遇到过什么样的异常问题,请举例说明一下。 + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果你读完这篇文章有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/29异常场景:如何模拟不同组件层级的异常?.md b/专栏/高楼的性能工程实战课/29异常场景:如何模拟不同组件层级的异常?.md new file mode 100644 index 0000000..6dac47a --- /dev/null +++ b/专栏/高楼的性能工程实战课/29异常场景:如何模拟不同组件层级的异常?.md @@ -0,0 +1,532 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 29 异常场景:如何模拟不同组件层级的异常? + 你好,我是高楼。 + +上节课我们讲到,在具体的项目中,异常场景如果想做得细而全,是不可能的,成本高不说,收益看起来也并不大。 + +不过,我们在判断异常场景的范围和设计异常场景的时候,还是要注意把整个架构中的所有层级的组件都覆盖全,不能遗漏。这就要求异常场景的设计者必须对架构有足够的了解,这也是设计的难点。 + +在当前的技术市场中,应用异常、操作系统异常、容器异常和虚拟机异常是大家经常考虑的几个异常场景,而这些场景也基本上模拟了微服务分布式架构中非常常见的异常问题。 + +因此,这节课,我就带你从这几个异常场景出发,看看怎么解决设计上的一些难点。 + +应用异常 + +在应用的异常场景中,我会用限流、降级熔断这两个示例来给你讲解。在传统的异常场景中,并没有这两个异常场景。不过,在当前微服务发展迅猛的技术市场中,限流和降级熔断是必不可少的。 + +这里我选择用Sentinel工具(如果你对工具本身不熟悉的,请搜索下网上的资源),它是面向分布式服务架构的流量防护组件,主要以流量为切入点,从限流、熔断降级、系统负载保护、热点防护等多个维度来帮助开发者保障微服务的稳定性。 + +当然你也可以用其他的工具来实现,只要在设计上符合我们在上节课中讲的这张图就可以了: + + + +限流 + +对于限流来说,我们首先得知道系统中有多大的流量,在我们这个异常场景中,可以通过实时的监控看到每个服务的流量。 + +在我们的这个架构中,Order服务显然是个重点服务,所以,这里我用Order服务来做演示。 + +为了让限流产生效果,我们要把压力产生的TPS限制在限流的配置以下,根据第21讲中的优化结果,我们知道支付前查询订单列表的TPS能达到700~800TPS。现在我把限流设置在100以下,规则配置如下: + + + +配置了以后,我们再去看实时流量中的Order服务: + + + +可以看到,Order服务通过的QPS确实被限制在了100,100就是我们在上一步配置限流规则时设置的单机阈值。我说明一下,这个工具中的QPS,就是每秒请求数,而我在压力工具中是直接用一个请求对应一个事务的,所以,你可以把QPS看成TPS。 + +通过上图“通过QPS”和“拒绝QPS”的数值来看,只有约20%的请求通过了,其他的都被拒绝了。对应到压力工具中,支付前查询订单列表的TPS图如下: + + + +你看,TPS下降了很多。但是,我们同时也可以看到了大量的报错。这时候我们就要注意了,看看这个报错合理不合理。 + +如果是最终用户,他看到的应该是“系统繁忙,请稍后再试”这样的提示,而不是“http error code”。所以,如果这里是因为我们在脚本中做了断言而报错,那就要修改下断言的判断内容了。而在我的脚本中,由于只断言了http 200,对其他的http返回码都报错,我们才会在Errors图中看到很多的红色错误。 + +如果你想处理这样的错误,可以在代码中加上友好返回的逻辑,但我们现在是在分析性能问题,所以对这个功能点的优化建议只要提给开发就可以了。 + +那对应的系统资源呢? + + + +可以看到,系统资源使用率也下降了,这就达到了限流的效果,至少我们保证了这个服务自己不会死。 + +对应到Sentinel中的数据,当我们把限流规则删掉之后,看到请求也是可以恢复的: + + + +所以说,在我们这个案例中,限流是生效的,并且效果还挺明显。 + +降级熔断 + +针对降级熔断,我们也做个案例看一下。 + +我把Portal服务的最大响应时间设置为10ms。请你注意哦,在我们这个案例中,我使用的是打开首页这个业务,而打开首页业务的响应时间都是大于10ms的。所以,我们看看降级规则有没有用。 + +我们先配置一下降级规则,主要参数有: + + +资源名,也就是服务名; +最大RT(响应时间); +比例阈值,就是当慢的请求超过设置的比例阈值时就会被熔断; +熔断时长,也就是熔断的时间长度,在这个时间之后TPS就会恢复; +最小请求数,即允许有多少请求通过。 + + + + +然后,我们把打开首页的压力场景跑起来并持续运行一会,看看TPS曲线是个什么样子: + + + +这个结果很清楚,当场景运行了一段时间之后,由于响应时间大于降级规则中的最大响应时间10ms,所有请求随后被熔断10s,而在这10s中,TPS是直接掉到零的,并且也报了错。 + +我们再来看Sentinel中的TPS曲线图,是否和上图中的TPS曲线一致: + + + +可以看到,Sentinel中的TPS曲线图和上图中的TPS曲线是一致的,说明降级熔断规则确实生效了。因为没有小于10ms的请求,所以在降级熔断区间一直在报错。当我们删除了规则之后,TPS也就恢复了。 + +我们重新设置一下最大响应时间为30ms,因为打开首页的平均响应时间在30ms以下,这时我们来看一下降级熔断的效果如何。注意,熔断时长为10s。 + + + +我们来看看对应的TPS图: + + + +报错断断续续的;TPS时而掉下来,但也有恢复的时候。这就是当打开首页的响应时间超过30ma的时候,TPS就会断一次,并且一次断10s,然后又恢复。刚恢复的时候又判断出有响应时间大于30ms的请求,于是又接着熔断……因此,我们看到了这样的结果。 + +这说明我们制定的降级熔断规则生效了。 + +在上述限流和降级熔断的两个例子中,有两点需要你作出判断: + + +规则有没有生效; + +终端用户看到的是什么界面,如果看到的不是友好界面,你就可以提Bug了。 + + +下面我们来模拟一下操作系统级别的异常。 + +操作系统级别异常 + +我们知道,操作系统有好几个层级,包括物理机和虚拟机,此外,有些企业的Pod中也有全量操作系统。在这里,我们用虚拟机(也就是我们的worker机器)级别的操作系统来模拟异常。如果你想在项目中做全,也可以采用同样的逻辑把每个层级的操作系统都做一遍。 + +在这里我用CPU、内存、网络、磁盘四个角度来模拟操作系统中的异常,因为这几个是操作系统中最重要的资源了。 + +CPU异常 + +我们先来看CPU的异常。 + +请你注意,在模拟CPU异常的时候,我们一定要知道是从哪个角度模拟的。 + +如果你想模拟应用本身消耗CPU高,那就得去改代码了。如果没改代码,CPU就已经很高了,那就是明显的Bug。像这样的Bug,我们在第22讲的案例中已经写过,你可以再回顾下怎么处理。 + +而CPU异常模拟,有两种情况: + +1.在应用中模拟业务线程之间抢CPU的异常; + + +在同一台机器上的其他进程抢被测业务进程的CPU。 + + +在这里,我们来模拟一下CPU被其他进程占用的异常。 + +我们先查看一下当前的CPU消耗情况: + +%Cpu0 : 46.4 us, 2.7 sy, 0.0 ni, 48.8 id, 0.0 wa, 0.0 hi, 2.0 si, 0.0 st +%Cpu1 : 29.4 us, 4.2 sy, 0.0 ni, 64.0 id, 0.0 wa, 0.0 hi, 2.4 si, 0.0 st +%Cpu2 : 37.8 us, 3.8 sy, 0.0 ni, 55.6 id, 0.0 wa, 0.0 hi, 2.4 si, 0.3 st +%Cpu3 : 26.0 us, 4.6 sy, 0.0 ni, 67.4 id, 0.0 wa, 0.0 hi, 1.8 si, 0.4 st +%Cpu4 : 33.7 us, 4.8 sy, 0.0 ni, 59.1 id, 0.0 wa, 0.0 hi, 2.4 si, 0.0 st +%Cpu5 : 29.9 us, 3.8 sy, 0.0 ni, 63.6 id, 0.0 wa, 0.0 hi, 2.7 si, 0.0 st + + +从上面的数据来看,在当前的压力场景下,us cpu用到了30%左右,而id cpu还有60%左右。显然,操作系统还是有空闲的CPU可以用的。 + +接下来,我们使用stress命令模拟CPU被消耗完,我打算把6个C全都占完: + +stress -c 6 -t 100 + + +然后我们用top命令查看一下效果: + +%Cpu0 : 97.3 us, 2.3 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.3 si, 0.0 st +%Cpu1 : 93.5 us, 2.4 sy, 0.0 ni, 2.4 id, 0.0 wa, 0.0 hi, 1.7 si, 0.0 st +%Cpu2 : 98.0 us, 1.3 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.7 si, 0.0 st +%Cpu3 : 98.0 us, 1.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 1.0 si, 0.0 st +%Cpu4 : 97.7 us, 1.3 sy, 0.0 ni, 0.3 id, 0.0 wa, 0.0 hi, 0.7 si, 0.0 st +%Cpu5 : 94.2 us, 3.1 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 2.7 si, 0.0 st + + +看,us cpu占用很高了! + +我们再执行vmstat命令,对比一下模拟前后的数据: + +模拟前: +[root@k8s-worker-6 ~]# vmstat 1 +procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu----- + r b swpd free buff cache si so bi bo in cs us sy id wa st +10 0 0 6804936 140 4640292 0 0 1 5 1 2 12 3 85 0 0 + 3 0 0 6806228 140 4640336 0 0 0 0 12290 15879 21 5 74 0 0 + 1 0 0 6806972 140 4640336 0 0 0 0 11070 13751 24 5 71 0 0 + 1 0 0 6808124 140 4640416 0 0 0 9 10944 13165 27 5 68 0 0 + 6 0 0 6806400 140 4640504 0 0 0 0 11591 14836 24 6 71 0 0 +11 0 0 6801328 140 4640516 0 0 0 0 11409 13859 31 6 63 0 0 +模拟后: +[root@k8s-worker-6 ~]# vmstat 1 +procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu----- + r b swpd free buff cache si so bi bo in cs us sy id wa st +27 0 0 7072940 140 4363564 0 0 1 5 1 2 12 3 85 0 0 +30 0 0 7072244 140 4363620 0 0 0 0 10523 6329 97 3 0 0 0 +40 0 0 7052732 140 4363584 0 0 472 176 11478 8399 95 5 0 0 0 +40 0 0 7070636 140 4363660 0 0 0 0 9881 6546 98 2 0 0 0 +28 0 0 7074060 140 4363676 0 0 0 0 9919 6520 98 2 0 0 0 +38 0 0 7074180 140 4363688 0 0 0 0 10801 7946 97 3 0 0 0 +34 0 0 7074228 140 4363692 0 0 0 0 10464 6298 97 3 0 0 0 + + + +看到没?us cpu使用率很高,CPU队列也长了很多,in并没有什么太大的变化,不过cs倒是小了很多。这说明我们并没有模拟出CPU争用的情况,只是消耗了CPU而已。 + +这时候,压力工具中的曲线效果如下: + + + +从TPS和响应时间上来看,业务确实是慢了。但是,没有报错哦。我们这个场景就是典型的CPU不足把应用拖慢的情况了。 + +内存异常 + +内存异常也是性能分析中的一大要点,我们用下面这个命令来模拟一下。在这个命令中,我们用30个工作线程模拟分配10G的内存并持续50秒: + +stress --vm 30 --vm-bytes 10G --vm-hang 50 --timeout 50s + + +在压力持续期间,我把上面这个命令执行了两次,我们来看看TPS曲线效果: + + + +可以看到,应用照样没有报错,只是响应时间增加了很多。 + +从CPU异常和内存异常这两个例子中,相信你不难发现,操作系统还是比较坚强的,即便是资源不够用了,它也在努力为你服务。 + +那对于这两个例子中的异常,我们后续的步骤是什么呢? + +首先,我们肯定得找到问题点在哪里,然后把它解决掉。至于具体的分析步骤,就是我们在第3讲中描述的RESAR性能工程的分析逻辑了。 + +如果在生产环境中出现CPU或内存问题的话,请注意,最重要的是系统能不能快速恢复。因此,如果你在执行异常场景时,看到CPU或内存消耗高、TPS下降或者响应时间增加等问题,并且在模拟异常没有停止的时候,TPS和响应时间也一直没有恢复,那你就可以报Bug了。因为我们期望的是业务能恢复。 + +网络异常 + +网络可以说是非常大的一块知识点,它涉及到的细节太多了。不过,做性能分析的人一定要懂网络,不是要学得有多深,而是要会判断问题。我选择两个常见的网络异常案例:丢包和延迟,来给你具体讲讲。 + +事先说明一下,我用的是操作系统的tc命令,来模拟网络丢包和延迟,因为这是最简单直接的手段了,现在有很多混沌工具也是用这个命令来实现的。我觉得用命令可能更直接点,不用装什么工具,快捷方便。 + +丢包 + +我们先模拟丢包10%: + +tc qdisc add dev eth0 root netem loss 10% + + +然后查看对应的压力工具曲线: + + + +可以看到,在模拟丢包的过程中,我们只丢包了10%,并不是全丢,TPS就已经从200左右降到80左右。 + +而TCP层发现了丢包之后,会根据TCP重传机制触发重传(这个重传的逻辑你可以自行搜索一下)。如果这时候我们抓包的话,会看到retransmission的包。 + +在这种情况之下,响应时间就会增加,但是业务还没到报错的程度,这一点我们在上图中也可以看到。 + +为了看到当前的网络健康状态,我们在丢包过程中,使用ping命令来查看一下: + +C:\Users\Zee>ping 172.16.106.79 -t + + +正在 Ping 172.16.106.79 具有 32 字节的数据: +来自 172.16.106.79 的回复: 字节=32 时间=79ms TTL=62 +来自 172.16.106.79 的回复: 字节=32 时间=57ms TTL=62 +来自 172.16.106.79 的回复: 字节=32 时间=74ms TTL=62 +来自 172.16.106.79 的回复: 字节=32 时间=60ms TTL=62 +来自 172.16.106.79 的回复: 字节=32 时间=55ms TTL=62 +请求超时。 +请求超时。 +来自 172.16.106.79 的回复: 字节=32 时间=71ms TTL=62 +来自 172.16.106.79 的回复: 字节=32 时间=75ms TTL=62 +来自 172.16.106.79 的回复: 字节=32 时间=71ms TTL=62 +来自 172.16.106.79 的回复: 字节=32 时间=71ms TTL=62 +来自 172.16.106.79 的回复: 字节=32 时间=62ms TTL=62 +请求超时。 +来自 172.16.106.79 的回复: 字节=32 时间=51ms TTL=62 +来自 172.16.106.79 的回复: 字节=32 时间=64ms TTL=62 +来自 172.16.106.79 的回复: 字节=32 时间=74ms TTL=62 +来自 172.16.106.79 的回复: 字节=32 时间=83ms TTL=62 +来自 172.16.106.79 的回复: 字节=32 时间=69ms TTL=62 + + +明显是丢包了对不对?从逻辑上来看,丢包重传会导致TPS下降和响应时间增加,但是并不会报错,这就是TCP功劳了。 + +不过,要是在模拟的整个过程中,业务都没有自动恢复,那你就要报Bug了。因为对于成熟的架构来说,应该自己就能判断出丢包的应用节点,并做流量的转发控制,这也是集群的策略应该保证的。 + +延迟 + +延迟问题很常见,偶尔出现的大流量或者网络设备资源争用,都有可能导致网络延迟。我们在延迟高的时候,要关注一下网络设备,比如路由器、交换机,看看它们有没有出现延迟高的问题。最后,还有一个环节不要忘了,就是防火墙,因为防火墙也可以配置惩罚规则,导致延迟增加。 + +我们在这里给本机网络加上100ms的延迟,网络延迟的模拟命令如下: + +tc qdisc add dev eth0 root netem delay 100ms + + +要想判断延迟有没有出现也比较容易,直接用ping命令就可以,ping后的效果如下: + +64 bytes from 172.16.106.79: icmp_seq=73 ttl=64 time=0.234 ms +64 bytes from 172.16.106.79: icmp_seq=74 ttl=64 time=0.259 ms +64 bytes from 172.16.106.79: icmp_seq=75 ttl=64 time=0.280 ms +64 bytes from 172.16.106.79: icmp_seq=76 ttl=64 time=0.312 ms +64 bytes from 172.16.106.79: icmp_seq=77 ttl=64 time=0.277 ms +64 bytes from 172.16.106.79: icmp_seq=78 ttl=64 time=0.231 ms +64 bytes from 172.16.106.79: icmp_seq=79 ttl=64 time=0.237 ms +64 bytes from 172.16.106.79: icmp_seq=80 ttl=64 time=100 ms +64 bytes from 172.16.106.79: icmp_seq=81 ttl=64 time=100 ms +64 bytes from 172.16.106.79: icmp_seq=82 ttl=64 time=100 ms +64 bytes from 172.16.106.79: icmp_seq=83 ttl=64 time=100 ms +64 bytes from 172.16.106.79: icmp_seq=84 ttl=64 time=100 ms +64 bytes from 172.16.106.79: icmp_seq=85 ttl=64 time=100 ms +64 bytes from 172.16.106.79: icmp_seq=86 ttl=64 time=100 ms +64 bytes from 172.16.106.79: icmp_seq=87 ttl=64 time=100 ms + + +看到了没?ping的time直接就到100ms了,这和我们加在网络上的延迟一致。 + +延迟出现后,整个系统对应的TPS曲线效果如下: + + + +可以清楚地看到,网络延迟会导致TPS下降、响应时间增加,并且影响是非常明显而直接的:我们只模拟了100ms的延迟,响应时间就增加了几十倍。 + +针对网络延迟对业务造成的影响,我们的应对机制仍然是快速恢复,这时候就要看网络上有没有备用资源了。如果在模拟的时间内,备用资源没有生效,那你就可以报Bug了。 + +磁盘异常 + +对于磁盘异常,有很多工具可以模拟。不过,由于我比较喜欢fio,简单便捷,我就用fio来模拟一下异常的大量随机写: + +fio --filename=fio.tmp --direct=1 --rw=randwrite --bs=4k --size=1G --numjobs=64 --runtime=100 --group_reporting --name=test-rand-write + + +接着,我们在top中看看wa cpu是不是高起来了: + +%Cpu0 : 46.2 us, 4.3 sy, 0.0 ni, 2.4 id, 46.6 wa, 0.0 hi, 0.5 si, 0.0 st +%Cpu1 : 15.5 us, 8.3 sy, 0.0 ni, 2.9 id, 70.9 wa, 0.0 hi, 1.9 si, 0.5 st +%Cpu2 : 13.8 us, 6.9 sy, 0.0 ni, 3.4 id, 74.4 wa, 0.0 hi, 1.5 si, 0.0 st +%Cpu3 : 24.1 us, 7.9 sy, 0.0 ni, 0.0 id, 67.5 wa, 0.0 hi, 0.5 si, 0.0 st +%Cpu4 : 27.1 us, 6.4 sy, 0.0 ni, 0.0 id, 65.5 wa, 0.0 hi, 1.0 si, 0.0 st +%Cpu5 : 19.8 us, 5.9 sy, 0.0 ni, 3.5 id, 69.8 wa, 0.0 hi, 1.0 si, 0.0 st + + + +从上面的数据看,wa cpu已经达到70%左右,这就是我们要的效果。 + +我们再来看看对应的TPS曲线效果: + + + +你看,TPS曲线中间有掉下来的情况,响应时间也有增加的趋势,但并没有报错。 + +虽然响应时间有所增加,但是请你注意,我们这里持续的模拟时间比图中TPS下降的这一段时间是要长的,这说明我们这个应用对IO的依赖并不大。想想也是,这个应用只不过是写写日志,还是异步写,能有多大的依赖。 + +这里我要说明一下,wa cpu其实并没有被真正的消耗掉,是空闲的CPU,它的百分比只是记录了CPU等IO的时间片比例。所以,虽然wa cp看起来很高,但是如果有其他应用需要CPU的话,也是能抢过去的。 + +针对操作系统级别的异常,我们这里用了CPU、内存、网络、磁盘几个最重要的系统资源做了演示,你可以根据这个思路在具体的项目中做更多的扩展,还会有很多的异常场景可以设计。 + +容器异常 + +对于当前技术市场中流行的Kubernetes+容器的架构来说,不做容器级异常,实在是说不过去。 + +我们知道,容器的基础镜像有大有小,具体多大就要看你用的是哪种镜像了。不过,我们先不管它,直接从操作容器的角度来模拟。因为如果容器出现异常的话,Kubernetes基本上就会直接操作整个容器,不会对容器做什么细节上的调整。 + +这里,我们来看看Kubernetes经常对容器做的两个操作,kill容器和驱逐容器。 + +kill容器 + +为了方便操作,我先把两个portal实例都指定到一个worker上去。 + +我们先查看一下,在这个worker上是否已经有了两个Portal实例了,以便我们做操作时再来查看以做对比。 + +[root@k8s-worker-6 ~]# docker ps |grep portal +c39df7dc8b1b 243a962aa179 "java -Dapp.id=svc-m…" About a minute ago Up About a minute k8s_mall-portal_svc-mall-portal-5845fcd577-dddlp_default_4ccb1155-5521-460a-b96e-e2a22a82f5ee_0 +4be31b5e728b registry.aliyuncs.com/k8sxio/pause:3.2 "/pause" About a minute ago Up About a minute k8s_POD_svc-mall-portal-5845fcd577-dddlp_default_4ccb1155-5521-460a-b96e-e2a22a82f5ee_0 +c9faa33744e0 243a962aa179 "java -Dapp.id=svc-m…" About a minute ago Up About a minute k8s_mall-portal_svc-mall-portal-5845fcd577-cx5th_default_06117072-9fe2-4882-8939-3a313cf1b3ad_0 +7b876dd6b860 registry.aliyuncs.com/k8sxio/pause:3.2 "/pause" About a minute ago Up About a minute k8s_POD_svc-mall-portal-5845fcd577-cx5th_default_06117072-9fe2-4882-8939-3a313cf1b3ad_0 + + +你看,这个worker上确实已经有这两个Portal实例了。 + +现在我们杀一个pod,看看Kubernetes会做出什么样的反应。 + +[root@k8s-worker-6 ~]# docker kill -s KILL c39df7dc8b1b +c39df7dc8b1b + + +接下来,我们再执行命令查看一下当前Portal POD的POD ID: + +[root@k8s-worker-6 ~]# docker ps |grep portal +080b1e4bd3b3 243a962aa179 "java -Dapp.id=svc-m…" 58 seconds ago Up 57 seconds k8s_mall-portal_svc-mall-portal-5845fcd577-dddlp_default_4ccb1155-5521-460a-b96e-e2a22a82f5ee_1 +4be31b5e728b registry.aliyuncs.com/k8sxio/pause:3.2 "/pause" 4 minutes ago Up 4 minutes k8s_POD_svc-mall-portal-5845fcd577-dddlp_default_4ccb1155-5521-460a-b96e-e2a22a82f5ee_0 +c9faa33744e0 243a962aa179 "java -Dapp.id=svc-m…" 4 minutes ago Up 4 minutes k8s_mall-portal_svc-mall-portal-5845fcd577-cx5th_default_06117072-9fe2-4882-8939-3a313cf1b3ad_0 +7b876dd6b860 registry.aliyuncs.com/k8sxio/pause:3.2 "/pause" 4 minutes ago Up 4 minutes k8s_POD_svc-mall-portal-5845fcd577-cx5th_default_06117072-9fe2-4882-8939-3a313cf1b3ad_0 +[root@k8s-worker-6 ~]# + + +不难看出,有一个POD ID已经变了,说明Kubernetes已经把杀掉的POD自动拉起来了。 + +对应的TPS效果如下: + + + +因为有两个Portal实例,所以TPS没有掉到底部,也就是说,另一个POD可以接管流量。所以,我们看到虽然业务有报错,但很快就恢复了。请你注意哦,我在这里的说“恢复”,并不是说被杀的容器也启动完成了,而是业务被另一个容器接管了。 + +为了验证Kubernetes拉起异常POD的处理能力,我们直接杀掉两个portal POD试一下: + +[root@k8s-worker-6 ~]# docker kill -s KILL 080b1e4bd3b3 c9faa33744e0 +080b1e4bd3b3 +c9faa33744e0 +[root@k8s-worker-6 ~]# docker ps |grep portal +d896adf1a85e 243a962aa179 "java -Dapp.id=svc-m…" About a minute ago Up About a minute k8s_mall-portal_svc-mall-portal-5845fcd577-dddlp_default_4ccb1155-5521-460a-b96e-e2a22a82f5ee_2 +baee61034b5f 243a962aa179 "java -Dapp.id=svc-m…" About a minute ago Up About a minute k8s_mall-portal_svc-mall-portal-5845fcd577-cx5th_default_06117072-9fe2-4882-8939-3a313cf1b3ad_1 +4be31b5e728b registry.aliyuncs.com/k8sxio/pause:3.2 "/pause" 7 minutes ago Up 7 minutes k8s_POD_svc-mall-portal-5845fcd577-dddlp_default_4ccb1155-5521-460a-b96e-e2a22a82f5ee_0 +7b876dd6b860 registry.aliyuncs.com/k8sxio/pause:3.2 "/pause" 7 minutes ago Up 7 minutes k8s_POD_svc-mall-portal-5845fcd577-cx5th_default_06117072-9fe2-4882-8939-3a313cf1b3ad_0 +[root@k8s-worker-6 ~]# + + +我们看下对应的TPS曲线表现如何: + + + +这下就很明显了,因为对应这个Portal服务,我只启动了两个Portal实例,所以在杀掉两个Portal服务的POD后,业务的TPS曲线直接全部报错了,过了1分30秒左右才恢复。至于这个恢复时间算不算长,就要取决于业务的成功率指标了。 + +在这个例子中,我们看到容器是可以自动恢复的,说明Kubernetes起了作用,我们只需要关注恢复时间是否达到业务的成功率指标就可以了。 + +驱逐容器 + +“容器被驱逐”在Kubernetes中是比较常见的问题,一旦资源不足就会出现。 + +现在,我直接在容器管理工具中点击“驱逐”来模拟场景。 + +为了展示在驱逐前后,POD确实被赶到其他worker上了,我们在模拟之前,先确定一下Order服务当前的状态: + + + +可以看到,这个服务处于正常的Running状态下。 + +而接下来模拟驱逐容器,我们只需要在Kubernetes的管理界面找到这个容器,然后直接点“驱逐”按钮就可以了。 + +模拟好后,我们查看一下效果: + +驱逐前: +[root@k8s-master-1 ~]# kubectl get pods -o wide | grep portal +svc-mall-portal-54ddfd6798-766pj 1/1 Running 0 36h 10.100.227.136 k8s-worker-6 +svc-mall-portal-54ddfd6798-ckg7f 1/1 Running 0 36h 10.100.227.137 k8s-worker-6 +驱逐后: +[root@k8s-master-1 ~]# kubectl get pods -o wide | grep portal +svc-mall-portal-7f7f69c6cf-5czlz 1/1 Running 0 47s 10.100.69.242 k8s-worker-3 +svc-mall-portal-7f7f69c6cf-7h8js 1/1 Running 0 110s 10.100.140.30 k8s-worker-2 +[root@k8s-master-1 ~]# + + +你看,POD的ID变了,worker也变了,说明Kubernetes已经把驱逐的POD拉起来了。 + +我们再来看看对应的TPS曲线: + + + +可见驱逐后的容器也恢复了。 + +虚拟机异常 + +我们在前面已经模拟了操作系统内部的异常,现在我们换个视角,从整个虚拟机KVM的操作系统角度来操作,看看虚拟机异常是什么样的效果。 + +这里我们直接kill虚拟机来模拟异常,其实这个异常在第26讲中就已经出现过,说明它是一个比较常见的异常场景。 + +kill虚拟机 + +我先把应用微服务移到worker-6上,后面我们就直接kill这个worker-6虚拟机。但是你要注意,不要把我们的微服务指定到worker-6上哦,因为指定了的话,微服务就不能运行在其他虚拟机上了。 + +然后我们来执行kill虚拟机的动作,具体这样操作: + +[root@dell-server-2 ~]# virsh list --all + Id 名称 状态 +---------------------------------------------------- + 1 vm-k8s-master-2 running + 2 vm-k8s-worker-5 running + 3 vm-k8s-worker-6 running + + +这中间有一个top查看虚拟机进程ID的动作哦,直接top后按c即可。 + + +[root@dell-server-2 ~]# kill -9 3822 +[root@dell-server-2 ~]# virsh list --all + Id 名称 状态 +---------------------------------------------------- + 1 vm-k8s-master-2 running + 2 vm-k8s-worker-5 running + - vm-k8s-worker-6 关闭 + + +[root@dell-server-2 ~ + + +你看,最后worker-6确实被关闭了。 + +现在我们再看看对应的TPS: + + + +你看,worker-6被杀掉之后,TPS直接就掉到零了,同时也报了错。过了一段时间,应用被移走了,服务也恢复了。 + +最后,我们来看看转移的效果: + +# 转移前 +[root@k8s-master-1 ~]# kubectl get pods -o wide | grep portal +svc-mall-portal-54ddfd6798-766pj 1/1 Running 0 36h 10.100.227.136 k8s-worker-6 +svc-mall-portal-54ddfd6798-ckg7f 1/1 Running 0 36h 10.100.227.137 k8s-worker-6 + + +# 转移后 +[root@k8s-master-1 ~]# kubectl get pods -o wide | grep portal +svc-mall-portal-7f7f69c6cf-5kvtl 1/1 Running 0 4m40s 10.100.69.249 k8s-worker-3 +svc-mall-portal-7f7f69c6cf-jz48w 1/1 Running 0 4m50s 10.100.140.24 k8s-worker-2 +[root@k8s-master-1 ~]# + + +你看,worker-6上的应用已经调度到其他节点(worker-2、worker-3)上了,说明生成了新的容器。 + +总结 + +如果你要做这样的异常场景,那么请你事先考虑好你的预期。我们对异常场景最基础的预期就是,在异常出现的时候,系统能快速恢复,这也是我们做异常场景的价值。如果不能快速恢复,业务也就随着异常掉下去了,这时候我们就要提Bug、提风险。 + +在这节课中,我们模拟了应用级异常、操作系统内部异常、容器级异常和整个操作系统级异常,在当前的微服务架构中,这些都是经常出现的异常场景。当然,它们并不能覆盖微服务架构中的全部异常场景。你可以根据上节课讲的异常范围图,把缺少的异常场景设计出来,以覆盖全面。 + +课后作业 + +最后,我给你留两道题,请你思考一下: + + +异常场景设计的关键点是什么? +如何判断异常场景的预期? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果你读完这篇文章有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/30如何确定生产系统配置?.md b/专栏/高楼的性能工程实战课/30如何确定生产系统配置?.md new file mode 100644 index 0000000..28689d8 --- /dev/null +++ b/专栏/高楼的性能工程实战课/30如何确定生产系统配置?.md @@ -0,0 +1,304 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 30 如何确定生产系统配置? + 你好,高楼。 + +在性能“测试”的范畴中,配置生产系统一直都是运维的活,和我们“测试”没啥关系。 + +但是,我在第一节课里就强调,在我的RESAR性能工程理念中,性能工程要考虑到运维阶段。这看似是一个比较小的改变,但实际上延展了性能团队的工作范围,执行起来并不容易,尤其是对于那些运维和性能“测试”团队严重脱节的企业。 + +我们暂且不说性能“测试”团队能不能给出生产上想要的配置,很多性能“测试”团队可能连当前生产的配置都不知道。面对这样的情况,我认为如果我们还龟缩在“测试”团队中,就必然做不出什么贡献了。 + +我们想想性能项目的目标,就很容易理解这一点。通常我们在制定目标的时候,会有这样的说法:保证线上系统正常运行。 + +这个目标看起来应该在性能项目中完成,可是,在当前的性能行业中,又是怎么做的呢?如果你是一个性能“测试”工程师,是不是连生产的样子都没有见过?连数据也没有拿到过?性能参数也没有分析过?更有甚者,可能连机器都没有见过。在这样的情形之下,性能项目也只能找一些系统上明显的软件性能瓶颈而已。 + +而一个系统整体的容量,绝对不是仅由软件组成的,还有硬件环境、网络、存储、负载均衡、防火墙等等一系列的软硬件。如果性能团队对这些都不了解,那就不能指望他们可以给出什么生产配置。 + +当我们把这个问题后移到生产环境中时,运维团队有经验的人也许可以给出合理的性能参数配置。但是,这些参数配置是否和现在的业务目标匹配呢?可能大部分运维会先上线,然后再调优校准参数。而这样就意味着,系统在上线一开始是不稳定的。 + +所以,在我看来,应该由性能团队给出生产环境中的性能参数配置,这是最为合理的。 + +预判生产容量 + +在确定性能参数配置之前,我们要先预判生产的大概容量,不用特别精确,像“在1000TPS左右”这样的预估就可以了。其实,这就是预估一个系统的容量水位。 + +就如这张图所示,我们要先大致估计出每个服务在不同的容量之下,会使用到多少的资源。然后尽量让资源均衡使用,减少成本。 + + + +经常会有人问这样的问题:怎么评估一个系统的容量?比如说,我们拿到一个4C8G的机器配置,在一个我们测试过的系统中,怎么评估这个机器能跑出多少TPS? + +其实,我们可以从最简单的做起:基准测试。 + +之前,有一个学员问我,一个8C16G的机器能跑出多少TPS?我回答说不知道。因为我不清楚是什么业务,如果是我没有测试过的业务,那我就更没有什么经验数据了。所以,我建议她去做一下基准测试,哪怕是最简单的没有业务逻辑的CRUD服务,也能知道跑出多少TPS。 + +根据我的经验,在我的一个2C4G的机器上,如果只跑最简单的查询接口,并且没有任何业务逻辑,那跑出1000TPS(一个T就是一次接口请求)是没问题的。 + +那个学员也比较认真,回去就弄了一个简单的服务试了一下,然后告诉我8C16G的机器能跑出三、四千的TPS。这个结果和我的经验结果差不多,因为她的环境是我的四倍,跑出来的TPS也能达到我的四倍。 + +不过,这其中有一个很明显的问题,就是这个实验示例没有业务逻辑。对于有业务逻辑的业务系统来说,最大容量取决于业务的复杂度。所以,我在进到一个新项目中时,通常都会先了解一下历史性能数据,再来判断是否有优化的必要。对于我了解的系统,在知道了硬件和软件架构之后,我心里大概能有一个预期目标。 + +对于不了解的系统,我们也不难得到最大容量的数据,只要做一下容量场景就可以知道了。 + +当然,在一个生产系统中有相应的判断能力。笼统地说就是,如果有1000C 2.5GHz的CPU资源,我们要根据历史经验数据,判断出最大容量能跑多少TPS;如果是2000C 2.5GHz的CPU资源,又能跑出多少TPS。而这些都可以通过容量场景计算出来。 + +之所以是“笼统地说“,是因为最大容量和很多细节都有关系,比如架构设计的合理性、预留多少生产资源等方方面面。因此,并没有一套所谓标准的配置,可以适配于任何一个系统。 + +可能有人会问,通过容量场景计算出TPS之后,是不是可以再用排队论模型,来计算需要多少服务器资源呢?这个逻辑的确行得通,不过需要先建模,并采样大量的数据来做计算。这个话题很大,我在这里不展开讨论了,但你可以知道有这么一个方向。 + +而在这节课中,我希望能通过实践让你明白获得合理配置的逻辑。 + +你还记得这个性能分析决策树吗? + + + +图中这些是在我这个课程的示例系统中使用的各种组件。对应各个组件,我们都应该给出合理的性能配置。 + +那性能配置主要是指哪些方面呢?我们要分为硬件和软件两大角度来看。 + +硬件配置 + +硬件配置其实是很大一块内容,通常,我们都会在测试环境中受到硬件资源的限制。因此,我们会这样来计算大概的容量: + + +拿到生产环境的硬件配置,以及峰值场景下的资源利用率、TPS、RT数据。 +在测试环境硬件配置下,通过容量场景,得到测试环境中的峰值场景下的资源利用率、TPS、RT数据。 +拿第一步和第二步中得到的数据做对比。 + + +通过这三个步骤,我们就能知道在生产环境中,系统所能支撑的最大TPS大概是多少。如果列一个简单的示例表格,那就是这样: + + + +也就是说,如果在生产环境用1000C的30%,同时容量可以达到10000TPS,平均响应时间可以达到0.1s,那么在测试环境中,我们至少在300C的使用率达到100%的时候,容量才能达到10000TPS、平均响应时间0.1s。 + +当然,你可以有一百种理由说我这个逻辑不合理,比如说,最明显的问题就是CPU用到100%,业务系统显然不稳定,并且TPS的增加也不可能是线性的;这里没考虑到其他的硬件资源情况等等。 + +没错,这显然是一个非常粗糙的计算过程,而我在这里也只是为了给你举一个例子。在你真正做计算时,可以把相应的重要资源都列上去。而这个建模过程需要拿大量的样本数据做分析。 + +我们用一个表格来大概建模,计算一下不同环境的资源产生的TPS比对: + + + +如果我们测试环境有300C资源,使用率也为30%,要是我还想保证0.1秒的平均响应时间,那么TPS就应该是3000。这是最简单的等比方式了。 + +但是,硬件的不同有很多因素,所以,我们要在一个项目中要建模才可以。而建模要考虑的因素只有从具体的项目中才能拿到,大概有这几点: + + +硬件、软件配置; +生产环境和测试环境的TPS、RT数据; +生产环境和测试环境的资源利用率数据(用性能决策树中的全局计数器)。 + + +因为每个业务系统消耗的资源会有偏向,要么是计算密集型,要么是IO密集型,所以,我们在比对计数器的时候,肯定要比对那些消耗得快的计数器。 + +拿到上面这些数据后,我们再创建上面表格中的等比模型,就可以计算测试环境中的最大容量了。 + +但是这个数据仍然不够完整,因为我们还要关注软件配置。 + +软件配置 + +对于软件配置,也同样需要我们做相应的等比计算。我们扩展一下上面的表格: + + + +如果我们在测试环境中达到了硬件配置,没达到软件配置,就像下面表格这样,我们该怎么计算测试环境中的TPS和资源使用率呢? + + + +显然,这时候表格中两个问号代表的数据就不一样了。通过计算你就可以知道,测试环境要想达到1000TPS,而资源使用率也只能用到1/10(也就是30C)了。 + +当然,实际的建模过程不会这么简单,不会只靠这么一两个计数器就能完成。那我们在实际建模过程中,应该把哪些计数器纳入到计算当中呢?这就涉及到性能分析决策树中,所有的性能计数器了。而这些计数器会和相对应的性能配置相关。因此,我们要对应性能分析决策树,我画一个性能配置树出来。 + +性能配置树 + +对应前面的性能分析决策树,我们画一个性能配置树。 + +性能分析决策树: + + + +性能配置树: + + + +通过对比,相信你已经发现,我在性能配置树中加了一个“主要参数类型”。把“主要参数类型”展开之后,我们可以看到这样的列表: + + + +其中,硬件包含的参数和操作系统包含的参数看上去一样,不过,我们实际上要对比的内容并不一样。比如说CPU,在硬件的层面,我们要对比的是型号、主频、核数/NUMA等信息;而在软件层面,我们要对比的则是CPU使用率。其他的性能参数和计数器也有类似区别。 + +而在应用软件方面,我罗列了最常见的比对参数,也就是说在每一个软件技术组件中,我们都要从这些角度去考虑需要提取的配置。 + +在这里,我要说明一下,我在性能配置树中描述的是一种通用特征,因此无法对每个组件的配置都那么面面俱到。在具体的技术组件中,需要你灵活更改。就以MySQL为例,对于内存,我们通常会考虑innodb_buffer_pool_size;而对于java微服务,我们通常是用JVM来表达。 + +所以,针对性能配置树的每一个技术组件,我们还需要细化,就拿最常见的Java微服务应用来说,我们要考虑的范围如下图所示: + + + +由于参数太多,无法在图中完全表达出来,我直接用省略号代替了。对于其他技术组件,我们也要像这样一一列出重要的配置。 + +在这里,我给你一个常见的各系统性能参数表格,同时,我也把完整的性能配置树也放在一起了,供你参考。点击此处就能下载,密码为4f6u。 + +在这个文件中,并非所有的参数都与性能相关,你只需要根据我前面说的类型(比如线程数、超时、队列、连接、缓存等)自己筛选就好了。另外,我根据自己的工作经验,把其中重要的参数都标红了,当然这也只是给你借鉴。在你自己的项目中,你可以按性能配置树中的逻辑罗列自己的参数列表。 + +讲到这里,我们就要进入下一步了:获得这些参数在生产环境中的具体配置值。 + +如何获取配置值 + +获取配置值的方法主要分为两个步骤: + + +运行场景; +查看相应的计数器。 + + +现在,我们就以Order服务为例,看看到底怎么确定相关参数的配置值。 + +压力场景数据 + +我们先执行性能项目中的容量场景,判断一下TPS大概能达到多少。 + + + +在这个场景中,你可以看到,在30压力线程时,TPS大概能达到800左右。但是,随着压力的增加,TPS也能达到1000,只是响应时间也随之有了明显的递增趋势。 + +接下来,我们就分析一下这个状态需要什么样的配置。 + +由于配置太多,而确认配置又是一个非常细致的工作,我们不太可能尽述。不过,我会告诉你确定配置的逻辑是什么。这样,你在自己的项目中,都可以按这个逻辑来确定每个技术组件的相关性能参数。 + +应用服务的线程数配置 + +我们先看看Order的当前配置是什么样的: + +server: + port: 8086 + tomcat: + accept-count: 10000 + threads: + max: 200 + min-spare: 20 + max-connections: 500 + + +在没有压力之前,应用线程的状态是这样的: + + + +压力起来之后,应用线程的状态是这样: + + + +从线程的数量来看,线程数是在自适应增加的。对应压力中的TPS曲线和响应时间增加的地方,我们可以看到大概41个工作线程。随着压力的持续增加,TPS还在增加,但是,响应时间慢慢变长了。从提供服务的角度来说,用户会感觉系统在逐渐变慢。 + +如果为了保证系统在生产上,用户的响应时间不想因为用户量的增加而变慢,这时候我们就可以考虑在这个服务中加上限流的手段了。 + +而对于我们这节课要确认的服务线程来说,我们想要支撑800TPS左右,其实只需要41个线程,所以,我们设置的200线程是用不到的。 + +到这里,我们就确定了一个非常重要的性能参数——线程数,那我们应该把它配置为多少呢? + +这时候,我们就得考虑一下,在这个服务中,我们想让Order服务支撑多少的容量?如果一个节点提供800TPS是可以接受的,并且对应的响应时间也都稳定,那我们就可以把线程数设置为稍高于41个线程,比如说45或50个线程。 + +你可能会想,200远大于41个线程,把线程数直接设置成200不是更好吗?其实不是,如果我们要考虑峰值的流量,那么当流量大的时候,这个服务的响应时间会变长,直到超时退出,这给用户的感受显然是更糟糕的。因此,不建议做这样的配置。 + +而更好的处理方式是,当这个服务不能提供稳定的响应时间,我们应该给用户一个友好的提示,这样不仅可以保证用户的访问质量,也能保证服务一直稳定。 + +现在,我在Nacos中把max thread改为50,并发布配置: + + + +然后我们再重启Order服务。重启的时候你要注意,因为我们采用的是Kubernetes自动调度机制,所以我们要指定一下节点。如果不指定的话,重启之后的POD说不定会跑到其他的worker上去。我们还是要尽量保证两次测试处于同样的环境。 + + + +我们再执行一下场景看看: + + + +TPS达到了1000,我们再看一下线程数: + + + + + +线程数正好是50个,也就是说50个线程就能支持到1000TPS了。 + +应用服务的超时和队列配置 + +而对于Java这样的应用服务,我们还需要考虑其他几个重要的性能配置参数,比如超时、队列等,这一点我们在前面的配置树中也有罗列。现在我们在保持50个线程的同时,再改一下队列长度。我们在上面看到的accept-count是10000,为了让试验有效果,我们直接降为1000,然后看看压力场景效果: + + + +你看,还是能达到1000TPS。那我们再把accept-count降下来一些,这次我们降狠一点,直接降为10,希望达到因为队列不够长而产生报错的效果,来看下效果: + + + +咦,怎么还没有报错?哦,是我大意了,没有设置超时。 + +那我们就增加一个参数connection-timeout。在Spring Boot默认的Tomcat中,connection-timeout是60s。现在,我直接把它设置为100ms,因为我们Order服务的响应时间有超过100ms的时候: + + + +我们再次执行场景,看一下结果: + + + +你看,报错了吧。这说明队列为10、超时为100的设置过小了,无法保持每个请求都能正常返回。现在,我们把队列设置为100,再来看一下: + + + +看到没有,报错更多了,这符合我们的预期。因为队列长了,超时又短,队列中超时的请求自然会变多。并且在上面的曲线中,我们也可以看到,报错增加了不少。 + +那怎么配置超时时长呢,我们要做的就是把超时增加,增加到大于响应时间中的最大值,只有这样才能不报错。 + +我们在上面的结果中看到,响应时间基本在200ms以下,那我们就把超时设置为200ms,看一下结果: + + + +你看,报错少了很多。这说明超时在性能调优中是一个很重要的参数,而它又和队列长度相关。 + +我们把前面的几个场景的结果都放到一个图中看一下: + + + +通过这样一张图,我们就能清楚地看到线程池(线程数)大小 、超时、队列长度在不同设置下产生的效果比对。 + +因此,在这个应用中,我们可以设置的关键参数是: + + + +在这样的配置下,在加上限流、降级、熔断等手段,我们要保证的是,到这个服务的请求在1000TPS以内。 + +如果你想让这个系统在牺牲响应时间的前提下支撑更多的请求,就可以把上面的参数调大一些,具体调大到多少,就取决于你是想让系统支撑更多的请求,还是想让用户有更好的体验了。 + +总结 + +通过这节课,我给出了确定生产系统配置的思路。而做这件事情的前提是,我们对被测环境有明确的容量预期。在有了容量预期,并且对系统进行了调优之后,我们就可以通过这两个步骤把各个性能参数确定下来: + + +发起压力; +通过监控和场景执行数据,判断每个重要的性能参数的具体配置值。我强调一下,这一步需要我们非常细心,试验也要做很多遍。 + + +由于性能相关参数有很多,这就需要我们结合性能配置树中罗列出的每个性能配置,一一确定。你可能会觉得这是一个非常费时费力的活。其实在一个项目中,这个步骤只需要全面地做一次,在后面的版本变更中,我们只需要根据性能分析的结果做相应的更新就可以了。并且在大部分项目中,这种更新不会出现大面积的参数变动情况。 + +课后作业 + +最后,请你思考一下: + + +为什么性能项目中要做性能参数配置的确定? +如何确定数据库及其他技术组件的性能参数呢? + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果你读完这篇文章有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下这节课再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/31怎么写出有价值的性能报告?.md b/专栏/高楼的性能工程实战课/31怎么写出有价值的性能报告?.md new file mode 100644 index 0000000..1f5e153 --- /dev/null +++ b/专栏/高楼的性能工程实战课/31怎么写出有价值的性能报告?.md @@ -0,0 +1,278 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 31 怎么写出有价值的性能报告? + 你好,我是高楼。 + +在性能项目中,有三个文档在我看来是最为重要的,分别是性能方案、性能报告和调优报告。在第5讲中,我们已经给出了性能方案的完整内容。而调优报告,其实我已经不用写了,因为我们前面对每个场景的分析全都是调优的内容。 + +今天这节课我们来看看性能报告。性能报告在项目中一般被称为“性能测试报告”。不过,接下来我会弱化“测试”这两个字,因为我会将整个性能项目各方面的内容都包含在内。 + +性能报告是一个性能项目的总结,是性能价值的最终体现,所以性能报告是非常重要的。就像我们减肥的时候经常会说“三分运动七分吃”,性能报告也一样,我们也可以说它是“三分干活七分报告”,也就是说干活的辛苦都是留给自己体会的,报告如果做得不好,你再累、再辛苦,所有的付出都会付诸东流。 + +但是,我看到在当前性能市场中,很多性能报告都写得非常潦草,要么是数据收集得不完整,要么就是结论描写得不合理,让本来做得很好的性能项目没有体现出价值。 + +那怎么写出有价值的性能报告呢?今天我们一起来看看。 + +性能报告要给出明确的结论 + +在性能报告中,有一个环节应该是大部分人的恶梦,那就是给出明确的结论。 + +什么是明确的结论呢?我先给你列举几个常见的结论描述。 + + +容量场景结论: + + + +描述1:服务器资源有明显性能瓶颈,建议升级或增加服务器;存储性能差,建议更换性能更好的存储;某服务有明显的性能瓶颈,建议开发人员优化。 + +描述2:测试调优前50TPS,测试调优后100TPS;也有人说,测试调优前有错,测试调优后没有错;测试调优前CPU使用率90%,测试调优后CPU使用率50%;测试调优前资源消耗1000C2000G,测试调优后资源消耗500C1000G。 + +描述3:在100并发用户数下,某系统各功能点的平均响应时间均满足性能指标,功能点TPS总和为3000,成功率均为100%,各服务器资源平均使用率均在指标范围内;某系统在200并发用户时,系统处理能力为4000TPS,继续增加并发用户数时,系统处理能力下降。 + +描述4:系统可支持2000万人同时在线,20000人并发。 + + + +稳定性场景的结论: + + + +某系统在400并发用户下稳定运行了120小时,各交易平均响应时间小于性能指标要求, TPS基本呈平稳趋势,交易成功率为100%,各服务器资源使用率趋势平稳,满足性能需求指标。 + + + +批量场景的结论: + + + +A业务联机批量(批量交易ID:001、002、003)、B业务联机批量(批量交易ID:004、005)和C业务联机批量(批量交易ID:006)执行时长为130000毫秒、10000毫秒、1000毫秒、12000毫秒、10000毫秒、160000毫秒,满足性能指标要求。系统资源使用情况均满足指标要求。 + + +此外,还有更多的结论描述,我就不一一列举了。 + +你乍一看这些结论,是不是觉得还挺合理的?确实是这样,如果我们只看一个报告的结论,很难看出结论本身有什么问题,我们最多能知道的就是这个结论偏向哪个层面(技术层面或业务层面)。 + +但是,在上述容量场景结论的几个描述中,描述1显然是不合格的结论,因为没有一句话是具体的。我不建议你用“明显”、“建议”、“差”、“可能”之类的词来写性能结论,这样的词都不够精准。 + +描述2看起来已经非常精准了,不过只描述了技术的角度,并没有给出系统是否可以支撑业务的结论;而描述3中规中矩,但你心里要清楚,像“服务器平均使用率均在指标范围内”这样的描述,其中的这个“指标”是有具体值的;描述4非常直接地说明了业务的结论,我觉得比较合理。 + +对于稳定性和批量场景的结论,你思考一下,我就不一一点评了。 + +我们说了这么多,你可能会奇怪,那到底性能结论要写成什么样呢?我们需要明确,性能报告表达的是业务系统的性能结论。 + +曾经,某公司的性能工程师拿出一份报告给我看,我看过之后问:“你这份报告是想表达自己干得有多累吗?”对方答:“不是呀,我是想表达这个项目做得不错。”我说:“你这里面并没有说哪里做得不错呀,我只看到了你们干得有多累……” + +为什么会出现这种情况呢?主要是因为在他这样的报告中,把用了多少人、干了多长时间、做了哪些工作都写得清清楚楚,但在结论部分却是非常笼统的描述,就像前面我们举例的实时业务容量场景的第一个描述那样。 + +于是,我告诉他,老板不需要看这样的报告,如果你要给老板做汇报,简明扼要即可,不用写那么花哨。我们的报告不是用来展现自己做得有多么辛苦,也请你务必牢记这一点。 + +先确定受众,再写性能报告 + +那性能报告应该写成什么样呢?这里我再给你举两个例子。 + +之前我做过一个性能项目,业务目标很明确:一小时完成6000万用户的完整业务流程。在项目结束之际,我写了一个Word版的详细报告,大概80页左右。而在给客户汇报的时候,我只用了一份不到10页的PPT。 + +在第一页PPT上,我只写了两个数据: + + + +并在汇报时说:“根据我们场景执行、分析、优化后的结论来看,当前系统可以一小时支持6100万的完整业务流,高于业务目标的6000万。” + +然后,我接着说:“如果各位有兴趣,我可以大概讲一讲这个目标是怎么实现的。”这时候,你要注意,如果大家没反应,那就接着讲下去,不用讲得太细,只要笼统概括一下专业内容即可。 + +如果在你讲完第一页PPT后,有人开始聊待会儿去哪庆祝的话题,那就没必要再往下讲了。因为在汇报的场合里,专业技术的内容可能并不是受欢迎的话题,老板听得索然无味,业务方也听得一头雾水。要是有人对技术细节感兴趣,你到时候可以多讲两句。 + +总之,做性能汇报时,控场很重要,我们要引导现场,而不是被现场引导。 + +我还做过一个性能项目,大概耗时三个月,几乎每天都加班加点,非常辛苦。在写汇报PPT时,我首先按逻辑把能想到的内容全都写了出来,总共写了120多页PPT(这是我写汇报PPT的一个习惯,就是先尽量写全,然后删减)。 + +在汇报的前一天晚上,我看着这120多页的PPT直犯迷糊,我没想到自己会整理出这么多东西。不过,我心里清楚,这些内容肯定不是汇报里该有的,所以,我决定删减。第一遍,我删到了60页左右,还是太多了;第二遍,我删到了40页,还是觉得多;第三遍,我删到了20页,这才感觉差不多了。 + +于是,在那次四十多人的汇报会议中,我用这20页的PPT只讲了不到10分钟。在汇报结束时,我说:“这些就是我们的结论了,如果在场有技术人员对项目的具体实施过程感兴趣,可以看一下我们在会前发出的240多页的Word版技术报告。要是各位没有疑问,我的汇报就到这里了。” + +汇报结束后,大家的反应都还不错。 + +我讲这两个案例是为了告诉你,在我的逻辑中,性能报告应该有两种表现形式: + + +尽量详细的技术型报告:这种报告通常是Word、PDF、HTML形式,报告内容包括项目背景、测试范围、需求指标、工具环境、数据量级、业务模型、场景执行策略、场景结果整理、场景结果分析、结论、问题汇总、后续性能工作建议、运维建议。 + +尽量简单的汇报型报告:这种报告通常是PPT、Keynote形式,报告内容包括结论、基本信息描述(用几个简单的页面概括一下即可)、问题汇总、后续性能工作建议、运维建议。 + + +第一种报告是给技术人看的,第二种报告显然是在汇报场合中用的。 + +所以,我们在写报告的时候就要先考虑清楚,报告是给谁看,这一点至关重要。给领导看,不用过于细节;给技术人员看,不要过于笼统。 + +另外,我要向你多嘱咐一点,在汇报的场合,切忌与提出异议的人争论。即便有人提出的问题很尖锐,你也一定要磨圆了再回答,在这一过程中要不退不让、不卑不亢。不退不让,是因为你是汇报人,你是专业的,你要控制全场;不卑不亢,则是一种沟通的能力和技巧,不要让听汇报的人觉得你骄傲自负,接受不了别人的意见。 + +性能报告具体怎么写? + +通常我写性能报告都是不用模板的,因为基本的大纲是明确的,而我刚才罗列的技术性报告中的内容,就已经足够了。至于更具体的细节,每个项目肯定是不同的。如果你要用模板的话,容易限制住思维,我建议你最好自己一个字一个字去写报告。 + +由于我们这个课程的示例项目是一个非常完整的项目,下面我们就以此为例,看看性能报告具体该怎么写。 + +首先,我们需要明确的是,一个完整的性能报告基本上可以分为两大部分: + + +第一部分是执行场景之前的信息,也就是这里第5讲方案中所列的部分,比如项目背景、测试范围、业务模型、性能指标、系统架构图、软硬件环境、压力工具及监控工具、数据、场景设计及报告策略、监控设计。 + +第二部分是执行场景之后的信息,包括场景结果整理、场景结果分析、结论、问题汇总、后续性能工作建议、运维建议。 + + +在你自己的项目中,性能报告倒是不用这么完整,可以做相应的删减。 + +关于第一部分的内容,我在第5讲中已经给出详细的信息了,在这里不再赘述。接下来,我们重点看看第二部分。 + +在第二部分中,我们要整理各场景的结果,整体结构如下: + + + +其中,“场景结果整理”和“场景分析”两部分,在我们课程前面的案例分析中已经有很多描述了,我在这里就不重复了。你在写具体的项目报告时,直接贴上相应的截图,再加点描述即可。 + +现在,我们对应这个结构,看一看在我们这个课程的示例项目中,“场景结论”、“对后续性能工作的建议”、“生产配置建议”、“对运维的建议”分别是怎样的。 + +场景结论 + + +基准场景 + + +我们先画出每个业务的基准场景,在优化前和优化后的TPS对比图表: + + + +通过这张图表,我们就能清楚地看出测试结果,我们在基准场景中努力做的所有优化,都体现在这个结果里了。 + +那我们要给出的结论是什么呢?其实,我们只需要一句话来总结:所有业务的基准场景都可以达到目标TPS。这一句是想表明,从基准场景的结果来看,每个业务不会成为混合场景中拖后腿的业务,这就是基准场景给容量场景提供的最有价值的信息了。 + + +容量场景 + + +我们先画出容量场景在优化前和优化后的TPS对比图: + + + +通过图表,我们可以看到容量场景中所有优化的效果。当然,我们同样得给出一个结论:容量场景可满足线上业务的性能指标。 + +这个结论的来源是什么呢?就是前面我们预估的1000TPS。如果你要做汇报的话,可以展示这样一张图: + + + +有了这张图之后,关于技术方面的实现,你想怎么描述都行,完全可以按照自己的喜好来。 + +不过,你要注意,”容量场景可满足线上业务的性能指标“这样的结论只是停留在技术层面,你要是想进一步给出具体的业务级和用户级结论,就得参考我们在第8讲中说的并发用户、在线用户、TPS、并发度等计算逻辑。 + +对于我们这个课程的示例系统来说,由于这是一个Demo系统,我们并没有生产数据来做在线、并发之类的数据统计,不过,为了给你一个更直接的结论,我在这里用第5讲中的业务模型和第8讲的数据来做一个计算过程说明。 + +根据第5讲中的业务模型,一个用户完整的接口级请求是11个,但并不是每一个用户都会完整地走完这11个接口。按照业务模型中的比例算下来,100个TPS(一个T对应着一次接口请求)可以支持54个并发用户,也就是说平均单个用户需要的TPS是: + +\( 100\\div54\\approx1.85 \) + +而当前的TPS是1700,所以,当前系统支持的并发用户数是: + +\( 并发用户= \\frac{最大TPS}{单用户级TPS} = \\frac{1700}{100\\div54} \\approx 918 \) + +我们再根据第8讲中的并发度2.4%,来计算对应的在线用户数: + +\( 在线用户= \\frac{并发用户}{并发度} = \\frac{918}{2.4\\%} \\approx 38250 \) + +计算到这里,我们就可以进一步写出更为具体的结论了:通过容量场景的结论可知,系统最大TPS为1700,系统可支撑最大918并发用户;系统可支撑最大38250在线用户。 + +我要特别说明一点:上面整个计算过程所用的数据,都来自于我们这个课程的示例项目。你在真实的项目中做计算时,可以使用这个计算逻辑,但具体的数据,还需要你们自己做相应的统计。 + +对于容量场景的结论来说,我们写到这里就可以了。如果你非常想描述场景的过程,那么根据我们这个课程的示例项目,你可以这样描述容量场景的细节: + + +在容量场景中,一共做了四个阶段的优化。第一阶段,优化了参数化数据导致响应时间不断上升的问题;第二阶段,优化了业务表索引问题;第三阶段,优化了资源使用不均衡问题;第四阶段,优化了磁盘慢导致的redis持久化问题。 + +做了上述优化之后,最大容量可达到1700TPS,支撑最大918并发用户、最大38250在线用户,业务最大的平均响应时间在200ms以下,完全满足线上业务容量的性能需求指标。同时,应用服务的CPU资源可达到80%左右,资源使用均衡。 + + +你想把这样概括性的描述加在结论当中,也是可以的。但是,除此之外,不用再描述更多的内容了,我觉得没有什么必要。 + + +稳定性场景 + + +对于稳定性场景来说,最重要的结论就是所有的业务积累量和持续时间。根据第27讲中稳定性场景的结果,我们可以得出这样的结论:稳定性场景可持续时间超过16个小时,所有业务积累量可达到7700万以上,系统资源使用率稳定保持在80%左右。 + +你要是有足够的时间和资源,做好定时定量的归档策略、分库分表等动作,也可以扩展稳定性场景的持续时间和业务积累量。 + + +异常场景 + + +在执行异常场景时,我们模拟了几类异常问题,比如应用异常、操作系统异常、容器异常、虚拟机异常等。 + +根据执行结果,我在这里写一个笼统些的结论(如果你有兴趣,可以细化):在异常场景执行过程中,TPS趋势符合预期,但应用未对异常情况进行处理,导致终端用户可以看到错误而不是友好提示,故存在Bug需要修复。 + +相信你从前面几节课中可以看到,我们能做的异常场景非常多。而我们在写结论的时候,对没有问题的场景,可以描述的笼统一些;对普遍存在的问题,也可以做一些笼统描述。因为如果把每一个场景都挨个描述一遍,实在是太长了。 + +对后续性能工作的建议 + +在我们这个课程的示例项目中,有三个典型的问题需要在后续的性能工作中完善,我大概描述一下: + + +定时任务必须和实时业务分离。这条建议只是针对我们这个开源项目写的,在真实的项目中,估计不会有人不分离定时任务和实时业务; + +制定符合业务的定时定量归档计划和分库分表策略; + +返回用户友好提示。 + + +生产配置建议 + +关于生产配置建议,我们可以结合第30讲的内容进行总结。在我们这个课程的示例项目中,我做了三个参数的配置的确认过程,所以,我们只能列三个生产配置建议了: + + + +在真实的项目中,你可以结合我提供的项目级性能配置树,来确认所有的生产配置,而这个表格中的内容也会比现在的丰富很多。 + +对运维的建议 + +其实写到这里,我们对运维的建议已经比较明确了: + + +做好项目级的全局监控设计和实现策略,并实现实时预警功能; + +做好限流、降级熔断策略,并实现自动容量扩展功能; + +结合项目级性能参数配置列表,在生产环境中做好相应的性能参数配置,以符合业务容量的要求; + +实现生产环境的定时定量归档和分库分表策略。 + + +对于更具体的建议,我们可以形成相应的文档,直接放在报告的附录中。 + +至此,我们的性能报告就非常完善了。 + +总结 + +写性能报告,其实是对前面的所有工作做一个总结。因此,性能报告中的所有数据来源都是确定的。至于表达的形式,我建议你直接明了,不要啰嗦。另外,尽量使用图来表达结论,不要用表格,因为表格无法呈现比较直观的趋势。 + +性能报告作为性能项目中最重要、最能体现性能价值的一个输出文档,我们做性能的人必须要学会编写。而在编写的过程中,我们一定要先考虑清楚受众是谁,然后从受众的角度考虑报告内容的表现形式。 + +在做汇报时,我们一定要做到简明扼要,不过分表达,但也不能遗漏。 + +课后作业 + +最后,请你思考一下: + + +考虑一下你之前编写的性能报告,和本篇的描述有什么不同? +根据专栏内容,尝试写一个你认为的性能报告。 + + +记得在留言区和我讨论、交流你的想法,每一次思考都会让你更进一步。 + +如果你读完这篇文章有所收获,也欢迎你分享给你的朋友,共同学习进步。我们下这节课再见! + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/我们这个课程的系统是怎么搭建起来的?.md b/专栏/高楼的性能工程实战课/我们这个课程的系统是怎么搭建起来的?.md new file mode 100644 index 0000000..d56130c --- /dev/null +++ b/专栏/高楼的性能工程实战课/我们这个课程的系统是怎么搭建起来的?.md @@ -0,0 +1,636 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 我们这个课程的系统是怎么搭建起来的? + 你好,我是高楼。 + +在我们这个课程里,为了让你更好地理解我的性能工程理念,我专门搭建了一个完整的系统,我们所有的内容都是基于这个系统展开的。 + +自课程更新以来,有不少同学问我要这个系统的搭建教程,想自己试一试。因此,我梳理了一版搭建教程,希望能帮到你。 + +由于整个系统相对复杂,有很多需要考虑、部署的细节,所以这节课的内容会比较长。下面这张图是我们这节课的目录,你可以整体了解一下,然后对应这张目录图,来学习具体的搭建步骤,以免迷失方向。 + + + +一. 物理服务器 + +1. 服务器规划 + +在这个系统中,我们主要用到了四台服务器,下面是具体的硬件配置: + + + +我们可以看到,当前服务器在应用中使用的资源总共是 64C 的 CPU 资源,以及 128 G 的内存资源。由于 NFS (网络存储)服务器不用在应用中,我们不计算在内。 + +因为单台机器的硬件资源相对较多,所以,在后续的工作中,我们将这些物理机化为虚拟机使用,以方便应用的管理。 + +在成本上,所有物理机的费用加在一起大概八万元左右,这其中还包括交换机、机柜、网线等各类杂七杂八的费用。 + +2. 服务器搭建 + +目前,行业内主流的基于 x86 架构的 Linux 系统,无非是 CentOS 和 Ubuntu。在我们这个项目中,我选择 CentOS 系列来搭建 Linux 系统,主要是考虑到系统的稳定性。CentOS 来自 Redhat 商业版本的重新编译,它在稳定性、系统优化以及兼容性方面,具有比较完善的测试和发版流程。 + +在 CentOS 7 之后的版本中,CentOS 的内核换成了 Linux 3.x,因此,我们这个课程的分析都是基于 Linux 3.x 这个内核版本展开的。 + +在搭建过程中,我们给每台服务器都安装了 CentOS 7.8 的操作系统。如果你是新手,我建议你使用带 GUI 桌面的系统,方便后续操作和管理虚拟机。具体的操作系统安装步骤,你可以参考这个链接来部署: HP 服务器安装 CentOS 7 。 + +二. 虚拟化 + +1. 虚拟机规划 + +我们接着来看虚拟机规划。我们部署了至少五台虚机,并且把虚拟机类型分为两种主机节点类型: + + +普通节点: + + +普通节点用来做非被测系统使用,比如压力机、管理平台等。我们可以选择采用 Docker、二进制等方式来部署。 + + +Kubernetes节点: + + +Kubernetes节点用于部署项目的应用服务,包括 mall-admin、mall-portal、mall-gateway、mall-member、mall-cart 等应用服务,还包括 SkyWalking、Nacos 等基础组件。这些都采用 Kubernetes的方式来部署。 + +具体的节点规划,你可以参考这张表: + + + +在这里,我们规划了三个Kubernetes控制节点,这是为后续的高可用方案准备的。如果你计划搭建单 Master 集群,只需要一个Kubernetes控制节点即可。至于Kubernetes计算节点,结合前面的节点规划,我们在这里配置 9 个 worker 节点,其他的节点根据自己的需求灵活扩展。 + +2. 虚机安装 + +到了安装虚拟机这一步,我们最终选择以 KVM 为主的方案。这主要考虑到,KVM 是目前比较成熟的开源虚拟化平台,在 2006 年被写入到 Linux 内核中。并且在 RedHat 6 以后,RedHat 开始转向支持 KVM,而非之前大力推广的 Xen 虚拟化方案,随后 Intel 也开始全面支持 KVM。KVM 相比较于 Xen,更小,更轻量级,也更方便管理。 + +在项目搭建之初,我们也尝试过用 OpenStack 做底层,但是 OpenStack 部署起来不仅繁杂,而且坑也多,需要投入大量的时间成本。我们当时在分析 OpenStack 本身的问题上花费了很多时间,对于我们的这个系统来说,这是没有必要的。 + +所以,我们最终选择用 KVM 来做虚拟化,它的技术相对成熟,操作又比较简单。 + +你可能会有疑问,为什么不用 VMware 呢?我们知道,在虚拟化平台中,VMware 在 IO 和稳定性方面都算是目前最优的一个方案了,也能满足我们的需求。不过,它是一款商业软件,授权比较昂贵,这是我们这个项目不得不放弃的一个原因。当然,如果你的项目有充足的预算, VMware 是一个不错的选择。 + +在安装之前,你可以大概了解一下 KVM 性能、热迁移、稳定性、应用移植、搭建等方面的注意事项,做为知识的扩展补充。对性能分析来说,我们要关注一下KVM的优化重点:关于KVM 虚拟化注意的二三事整理 + +至于 KVM 的安装和使用,你可以参考这个链接里的内容:Linux KVM 安装使用手册。 + +三. Kubernetes 集群 + +1. 计算资源 + +关于集群计算资源,你可以参考这张表: + + + +我们在做计算资源规划的时候,通常需要考虑不同的应用场景: + + +传统虚拟化技术的 I/O 损耗较大,对于 I/O 密集型应用,物理机相比传统虚拟机(像VMware的传统虚拟化做出来的虚拟机)有更好的性能表现; +在物理机上部署应用,有更少的额外资源开销(如虚拟化管理、虚拟机操作系统等),并且可以有更高的部署密度,来降低基础设施成本; +在物理机上可以更加灵活地选择网络、存储等设备和软件应用生态。 + + +如果从实际生产环境考虑,一般而言建议: + + +对性能极其敏感的应用,如高性能计算,物理机是较好的选择; +云主机支持热迁移,可以有效降低运维成本; +在工作实践中,我们会为 Kubernetes 集群划分静态资源池和弹性资源池。通常而言,固定资源池可以根据需要选择物理机或者云主机实例;弹性资源池则可以根据应用负载,使用合适规格的云主机实例来优化成本,避免资源浪费,同时提升弹性供给保障。 + + +由于我们这个系统只是课程的示例项目,为了尽可能压榨服务器资源,节省服务器成本,我们选择了自行准备虚机的方案,这样可以充分使用硬件资源。 + +2. 集群搭建 + +关于集群搭建,我们的节点规划如下: + + + +关于集群搭建的具体步骤,你可以按照下面这两个文档进行部署: + + +单 Master 集群:使用 kubeadm 安装单master kubernetes 集群(脚本版) +高可用方案: Kubernetes 高可用集群落地二三事 + + +安装的负载均衡组件如下: + + + +如果你没有Kubernetes的使用基础,那么我建议学习一下这几篇入门文章: + + +Kubernetes 集群基本概念 +k8s入门篇-Kubernetes的基本概念和术语 +K8s命令篇-Kubernetes工作实用命令集结号 +Kubernetes 集群常用操作总结 + + +3. 插件安装 + +我们需要安装的插件主要有三种:网络插件、存储插件以及组件。 + +对于网络插件,我们选用的是目前主流的网络插件 Calico。如果你的系统有其它选型需求,那你可以参考下面这篇文章,这里我就不做赘述了。 + + +Kubernetes 网络插件(CNI)超过 10Gbit/s 的基准测试结果 + + +安装Calico插件的具体步骤,在前面的单 Master 集群部署文档中已有说明,你可以参考一下。 + +对于存储插件,我们选用的是 NFS 网络存储。因为 NFS 相对简单,上手快,我们只需要部署一个NFS服务,再由Kubernetes提供一个自动配置卷程序,然后通过 StoageClass 动态配置 PVC 就可以了。 而且在性能上,NFS 也能满足我们这个系统的需求。 + +只不过,NFS 并不是高可用方案。如果你是在生产环境中使用,可以考虑把 Ceph 作为存储选型方案。Ceph 是一个统一的分布式存储系统,也是高可用存储方案,并且可以提供比较好的性能、可靠性和可扩展性。但是,Ceph 部署起来更复杂些,同时维护也比 NFS 复杂。 + +我把 NFS 和 Ceph 的详细安装步骤放在这里,你如果有需要,可以学习参考。 + + +NFS: Kubernetes 集群部署 NFS 网络存储 +Ceph: Kubernetes 集群分布式存储插件 Rook Ceph部署 + + +另外,不要忘了,NFS 配置中还需要这两个组件: + + + +4. Kubernetes管理平台 + +安装组件: + + + +Kuboard 采用的是可视化UI的方式来管理应用和组件,降低了Kubernetes集群的使用门槛。下面我们看看怎么部署 Kuboard 组件。 + +第一步,k8s 集群执行资源文件: + +kubectl apply -f https://kuboard.cn/install-script/kuboard.yaml +kubectl apply -f https://addons.kuboard.cn/metrics-server/0.3.7/metrics-server.yaml + + +第二步,把 Kuboard 安装好后,我们看一下 Kuboard 的运行状态: + +kubectl get pods -l k8s.kuboard.cn/name=kuboard -n kube-system + + +输出结果: + +NAME READY STATUS RESTARTS AGE +kuboard-54c9c4f6cb-6lf88 1/1 Running 0 45s + + +这个结果表明 kuboard 已经成功部署了。 + +接着,我们获取管理员 Token 。这一步是为了登录访问 Kuboard,检查组件是否成功运行。 + +# 可在第一个 Master 节点上执行此命令 +echo $(kubectl -n kube-system get secret $(kubectl -n kube-system get secret | grep kuboard-user | awk '{print $1}') -o go-template='{{.data.token}}' | base64 -d) + + +通过检查部署我们了解到,Kuboard Service 使用了 NodePort 的方式暴露服务,NodePort 为 32567。因此,我们可以按照下面这个方式访问 Kuboard: + +http://任意一个Worker节点的IP地址:32567/ + + +然后,在登录中输入管理员 Token,就可以进入到 Kuboard 集群的概览页了。 + +注意,如果你使用的是阿里云、腾讯云等云服务,那么你可以在对应的安全组设置里,开放 worker 节点 32567 端口的入站访问,你也可以修改 Kuboard.yaml 文件,使用自己定义的 NodePort 端口号。 + +四. 依赖组件 + +1. 部署清单 + + + +2. 安装部署 + +对于上述依赖组件的安装部署,我整理了对应的教程放在这里,你有兴趣可以尝试一下。 + +MySQL 的二进制安装方式,在网上的教程多如牛毛,我在这里就不介绍了,如果你想知道怎么在Kubernetes下部署 MySQL,你可以参考这个链接中的详细步骤:如何在 Kubernetes 集群中搭建一个复杂的 MySQL 数据库。 + +Elasticsearch 集群的部署可以参考: + + +Kubernetes Helm3 部署 Elasticsearch & Kibana 7 集群 + + +JMeter的部署可以参考: + + +二进制:性能工具之JMeter+InfluxDB+Grafana打造压测可视化实时监控 +Kubernetes:Kubernetes 下部署 Jmeter 集群 + + +镜像仓库 Harbor 的部署可以参考: + + +Kubernetes 集群仓库 harbor Helm3 部署 + + +Nacos 的部署可以参考: + + +Docker 单机模式: Nacos Docker 快速开始 +Kubernetes:Kubernetes Nacos + + +Redis、RabbitMQ、MongoDB 单机部署的部署可以参考: + + +Kubernetes 集群监控 kube-prometheus 自动发现 + + +Logstash 的部署可以参考: + + +整合ELK实现日志收集 + + +五. 监控组件 + +1. 全局监控 + +不知道你还记不记得,我们这个系统的架构: + + + +根据这个系统的架构,我们选择的工具要监控到这几个层面: + + +第一层,物理主机; +第二层,KVM 虚拟机; +第三层,Kubernetes套件; +第四层,各种应用所需要的技术组件。 + + +其实,有了上面的系统架构,监控设计就已经出现在写方案之人的脑袋里了。对于我们这个课程所用的系统,全局监控如下所示: + + + +从上图来看,我们使用 Prometheus/Grafana/Spring Boot Admin/SkyWalking/Weave Scope/ELK/EFK 就可以实现具有全局视角的第一层监控。对于工具中没有覆盖的第一层计数器,我们只能在执行场景时再执行命令来补充了。 + +2. 部署清单 + + + +3. 安装部署 + +对于上面这些监控工具的部署,我也把相应的安装教程放在这里,供你参考学习。 + +Kubernetes集群资源监控的部署: + + +Kubernetes 集群监控 kube-prometheus 部署 +Kubernetes 集群监控 controller-manager & scheduler 组件 +Kubernetes 集群监控 ETCD 组件 + + +日志聚合部署的部署: + + +Kubernetes 集群日志监控 EFK 安装 + + +依赖组件的部署: + + +Kubernetes 集群监控 kube-prometheus 自动发现 + + +APM 链路跟踪的部署: + + +Kubernetes + Spring Cloud 集成链路追踪 SkyWalking + + +六. 微服务 + +1. 项目介绍 + +在搭建这个课程所用的系统时,我采用了微服务的架构,这也是当前主流的技术架构。 + +如果你有兴趣了解详细的项目介绍,可以参考这篇文章:《高楼的性能工程实战课》微服务电商项目技术全解析。这里面主要介绍了该项目的一些预备知识、系统结构、主要技术栈以及核心组件。此外,还有相关的运行效果截图。 + +2. 拉取源代码 + +我们把 git clone 项目源代码下载到本地,来部署我们的被测系统: + +git clone https://github.com/xncssj/7d-mall-microservice.git + + +3. 修改 Nacos 配置 + +我们先将项目 config 目录下的配置包导入到 Nacos 中,然后根据自己的实际需要修改相关配置。 + +接着,我们将配置信息导入到 Nacos 中后,会显示这样的信息: + + + +请你注意,我们修改的配置文件主要是每个单体服务下的 application-prod.yml 和 bootstrap-prod.yml。因为两个全局配置文件,都是服务容器内加载的配置文件。 + +4. 镜像打包及推送 + +我们使用 Java 语言的 IDE (推荐 IDEA )打开项目工程。 + +首先,修改项目根目录下的 pom.xml 文件: + + + + http://172.16.106.237:2375 + + + +在 IDEA 的右边 Maven 标签页,我们可以找到 root 工程下的 package 按钮,选中并执行: + + + +然后,在编译的远程 Docker 主机上,我们修改所有服务的镜像标签名称。之后,再推送镜像到 Docker 仓库。 + +5. 导入数据库 + +这一步需要将项目 document/sql 目录下的 SQL 脚本导入到 MySQL 数据库中。 + +6. 初始化依赖组件 + +6.1. RabbitMQ + +第一步,进入 RabbitMQ 容器并开启管理功能: + +#登录容器的时候需要注意到容器支持的 shell 是什么。 +kubectl exec -it -n bash +kubectl exec -it -n sh + + +root@cloud-rabbitmq-5b49d784c-gbr8m:/# rabbitmq-plugins enable rabbitmq_management +Enabling plugins on node rabbit@cloud-rabbitmq-5b49d784c-gbr8m: +rabbitmq_management +The following plugins have been configured: + rabbitmq_management + rabbitmq_management_agent + rabbitmq_web_dispatch +Applying plugin configuration to rabbit@cloud-rabbitmq-5b49d784c-gbr8m... +Plugin configuration unchanged. + + +因为 RabbitMQ Service 使用 NodePort 的方式暴露控制台地址,比如 NodePort 为 15672。所以,第二步,我们访问地址 http://计算节点IP:15672/ 地址,查看是否安装成功: + + + +第三步,输入账号密码并登录guest/guest。 + +第四步,创建帐号并设置其角色为管理员 mall/mall。 + + + +第五步,创建一个新的虚拟 host 为 /mall。 + + + +第六步,点击 mall 用户进入用户配置页面,给 mall 用户配置该虚拟 host 的权限。 + + + +到这里,RabbitMQ 的初始化就完成了。 + +6.2. Elasticsearch + +安装中文分词器 IKAnalyzer,并重新启动: + +#此命令需要在容器中运行 +elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.6.2/elasticsearch-analysis-ik-7.6.2.zip + + +7. 使用 yaml 资源文件部署应用 + +将项目 document/k8s 目录下的 yaml 资源文件中的 Dokcer 镜像,修改为自己的 Tag 并上传到 k8s 集群中执行: + +kubectl apply -f k8s/ + + +七. 运行效果展示 + +1. 服务器 + + + +2. 虚拟机 + + + +3. Kubernetes 集群 + +Kubernetes 集群: + +[root@k8s-master-1 ~]# kubectl get nodes +NAME STATUS ROLES AGE VERSION +k8s-master-1 Ready master 26d v1.19.2 +k8s-master-2 Ready master 26d v1.19.2 +k8s-master-3 Ready master 26d v1.19.2 +k8s-worker-1 Ready 26d v1.19.2 +k8s-worker-2 Ready 26d v1.19.2 +k8s-worker-3 Ready 26d v1.19.2 +k8s-worker-4 Ready 26d v1.19.2 +k8s-worker-5 Ready 26d v1.19.2 +k8s-worker-6 Ready 26d v1.19.2 +k8s-worker-7 Ready 26d v1.19.2 +k8s-worker-8 Ready 26d v1.19.2 +k8s-worker-9 Ready 26d v1.19.2 +[root@k8s-master-1 ~]# + + +微服务管理: + + + +4. 微服务 + +部署架构图: + + + +API 文档: + + + +调用链监控:- +- + + + + +服务注册: + + + +服务监控:- + + +日志聚合: + + + +配置管理: + + + +系统保护: + + + +容器仓库: + + + +压力引擎: + + + +5.资源监控 + +Kubernetes集群资源监控: + + + +Linux 资源监控: + + + +MySQL 资源监控: + + + +RabbitMQ 资源监控: + + + +MongoDB 数据库资源监控: + + + +Kubernetes etcd 资源监控: + + + +Kubernetes API Server 资源监控: + + + +Kubernetes 服务拓扑: + + + +八. 总结 + +这节课的内容包括了物理环境的说明、技术组件的具体搭建过程、示例系统的搭建过程以及运行效果。经过上面所有的步骤,我们就把整个课程涉及的所有技术组件、示例系统完全搭建起来了。 + +而我之所以选择这样的技术栈,主要有三方面的考虑: + +1. 核心优势 + + +任务调度:为集群系统中的任务提供调度服务,自动将服务按资源需求分配到资源限制的计算节点; +资源隔离:为产品提供管控与服务节点隔离能力,保证研发应用和管控服务不产生相互的影响; +高可用能力:自动监控服务运行,根据运行情况对失效的服务进行自动重启恢复; +网络互联互通能力:提供统一的IP地址分配和网络互通能力; +统一编排管理能力:结合 Gitlab 和 k8s ,对输出的产品进行统一的编排管理; +公共产品组件可以为团队提供统一部署、验证、授权、调度和管控能力,为私有云服务提供基础性的支撑。 + + +2. 核心设施平台(IaaS云) + + +提供计算、网络、存储等核心资源设备的虚拟化; +支持不同操作系统,包括主流的 Win 和 Linux 系统; +提供主要的三种服务:云主机、云网络、云硬盘; +提供可视化 Web UI; +提供 k8s 集群(容器云)规划、部署和运营; +支持多种计算、存储和网络方案。 + + +3. 基础服务平台(PaaS云) + + +提供数据存储:支持常见 NFS 、Ceph RBD、Local Volume 等; +提供应用服务:支持自愈和自动伸缩、调度和发布、负载均衡等; +提供运维管理:支持日志监控、资源监控、消息告警等。 + + +我们这个系统采用的技术栈,是当前技术市场中流行的主流技术栈,这样的环境具有很高的借鉴价值。而且,从我们要表达的 RESAR 性能分析架构和逻辑来说,也说明 RESAR 性能分析理念是足以支撑当前的技术栈的。 + +参考资料汇总 + +1. CentOS 7的部署:HP 服务器安装 CentOS 7- +2. KVM的优化重点:关于KVM 虚拟化注意的二三事整理- +3. KVM 的安装和使用:Linux KVM 安装使用手册- +4. Kubernetes 集群搭建: + + +单 Master 集群:使用 kubeadm 安装单master kubernetes 集群(脚本版) +高可用方案: Kubernetes 高可用集群落地二三事 + + +5. Kubernetes的使用基础: + + +Kubernetes 集群基本概念 +k8s入门篇-Kubernetes的基本概念和术语 +K8s命令篇-Kubernetes工作实用命令集结号 +Kubernetes 集群常用操作总结 + + +6. Kubernetes网络插件选型:Kubernetes 网络插件(CNI)超过 10Gbit/s 的基准测试结果 + +7. NFS部署: Kubernetes 集群部署 NFS 网络存储 + +8. Ceph部署: Kubernetes 集群分布式存储插件 Rook Ceph部署 + +9. Kubernetes下的MySQL部署:如何在 Kubernetes 集群中搭建一个复杂的 MySQL 数据库 + +10. Elasticsearch 集群的部署:Kubernetes Helm3 部署 Elasticsearch & Kibana 7 集群 + +11. JMeter的部署: + + +二进制:性能工具之JMeter+InfluxDB+Grafana打造压测可视化实时监控 +Kubernetes:Kubernetes 下部署 Jmeter 集群 + + +12. 镜像仓库 Harbor 的部署:Kubernetes 集群仓库 harbor Helm3 部署 + +13. Nacos 的部署: + + +Docker 单机模式: Nacos Docker 快速开始 +Kubernetes:Kubernetes Nacos + + +14. Redis、RabbitMQ、MongoDB 单机部署的部署:Kubernetes 集群监控 kube-prometheus 自动发现 + +15. Logstash 的部署:整合ELK实现日志收集 + +16. Kubernetes集群资源监控的部署: + + +Kubernetes 集群监控 kube-prometheus 部署 +Kubernetes 集群监控 controller-manager & scheduler 组件 +Kubernetes 集群监控 ETCD 组件 + + +17. 日志聚合部署的部署:Kubernetes 集群日志监控 EFK 安装 + +18. 依赖组件的部署:Kubernetes 集群监控 kube-prometheus 自动发现 + +19. APM 链路跟踪的部署:Kubernetes + Spring Cloud 集成链路追踪 SkyWalking + +20. 微服务项目介绍:《高楼的性能工程实战课》微服务电商项目技术全解析 + +21. 其他学习资料推荐: + + +SpringCloud 日志在压测中的二三事 +高楼的性能工程实战课之脚本开发 +《高楼的性能工程实战课》学习所推荐的知识点 + + + + + \ No newline at end of file diff --git a/专栏/高楼的性能工程实战课/结束语做真正的性能项目.md b/专栏/高楼的性能工程实战课/结束语做真正的性能项目.md new file mode 100644 index 0000000..e3db7b0 --- /dev/null +++ b/专栏/高楼的性能工程实战课/结束语做真正的性能项目.md @@ -0,0 +1,98 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 结束语 做真正的性能项目 + 你好,我是高楼。 + +到这里,我终于完成了第二个课程的编写和更新。我粗略统计了一下,这个课程的正文超过了18万字,环境搭建部分有9万多字,加起来总共有28万字左右,纯手工,无添加。 + +相比较上一个课程《性能测试实战30讲》,这个课程我感觉写得很辛苦,因为里面所有的案例都不是造出来的,而是我真正在面对一个未知的系统,把遇到的各种问题一个个进行分析得来的。 + +如果你学过上一个课程就会发现,我的重点是分析单个组件,想要把每个组件的分析逻辑都尽可能地给你讲明白,而各个章节之间其实并没有太多的关联分析,所以这并不足以支撑我们做好性能项目。 + +因为在一个实际的项目中,我们分析性能瓶颈,在大部分情况下靠分析单个组件是不会有证据链的,除非我们恰好分析到了有问题的组件。因此,从逻辑上来讲,只有关联分析,才能帮助我们形成更有效的思路。这也是为什么,我会在这个课程中用案例的形式,给你展示我整体的分析过程。 + +当然了,还有一个更重要的原因就是,帮你修炼“内功”。 + +做性能分析,一定要具备“内功” + +我一直在强调,性能应该是一个工程级的活动。但是,现在很多企业都把它做成了测试阶段的一个任务。其结果就是,测试任务做完了,对系统能不能正常运行仍然没有底气;一旦系统上线,运维就陷入疲于奔命的状态,忙着处理各种源源不断的问题。 + +更要命的是,“测试”行业对工具的关注程度要远远大于性能目标,甚至忽略了自身分析能力的重要性。因此,很多人即使做了测试,也不清楚达没达到目标。这就像你有了倚天剑、屠龙刀,却没有内功,你终究发挥不出它的威力。但是,如果你有九阳神功傍身,那就不一样了,你不会再过分纠结于使用什么样的武器。 + +因此,在我的RESAR性能工程理念中,我想要告诉你的就是,做性能分析,一定要具备“内功”。 + +那我们要具备什么样的内功呢?你看到下面这张性能分析优化技术图谱了吗?这个图谱中的技术就是我们要修炼的内功。 + + + +你可能会想,这些内容实在太多了,一个人怎么可能做得到? + +我不知道你在冒出这个想法之前,有没有做过尝试和努力。其实,你只要在这个图谱中任选一个模块,然后再挑选其中一个技术组件,把它吃透,其他相似的技术组件大多能触类旁通。因为我们的重点是掌握背后的性能分析逻辑,而不是学会使用所有的工具。 + +就比如我们这个课程中最重要的两个技术逻辑:性能分析决策树和性能瓶颈证据链,这两个技术逻辑就是在教你怎么去思考,怎么去分析性能问题。如果你想学的是工具操作,那完全可以对着工具的手册自行修炼。 + +其实,对于基础知识和技术细节来说,不管你是看书还是学习课程,我觉得只有一个途径是进步最快的,那就是“动手实践”。 + +而当下的现状是,大多数人只有在具体的工作中才会动手实践,不工作了就只看看资料。这也是为什么很多人看资料似乎都看懂了,但一动手就废。 + +就像有些人一边抱怨身上的肥肉长得快,一边又大吃大喝不运动。这样的人,完全是惰性使然,他们只有在真正的危害降临时,才会临时抱佛脚。人的惰性真是不可估量,且难以遏止。 + +可是你要知道,学习和思考是每一个人都不可逃避的过程,内功也不是突击几日就能练就的。只要我们开始,并且不间断地让自己进步,哪怕一天只学到一个知识点,那么在一段时间之后,我们就会战胜自己的惰性,享受到进步带来的快感。久而久之,那些庞杂的基础知识和技术细节也就烂熟于心了。 + +有了这些知识储备之后,紧接着你会面临这样一个问题:怎么把它们融会贯通? + +这就要靠“思路”了,也就是这些知识在具体运用的过程中产生的方法论。这一点非常重要,因为如果这个阶段你不走过去,就只能永远停留在工具层面。 + +而我说的这个方法论,其实就是我在这个课程中想要告诉你的“RESAR性能工程”:- + + +不过,你要记住,方法论只有落地才有价值,这也是为什么我在这个课程中,尽量把每一个细节都努力写出来,给你一个参考。 + +从性能目标反推性能工程的落地价值 + +在你修炼内功的同时,我也希望你能提升对性能的认知,真正明白性能项目的价值。 + +因为放眼望去,现在的性能市场真的是一片胶着的存在,就像《呼兰河传》中东二道街中央的大泥坑,纵然大部分人都知道泥坑的危害,除了深陷其中的人和热心帮忙的人在努力面对之外,其他人或拍手喝彩、或起哄架秧子,宁愿贴着路边的树根天天走,也没有人考虑去填这个坑。 + +这种现象非常普遍。在一个企业中,很少有人去计算性能问题导致了多少利润流失;也没有人去计算,因系统性能低下而产生的成本代价。有的企业甚至动用上万的CPU资源(每天的利用率只有不到5%)来维系着心里的安全感;也有的企业一遇到线上性能问题就气急败坏,但救火结束后仍不思悔改……而这些现象都源于对性能的认知不够。 + +可能有人会说,在性能上花费再多的人力和时间成本,也不能保证出成效。对!这才是关键!到底什么是成效?不就是给生产上的保证吗?性能给出业务容量的保证,才是真正的价值体现。 + +而现实的情况是,大部分性能人员都做不到这一点,所以性能价值才会不断被轻视。最后,性能项目只能沦为交差的过场,上线的系统该怎么死还怎么死。 + +我之前评审一个性能标准的时候,开过几次专家组讨论会。会上,大家就“性能项目要不要做调优”这个问题争论不休。有人认为测试周期短,不需要做太多调优;有人认为要求性能工程师理解架构有点赶鸭子上架了;还有人认为性能测试就只是测试阶段中一个短暂的任务,不用过于吹毛求疵…… + +在这样的会议中,我默默听完了所有人的发言后,说:“我只提一个问题,如果你们能解决这个问题,就可以按你们的思路走。我的问题是,你们的思路可以明确给出系统最大容量是多少这个结论吗?”然后,大家突然都沉默了,会议室里安静得可怕。 + +这就是很多人对性能的认知。在他们的脑海里,这个结论是根本不可能实现的,因为他们一直从职位的能力范围来看性能这件事情。 + +如果我们从性能结论(目标)反推性能该如何做的时候,就可以明显地知道,性能不应该受到个人或固定团队的技术能力限制,而应该是从成本和利润损失的角度去思考如何做。 + +也正因为如此,我在编写那个性能标准的时候,毫不犹豫地加上了“调优”和“线上性能数据环比”的部分,让性能成为一个完整的闭环。 + +我们这个课程也正是基于这样完整的闭环思路,提出了RESAR性能工程方法论。在这套方法论中,我已经将我能想得到的角度都完整地阐述了。如果你觉得还不够清楚或不够完整,欢迎随时找我讨论。在不动手的范围内争论,我都是可以接受的。 + +蜕变,必须经历思维转变 + +在上一个课程上线之后,其实就有不少人找我讨论,说我颠覆了很多人对性能的认识,而且还触碰了一些人敏感的心理承受底线。于是,就有人雄纠纠气昂昂地想找我理论一下性能方法论的问题。 + +在我耐着性子听完那些漏洞百出的陈词老调之后,我告诉他:如果你能把一个按你说的方法论完全落地的项目展现到我面前,并且没有被问倒,同时又体现了你说的方法论的价值,那我觉得你就是对的;如果你没有这样做过,麻烦让一下,不要消耗我的网络流量。 + +说真的,我切身在一个个性能项目执行过程中做归纳总结,不是为了和人争论高下的,这种毫无意义并且子无虚有的虚荣心和满足感不是我所追求的。我希望的是,我的方法论能实际落地,并且能体现出性能项目的价值。要是只想提一个语不惊人死不休的话题来引起争论的话,我应该去学学西晋王衍,而不是在这干需要实践出真知的技术行业了。 + +从性能“测试”到性能“工程”的转变,是每一个做性能的人在蜕变的过程中,都必须经历的思维转变。而性能工程在落地中所体现出的技术价值和业务价值,才是在真正考验性能工程的具体可操作性,脱离了价值的考量终究只是一场虚枉。 + +在这个课程中,从性能方案、业务模型、场景、数据、环境,到具体的分析逻辑、并发计算、性能监控等一系列落地过程,就是我的性能工程理念想要表达的完整内容,也只有这样,才能帮助你做一个真正的性能项目。 + +总之,我希望学习这个课程的你,能仔细思考一下我的理念,至于它能发挥出的威力有多大,就要取决于你的基础功底了。也希望你能在不断实践的过程中,丰富自己的思维逻辑,做真正有价值的性能项目,不纠结,不盲从,不退不让,不卑不亢。 + +在课程的最后,我为你准备了一份结课问卷,希望你能花 1 分钟时间填写一下,我想听一听你对这门课的反馈。只要填写,就有机会获得一顶极简棒球帽或者是 价值 99 元的课程阅码。期待你的畅所欲言。 + + + + + + \ No newline at end of file