19 性能为王:微服务架构中的多级缓存设计

前一讲我们学习了利用 Seata 构建微服务环境的分布式事务架构,通过完整的案例也了解了 Seata 的执行过程。

本讲咱们探讨缓存这个话题,看在微服务环境下如何设计有效的多级缓存架构。本讲涉及三方面内容:

  • Web 应用的客户端缓存;

  • 应用层静态资源缓存;

  • 服务层多级缓存。

首先,咱们先讲解微服务架构的多级缓存设计。

微服务架构中的多级缓存设计

提到缓存,想必每一位软件工程师都不陌生,它是目前架构设计中提高性能最直接的方式。这里我们举个例子:

Redis 缓存

假设应用程序将原始数据存储在 MySQL 数据库中。众所周知 MySQL 数据库会将数据存储在硬盘以防止掉电丢失,但是受制于硬盘的物理设计,即便是目前性能最好的企业级 SSD 硬盘,也比内存的这种高速设备 IO 层面差一个数量级,而以淘宝、京东这种电商为代表的互联网应用,都是典型的“读多写少”的场景,因此我们需要在设计上进行数据的读写分离,在数据写入时直接落盘处理,而占比超过 90% 的数据读取操作时则从以 Redis 为代表的内存 NoSQL 数据库提取数据,利用内存的高吞吐瞬间完成数据提取,这里 Redis 的作用就是我们常说的缓存。

当然,缓存可不只有用内存替代硬盘这一种形式,在分布式架构下缓存在每一层都有自己的设计,下面咱们通过这个微服务的多级缓存架构图为主线进行讲解。

X 缓存多级缓存架构纵览

这张图从上到下包含四层,分别为:客户端应用层服务层以及数据层

客户端缓存

X 商城客户端为浏览器,在浏览器层面我们主要是对 HTML 中的图片、CSS、JS、字体这些静态资源进行缓存。

浏览器缓存

我们以百度 Logo 图片为例,百度在 HTTP 通过 Expires 响应头控制静态图片的有效期。Expires 代表过期时间。当前百度 Logo 的过期时间为 2031 年 2 月 8 日 9 时 26 分 31 秒。在这个时间段内,浏览器会将图片以文件形式缓存在本地,再次访问时会看到“from disk cache”的提示,此时浏览器不再产生与服务器的实际请求,会从本地直接读取缓存图片。通过在浏览器端设置 Expires 可以在很大程度减少重复请求静态资源带来的带宽损耗,这在高并发 Web 应用中是基础而重要的设置。

应用层缓存

那 Expires 到底在哪里进行设置呢?对于浏览器来说它只是客户端,只负责读取Expires响应头,对于 Expires 要在应用层,也就是 CDN 与 Nginx 中进行设置。

CDN 内容分发网络

CDN 全称是 Content Delivery Network,即内容分发网络,是互联网静态资源分发的主要技术手段。

CDN 内容分发网络

中国幅员辽阔,从北京到上海就有上千公里,如果大量的上海用户同时要访问千里之外的北京服务器的资源,这么长的通信必然带来高延迟与更多不可控因素影响数据传输,如果有某种机制允许将北京的静态文件缓存到上海的服务器,上海用户自动就近访问服务器获取资源,这样便可很大程度降低网络延迟,进而提高系统的可用性。而刚才提到的分布式缓存技术就是我们常提到的CDN(内容分发网络)。

对于广域的互联网应用,CDN 几乎是必需的基础设施,它有效解决了带宽集中占用以及数据分发的问题。像 Web 页面中的图片、音视频、CSS、JS 这些静态资源,都可以通过 CDN 服务器就近获取。

CDN 技术的核心是“智能 DNS”,智能 DNS 会根据用户的 IP 地址自动确定就近访问 CDN 节点,咱们以下图为例:

CDN 执行流程

以某上海用户的浏览器要访问商城首页广告位的 banner.jpg 文件,浏览器通过服务商提供的智能 DNS 服务,将请求自动转发到商城在上海地区准备的 CDN 服务器,上海 CDN 收到请求后首先检查本机是否已缓存过 banner.jpg,如果文件已存在便直接将图片数据返回给客户端;如果没有缓存过,则回源到北京的源数据节点,将 banner.jpg 文件抽取并缓存到上海服务器,最后上海 CDN 节点再将本机的 banner.jpg 返回给客户端。对于 banner.jpg 来说,第一次访问后上海 CDN 节点已缓存该文件,则之后的缓存有效期内所有后续访问由上海 CDN 直接提供。与之类似的,商城应用可以在重要城市搭建 CDN 节点,这样原本集中被发往北京服务器的请求就被分摊到 CDN 节点,这也直接降低了北京机房的带宽压力。

在互联网应用中,因为 CDN 涉及多地域多节点组网,前期投入成本较高,更多的中小型软件公司通常会选择阿里云、腾讯云等大厂提供的 CDN 服务,通过按需付费的方式降低硬件成本。而这些服务商又会为 CDN 赋予额外的能力,比如阿里云、腾讯云 CDN 除了缓存文件之外,还提供了管理后台能为响应赋予额外的响应头。如下所示在阿里云 CDN 后台,就额外设置了 Cache-Control 响应头代表缓存有效期为 1 小时。这里我们额外提一下 Expires 与的 Cache-Control 的区别,Expires 是指定具体某个时间点缓存到期,而 Cache-Control 则代表缓存的有效期是多长时间。Expires 设置时间,Cache-Control 设置时长,根据业务场景不同可以使用不同的响应头。

阿里云自定义响应头

Nginx 缓存管理

说完 CDN,下面再来聊一下 Nginx。Nginx 是一款开源的、跨平台的高性能 Web 服务器,它有着高性能,稳定性好,配置简单,模块结构化,资源消耗低的优点。同时支持反向代理、负载均衡、缓存的功能。Nginx 是 Web 应用架构中的常客,例如后端 Tomcat 集群便可通过增加 Nginx 前置做软负载均衡,为应用提供高可用特性。

Nginx 代理服务器

在互联网应用中,用户分布在全国各地,对资源的响应速度与带宽要求较高,因此部署 CDN 是十分有必要的。但在更多的企业应用中,其实大部分的企业用户都分布在指定的办公区域或者相对固定的场所,再加上并发用户相对较少,其实并不需要额外部署 CDN 这种重量级解决方案。在架构中只需要部署 Nginx 服务器,利用 Nginx 自带的静态资源缓存与压缩功能便可胜任大多数企业应用场景。

在 Nginx 中自带将后端应用中图片、CSS、JS 等静态资源缓存功能,我们只需在 Nginx 的核心配置 nginx.conf 中增加下面的片段,便可对后端的静态资源进行缓存,关键配置我已做好注释,同学们可以直接使用。

# 设置缓存目录
# levels代表采用1:2也就是两级目录的形式保存缓存文件(静态资源css、js)
# keys_zone定义缓存的名称及内存的使用,名称为babytun-cache ,在内存中开始100m交换空间
# inactive=7d 如果某个缓存文件超过7天没有被访问,则删除
# max_size=20g;代表设置文件夹最大不能超过20g,超过后会自动将访问频度(命中率)最低的缓存文件删除
proxy_cache_path d:/nginx-cache levels=1:2 keys_zone=babytun-cache:100m inactive=7d max_size=20g;
#配置xmall后端服务器的权重负载均衡策略
upstream xmall {server 192.168.31.181 weight=5 max_fails=1 fail_timeout=3s;server 192.168.31.182 weight=2;server 192.168.31.183 weight=1;server 192.168.31.184 weight=2;
}
server {#nginx通过80端口提供Web服务listen 80;# 开启静态资源缓存# 利用正则表达式匹配URL,匹配成功的则执行内部逻辑# ~* 代表URL匹配不区分大小写location ~* \.(gif|jpg|css|png|js|woff|html)(.*){# 配置代理转发规则proxy_pass http://xmall;proxy_set_header Host $host;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_cache xmall-cache;#如果静态资源响应状态码为200(成功)  302(暂时性重定向)时 缓存文件有效期1天proxy_cache_valid 200 302 24h;#301(永久性重定向)缓存保存5天proxy_cache_valid 301 5d;#其他情况proxy_cache_valid any 5m;#设置浏览器端缓存过期时间90天expires 90d;}
#使用xmall服务器池进行后端处理
location /{proxy_pass http://xmall; proxy_set_header Host $host;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

}

增加上面配置后,每一次通过 Nginx 访问应用中新的静态文件时,在 Nginx 服务的缓存目录便会生成缓存文件,在缓存有效期内该静态资源的请求便不再送到后端服务器,而直接由 Nginx 读取本地缓存并返回。

Nginx 本地缓存

服务层缓存

在前面无论是 CDN 还是 Nginx,都是对 Web 应用中的静态资源文件进行缓存。但后端应用与服务更多的是访问接口与数据,对于这些对象我们如何利用缓存技术进行性能优化呢?对于后端应用与服务的缓存可以按部署方式分为进程内缓存分布式缓存服务。

进程内缓存

所谓进程内缓存,就是在应用中开辟的一块内存空间,数据在运行时被载入这块内存,通过本地内存的低延迟、高吞吐的特性提高程序的访问速度。进程内缓存在众多 Java 框架内都有广泛应用,例如 Hibernate、Mybatis 框架的一二级缓存、Spring MVC 的页面缓存都是进程内缓存的经典应用场景,这些进程内缓存在 Java 中也有着非常多优秀的开源实现,如 EhCache、Caffeine 都是代表性产品。

分布式缓存服务

与进程内相对的,就是需要独立部署的分布式缓存服务。最常用的是基于 Redis 这种内存型 NoSQL 数据库,对整体架构中的应用数据进行集中缓存。

在架构设计时,很多新架构师一听到缓存,下意识认为增加 Redis 分布式缓存服务器就够了,其实这是片面的做法。在缓存架构设计时,一定要按照由近到远、由快到慢的顺序进行逐级访问。假设在电商进行商品秒杀活动时,如果没有本地缓存,所有商品、订单、物流的热点数据都保存在 Redis 服务器中,每完成一笔订单,都要额外增加若干次网络通信,网络通信本身就可能由于各种原因存在通信失败的问题。即便是你能保证网络 100% 可用,但 Redis 集群承担了来自所有外部应用的访问压力,一旦突发流量超过 Redis 的负载上限,整体架构便面临崩溃的风险。

Redis 缓存服务集群

因此在 Java 的应用端也要设计多级缓存,我们将进程内缓存与分布式缓存服务结合,有效分摊应用压力。在 Java 应用层面,只有 EhCache 的缓存不存在时,再去 Redis 分布式缓存获取,如果 Redis 也没有此数据再去数据库查询,数据查询成功后对 Redis 与 EhCahce 同时进行双写更新。这样 Java 应用下一次再查询相同数据时便直接从本地 EhCache 缓存提取,不再产生新的网络通信,应用查询性能得到显著提高。

多级缓存设计

保障缓存一致性

但事无完美,当引入多级缓存后,我们又会遇到缓存数据一致性的挑战,以下图为例:

缓存一致性问题

我们都知道作为数据库写操作,是不通过缓存的。假设商品服务实例 1 将 1 号商品价格调整为 80 元,这会衍生一个新问题:如何主动向应用程序推送数据变更的消息来保证它们也能同步更新缓存呢?

相信此时你已经有了答案。没错,我们需要在当前架构中引入 MQ 消息队列,利用 RocketMQ 的主动推送功能来向其他服务实例以及 Redis 缓存服务发起变更通知。

通过 RocketMQ 解决保证消息一致性

如上图所示,在商品服务实例 1 对商品调价后,主动向 RocketMQ Broker 发送变更消息,Broker 将变更信息推送至其他实例与 Redis 集群,这些服务实例在收到变更消息后,在缓存中先删除过期缓存,再创建新的数据,以此保证各实例数据一致。

看到这里你会发现,对于缓存来说,并没有终极的解决方案。虽然多级缓存设计带来了更好的应用性能,但也为了缓存一致性必须引入 MQ 增加了架构的复杂度。那到底多级缓存设计该如何取舍呢?在我看来,有三种情况特别适合引入多级缓存。

第一种情况,缓存的数据是稳定的。例如邮政编码、地域区块、归档的历史数据这些信息适合通过多级缓存减小 Redis 与数据库的压力。

第二种情况,瞬时可能会产生极高并发的场景。例如春运购票、双 11 零点秒杀、股市开盘交易等,瞬间的流量洪峰可能击穿 Redis 缓存,产生流量雪崩。这时利用预热的进程内缓存分摊流量,减少后端压力是非常有必要的。

第三种情况,一定程度上允许数据不一致。例如某博客平台中你修改了自我介绍这样的非关键信息,此时在应用集群中其他节点缓存不一致也并不会带来严重影响,对于这种情况我们采用T+1的方式在日终处理时保证缓存最终一致就可以了。

以上是我总结的三种适合服务层做多级缓存的场景。当然如果你们的应用并发量不大,在未来的1~2 年内利用 Redis 分布式缓存集群完全可以胜任应用性能要求,那自然就没有必要设计多级缓存,我们要根据业务特点灵活调整架构。

小结与预告

本讲咱们介绍了在应用微服务架构下从客户端到服务层,各层的缓存设计以及解决方案,讲解了从浏览器的 Expires 响应头到 CDN、Nginx 的静态资源缓存,再到服务层针对数据的多级缓存,使你对微服务架构的缓存有了总体的了解。

下一讲,咱们聊一聊在传统架构下如何一步步向微服务进行转型,中间会遇到哪些新问题。


20 升级改造:老项目升级到微服务的重构策略

前一讲我们分析了在微服务架构下,如何通过多级缓存提升静态资源与数据的访问性能。本讲咱们谈一谈技术每一个公司都要面对的问题:如何将公司陈旧的单体应用改造升级为微服务架构。

本文咱们介绍六条改造策略:

  • 严禁 Big Bang(一步到位);

  • 尽早体现价值;

  • 优先分离做前后端;

  • 新功能构建成微服务;

  • 利用 Spring AOP 开发低侵入的胶水代码;

  • 基于 MQ 构建反腐层。

开始之前,我们先来分析下为什么要进行微服务化改造。在项目发展初期或者规模不大的时候,架构师和程序员主要关注是如何快速交付商业价值,很多项目开始时并没有经过架构层面的精细打磨,也没有考虑架构的延展性。在紧迫的工期下,所有人员都在关注如何尽快实现业务代码,这是在中国乃至全世界中小型软件公司的通病。在这种背景下,大量粗糙的单体式、伪分布式的应用程序被开发出来,这些软件模块间的调用关系盘根错节,在长年累月的更新迭代中,代码变得臃肿不堪,任何一个微小的改动都可能“牵一发动全身”,甚至公司内没有一个人可以梳理清模块间的调用关系,这种软件产品对于整个公司都是一场噩梦。

难以维护的软件架构

随着微服务架构日渐成熟,以 Spring Cloud 为代表的技术生态大行其道,很多软件公司希望通过对单体系统进行微服务化改造来提高整体质量,但是在落地过程中就会遇到大量新问题,比如:

  • 改造是一步到位还是逐渐迭代?

  • 微服务拆分的粒度是什么?

  • 如何保证数据一致性?

  • 新老交替过程中如何不影响公司业务进展?

  • ……

由此衍生的新问题会成为改造过程中的风险,幸运的是我们可以工作开发“绞杀者应用程序(Strangler Application)”让单体应用从容自然的完成微服务的升级改造。下面咱们先来了解什么是绞杀者应用程序。

绞杀者应用程序

所谓绞杀者应用程序的想法来自绞杀式藤蔓,这些藤蔓在雨林中生长,他们围绕树木生成,甚至有时会杀死树木。绞杀者应用程序是一个由微服务组成的新应用程序,通过将新功能作为服务,并逐渐从单体应用中提取服务来实现。随着时间的推移,越来越多单体应用内的功能被逐渐剥离为独立的微服务,最终达到消灭单体应用的目的。绞杀者应用程序最大的好处是,升级改造过程中并不需要推翻原有的代码,而是在新老更迭的过程中一步步完成微服务架构的升级改造。

绞杀单体应用

绞杀者应用的重构过程往往需要数月乃至数年,我以前在中国顶级的普惠金融机构从事架构设计工作,这家机构的普惠金融核心业务线从立项重构到完成绞杀历经 28 个月,从基本的用户管理到高级的定价模型都遵循相同的策略一步步处理,过程中也保证了公司业务的正常开展。

下面我就来介绍几个重要的改造策略:

严禁 Big Bang(一步到位)

在我和同行沟通时发现,特别多的架构师、项目经理是强迫症晚期,当一个微服务改造立项后,总想着搞点大事情,看到以前单体代码特别不顺眼,于是调用人力物力,从数据库表到应用代码全部推翻重来,希望让整个项目脱胎换骨,好在老板面前能体现他的工作能力。但在我看来,这是风险极高的做法,很可能以失败告终,你花费数月甚至数年复制现有功能来实现业务今天的需要。正如 Martin Fowler 所说:“推到重写的唯一保证,就是彻底搞砸一切”。正确的做法是逐步重构你的单体应用,采用绞杀者应用策略,将应用变为单体与微服务的混合状态,随着时间增加一点点蚕食掉单体应用。

尽早地体现价值

逐步重构微服务的一个重要好处是立即获得投资回报。我们还是通过案例分析,假设你有 1 个月时间重构“普惠金融业务线”的某一个模块,你会选择剥离“用户与授权管理”模块还是选择“信审风控”模块呢?传统的软件理论肯定会告诉你应该打好基础,先从用户管理这些基础模块做起。但从另一个维度来说,你的工作是要产生价值的,如果重构以后新的信审风控能更有效、更准确地为客户经理提供决策依据,那你的工作价值就能立即体现出来。

所以在排期时应按价值的重要性进行排序,优先解决公司的痛点,尽快体现出你们的工作成果。

优先分离做前后端

在实施重构改造时,优先要完成应用与业务逻辑的分离。在原本单体应用中,基于经典的分层理论将程序分为四层:表示层、控制层、业务逻辑层、数据访问层。前面两层表示层与控制层,我们拆解为应用前端,业务逻辑与数据访问拆解为服务后端。应用前端与服务后端在物理上进行切割,中间采用 RESTful API 进行通信,应用前端的职责就是负责与用户交互,服务后端只暴露细粒度的 RESTful API 提供业务处理接口。

这样做有两大好处,首先,它使得前后端独立部署、扩展与维护,尤其是表示层在快速迭代部署时并不影响后端功能,可以轻松进行 A/B 测试。其次,后端分离后采用 RESTful 方式暴露接口,这与微服务的设计要求是一致的,这位未来的微服务剥离工作打下良好基础。

前后端分离策略

新功能构建成微服务

在系统改造的过程中,业务部门也会提出许多全新的需求,对于这些新需求我们首先要做的是将其剥离成新的微服务,以此遏制老系统的野蛮生长。我们举例说明:业务部门提出新要求,希望参考京东商城提供多维度、条件丰富的商品查询系统,来替代原本简陋的关键字查询。

京东商城商品检索页

对于这种新功能,在改造过程中首先要将其构建为新的“产品检索”微服务,而不应再为单体应用添加代码。如图所示:

新功能构建成微服务

在原本服务后端不变的前提下,额外引入 Spring Cloud 微服务体系,我们在前端向后端访问时增加了 API Gateway 网关,该网关对前端访问的 URL 进行路由。如果前端访问 search 接口,则请求被重定向到新创建的商品检索微服务,通过 ElasticSearch 这种专用的全文检索引擎提供更高级的查询功能;而访问其他 URL 时则将请求转发到原本的服务后端进行处理。

在这个过程中,还有一个重要原则:数据源不允许混用。商品数据保存在 MySQL 数据库,但绝不允许让微服务直接访问 MySQL 的数据,因为在未来的很长时间,单体应用与微服务是混合运行的,如果出现数据源的交叉访问,稍有不注意便会出现数据问题,因此两端的数据源应完全隔离。正确的做法是引入 Alibaba Canal 做数据源同步,Canal 是阿里巴巴旗下的开源项目,纯 Java 开发。基于数据库增量日志解析,提供增量数据订阅&消费,可自动实现从 MySQL 数据源向其他数据源同步数据的任务。

Alibaba Canal

前面我们将全新功能单独构建为微服务,在网关层面进行 URL 的转发,但这种情况太过理想,毕竟更多的情况是在原有单体代码中,剥离一部分成为独立的微服务,在这个过程中既要减少对原始代码的修改,又要实现微服务的远程调用。

在以前项目中我们运用 Spring AOP 技术良好地解决了这个问题。Spring AOP 称为面向切面编程,Spring 框架底层通过 Java 动态代理或者 CGLib 技术,允许 Java 程序在运行时自动生成代理类,实现在不修改源码的前提下对应用进行动态拦截与扩展。

为了方便理解,我们还是通过案例讲解。

京东商品页

以京东的 iPhone11 为例,这个页面的数据其实来自多张数据表,商品标题来自商品基础信息表,而价格信息则来自活动价格表。商品的基础信息相对稳定,而价格数据随着时间在不断变化。

在单体应用时,因为所有数据都存在同一个 MySQL 数据库,获取数据时处理是很简单的,下面是我给出的伪代码。

@Service
public class GoodsService{@Resourceprivate GoodsDao goodsDao;@Resourceprivate PriceService priceService; //定价服务类public Map selectGoods(Long skuId){Goods goods = goodsDao.findById(skuId);//从本地查询商品基本信息Price price = priceService.findByGoodsId(skuId);//在定价服务查询商品定价... //组织Map对象省略}
}

可以看到在单体应用时,所有的调用都在 JVM 进程内完成。

但是随着业务发展,定价表数据量越来越大,业务逻辑也越发复杂,我们希望剥离出独立的“定价服务”,将原本进程内调用变为 RESTful 远程通信,还要对原始代码尽可能少做修改。

改造前后对比

如果你了解 Spring AOP,便不难想到利用 Around 环绕通知便可轻松实现从本地调用到远程访问的修改。这里只需额外定义一个切面类,伪代码如下:

@Component("priceServiceAspect") //声明Bean Id
@Aspect //定义切面类
public class PriceServiceAspect{@Resourceprivate PriceServiceFeignClient priceServiceFeignClient;//利用环绕通知实现对PriceService.findByGoodsId的动态代理@Around("execution(* com.lagou..PriceService.findByGoodsId(..)")public Object selectGoods(ProceedingJoinPoint joinPoint){//通过OpenFeign客户端向定价服务发起远程请求,替代JVM本地访问return priceServiceFeignClient.selectGoods((Long)joinPoint.getArgs()[0]);}
}

在上面的伪代码片段中,在 selectGoods 调用 PriceService.findByGoodsId 方法时,会自动进入 PriceServiceAspect 切面类,该切面类会拦截 PriceService.findByGoodsId 方法的执行,不再进行本地调用,而是通过 Spring Cloud OpenFeign 客户端向定价服务发起 RESTful 请求并得到定价结果。因为 Spring AOP 是无侵入的,所以对于原本的 GoodsService 代码无须做任何调整就可将 PriceService.findByGoodsId 方法转为远程访问。
以上了数据查询时利用 Spring AOP 实现零侵入改造,对于这种只读的查询操作改造是非常轻松的,但如果涉及事务处理问题就会变得非常复杂。

以新增商品为例,在单体应用时利用进程内事务便可保证数据一致性,例如:

开启事务;
新增商品基础数据;
新增商品价格数据;
提交事务;

当我们将"定价服务"剥离为独立的服务后,因为跨进程调用会导致原本进程内事务失效,这就强制要求引入分布式事务来保证数据的一致性,尽管前面我们学习过 Seata 的 AT 模式可以在较少修改的前提下自动实现分布式事务,但这也不可避免的要求额外部署 Seata-Server 集群,这也必然导致架构复杂度的增加。那有没有更简单的做法呢?其实我们变通一下,在开始切分微服务时,在不确定外界依赖的情况下可以将微服务粒度做的粗一些,极力避免分布式事务的产生就可以了。如当前案例,商品信息本身是内聚的,价格只是商品实体的一个属性,因此在设计之初我们可以剥离出粗粒度的“商品服务”,将商品管理与定价策略内聚合在一起,就可以避免分布式事务。

粗粒度切分策略

基于 MQ 构建反腐层

构建反腐层实现应用隔离

随着改造的持续进行,我们在单体应用中额外增加了大量 Spring AOP 切面类,这样做虽然对原始代码改动较小,但基于 OpenFeign 直接面向微服务调用本身就破坏了单体应用与微服务间的隔离原则,这也是需要极力避免的。因此我们可以再进一步,将 OpenFeign 的 RESTful 调用改为利用 MQ 实现消息的“发布/订阅”,让单体应用与微服务持续解耦。这个引入 MQ 中间层的解耦策略,在微服务改造中被称为“反腐层”。通过反腐层,服务后端无须关注具体是哪个微服务实例消费数据,OpenFeign 也不再越界访问微服务,同时因为 MQ 自带特性,还赋予了应用消息追溯、流控等额外特性,可谓一举多得。

小结与预告

本讲我为你分享了了微服务重构改造的六条改造策略,分别是:严禁 Big Bang、尽早且频繁的体现价值、优先分离做前后端、新功能构建成微服务、利用 Spring AOP 开发低侵入的胶水代码、基于 MQ 构建反腐层,希望你在认真思考后可以把这些策略运用在项目中。

下一讲,我们将学习在微服务架构下构建统一的用户认证与授权方案。


21 统一门户:基于网关的统一用户认证方案

前一讲我们学习了老项目向微服务架构转化的改造策略,通过开发绞杀者应用程序让应用进行平滑升级。本讲咱们继续学习另外一个关键的设计,如何设计微服务架构下的用户认证方案。

本讲咱们涉及以下三方面内容:

  • 传统的用户认证方案;

  • JWT 与 JJWT;

  • 基于网关的统一用户认证。

传统的用户认证方案

我们直奔主题,什么是用户认证呢?对于大多数与用户相关的操作,软件系统首先要确认用户的身份,因此会提供一个用户登录功能。用户输入用户名、密码等信息,后台系统对其进行校验的操作就是用户认证。用户认证的形式有多种,最常见的有输入用户名密码、手机验证码、人脸识别、指纹识别等,但其目的都是为了确认用户的身份并与之提供服务。

用户认证

在传统的单体单点应用时代,我们会开发用户认证的服务类,从登录界面提交的用户名密码等信息通过用户认证类进行校验,然后获取该用户对象将其保存在 Tomcat 的 Session 中,如下所示:

单点应用认证方案

随着系统流量的增高,单点应用以无法支撑业务运行,应用出现高延迟、宕机等状况,此时很多公司会将应用改为 Nginx 软负载集群,通过水平扩展提高系统的性能,于是应用架构就变成了这个样子。

Java Web 应用集群

虽然改造后系统性能显著提高,但你发现了么,因为之前用户登录的会话数据都保存在本地,当 Nginx 将请求转发到其他节点后,因为其他节点没有此会话数据,系统就会认为没有登录过,请求的业务就会被拒绝。从使用者的角度会变成一刷新页面后,系统就让我重新登录,这个使用体验非常糟糕。

我们来分析下,这个问题的根本原因在于利用 Session 本地保存用户数据会让 Java Web 应用变成有状态的,在集群环境下必须保证每一个 Tomcat 节点的会话状态一致的才不会出问题。因此基于 Redis 的分布式会话存储方案应运而生,在原有架构后端增加 Redis 服务器,将用户会话统一转存至 Redis 中,因为该会话数据是集中存储的,所以不会出现数据一致性的问题。

Redis 统一存储用户会话

但是,传统方案在互联网环境下就会遇到瓶颈,Redis 充当了会话数据源,这也意味着 Redis 承担了所有的外部压力,在互联网数以亿计的庞大用户群规模下,如果出现突发流量洪峰,Redis 能否经受考验就会成为系统的关键风险,稍有差池系统就会崩溃。

那如何解决呢?其实还有一种巧妙的设计,在用户认证成功,后用户数据不再存储在后端,而改为在客户端存储,客户端每一次发送请求时附带用户数据到 Web 应用端,Java 应用读取用户数据进行业务处理,因为用户数据分散存储在客户端中,因此并不会对后端产生额外的负担,此时认证架构会变成下面的情况。

客户端存储用户信息

当用户认证成功后,在客户端的 Cookie、LocalStorage 会持有当前用户数据,在 Tomcat 接收到请求后便可获取用户数据进行业务处理。但细心的你肯定也发现,用户的敏感数据是未经过加密的,在存储与传输过程中随时都有泄密的风险,决不能使用明文,必须要对其进行加密。

那如何进行加密处理呢?当然,你可以自己写加解密类,但更通用的做法是使用 JWT 这种标准的加密方案进行数据存储与传输。

Json Web Token(JWT)介绍

无论是微服务架构,还是前后端分离应用,在客户端存储并加密数据时有一个通用的方案:Json Web Token(JWT),JWT是一个经过加密的,包含用户信息的且具有时效性的固定格式字符串。下面这是一个标准的JWT字符串。

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ7XCJ1c2VySWRcIjoyLFwidXNlcm5hbWVcIjpcImxpc2lcIixcIm5hbWVcIjpcIuadjuWbm1wiLFwiZ3JhZGVcIjpcInZpcFwifSJ9.NT8QBdoK4S-PbnhS0msJAqL0FG2aruvlsBSyG226HiU

这段加密字符串由三部分组成,中间由点“.”分隔,具体含义如下。

  • 第一部分 标头(Header):标头通常由两部分组成:令牌的类型(即 JWT)和所使用的签名算法,例如 HMAC SHA256 或 RSA,下面是标头的原文:

{"alg": "HS256","typ": "JWT"
}

然后,此 JSON 被 Base64 编码以形成 JWT 的第一部分。

eyJhbGciOiJIUzI1NiJ9
  • 第二部分 载荷(Payload):载荷就是实际的用户数据以及其他自定义数据。载荷原文如下所示。

{"sub": "1234567890","name": "John Doe","admin": true
}

然后对原文进行 Base64 编码形成 JWT 的第二部分。

eyJzdWIiOiJ7XCJ1c2VySWRcIjoyLFwidXNlcm5hbWVcIjpcImxpc2lcIixcIm5hbWVcIjpcIuadjuWbm1wiLFwiZ3JhZGVcIjpcInZpcFwifSJ9
  • 第三部分 签名(Sign):签名就是通过前面两部分标头+载荷+私钥再配合指定的算法,生成用于校验 JWT 是否有效的特殊字符串,签名的生成规则如下。

HMACSHA256(base64UrlEncode(header) + "." +  base64UrlEncode(payload),  secret)

生成的签名字符串为:

NT8QBdoK4S-PbnhS0msJAqL0FG2aruvlsBSyG226HiU

将以上三部分通过“.”连接在一起,就是 JWT 的标准格式了。

JWT 的创建与校验

此时,你肯定有疑问 JWT 是如何生成的,又是如何完成有效性校验呢?因为 JWT 的格式与算法是固定的,在 Java 就有非常多的优秀开源项目帮我们实现了JWT 的创建与验签,其中最具代表性的产品就是 JJWT。JJWT 是一个提供端到端的 JWT 创建和验证的 Java 库,它的官网是:https://github.com/jwtk/jjwt,有兴趣的话你可以到官网阅读它的源码。

JJWT 的使用是非常简单的,下面我们用代码进行说明,关键代码我已做好注释。

  • 第一步,pom.xml 引入 JJWT 的 Maven 依赖。

<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>0.11.2</version>
</dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>0.11.2</version><scope>runtime</scope>
</dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred --><version>0.11.2</version><scope>runtime</scope>
</dependency>
  • 第二步,编写创建 JWT 的测试用例,模拟真实环境 UserID 为 123 号的用户登录后的 JWT 生成过程。

@SpringBootTest
public class JwtTestor {/*** 创建Token*/@Testpublic void createJwt(){//私钥字符串String key = "1234567890_1234567890_1234567890";//1.对秘钥做BASE64编码String base64 = new BASE64Encoder().encode(key.getBytes());//2.生成秘钥对象,会根据base64长度自动选择相应的 HMAC 算法SecretKey secretKey = Keys.hmacShaKeyFor(base64.getBytes());//3.利用JJWT生成TokenString data = "{\"userId\":123}"; //载荷数据String jwt = Jwts.builder().setSubject(data).signWith(secretKey).compact();System.out.println(jwt);}
}

运行结果产生 JWT 字符串如下:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ7XCJ1c2VySWRcIjoxMjN9In0.1p_VTN46sukRJTYFxUg93CmfR3nJZRBm99ZK0e3d9Hw
  • 第三步,验签代码,从 JWT 中提取 123 号用户数据。这里要保证 JWT 字符串、key 私钥与生成时保持一致。否则就会抛出验签失败 JwtException。

/*** 校验及提取JWT数据*/
@Test
public void checkJwt(){String jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ7XCJ1c2VySWRcIjoxMjN9In0.1p_VTN46sukRJTYFxUg93CmfR3nJZRBm99ZK0e3d9Hw";//私钥String key = "1234567890_1234567890_1234567890";//1.对秘钥做BASE64编码String base64 = new BASE64Encoder().encode(key.getBytes());//2.生成秘钥对象,会根据base64长度自动选择相应的 HMAC 算法SecretKey secretKey = Keys.hmacShaKeyFor(base64.getBytes());//3.验证Tokentry {//生成JWT解析器 JwtParser parser = Jwts.parserBuilder().setSigningKey(secretKey).build();//解析JWTJws<Claims> claimsJws = parser.parseClaimsJws(jwt);//得到载荷中的用户数据String subject = claimsJws.getBody().getSubject();System.out.println(subject);}catch (JwtException e){//所有关于Jwt校验的异常都继承自JwtExceptionSystem.out.println("Jwt校验失败");e.printStackTrace();}
}

运行结果如下:

{"userId":123}

以上便是 JWT 的生成与校验代码,你会发现在加解密过程中,服务器私钥 key 是保障 JWT 安全的命脉。对于这个私钥在生产环境它不能写死在代码中,而是加密后保存在 Nacos 配置中心统一存储,同时定期更换私钥以防止关键信息泄露。

讲到这应该你已掌握 JWT 的基本用法,但是在微服务架构下又该如何设计用户认证体系呢?

基于网关的统一用户认证

下面我们结合场景讲解 JWT 在微服务架构下的认证过程。这里我将介绍两种方案:

  • 服务端自主验签方案;

  • API 网关统一验签方案。

服务端自主验签方案

首先咱们来看服务端验签的架构图。

服务端自主验签方案

首先梳理下执行流程:

  • 第一步,认证中心微服务负责用户认证任务,在启动时从 Nacos 配置中心抽取 JWT 加密用私钥;

  • 第二步,用户在登录页输入用户名密码,客户端向认证中心服务发起认证请求:

http://usercenter/login #认证中心用户认证(登录)地址
  • 第三步,认证中心服务根据输入在用户数据库中进行认证校验,如果校验成功则返回认证中心将生成用户的JSON数据并创建对应的 JWT 返回给客户端,下面是认证中心返回的数据样本;

{"code": "0","message": "success","data": {"user": {"userId": 1,"username": "zhangsan",},"token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ7XCJ1c2VySWRcIjoxLFwidXNlcm5hbWVcIjpcInpoYW5nc2FuXCIsXCJuYW1lXCI6XCLlvKDkuIlcIixcImdyYWRlXCI6XCJub3JtYWxcIn0ifQ.1HtfszarTxLrqPktDkzArTEc4ah5VO7QaOOJqmSeXEM"}
}
  • 第四步,在收到上述 JSON 数据后,客户端将其中 token 数据保存在 cookie 或者本地缓存中;

  • 第五步,随后客户端向具体某个微服务发起新的请求,这个 JWT 都会附加在请求头或者 cookie 中发往 API 网关,网关根据路由规则将请求与jwt数据转发至具体的微服务。中间过程网关不对 JWT 做任何处理;

  • 第六步,微服务接收到请求后,发现请求附带 JWT 数据,于是将 JWT 再次转发给用户认证服务,此时用户认证服务对 JWT 进行验签,验签成功提取其中用户编号,查询用户认证与授权的详细数据,数据结构如下所示:

{"code": "0","message": "success","data": {"user": { #用户详细数据"userId": 1,"username": "zhangsan","name": "张三","grade": "normal""age": 18,"idno" : 130.......,...},"authorization":{ #权限数据"role" : "admin","permissions" : [{"addUser","delUser","..."}]}}
}
  • 第七步,具体的微服务收到上述 JSON 后,对当前执行的操作进行判断,检查是否拥有执行权限,权限检查通过执行业务代码,权限检查失败返回错误响应。

到此从登录创建 JWT 到验签后执行业务代码的完整流程已经完成。

下面咱们来聊一聊第二种方案:

API 网关统一验签方案

API 网关统一验签方案

API 网关统一验签与服务端验签最大的区别是在 API 网关层面就发起 JWT 的验签请求,之后路由过程中附加的是从认证中心返回的用户与权限数据,其他的操作步骤与方案一是完全相同的。

在这你可能又会有疑惑,为什么要设计两种不同的方案呢?其实这对应了不同的应用场景:

  • 服务端验签的时机是在业务代码执行前,控制的粒度更细。比如微服务 A 提供了“商品查询”与“创建订单”两个功能,前者不需要登录用户就可以使用,因此不需要向认证中心额外发起验签工作;而后者是登录后的功能,因此必须验签后才可执行。因为服务端验签是方法层面上的,所以可以精确控制方法是否验签。但也有不足,正是因为验签是在方法前执行,所以需要在所有业务方法上声明是否需要额外验签,尽管这个工作可以通过 Spring AOP+注解的方式无侵入实现,但这也无疑需要程序员额外关注,分散了开发业务的精力。

  • 相应的,服务端验签的缺点反而成为 API 网关验签的优势。API 网关不关心后端的服务逻辑,只要请求附带 JWT,就自动向认证中心进行验签。这种简单粗暴的策略确实让模块耦合有所降低,处理起来也更简单,但也带来了性能问题,因为只要请求包含 JWT 就会产生认证中心的远程通信。如果前端工程师没有对 JWT 进行精确控制,很可能带来大量多余的认证操作,系统性能肯定会受到影响。

那在项目中到底如何选择呢?服务端验签控制力度更细,适合应用在低延迟、高并发的应用,例如导航、实时交易系统、军事应用。而 API 统一网关则更适合用在传统的企业应用,可以让程序员专心开发业务逻辑,同时程序也更容易维护。

全新的挑战

虽然 JWT 看似很美,在实施落地过程中也会遇到一些特有的问题,例如:

  • JWT 生成后失效期是固定的,很多业务中需要客户端在不改变 JWT 的前提下,实现 JWT 的“续签”功能,但这单靠 JWT 自身特性是无法做到的,因为 JWT 的设计本身就不允许生成完全相同的字符串。为了解决这个问题,很多项目在生成的 JWT 设为“永久生效”,架构师利用 Redis 的 Expire 过期特性在后端控制 JWT 的时效性。这么做虽然让 JWT 本身变得有状态,但这可能也是在各种权衡后的“最优解”。类似的,例如:强制 JWT 立即失效、动态 JWT 有效期都可以使用这个办法解决。

某个 JWT 在 3600 秒后过期

  • 对于上面两种认证方案,还有优化的空间,比如在服务A第一次对某个 JWT 进行验签后获取用户与权限数据,那在 JWT 的有效期内便可将数据在本地内存或者 Redis 中进行缓存,这样下一次同样的 JWT 访问时直接从缓存中提取即可,可以节省大量服务间通信时间。但引入缓存后你也要时刻关注缓存与用户数据的一致性问题,是要性能还是要数据可靠,这又是一个架构师需要面对的抉择。

小结与预告

本讲咱们学习了三方面内容,首先咱们回顾了基于 Session 的有状态用户认证解决方案,其次介绍了 JWT 与 JJWT 的使用,最后讲解了利用 JWT 实现微服务架构认证的两种方案,对产生的新问题也进行了梳理。

在多年的架构生涯中,我自己也在不断感慨,架构是一门取舍的艺术,没有完美的架构,只有适合的场景,希望未来同学们可以多学习一些前沿技术,兴许随着技术发展没准鱼和熊掌真的可以兼得呢。

下一讲,咱们学习在微服务架构中,有哪些成熟的一致性方案可以为我所用。


SpringCloud Alibaba实战第八课 缓存设计、网关认证、重构策略相关推荐

  1. SpringCloud Alibaba实战--第八篇:Seata分布式事务处理

    系列文章目录 微服务新王SpringCloudAlibaba 文章目录 系列文章目录 前言 一.Seata简介 1. Seata是什么? 2. ID+三组件模型 3. 处理过程 二.Seata下载安装 ...

  2. SA实战 ·《SpringCloud Alibaba实战》第03章-微服务介绍

    作者:冰河 星球:http://m6z.cn/6aeFbs 博客:https://binghe001.github.io 文章汇总:https://binghe001.github.io/md/all ...

  3. SpringCloud Alibaba实战第一课 架构演进之路

    开篇词 Spring Cloud Alibaba 未来的微服务生态标准 你好,我是老齐,一名从业近 20 年的 IT 老兵,曾在京东.财政部.宜信.工商银行等机构从事架构设计与核心研发工作,有多个亿级 ...

  4. SpringCloud Alibaba实战第九课 分布式事务理论、DevOps运维

    22 一致性挑战:微服务架构下的数据一致性解决方案 上一讲我们介绍了如何在微服务架构中设计统一的用户认证方案.本讲咱们填之前埋下的一个坑,如何在微服务架构下有效保障数据一致性问题.本讲咱们涉及三方面内 ...

  5. SpringCloud Alibaba实战--第二篇:NacosⅠ服务注册和配置中心

    系列文章目录 微服务新王SpringCloudAlibaba 文章目录 系列文章目录 前言 一.Nacos是什么?能干啥? 二.Nacos下载及安装 1. 下载 2. 安装并运行 3. 对比Eurek ...

  6. SpringCloud Alibaba实战(12:引入Dubbo实现RPC调用)

    源码地址:https://gitee.com/fighter3/eshop-project.git 持续更新中-- 大家好,我是老三,断更了半年,我又滚回来继续写这个系列了,还有人看吗-- 在前面的章 ...

  7. Docker容器化实战第八课 DevOps和CI/CD

    22 多阶段构建:Docker 下如何实现镜像多阶级构建? 通过前面课程的学习,我们知道 Docker 镜像是分层的,并且每一层镜像都会额外占用存储空间,一个 Docker 镜像层数越多,这个镜像占用 ...

  8. 从零开始SpringCloud Alibaba实战(32)——spring-cloud-starter-oauth2认证授权服务

    OAuth2.0介绍 OAuth(开放授权)是一个开放标准,允许用户授权第三方应用访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方应用或分享他们数据的所有内容.OAuth2. ...

  9. Spring Cloud Alibaba 实战(八)SkyWalking篇

    1. SkyWalking 简介 Skywalking 是由国内开源爱好者吴晟(原 OneAPM 工程师,目前在华为)开源并提交到 Apache 孵化器的产品,它同时吸收了 Zipkin/Pinpoi ...

最新文章

  1. 优化C#程序的48种方法
  2. 现代密码学5.1--哈希函数定义
  3. “熊猫烧香”式的病毒营销
  4. 程序员该知道的7个必经阶段
  5. java中final的意义
  6. linux c用open打开(创建)一个文件
  7. CentOS4.4平台下安装EXTMAIL手记V1.3
  8. 取消链接文件失败。 我应该再试一次吗?
  9. enctype =#39;multipart / form-data#39;是什么意思?
  10. linux软链接和硬链接的区别
  11. win10X64 + vs2015通过Cmake编译Opencv(一)
  12. 《Involution:Inverting the Inherence of Convolution for Visual Recognition》论文笔记
  13. 微信公众平台:微信网页授权和微信支付
  14. 计算机软件职业资格注册,计算机软件职业资格证具体需要考什么科目
  15. 2018软工个人总结
  16. 数据结构——数组以及n维数组
  17. OpenCV计算机视觉(二) —— 图像的算数运算与逻辑运算
  18. sem竞价账户怎么提升效果提高转化
  19. 如何爬取微信公众号的所有文章
  20. 【HBase】HBase入门详解(一)

热门文章

  1. 【HTCVR】VRTK插件案例分析之0021~030
  2. 电影评论系统C语言,如何写出一篇真正的电影评论?——《如何写影评》
  3. 三次函数求近似解(牛顿迭代法/二分法)
  4. Java Future接口、Future模式理解
  5. windows平台 VS2017 live555 rtsp推流服务器编译
  6. 诊所管理系统方案/案列/APP/软件/小程序/网站
  7. PostgreSQL 儒略历学习资料
  8. DirectX简介 第四篇 DirectPlay简介
  9. Debian侵犯Rust商标,妥协改名还是会得到豁免?
  10. 选硬盘时,该选择SSD/SATA/SAS哪个好?