浅析 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);

【参赛总结】第二届云原生编程挑战赛-冷热读写场景的RocketMQ存储系统设计

前言

人总是这样,年少时,怨恨自己年少,年迈时,怨恨自己年迈,就连参加一场比赛,都会纠结,工作太忙怎么办,周末休息怎么办,成年人的任性往往就在那一瞬间,我只是单纯地想经历一场酣畅的性能挑战赛。所以,云原生挑战赛,我来了,Kirito 带着他的公众号来了。

读完寥寥数百多字的赛题描述,四分之一炷香之后一个灵感出现在脑海中,本以为这个灵感是开篇,没想到却是终章。临近结束,测试出了缓存命中率更高的方案,但评测已经没有了日志,在茫茫的方案之中,我错过了最大的那一颗麦穗,但在一个月不长不短的竞赛中,我挑选到了一颗不错的麦穗,从此只有眼前路,没有身后身,最终侥幸跑出了内部赛第一的成绩。

传统存储引擎类型的比赛,主要是围绕着两种存储介质:SSD 和 DRAM,不知道这俩有没有熬过七年之痒,Intel 就已经引入了第三类存储介质:AEP(PMem 的一种实现)。AEP 的出现,让原本各司其职的 SSD 和 DRAM 关系变得若即若离起来,它既可以当做 DRAM 用,也可以当做 SSD 用。蕴含在赛题中的”冷热存储“这一关键词,为后续风起云涌的赛程埋下了伏笔,同时给了 AEP 一个名分。

AEP 这种存储介质不是第一次出现在我眼前,在 ADB 比赛中就遇到过它,此次比赛开始时,脑子里面对它仅存的印象便是”快”。这个快是以 SSD 为参照物,无论是读还是写,都高出传统 SSD 1~n 个数量级。但更多的认知,只能用 SSD 来类比,AEP 特性的理解和使用方法,无疑是这次的决胜点之一。

曾经的我喜欢问,现在的我喜欢试。一副键盘,一个深夜,我窥探到了 AEP 的奥秘,多线程读写必不可少,读取速度和写入速度近似 DRAM,但细究之下写比读慢,从整体吞吐来看,DRAM 的读写性能略优于 AEP,但 DRAM 和 AEP 的读写都比 SSD 快得多的多。我的麦穗也有了初步的模样:第一优先级是降低 SSD 命中率,在此基础上,提高 DRAM 命中率,AEP 起到平衡的效果,初期不用特别顾忌 AEP 和 DRAM 的命中比例。


Unsafe与ByteBuffer那些事

上一篇文章《聊聊Unsafe的一些使用技巧》写作之后,阅读量很快超过了 1500,Kirito 在这里感谢大家的阅读啦,所以我又来更新了。如果你还没有阅读上一篇文章,我建议你先去看下,闲话不多说,开始今天的话题。

无论是日常开发还是竞赛,Unsafe 不常有而 ByteBuffer 常有,只介绍 Unsafe,让我的博文显得很“炫技”,为了证明“Kirito的技术分享”它可是一个正经的公众号,所以这篇文章会说到另一个比较贴地气的主角 ByteBuffer。我会把我这么多年打比赛的经验传授给你,只求你的一个三连。

从 DirectBuffer 的构造器说起

书接上文,我提到过 DirectBuffer 开辟的堆外内存其实就是通过 Unsafe 分配的,但没有详细介绍,今天就给他补上。看一眼 DirectBuffer 的构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
DirectByteBuffer(int cap) {                   // package-private
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
try {
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}

短短的十几行代码,蕴含了非常大的信息量,先说关键点

  • long base = unsafe.allocateMemory(size); 调用 Unsafe 分配内存,返回内存首地址
  • unsafe.setMemory(base, size, (byte) 0); 初始化内存为 0。这一行我们放在下节做重点介绍。
  • Cleaner.create(this, new Deallocator(base, size, cap)); 设置堆外内存的回收器,不详细介绍了,可以参考我之前的文章《一文探讨堆外内存的监控与回收》。

仅构造器中的这一幕,便让 Unsafe 和 ByteBuffer 产生了千丝万缕的关联,发挥想象力的话,可以把 ByteBuffer 看做是 Unsafe 一系列内存操作 API 的 safe 版本。而安全一定有代价,在编程领域,一般都有一个常识,越是接近底层的事物,控制力越强,性能越好;越接近用户的事物,更易操作,但性能会差强人意。ByteBuffer 封装的 limit/position/capacity 等概念,用熟悉了之后我觉得比 Netty 后封装 的 ByteBuf 还要简便,但即使优秀如它,仍然有被人嫌弃的一面:大量的边界检查。

一个最吸引性能挑战赛选手去使用 Unsafe 操作内存,而不是 ByteBuffer 地方,便是边界检查。如示例代码一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public ByteBuffer put(byte[] src, int offset, int length) {
if (((long)length << 0) > Bits.JNI_COPY_FROM_ARRAY_THRESHOLD) {
checkBounds(offset, length, src.length);
int pos = position();
int lim = limit();
assert (pos <= lim);
int rem = (pos <= lim ? lim - pos : 0);
if (length > rem)
throw new BufferOverflowException();
Bits.copyFromArray(src, arrayBaseOffset,
(long)offset << 0,
ix(pos),
(long)length << 0);
position(pos + length);
} else {
super.put(src, offset, length);
}
return this;
}

你不用关心上述这段代码在 DirectBuffer 中充当着什么作用,我想展示给你的仅仅是它的 checkBounds 和 一堆 if/else,尤其是追求极致性能的场景,极客们看到 if/else 会神经敏感地意识到分支预测的性能下降,第二意识是这坨代码能不能去掉。

如果你不希望有一堆边界检查,完全可以借助 Unsafe 实现一个自定义的 ByteBuffer,就像下面这样。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public class UnsafeByteBuffer {

private final long address;
private final int capacity;
private int position;
private int limit;

public UnsafeByteBuffer(int capacity) {
this.capacity = capacity;
this.address = Util.unsafe.allocateMemory(capacity);
this.position = 0;
this.limit = capacity;
}

public int remaining() {
return limit - position;
}

public void put(ByteBuffer heapBuffer) {
int remaining = heapBuffer.remaining();
Util.unsafe.copyMemory(heapBuffer.array(), 16, null, address + position, remaining);
position += remaining;
}

public void put(byte b) {
Util.unsafe.putByte(address + position, b);
position++;
}

public void putInt(int i) {
Util.unsafe.putInt(address + position, i);
position += 4;
}

public byte get() {
byte b = Util.unsafe.getByte(address + position);
position++;
return b;
}

public int getInt() {
int i = Util.unsafe.getInt(address + position);
position += 4;
return i;
}

public int position() {
return position;
}

public void position(int position) {
this.position = position;
}

public void limit(int limit) {
this.limit = limit;
}

public void flip() {
limit = position;
position = 0;
}

public void clear() {
position = 0;
limit = capacity;
}

}

在一些比赛中,为了避免选手进入无止境的内卷,Unsafe 通常是禁用的,但是也有一些比赛,允许使用 Unsafe 的一部分能力,让选手们放飞自我,探索可能性。例如 Unsafe#allocateMemory 是不会受到 -XX:MaxDirectMemory-Xms 限制的,在这次第二届云原生编程挑战赛遭到了禁用,但 Unsafe#putUnsafe#getUnsafe#copyMemory 允许被使用。 如果你一定希望使用 Unsafe 操作堆外内存,可以写出这样的代码,它跟示例代码一完成的是同样的操作。

1
2
3
4
5
byte[] src = ...;

ByteBuffer byteBuffer = ByteBuffer.allocateDirect(src.length);
long address = ((DirectBuffer)byteBuffer).address();
Util.unsafe.copyMemory(src, 16, null, address, src.length);

这便是我想介绍的第一个关键点:DirectByteBuffer 可以借助 Unsafe 完成内存级别细粒度的操作,从而绕开边界检查

DirectByteBuffer 的内存初始化

注意到 DirectByteBuffer 构造器中有另一个涉及到 Unsafe 的操作: unsafe.setMemory(base, size, (byte) 0);。这段代码主要是为了给内存初始化 0。说实话,我是没有太懂这里的初始化操作,因为按照我的认知,默认值也是 0。在某些场景或者硬件下,内存操作是非常昂贵的,尤其是大片的内存被开辟时,这段代码可能会成为 DirectByteBuffer 的瓶颈。

如果希望分配内存时,不进行这段初始化逻辑,可以借助于 Unsafe 分配内存,再对 DirectByteBuffer 进行魔改。

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 AllocateDemo {

private Field addressField;
private Field capacityField;

public AllocateDemo() throws NoSuchFieldException {
Field capacityField = Buffer.class.getDeclaredField("capacity");
capacityField.setAccessible(true);
Field addressField = Buffer.class.getDeclaredField("address");
addressField.setAccessible(true);
}

public ByteBuffer allocateDirect(int cap) throws IllegalAccessException {
long address = Util.unsafe.allocateMemory(cap);

ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1);
Util.unsafe.freeMemory(((DirectBuffer) byteBuffer).address());

addressField.setLong(byteBuffer, address);
capacityField.setInt(byteBuffer, cap);

byteBuffer.clear();
return byteBuffer;
}

}

经过这么一顿操作,我们便得到了一份没有初始化的 DirectByteBuffer,不过不用担心,一切都在正常工作,并且 setMemory for free!

聊聊 ByteBuffer 的零拷贝

算作是题外话了,主要是跟 ByteBuffer 相关的一个话题:零拷贝。 ByteBuffer 在作为读缓冲区时被使用时,有一部分小伙伴会选择使用加锁的方式访问内存,但其实这是非常错误的做法,应当使用 ByteBuffer 提供的 duplicate 和 slice 这两个方法。

并发读取缓冲的方案:

1
2
3
4
5
6
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
ByteBuffer duplicate = byteBuffer.duplicate();
duplicate.limit(512);
duplicate.position(256);
ByteBuffer slice = duplicate.slice();
// use slice

这样便可以在不改变原始 ByteBuffer 指针的前提下,任意对 slice 后的 ByteBuffer 进行并发读取了。

总结

最近时间有限,白天工作,晚上还要抽时间打比赛,先分享这么多。更多性能优化小技巧,可以期待一下 1~2 个星期云原生比赛结束,我就开始继续发总结和其他调优方案。

本文阅读求个 1000,不过分吧!

一键三连,这次一定。


聊聊Unsafe的一些使用技巧

前言

记得初学 Java 那会,刚学完语法基础,就接触到了反射这个 Java 提供的特性,尽管在现在看来,这是非常基础的知识点,但那时候无疑是兴奋的,瞬间觉得自己脱离了“Java 初学者”的队伍。随着工作经验的积累,我也逐渐学习到了很多类似的让我为之而兴奋的知识点,Unsafe 的使用技巧无疑便是其中一个。

sun.misc.Unsafe 是 JDK 原生提供的一个工具类,包含了很多在 Java 语言看来很 cool 的操作,例如内存分配与回收、CAS 操作、类实例化、内存屏障等。正如其命名一样,由于其可以直接操作内存,执行底层系统调用,其提供的操作也是比较危险的。Unsafe 在扩展 Java 语言表达能力、便于在更高层(Java层)代码里实现原本要在更低层(C层)实现的核心库功能上起到了很大的作用。

从 JDK9 开始,Java 模块化设计的限制,使得非标准库的模块都无法访问到 sun.misc.Unsafe。但在 JDK8 中,我们仍然可以直接操作 Unsafe,再不学习,后面可能就没机会了。


使用堆内内存HeapByteBuffer的注意事项

前言

国庆假期一眨眼就过去了,本来在家躺平的很舒服,没怎么肝云原生编程挑战赛,传送门:https://tianchi.aliyun.com/s/8bf1fe4ae2aea736e692c31c6952042d ,偏偏对手们假期开始卷起来了,眼看就要被人反超了,吓得我赶紧继续优化了。比赛大概还有一个月才结束,Kirito 的详细方案也会在比赛结束后分享,这期间我会分享一些比赛中的一些通用优化或者细节知识点,例如本文就是这么一个例子。

趁着假期最后一天,分享一个很多人容易踩得一个坑:HeapByteBuffer 的使用问题。我们都知道 NIO 分装了 ByteBuffer 接口,使得 filechannel 的文件 IO API 变得非常的简单。ByteBuffer 主要有两个实现类

  • HeapByteBuffer 堆内内存
  • DirectByteBuffer 堆外内存

按我的个人经验,大多数情况,无论是读操作还是写操作,我都倾向于使用 DirectByteBuffer,主要是因为 HeapByteBuffer 在和 FileChannel 交互时,可能会有一些出乎大家意料的内部操作,也就是这篇文章的标题中提到的注意事项,这里先卖个关子。

先来看看这次比赛为什么要用到 HeapByteBuffer 呢?

原因一:赛题需要设计分级存储,并且提供了 6G 堆内内存 + 2G 堆外内存,一个最直接的思路便是使用内存来存储热点数据,而内存存储数据最方便的数据结构便是 ByteBuffer 了。

原因二:由于堆内 6G 远大于堆外 2G,且 JVM 参数不能调整,所以要想利用好堆内富余的内存去做缓存,非 HeapByteBuffer 莫属了。

可能有一些读者并没有关注赛题,我这里简化一下前言,可以直接理解为:有一块 2G 的 HeapByteBuffer 用于文件 IO,我们该如何利用。


Your browser is out-of-date!

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

×