【项目实记】使用内存做关键信息的缓存来提升 QPS 和 减少开销
背景
当前业务需要使用 Oauth
授权的 accessToken
调用第三方系统获取用户资料。这里简化说明完成和第三方系统的对接需要两个接口。
通过
appkey
获取accessToken
。accessToken
2 小时过期,接口有quota
限制。使用
accessToken
获取用户信息,这个token
是通用的,只要是当前appkey
下面的用户都可以使用。
设计
考虑到有
quota
限制问题,所以一定要有缓存机制,不然获取用户信息的接口请求量大的时候quota
就不够用。accessToken
有两个小时过期问题,所有缓存要有失效机制。一个
appkey
有且只有一个accessToken
,并且不常变,可以考虑直接使用内存缓存。如果使用 Redis等分布式缓存系统,也会因为频繁网络请求。
下面的表格来源于 Jeff Dean的一个PPT,里面罗列了不同级别的IO时间,这正是我们评估如何设计系统的必要因素。
L1 cache reference | 0.5 ns | |
Branch mispredict | 5 ns | |
L2 cache reference | 7 ns | |
Mutex lock/unlock | 100 ns | |
Main memory reference | 100 ns | |
Compress 1K bytes with Zippy | 10,000 ns | 0.01 ms |
Send 1K bytes over 1 Gbps network | 10,000 ns | 0.01 ms |
Read 1 MB sequentially from memory | 250,000 ns | 0.25 ms |
Round trip within same datacenter | 500,000 ns | 0.5 ms |
Disk seek | 10,000,000 ns | 10 ms |
Read 1 MB sequentially from network | 10,000,000 ns | 10 ms |
Read 1 MB sequentially from disk | 30,000,000 ns | 30 ms |
Send packet CA->Netherlands->CA | 150,000,000 ns | 150 ms |
由上面表格,我们可以清楚的看出从网络上面获取1M数据和从内存中读取1M数据的差别。为什么说到这里呢,因为随着我们的用户的增加,集群的扩展,很少的情况下是把缓存数据库或者其他缓存中间件和应用程序放在一台服务器上,大部分情况都是分布式的应用系统和缓存系统,所以避免不了的我们需要考虑网络而的开销。
回到当前话题,对于一个 accessToken
占用内存足够小,即便是分布式系统每一个系统中都存储一个也不为过,只是能解决过期更新问题就好了。
实现
正好 Guava
就提供了这个功能, GuavaCache
缓存类似于 ConcurrentMap
,但不完全相同。 最根本的区别是, ConcurrentMap
会持续添加到其中的所有元素,如果你不手动删除它们会一直存在。然而 GuavaCache
可以通过缓存的大小,过期时间,或者其他策略自动地移除元素,来限制其内存占用。
引入依赖
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>19.0</version>
</dependency>
编写实现类
public class UserInfoProvider {
public static void main(String[] args) {
for (int i =0;i<100;i++) {
String accessToken = new AccessTokenProvider().getAccessToken();
System.out.println(accessToken);
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class AccessTokenProvider {
private final static String KEY = "key";
static Cache<String, Optional<String>> cache = CacheBuilder.newBuilder()
.expireAfterWrite(3, TimeUnit.SECONDS)
.removalListener(new RemovalListener<String, Optional<String>>() {
@Override
public void onRemoval(RemovalNotification<String, Optional<String>> notification) {
System.out.println("cache expired, remove key : " + notification.getKey());
}
}).build();
public String getAccessToken() {
try {
Optional<String> stringOptional = cache.get(KEY, new Callable<Optional<String>>() {
@Override
public Optional<String> call() throws Exception {
return Optional.of(getRemoteAccessToken());
}
});
return stringOptional.or("");
} catch (ExecutionException e) {
return null;
}
}
private String getRemoteAccessToken() {
return "accessToken:" + new Random().nextInt(1000);
}
}
简单对上面逻辑进行讲解
getRemoteAccessToken
是模拟远程获取 tokengetAccessToken
是先从 cache 中获取,如果没有获取到,从远程获取。expireAfterWrite(3,TimeUnit.SECONDS)
是3秒过期,这是为了测试。removalListener
监听过期。UserInfoProvider
,sleep 3秒看一下更新情况。
由下面日志可见执行情况
accessToken:651
accessToken:651
accessToken:651
cache expired, remove key : key
accessToken:639
accessToken:639
accessToken:639
cache expired, remove key : key
accessToken:850
accessToken:850
accessToken:850
我们使用的还只是 GuavaCache
的冰山一角,它可以支容量和时间多种策略配置回收策略,同时它是良好的 LRU
实现。如上场景比较简单,如果你考虑缓存其他需求,需要考虑 refreshAfterWrite
和 maximumSize
等配合使用,避免缓存击穿和性能问题。
原理
GuavaCache
的设计和 ConcurrentHashMap
非常类似,使用多个 segments 方式的细粒度锁,在保证线程安全的同时,支持高并发场景需求。看过 ConcurrentHashMap
实现原理的朋友跟进代码一看了然。当然它的设计更复杂一些,跟进代码的话也比较简单,在 get
的时候他会根据不同的策略进行比对是否过期,如果过期再进行相应的通知操作。同时他巧妙的使用的 WeakReference
,这样可以利用 GC
来做删除数据的通知。