SpringCloud Gateway 在微服务架构下的最佳实践

前言

本文整理自云原生技术实践营广州站 Meetup 的分享,其中的经验来自于我们团队开发的阿里云 CSB 2.0 这款产品,这是一款基于开源 SpringCloud Gateway 为 code base 开发的产品,在完全兼容开源用法的前提下,做了非常多企业级的改造,涉及功能特性、稳定性、安全、性能等方面。

为什么需要微服务网关

网关流量

从功能角度来看,微服务网关通常用来统一提供认证授权、限流、熔断、协议转换等功能。

从使用场景上来看

  • 南北向流量,需要流量网关和微服务网关配合使用,主要是为了区分外部流量和微服务流量,将内部的微服务能力,以统一的 HTTP 接入点对外提供服务
  • 东西向流量,在一些业务量比较大的系统中,可能会按照业务域隔离出一系列的微服务,在同一业务域内的微服务通信走的是服务发现机制,而跨业务域访问,则建议借助于微服务网关。

微服务网关核心功能

微服务架构、微服务/API 网关这些关键词发展至今,早已不是什么新鲜的概念,技术选型者也从出于好奇心关注一个技术,转移到了更加关注这个技术的本质。市场上各类网关产品的功能也逐渐趋于同质化,基本可以用同一张图来概括:

网关核心功能

网关选型对比

企业在选择使用一款网关产品时,通常会有两个选择,一是基于某一款开源产品做二次开发,二是选择某一款商业化产品开箱即用,无论如何,都应当从稳定性、安全、性能、业务兼容性等方面去进行选型。请相信我今天是站在 SpringCloud Gateway 角度进行的分享,我会尽可能做到客观、公正。

早期 SpringCloud 社区出现过 Zuul 这种产品,时至今日搜索微服务网关的资料,大概率都会出现它的身影,仅其通信模型是同步的线程模型这一条,就不足以支撑其成为企业级的网关产品选型,我会主要对比 SpringCloud Gateway、阿里云 CSB 2.0、Nginx、Kong、Envoy。

选型对比

严谨来说,这几个网关并不适合对比,因为他们都有其各自适用的场景,表格仅供参考。

SpringCloud Gateway 的优势在于其可以很好地跟 Spring 社区和 SpringCloud 微服务体系打通,这一点跟 Java 语言流行的原因如出一辙,所以如果一个企业的语言体系是 Java 技术栈,并且基于 SpringBoot/ SpringCloud 开发微服务,选型 SpringCloud Gateway 作为微服务网关,会有着得天独厚的优势。

SpringCloud Gateway 选型的优势:

  • SpringCloud Gateway 有很多开箱即用的功能,且扩展点多

  • 适合 Java 技术栈

  • Spring/SpringCloud 社区生态好

  • 适合跟 SpringBoot/ SpringCloud 微服务生态集成

SpringCloud Gateway 介绍

如果你之前没有了解过 SpringCloud Gateway,也不用担心,下面一小部分篇幅会介绍 SpringCloud Gateway 基本用法。这是一段非常基础的 SpringCloud Gateway 路由配置示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
spring:
cloud:
gateway:
routes:
- id: aliyun
uri: https://www.aliyun.com
predicates:
- Host=*.aliyun.com
- id: httpbin
uri: http://httpbin.org
predicates:
- Path=/httpbin/**
filters:
- StripPrefix=1
- id: sca-provider
uri: lb://sca-provider
predicates:
- Path=/sca/**
filters:
- StripPrefix=1
nacos:
discovery:
server-addr: mse-xxxxx-p.nacos-ans.mse.aliyuncs.com:8848

该示例介绍了微服务网关常见的几种路由配置示例

  • Host 路由匹配
  • 前缀 Path 路由匹配
  • 前缀 Path 路由匹配 & 服务发现

SpringCloud Gateway 支持丰富的路由匹配逻辑,以应对各种类型的业务诉求:

SCG断言

其中 Path、Header、Method 这几种断言最为常用。

针对于网关请求路径、参数和后端服务请求路径、参数不一致的场景,SpringCloud Gateway 也提供了诸多开箱即用的 GatewayFilter,以实现对请求和响应的定制。

SCG插件

SpringCloud Gateway 的 user guide 介绍到此为止,如果想要了解 develop guide,建议参考 SpringCloud Gateway 的官方文档。

开源特性 vs 企业级特性需求

众所周知,开源产品直接投入企业级生产使用一般是会面临一些挑战的,毕竟场景不同。以扩展性为例,开源产品大多讲究扩展点丰富,以应对开源用户千奇百怪的需求,而企业级产品场景更为单一,性能和稳定性是第一考虑因素,当二者发生 trade off 时,则需要一些取舍了。

企业级特性

开源 SpringCloud Gateway 没有开箱即用地支持一些重要的企业级特性,如果选型 SpringCloud Gateway 构建生产级别可用的微服务网关,那我的建议是需要补足以上这些能力。下面我会花较多的篇幅介绍我们在开源基础上做的一些企业级改造,希望能够抛砖引玉。

白屏化管控

白屏化

表面看来,SpringCloud Gateway 并没有配套一个管理控制台,深层次一点来看,是 SpringCloud Gateway 还停留在一个开发框架层面,不是那么的产品化,同时它的领域模型也不是划分的那么清晰,说的好听点,这说明 SpringCloud Gateway 有充足的改造空间。

我们的改造原则有两点,一是完全兼容开源的规则及模型,不破坏底层规则的语义,这样我们可以跟随社区的节奏一起演进,将来也有机会贡献给社区,二是区分研发态的领域模型和用户态的产品模型,我们抽象出了路由、服务、来源、消费者、策略、插件等领域对象,这算不上什么创新,实际上网关领域的这些模型早已有了一些约定俗成的规范。

白屏化管控的背后,也意味着一切配置:路由配置、服务配置、策略配置…都是动态的,并且配置的变更都会实时生效。

配置方案重构

上文提到了配置实时生效这一改造,有人可能会有疑问,开源不是已经支持将路由配置存储在 Nacos 中了吗?对的,开源支持两种配置方式,一是将路由配置在 application.yaml 中,这样最简单,但对于路由配置的 curd 都需要重启进程,非常繁琐,二是将配置托管到 Nacos 这样的配置中心组件中,实现分布式配置,能够动态刷新,但我们认为这还不足以支持企业级需求,将配置存储在单个 dataId 中这种开源方案有以下痛点:

  1. 配置推送慢:配置量大,网络传输慢,万级别配置推送耗时 5 分钟
  2. 爆炸半径大:不支持配置拆分,错误配置影响解析流程,导致网关路由整体不可用
  3. 配置规模:单个 value 有 10M大小限制,仅支持千级别路由

配置拆分势在必行,但其中困难也很多,例如动态监听的管理,稳定性的保障流程尤为复杂,额外提供的视图层与实际配置中心数据一致性保障等等。方案参考下图:

配置方案重构

图中还有一个细节,也是我们优先选择 Nacos 作为配置中心的原因,nacos-client 的 snapshot 机制可以保证在管控以及配置中心组件都不可用时,即使网关 broker 重启了,依旧保证路由不丢失,保证自身可用性。

经过这套方案的改造,我们获得显著的优化效果:

  • 推送时间优化:1w 配置 5 分钟 -> 30 秒

  • 配置量上限提升:1000 -> 10w

  • 确保了配置推送的最终一致性

协议转换 x 服务发现

这两个企业级改造放到一起说,在实现上这两个模块也耦合的比较紧密。

协议转换:就以 Java 微服务体系而言,后端服务很有可能会出现 Dubbo 框架或者 GRPC 框架,甚至有些老的业务还会使用 WebService 这类框架,大多数时候我们说的网关都是只对接 HTTP 这一类通信协议,这限制了我们后端服务只能是 SpringBoot 或者 SpringCloud 框架,网关支持后端不同协议类型的能力,我们称之为协议转换。

服务发现:微服务框架离不开服务发现,一般常见的注册中心包括 Nacos、Eureka 等,例如开源 SpringCloud Gateway 便支持对接 Nacos/Eureka 两类注册中心。

这类开源特性的痛点是:

  1. SpringCloud Gateway 仅支持 HTTP2HTTP,不支持 HTTP2DUBBO,HTTP2GRPC,HTTP2WEBSERVICE
  2. SpringCloud Gateway 仅支持单一注册中心的静态配置

一些常见的企业级诉求:

  1. 存在不同类型的微服务架构:SpringCloud、Dubbo、GRPC
  2. 网关支持跨环境访问,需要连接多个注册中心或者多个命名空间

针对这些痛点和诉求,分享一些我们改造时遇到的难点以及经验

服务发现x协议转换扩展

在支持不同协议时,对应的服务框架可能已经有了对应的 remoting 层和 discovery 层,我们的选择是仅引入该协议的 remoting 二方包解决协议转换问题,对于 discovery 层,应当自行封装,避免使用对应协议的 discovery 层这个误区,因为回归到网关领域,服务发现和协议转换是对等的模块,抽象 ServiceDiscoveryFIlter 负责服务发现,ProtocolTransferFilter 则负责点对点的协议通信。

在服务发现层,为了适配不同注册中心的模型(推和拉),提供了两个实现 PullServiceRegistry、PushServiceRegistry,这些改造是独立于 spring-cloud-loadbalancer 模块实现的,开源的默认实现存在诸多的限制,例如仅支持拉模型 + 缓存服务列表的方案,实际上推模型能够为网关的服务发现提供更高的实时性。

基本流程:服务发现 serviceName -> n x IP,负载均衡 IP n ->1,协议转换 IP 点对点通信

这样一套扩展机制可以在有新的协议类型、注册中心、负载均衡算法需要对接时实现快速扩展。

限流熔断

如果仔细阅读过 SpringCloud Gateway 的文档,你会发现,开源对限流熔断的支持是非常有限的,它强依赖一个 Redis 做集群限流,且限流方案是自己实现的,而我们可能会更加信赖 Sentinel 提供的解决方案。事实上,开源 Sentinel 也对 SpringCloud Gateway 提供了一部分开箱即用的能力,使用层面完全没问题,主要是欠缺了一部分可观测性的能力。

限流熔断

在改造中,尤为注意要使用高版本的 Sentinel,即按比例阈值这套模型实现的限流方案,集成 Sentinel 之后,我们按照网关的通用场景提供了两类限流模型:基于慢调用比例的限流熔断和基于响应码比例的限流熔断。借助于 Sentinel 的能力,可惜实现渐进式的恢复。

可观测体系建设

可观测性体系的建设,可以说是很多开源产品距离企业级使用的距离,SpringCloud Gateway 亦是如此。

可观测性

网关通常会需要记录三类可观测性指标。

  • Metrics:如上图所示,记录请求数、QPS、响应码、P99、P999 等指标
  • Trace:网关链路能够串联后续微服务体系链路,实现全链路监控
  • Logging:按类别打印网关日志,常见的日志分类如 accessLog、requestLog、remotingLog 等

开源 SpringCloud Gateway 集成了 micrometer-registry-prometheus,提供了一个开箱即用的大盘:https://docs.spring.io/spring-cloud-gateway/docs/3.1.8/reference/html/gateway-grafana-dashboard.json,需要更加丰富维度的指标则需要自行埋点。

Trace 方案推荐对接 opentelemetry。

Logging 方案则是 SpringCloud Gateway 开源欠缺的,在实际生产中至少应该打印 accessLog 记录请求信息,按需开启 requestLog 记录请求的 payload 信息和响应体信息,以及与后端服务连接的日志,用于排查一些连接问题。日志采集方案我们的实践是将 accessLog 输出到标准输出中,方便在 K8s 架构下配置采集,或者采用日志 agent 的方案进行文件采集。

性能优化

除了功能层面的优化与新增,网关的性能也是使用者尤为关注的点。在前文中,我并没有把 SpringCloud Gateway 归为一个性能特别高的网关分类中,主要是基于我们的实践,发现其有不少优化空间。下面的章节我会分享一些基于 SpringCloud Gateway 进行的性能优化。

网关优化道阻且长,为了验证优化效果,建设性能基线不可避免,需要面向 benchmark 进行优化。

一些常用的优化技巧在网关中也同样适用,例如:缓存、懒加载、预分配、算法复杂度优化、CPU 友好操作,减少线程切换。

性能基线

火焰图

通过火焰图观测性能可以从宏观角度分析大的性能损耗点

火焰图

一个理想的网关火焰图应当是大部分的时间片占用花费在 IO 上,即图中的 netty 相关的损耗,除此之外占用了 CPU 的类,都需要重点关注。通过火焰图,我们也定位到了相当多的性能损耗点,并针对进行了优化。

GlobalFilter 排序优化

SpringCloud Gateway 中通过 GlobalFilter、GatewayFilter 对请求进行过滤,在 FilteringWebHandler 中可以看到这段逻辑

1
2
3
4
5
6
7
8
9
10
11
public Mono<Void> handle(ServerWebExchange exchange) {
Route route = exchange.getRequiredAttribute(GATEWAY_ROUTE_ATTR);
List<GatewayFilter> gatewayFilters = route.getFilters();

List<GatewayFilter> combined = new ArrayList<>(this.globalFilters);
combined.addAll(gatewayFilters);
// TODO: needed or cached?
AnnotationAwareOrderComparator.sort(combined);

return new DefaultGatewayFilterChain(combined).filter(exchange);
}

开源实现在每次请求级别都会重新组装出一个 FilterChain,并进行排序,内存分配和排序会占用 CPU,无疑会导致性能下降,通过注释可以看到 Contributor 自己也意识到了这里的性能问题,但一直没有修复。

一个可行的优化手段是在路由或者策略变更时,触发 FilterChain 的更新,这样请求时 FilterChain 就没必要重新构造了。而观测到这一性能问题,正是通过了火焰图中的 FilteringWebHandler.handle 的占用。

路由增量推送

之前的企业级特性章节中,我介绍了配置中心改造的方案,其中提及了开源方案爆炸半径大的问题,可以从下面的代码中,窥见一斑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class RouteDefinitionRouteLocator implements RouteLocator {

@Override
public Flux<Route> getRoutes() {
Flux<Route> routes = this.routeDefinitionLocator.getRouteDefinitions().map(this::convertToRoute);

if (!gatewayProperties.isFailOnRouteDefinitionError()) {
// instead of letting error bubble up, continue
routes = routes.onErrorContinue((error, obj) -> {
if (logger.isWarnEnabled()) {
logger.warn("RouteDefinition id " + ((RouteDefinition) obj).getId()
+ " will be ignored. Definition has invalid configs, " + error.getMessage());
}
});
}

return routes.map(route -> {
return route;
});
}

可以见得,SpringCloud Gateway 认为路由配置是一个整体,任意路由的变更,就会导致整个 Route 序列重新构建。并且在默认情况下,如果其中一个路由配置出错了,会导致整个网关路由不可用,除非 isFailOnRouteDefinitionError 被关闭。

我们的改造方案是使用 Map 结构进行改造,配合路由配置的增量推送,实现 Route 的单点更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class DynamicRouteRepository implements Ordered, RouteLocator, ApplicationEventPublisherAware, RouteDefinitionWriter {

private RouteConverter routeConverter;

static class RouteKey implements Ordered {
private String id;
private int order;
...
}

static final Map<RouteKey, Route> ORDERED_ROUTE = new TreeMap<>((o1, o2) -> {
int order1 = o1.order;
int order2 = o2.order;
if (order1 != order2) {
return Integer.compare(order1, order2);
}
return o1.id.compareTo(o2.id);
});

private static final Map<String, Integer> ORDER = new HashMap<>();

public Route getRouteById(String id) {
return ORDERED_ROUTE.get(new RouteKey(id, ORDER.getOrDefault(id, 0)));
}
...
}

路由内存优化

这个优化来自于我们一次生产问题的排查,起初我们并没有意识到该问题。问题表现为路由数量非常大时,内存占用的消耗超过了我们的预期,经过 dump 发现,同一份路由的配置内容竟然以 3 种形式常驻于内存中

  • Nacos 配置中心自身的 Cache
  • SpringCloud Gateway 路由定义 RouteDefinition 的占用
  • SpringCloud Gateway 真实路由 Route 的占用

Nacos 的占用在我们预期之类,但 RouteDefinition 其实仅仅是一个中间变量,如果流程合理,其实是没必要常驻内存的,经过优化,我们去除了一份占用,增加了支持路由的数量。

内存泄漏优化

该问题通用来自于生产实践,SpringCloud Gateway 底层依赖 netty 进行 IO 通信,熟悉 netty 的人应当知道其有一个读写缓冲的设计,如果通信内容较小,一般会命中 chunked buffer,而通信内容较大时,例如文件上传,则会触发内存的新分配,而 SpringCloud Gateway 在对接 netty 时存在逻辑缺陷,会导致新分配的池化内存无法完全回收,导致堆外内存泄漏。并且这块堆外内存时 netty 使用 unsafe 自行分配的,通过常规的 JVM 工具还无法观测,非常隐蔽。

处于改造成本考量,我们最终选择的方案是增加一行启动参数 -Dio.netty.allocator.type=unpooled,使得请求未命中 chunked buffer 时,分配的临时内存不进行池化,规避内存性能问题。

可能有人会有疑问,-Dio.netty.allocator.type=unpooled会不会导致性能下降,这个担心完毕没有必要,首先只有大报文才会触发该内存的分配,而网关的最佳实践应该是不允许文件上传这类需求,加上该参数只是为了应对非主流场景的一个兜底行为。

预构建 URI

该热点问题由 org.springframework.cloud.client.loadbalancer.LoadBalancerUriTools 贡献,SpringCloud Gateway 引用了 spring-cloud-loadbalancer 解决服务发现和负载均衡的问题,

1
2
3
4
5
6
7
8
9
10
11
private static URI doReconstructURI(ServiceInstance serviceInstance, URI original) {
String host = serviceInstance.getHost();
String scheme = (String)Optional.ofNullable(serviceInstance.getScheme()).orElse(computeScheme(original, serviceInstance));
int port = computePort(serviceInstance.getPort(), scheme);
if (Objects.equals(host, original.getHost()) && port == original.getPort() && Objects.equals(scheme, original.getScheme())) {
return original;
} else {
boolean encoded = containsEncodedParts(original);
return UriComponentsBuilder.fromUri(original).scheme(scheme).host(host).port(port).build(encoded).toUri();
}
}

注意最后一行构建,实际是针对不可变对象的一次变更,从而进行了一次深拷贝,重新重构了一个 URI,这样的行为同样发生在调用级别,不要小看这类行为,它会严重占用 CPU。

优化方案便是,对于不可变部分的构造,提前到路由推送时构建,对于可变的调用级别的参数,支持修改。这一点跟路由增量推送的优化是一个道理。

Spring 体系出于契约考虑,大量使用了不可变变量传递契约信息,但某些扩展点中,又的确希望对其进行变更,不得已进行了深拷贝,从而造成了性能下降,企业级应用需要在其中寻找到一个平衡点。

对象缓存

尽量避免调用链路中出现 new 关键字,它会加大 CPU 的开销,从而影响 IO,可以使用 ThreadLocal 或者对象池化技术进行对象复用。

如果 new 关键词仅出现在初始化,配置推送等异步场景,通常是一次性的行为,则出于代码可读性的考虑,不做太多要求。

总结

今天的分享简单介绍了一些主流的网关的对比,并重点介绍了 SpringCloud Gateway 适用的场景。并分析了 SpringCloud Gateway 如果在企业中投入生产使用,我们认为需要新增&改造的一些能力,最后针对一些常见的性能优化场景,介绍了我们的一些优化方案。这些经验完全来源我们 CSB 2.0 微服务网关基于 SpringCloud Gateway 改造的实践,CSB 2.0 是一款适用于私有化输出的网关产品,在今年,我们也会在公有云 EDAS 中将其进行输出,敬请期待。


Kirito 全屋定制记 | 纯小白向全屋定制攻略

前言

继上一篇文章《Kirito 杭州买房记 | 纯小白向杭州购房攻略》过去已经有 2 年了,预计在今年 6~9 月,我的房子就要交付了,所以我也开始了全屋定制之路。现在杭州的期房大多数是精装修交付,也就是说厨房和卫生间等部分都包含在房价中,我需要操心的全屋定制部分仅包含:

柜子部分

  • 卧室衣柜
  • 书房书柜
  • 餐边柜
  • 电视柜

家具部分

  • 沙发
  • 茶几
  • 餐桌

最近一两个月,我跑遍了附近各个全屋定制的商家,从一个装修小白,成长为了一个略懂的小白,本文记录了我这段时间的积累,可以当做一份入门攻略。

本文主要介绍打柜子的部分,家具部分捎带提一下。

本文偏小白向,如果描述有偏差,欢迎指正,适合阅读人群:从未经历过但即将需要全屋定制,未亲自参与过全屋定制,关注了 Kirito 并好奇怎么发了一个技术无关的文章的读者。


记一次 Redis 连接问题排查

问题发现

客户端:业务应用使用 lettuce 客户端

服务端:Redis server 部署架构采用 1 主 + 1 从 + 3 哨兵

Redis 和业务应用部署在同一个 K8s 集群中,Redis Server 暴露了一个 redis-service,指向到 master 节点,业务应用通过 redis-service 连接 Redis。

某个时刻起,开始发现业务报错,稍加定位,发现是 Redis 访问出了问题,搜索业务应用日志,发现关键信息:

1
org.springframework.data.redis.RedisSystemException: Error in execution; nested exception is io.lettuce.core.RedisCommandExecutionException: READONLY You can't write against a read only replica.

这是一个 Redis 访问的报错,看起来跟 Redis 的读写配置有关。


聊聊服务发现的推拉模型

前言

过去一年,我的工作重心投入到了 API 网关(阿里云 CSB)中,这对于我来说是一个新的领域,但和之前接触的微服务治理方向又密不可分。API 网关适配微服务场景需要完成一些基础能力的建设,其一便是对接注册中心,从而作为微服务的入口流量,例如 Zuul、SpringCloud Gateway 都实现了这样的功能。实际上很多开源网关在这一特性上均存在较大的局限性,本文暂不讨论这些局限性,而是针对服务发现这一通用的场景,分享我对它的一些思考。


浅析 Open API 设计规范

背景

最近由于业务需求,我负责的一块系统需要对外开放 Open API,原本不是什么难事,因为阿里云内部的 Open API 开放机制已经非常成熟了,根本不需要我去设计,但这次的需求主要是因为一些原因,需要自己设计一些规范,那就意味着,需要对 Open API 进行一些规范约束了,遂有此文。

Open API 和前端页面一样,一直都是产品的门面, Open API 不规范,会拉低产品的专业性。在云场景下,很多用户会选择自建门户,对接云产品的 Open API,这对我们提出的诉求便是构建一套成熟的 Open API 机制。

站在业务角度,有一些指导原则,指导我们完善 Open API 机制:

  • 前端页面使用的接口和 Open API 提供的接口是同一套接口
  • 任意的前端页面接口都应该有对应的 Open API

站在技术角度,有很多的 API 开放标准可供我们参考,一些开源产品的 Open API 文档也都非常完善。一方面,我会取其精华,另一方面,要考虑自身产品输出形态的特殊性。本文将围绕诸多因素,尝试探讨出一份合适的 Open API 开放规范。


构建多系统架构支持的 Docker 镜像

前言

陪伴了我 3 年的 Mac 在几个月前迎来了它的退休时刻,我将其置换成了公司新发的 Mac M1。对电子产品并不太感冒的我,并没有意识到 M1 是 ARM 架构的(除了个别软件的安装异常之外),显然,Mac M1 做的是不错的,我并没有太多吐槽它的机会。这也是我第一次近距离接触 ARM 架构的机会。

很快,在工作上,我遇到了第二次跟 ARM 打交道的机会。我们越来越多的客户,开始选择 ARM 架构的服务器作为 IaaS 层资源,这给我们的交付带来了一些工作量。适配工作中比较重要的一环便是 Docker 镜像,需要产出支持 ARM 架构的版本。

本文主要记录笔者在构建多系统架构支持的 Docker 镜像时的一些经验,以及一些个人的理解。


聊聊服务治理中的路由设计

前言

路由(Route)的设计广泛存在于众多领域,以 RPC 框架 Dubbo 为例,就有标签路由、脚本路由、权重路由、同机房路由等实现。

在框架设计层面,路由层往往位于负载均衡层之前,在进行选址时,路由完成的是 N 选 M(M <= N),而负载均衡完成的是 M 选一,共同影响选址逻辑,最后触发调用。

在业务层面,路由往往是为了实现一定的业务语义,对流量进行调度,所以服务治理框架通常提供的都是基础的路由扩展能力,使用者根据业务场景进行扩展。

路由过程

今天这篇文章将会围绕路由层该如何设计展开。


Guava Cache 使用小结

闲聊

话说原创文章已经断更 2 个月了,倒也不是因为忙,主要还是懒。但是也感觉可以拿出来跟大家分享的技术点越来越少了,一方面主要是最近在从事一些“内部项目”的研发,纵使我很想分享,也没法搬到公众号 & 博客上来;一方面是一些我并不是很擅长的技术点,在我还是新手时,我敢于去写,而有了一定工作年限之后,反而有些包袱了,我的读者会不会介意呢?思来想去,我回忆起了写作的初心,不就是为了记录自己的学习过程吗?于是乎,我还是按照我之前的文风记录下了此文,以避免成为一名断更的博主。

以下是正文。

前言

“缓存”一直是我们程序员聊的最多的那一类技术点,诸如 Redis、Encache、Guava Cache,你至少会听说过一个。需要承认的是,无论是面试八股文的风气,还是实际使用的频繁度,Redis 分布式缓存的确是当下最为流行的缓存技术,但同时,从我个人的项目经验来看,本地缓存也是非常常用的一个技术点。

分析 Redis 缓存的文章很多,例如 Redis 雪崩、Redis 过期机制等等,诸如此类的公众号标题不鲜出现在我朋友圈的 timeline 中,但是分析本地缓存的文章在我的映像中很少。

在最近的项目中,有一位新人同事使用了 Guava Cache 来对一个 RPC 接口的响应进行缓存,我在 review 其代码时恰好发现了一个不太合理的写法,遂有此文。

本文将会介绍 Guava Cache 的一些常用操作:基础 API 使用,过期策略,刷新策略。并且按照我的写作习惯,会附带上实际开发中的一些总结。需要事先说明的是,我没有阅读过 Guava Cache 的源码,对其的介绍仅仅是一些使用经验或者最佳实践,不会有过多深入的解析。

先简单介绍一下 Guava Cache,它是 Google 封装的基础工具包 guava 中的一个内存缓存模块,它主要提供了以下能力:

  • 封装了缓存与数据源交互的流程,使得开发更关注于业务操作
  • 提供线程安全的存取操作(可以类比 ConcurrentHashMap)
  • 提供常用的缓存过期策略,缓存刷新策略
  • 提供缓存命中率的监控

基础使用

使用一个示例介绍 Guava Cache 的基础使用方法 – 缓存大小写转换的返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private String fetchValueFromServer(String key) {
return key.toUpperCase();
}

@Test
public void whenCacheMiss_thenFetchValueFromServer() throws ExecutionException {
LoadingCache<String, String> cache =
CacheBuilder.newBuilder().build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
return fetchValueFromServer(key);
}
});

assertEquals(0, cache.size());
assertEquals("HELLO", cache.getUnchecked("hello"));
assertEquals("HELLO", cache.get("hello"));
assertEquals(1, cache.size());
}

使用 Guava Cache 的好处已经跃然于纸上了,它解耦了缓存存取与业务操作。CacheLoaderload 方法可以理解为从数据源加载原始数据的入口,当调用 LoadingCache 的 getUnchecked 或者 get方法时,Guava Cache 行为如下:

  • 缓存未命中时,同步调用 load 接口,加载进缓存,返回缓存值
  • 缓存命中,直接返回缓存值
  • 多线程缓存未命中时,A 线程 load 时,会阻塞 B 线程的请求,直到缓存加载完毕

注意到,Guava 提供了两个 getUnchecked 或者 get 加载方法,没有太大的区别,无论使用哪一个,都需要注意,数据源无论是 RPC 接口的返回值还是数据库,都要考虑访问超时或者失败的情况,做好异常处理。

预加载缓存

预加载缓存的常见使用场景:

  • 老生常谈的秒杀场景,事先缓存预热,将热点商品加入缓存;
  • 系统重启过后,事先加载好缓存,避免真实请求击穿缓存

Guava Cache 提供了 putputAll 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
public void whenPreloadCache_thenPut() {
LoadingCache<String, String> cache =
CacheBuilder.newBuilder().build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
return fetchValueFromServer(key);
}
});

String key = "kirito";
cache.put(key,fetchValueFromServer(key));

assertEquals(1, cache.size());
}

操作和 HashMap 一模一样。

这里有一个误区,而那位新人同事恰好踩到了,也是我写这篇文章的初衷,请务必仅在预加载缓存这个场景使用 put,其他任何场景都应该使用 load 去触发加载缓存。看下面这个反面示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 注意这是一个反面示例
@Test
public void wrong_usage_whenCacheMiss_thenPut() throws ExecutionException {
LoadingCache<String, String> cache =
CacheBuilder.newBuilder().build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
return "";
}
});

String key = "kirito";
String cacheValue = cache.get(key);
if ("".equals(cacheValue)) {
cacheValue = fetchValueFromServer(key);
cache.put(key, cacheValue);
}
cache.put(key, cacheValue);

assertEquals(1, cache.size());
}

这样的写法,在 load 方法中设置了一个空值,后续通过手动 put + get 的方式使用缓存,这种习惯更像是在操作一个 HashMap,但并不推荐在 Cache 中使用。在前面介绍过 get 配合 load 是由 Guava Cache 去保障了线程安全,保障多个线程访问缓存时,第一个请求加载缓存的同时,阻塞后续请求,这样的 HashMap 用法既不优雅,在极端情况下还会引发缓存击穿、线程安全等问题。

请务必仅仅将 put 方法用作预加载缓存场景。

缓存过期

前面的介绍使用起来依旧没有脱离 ConcurrentHashMap 的范畴,Cache 与其的第一个区别在“缓存过期”这个场景可以被体现出来。本节介绍 Guava 一些常见的缓存过期行为及策略。

缓存固定数量的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
public void whenReachMaxSize_thenEviction() throws ExecutionException {
LoadingCache<String, String> cache =
CacheBuilder.newBuilder().maximumSize(3).build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
return fetchValueFromServer(key);
}
});

cache.get("one");
cache.get("two");
cache.get("three");
cache.get("four");
assertEquals(3, cache.size());
assertNull(cache.getIfPresent("one"));
assertEquals("FOUR", cache.getIfPresent("four"));
}

使用 ConcurrentHashMap 做缓存的一个最大的问题,便是我们没有简易有效的手段阻止其无限增长,而 Guava Cache 可以通过初始化 LoadingCache 的过程,配置 maximumSize ,以确保缓存内容不导致你的系统出现 OOM。

值得注意的是,我这里的测试用例使用的是除了 getgetUnchecked 外的第三种获取缓存的方式,如字面意思描述的那样,getIfPresent 在缓存不存在时,并不会触发 load 方法加载数据源。

LRU 过期策略

依旧沿用上述的示例,我们在设置容量为 3 时,仅获悉 LoadingCache 可以存储 3 个值,却并未得知第 4 个值存入后,哪一个旧值需要淘汰,为新值腾出空位。实际上,Guava Cache 默认采取了 LRU 缓存淘汰策略。Least Recently Used 即最近最少使用,这个算法你可能没有实现过,但一定会听说过,在 Guava Cache 中 Used 的语义代表任意一次访问,例如 put、get。继续看下面的示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
public void whenReachMaxSize_thenEviction() throws ExecutionException {
LoadingCache<String, String> cache =
CacheBuilder.newBuilder().maximumSize(3).build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
return fetchValueFromServer(key);
}
});

cache.get("one");
cache.get("two");
cache.get("three");
// access one
cache.get("one");
cache.get("four");
assertEquals(3, cache.size());
assertNull(cache.getIfPresent("two"));
assertEquals("ONE", cache.getIfPresent("one"));
}

注意此示例与上一节示例的区别:第四次 get 访问 one 后,two 变成了最久未被使用的值,当第四个值 four 存入后,淘汰的对象变成了 two,而不再是 one 了。

缓存固定时间

为缓存设置过期时间,也是区分 HashMap 和 Cache 的一个重要特性。Guava Cache 提供了expireAfterAccessexpireAfterWrite 的方案,为 LoadingCache 中的缓存值设置过期时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
public void whenEntryIdle_thenEviction()
throws InterruptedException, ExecutionException {

LoadingCache<String, String> cache =
CacheBuilder.newBuilder().expireAfterAccess(1, TimeUnit.SECONDS).build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
return fetchValueFromServer(key);
}
});

cache.get("kirito");
assertEquals(1, cache.size());

cache.get("kirito");
Thread.sleep(2000);

assertNull(cache.getIfPresent("kirito"));
}

缓存失效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
public void whenInvalidate_thenGetNull() throws ExecutionException {
LoadingCache<String, String> cache =
CacheBuilder.newBuilder()
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
return fetchValueFromServer(key);
}
});

String name = cache.get("kirito");
assertEquals("KIRITO", name);

cache.invalidate("kirito");
assertNull(cache.getIfPresent("kirito"));
}

使用 void invalidate(Object key) 移除单个缓存,使用 void invalidateAll() 移除所有缓存。

缓存刷新

缓存刷新的常用于使用数据源的新值覆盖缓存旧值,Guava Cache 提供了两类刷新机制:手动刷新和定时刷新。

手动刷新

1
cache.refresh("kirito");

refresh 方法将会触发 load 逻辑,尝试从数据源加载缓存。

需要注意点的是,refresh 方法并不会阻塞 get 方法,所以在 refresh 期间,旧的缓存值依旧会被访问到,直到 load 完毕,看下面的示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Test
public void whenCacheRefresh_thenLoad()
throws InterruptedException, ExecutionException {

LoadingCache<String, String> cache =
CacheBuilder.newBuilder().expireAfterWrite(1, TimeUnit.SECONDS).build(new CacheLoader<String, String>() {
@Override
public String load(String key) throws InterruptedException {
Thread.sleep(2000);
return key + ThreadLocalRandom.current().nextInt(100);
}
});

String oldValue = cache.get("kirito");

new Thread(() -> {
cache.refresh("kirito");
}).start();

// make sure another refresh thread is scheduling
Thread.sleep(500);

String val1 = cache.get("kirito");

assertEquals(oldValue, val1);

// make sure refresh cache
Thread.sleep(2000);

String val2 = cache.get("kirito");
assertNotEquals(oldValue, val2);

}

其实任何情况下,缓存值都有可能和数据源出现不一致,业务层面需要做好访问到旧值的容错逻辑。

自动刷新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
public void whenTTL_thenRefresh() throws ExecutionException, InterruptedException {
LoadingCache<String, String> cache =
CacheBuilder.newBuilder().refreshAfterWrite(1, TimeUnit.SECONDS).build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
return key + ThreadLocalRandom.current().nextInt(100);
}
});

String first = cache.get("kirito");
Thread.sleep(1000);
String second = cache.get("kirito");

assertNotEquals(first, second);
}

和上节的 refresh 机制一样,refreshAfterWrite 同样不会阻塞 get 线程,依旧有访问旧值的可能性。

缓存命中统计

Guava Cache 默认情况不会对命中情况进行统计,需要在构建 CacheBuilder 时显式配置 recordStats

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
public void whenRecordStats_thenPrint() throws ExecutionException {
LoadingCache<String, String> cache =
CacheBuilder.newBuilder().maximumSize(100).recordStats().build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
return fetchValueFromServer(key);
}
});

cache.get("one");
cache.get("two");
cache.get("three");
cache.get("four");

cache.get("one");
cache.get("four");

CacheStats stats = cache.stats();
System.out.println(stats);
}
---
CacheStats{hitCount=2, missCount=4, loadSuccessCount=4, loadExceptionCount=0, totalLoadTime=1184001, evictionCount=0}

缓存移除的通知机制

在一些业务场景中,我们希望对缓存失效进行一些监测,或者是针对失效的缓存做一些回调处理,就可以使用 RemovalNotification 机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
public void whenRemoval_thenNotify() throws ExecutionException {
LoadingCache<String, String> cache =
CacheBuilder.newBuilder().maximumSize(3)
.removalListener(
cacheItem -> System.out.println(cacheItem + " is removed, cause by " + cacheItem.getCause()))
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
return fetchValueFromServer(key);
}
});

cache.get("one");
cache.get("two");
cache.get("three");
cache.get("four");
}
---
one=ONE is removed, cause by SIZE

removalListener 可以给 LoadingCache 增加一个回调处理器,RemovalNotification 实例包含了缓存的键值对以及移除原因。

Weak Keys & Soft Values

Java 基础中的弱引用和软引用的概念相信大家都学习过,这里先给大家复习一下

  • 软引用:如果一个对象只具有软引用,则内存空间充足时,垃圾回收器不会回收它;如果内存空间不足,就会回收这些对象。只要垃圾回收器没有回收它,该对象就可以被程序使用
  • 弱引用:只具有弱引用的对象拥有更短暂生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。

在 Guava Cache 中,CacheBuilder 提供了 weakKeys、weakValues、softValues 三种方法,将缓存的键值对与 JVM 垃圾回收机制产生关联。

该操作可能有它适用的场景,例如最大限度的使用 JVM 内存做缓存,但依赖 GC 清理,性能可想而知会比较低。总之我是不会依赖 JVM 的机制来清理缓存的,所以这个特性我不敢使用,线上还是稳定性第一。

如果需要设置清理策略,可以参考缓存过期小结中的介绍固定数量和固定时间两个方案,结合使用确保使用缓存获得高性能的同时,不把内存打挂。

总结

本文介绍了 Guava Cache 一些常用的 API 、用法示例,以及需要警惕的一些使用误区。

在选择使用 Guava 时,我一般会结合实际使用场景,做出以下的考虑:

  1. 为什么不用 Redis?

    如果本地缓存能够解决,我不希望额外引入一个中间件。

  2. 如果保证缓存和数据源数据的一致性?

    一种情况,我会在数据要求敏感度不高的场景使用缓存,所以短暂的不一致可以忍受;另外一些情况,我会在设置定期刷新缓存以及手动刷新缓存的机制。举个例子,页面上有一个显示应用 developer 列表的功能,而本地仅存储了应用名,developer 列表是通过一个 RPC 接口查询获取的,而由于对方的限制,该接口 qps 承受能力非常低,便可以考虑缓存 developer 列表,并配置 maximumSize 以及 expireAfterAccess。如果有用户在 developer 数据源中新增了数据,导致了数据不一致,页面也可以设置一个同步按钮,让用户去主动 refresh;或者,如果判断当前用户不在 developer 列表,也可以程序 refresh 一次。总之非常灵活,使用 Guava Cache 的 API 可以满足大多数业务场景的缓存需求。

  3. 为什么是 Guava Cache,它的性能怎么样?

    我现在主要是出于稳定性考虑,项目一直在使用 Guava Cache。据说有比 Guava Cache 快的本地缓存,但那点性能我的系统不是特别关心。


Kirito 的博客崩了,这次是因为...

Hello 大家好呀,最近 Kirito 的博客因为没有备案的原因,给禁止访问了,折腾了一天才恢复,借着这个机会跟大家闲聊一下个人博客、域名那点事。

从 CSDN 到个人博客

很多人有写作的习惯,只不过有人喜欢写在 QQ 空间,有人喜欢写在专门的博客论坛,也有人喜欢写在个人博客。最开始,我也是在 CSDN 上进行写作的

CSDN旧址

在 2017 年的时候,喜欢折腾的我,开始在个人博客 www.cnkirito.moe 上更新文章。

没有什么特别的原因,只是觉得,这很酷。

Kirito的个人博客

我一直觉得个人博客就像程序员的名片一样,界面的风格和文章的内容都能彰显出一个程序的内心世界。

域名的由来

有些小伙伴很好奇,为什么我的域名:www.cnkirito.moe 是 .moe 结尾的,相比常见的 .com,.moe 的确名不见经传。moe 是日文“萌え”的罗马字写法,这也是汉语中“萌”一词的正确英文写法,当时喜欢二次元的我,购买了 moe 这个顶级域名。至于 cnkirito,懂得都懂,也是二次元的梗。

网站域名解析被封的过程

但申请这个域名时,万万没有想到,5 年后的今天,着实被它坑了一把。由于 moe 顶级域名的提供商是国外的域名提供商,这就给备案带来了很大的问题,按照信安部门的要求,域名解析到国内(香港除外)的服务器,都需要备案,否则就会被封禁。

云服务器通知

我的服务器是之前打比赛的时候,华为云送的 3 年的 4c8g 的机器,我用来搭建一个个人博客那不是绰绰有余吗。结果谁曾想,收到了华为云的通知,让我进行备案。

备案指的是域名解析到某个服务器的过程,并不仅针对域名或者某一服务器。

但我也不是不想备案呀,我的域名是从国外域名商买来的,压根没有备案这个功能。所以摆在我面前的,只有两条路。

  1. 域名转入。缴纳一笔钱,从国外域名商转入到国内域名商,例如阿里云、腾讯云都有这样的服务。
  2. 买一台香港的服务器。重新搭建一个博客。

我自然是不想浪费我的服务器,毕竟这年头买个服务器也不便宜。先尝试走方案一,一般转入域名花费在 100~300 不等,这个费用完全可以接受,毕竟是一次性的,但一番调研之后,却发现此路不通,没有任何一个国内的域名商支持转入 moe 类型的域名!好吧,只怪当年年少,选择了这个小众的域名。

image-20211211214720392

无奈之下,只能选择购买香港的服务器了,可惜了我 4c8g 的云服务器,只能当做实验机了。

搭建博客的建议

有了我上面的教训,如果大家有搭建个人博客的计划,建议当然是购买国内的服务器,使用国内的域名提供商了。

关于服务器的配置,如果是静态博客,像我使用的就是 hexo 搭建的,1c1g 固定带宽 1M 的服务器也完全够用,建议在大促的时候购买或者使用新人/学生等优惠条件,对比下各个云厂商的价格,300400 完全够撑一年,优惠粒度较大时,几十块都能搞定。域名的费用则是 100200 不等。

SEO 优化。路径需要足够短,正例:https://www.cnkirito.moe/learn-mmap,反例:https://www.cnkirito.moe/2021/11/26/learn-mmap,前者有利于搜索引擎的排名。

配置 HTTPS。有不少个人博客图省事,没有搞 HTTPS,我习惯会配置上 HTTPS。一方面小绿锁会提升个人博客的整体格调,另一方面,HTTPS 也会提高搜索引擎的排名(据说。配置也不麻烦,我就是用的七牛云免费的 SSL 证书,再加上 nginx 的 2 行配置而已,并不麻烦。

图床托管。我的图片都是存储在七牛云上,七牛云有一定的免费额度,我的博客只有在某几个人搜索量比较大,超过了额度,付了一定的费用,大多数时候,图片托管基本不会产生费用。

好,以上就是全部的内容啦,Kirito 的博客和公众号会保证同步更新。

PC 端建议访问博客,获得更好的阅读体验:https://www.cnkirito.moe

移动端建议访问微信公众号:Kirito的技术分享 (除了有恰饭广告之外,和博客没有区别 Orz


重新认识 Java 中的内存映射(mmap)

mmap 基础概念

mmap 是一种内存映射文件的方法,即将一个文件映射到进程的地址空间,实现文件磁盘地址和一段进程虚拟地址的映射。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read,write 等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。

mmap工作原理

操作系统提供了这么一系列 mmap 的配套函数

1
2
3
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
int munmap( void * addr, size_t len);
int msync( void *addr, size_t len, int flags);

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×