如何使用redis实现IP库的范围查询可以看上篇《基于Redis实现范围查询的IP库缓存设计方案》。
本篇内容:
- ES与Redis实现IP库的范围查询谁性能更强
- 远程 http调用耗时也能降低到0ms
ES与Redis实现IP库的范围查询谁性能更强
由于ip库的每条记录存储的是一段ip范围内的国家、城市和运营商信息,要想查询一个ip的信息,就需要用到像SQL那样的支持大于等于小于等于的查询。
在上一篇文章中,我介绍了如何使用Redis实现范围查询,也是一个很好的Sorted Set使用场景。在此,我再总结一下,使用Redis实现ip库缓存支持范围查询的设计方案,详细设计可以看下上篇文章。
- 1、使用hash存储记录
- 2、使用Sorted Set实现范围查询,查询时间复杂度O(log(n))
- 3、由于数据量较大,为避免单个Sorted Set过大,上升n,导致查询时间O(log(n))上升,在代码层加逻辑分区,将一个大的Sorted Set分为3750个小的Sorted Set。
在模拟200个线程并发执行的场景下,平均耗时20ms,因为是本地测的,可能受电脑配置差的影响,而且redis也是单节点,这个数值只能做参考。
但redis的执行命令单线程特性,一个查询耗时长会影响到其它业务的接口的执行耗时,并发越高越严重。
如果是几十万的数据是没有什么问题的,可以轻松应对,但,对于一个有一千两百多万条记录的ip库,且无法简单的用key-value方式存储。最重要的是用于高并发场景,对单次查询的耗时要求非常高。
因此,我想出了用ES替代Redis的第二个方案。
使用ES的实现方案就非常简单,直接创建索引将ip库数据写入ES即可,然后就是使用ES的搜索功能实现快速范围查询。
使用ES的方案,本地测试平均耗时也是二十多毫秒,但毕竟只是本地做的简单测试,并没有说服力,所以需要经过实际的线上考验,才能对比得出结论。
最后是实现两个方式共存,并将ip库的数据查询功能做为单独的服务,通过restful对外提供查询接口。目的是ip的缓存实现策略更换不影响正常的业务,也是让其它项目或微服务能够共用这个ip库。由于只是提供一个简单的查询接口,所以我使用netty+webflux实现。
为每个实现方案部署一个节点,通过负载均衡,将一半请求打到选用redis实现方案的节点,一半请求打到选用es实现方案的节点,最后统计两者在实际线上高并发场景下性能比较,选择平均耗时最短的方案。下面是测试结果比较。
[图为Redis实现方案的查询耗时日记]
[图为Es实现方案的查询耗时日记]
在实际的线上测试结果中,使用redis方案的查询平均耗时是2ms,而es高达10ms,结果很明显,还是使用redis的性能更高。
远程 http调用耗时也能降低到0ms
我提倡的是将IP库作为一个工具服务,提供api给其它服务调用。这样可以封装为一个工具类,在公共组件下。但是,面对高并发的服务,远程调用的耗时又将会是高并发服务的痛点。面对追求高并发低时延的需求,又不得不重新考虑。
如果必须要坚持将IP库查询作为一个独立的服务,那么当前可选的方案只有以下两种:
1、将IP库使用dubbo提供远程调用,注册到注册中心。
2、netty+webflux提供 restful接口,不需要注册中心,仅提供url。
对于方案一,IP库我并不想将它跟业务捆绑在一起,原本只是一个提供ip位置信息查询的服务,如果使用dubbo将会和业务偶尔,因为调用者必须要依赖一个只有一个接口的jar包。而像管理后台和定时任务这些服务如果用到ip库,又不得不再提供restful接口,这就需要dubbo跟restful共存。
dubbo的rpc远程调用相比http性能更高,如果使用方案二,就可能会增加原本高并发服务的单次处理请求的耗时,导致QPS下降。那么,是否能在高并发服务上消除http远程查询 ip信息的耗时呢。
我结合项目中业务需求,想到的方案就是异步http请求。在一次请求中,如果并不是接收到请求就立即需要知道来源ip的位置信息的时候,就可以使用异步的方式,如图。
从图中可以看出,在处理业务逻辑1和2的时候,还不需要知道request的ip所在国家和城市等位置信息。对于这种场景我们就可以考虑使用异步方案,在接收到请求的时候,发送一个异步请求获取位置,当处理到业务逻辑3时,再获取异步请求的响应结果,如果此时异步请求还未完成,再进行阻塞等待,改进后的流程如下。
在项目中我使用的是OkHttp框架实现http请求,而OkHttp就天然支持异步调用。其实同步也是在异步的基础上实现的,同步无非就是发送请求后就立即阻塞当前线程,直到接收到响应后再继续当前线程。不管是dubbo、okhttp,只要是远程调用的,都是异步调用,这句话百分百没有毛病。
来看下我的实现方案。描述ip国家信息的类IP2LocationBean
1、在业务层提供一个包装类AsyncWraper。包装类实现的功能跟代理差不多。
public class AsyncWraper<T> {
public interface RefProxy<T> {
T get();
}
private RefProxy<T> ref;
public AsyncWraper(RefProxy<T> ref) {
this.ref = ref;
}
public T getRef() {
// 调用引用对象的get方法获取结果,
// 对于异步调用,如果请求未得到响应,此时才会阻塞等待结果
return ref.get();
}
}
2、在接收请求时,异步调用获取ip位置信息
OkHttpUtils.Future<IP2LocationUtils.IP2LocationBean> future
= IP2LocationUtils.asyncLocationBy(platform, request.getIp());
bean.setLocationBeanAsyncWraper(new AsyncWraper<>(() -> {
try {
return future.get();
} catch (Exception e) {
return null;
}
}));
使用包装类的好处是,我们无需关心,业务什么时候是第一次需要获取位置信息,我们在接收请求的时候就将请求的参数解析为一个Bean,所以在其它地方需要获取到请求参数的,都必须是调用该bean的getXxx方法,那么就可以将描述ip位置信息的IP2LocationBean类型的字段使用包装类包装,通过setLocationBeanAsyncWraper方法赋值给bean。
3、在需要获取ip位置信息的地方调用
bean.getLocationBeanAsyncWraper.getRef().getCountryCode();
当调用getRef时,实际上是调用future的get方法获取结果。这就是提供包装类的目的。
4、实现异步请求
/**
* 异步获取结果,在业务之前发送请求,
* 在业务需要获取请求结果时再调用get
*/
public static class Future<T> {
private Class<T> tClass;
private volatile T response;
private volatile Exception exception;
public Future(Class<T> tClass) {
this.tClass = tClass;
}
void setResponse(OkHttpUtils.Response response) {
try {
if (response.code == 200) {
try {
this.response = JSON.parseObject(response.getBody(), tClass);
} catch (Exception e) {
this.exception = e;
}
} else {
exception = new Exception("response code:" + response.code + ", msg:" + response.getBody());
}
} finally {
this.notifyAll();
}
}
void setException(Exception exception) {
try {
this.exception = exception;
} finally {
this.notifyAll();
}
}
public T get() throws Exception {
while (response == null || exception == null) {
this.wait();
}
if (exception != null) {
throw exception;
}
return this.response;
}
}
/**
* 异步get
*
* @param url
* @return Future:在需要获取结果时,调用getResponse获取返回结果,
* 如果request过程中出错则会在该方法抛出异常
*/
public static <T> Future<T> asyncSendRequest(String url, Class<T> tClass) {
Request.Builder builder = new Request.Builder()
.url(url)
.get();
Future<T> future = new Future<>(tClass);
getHttpClient().newCall(builder.build()).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
future.setException(e);
}
@Override
public void onResponse(Call call, okhttp3.Response okHttpResponse) throws IOException {
OkHttpUtils.Response response = new OkHttpUtils.Response();
response.setCode(okHttpResponse.code());
if (okHttpResponse.isSuccessful()) {
response.setBody(okHttpResponse.body().string());
}
future.setResponse(response);
}
});
return future;
}
在将请求放入OkHttp的异步队列之后,就返回一个Future。通过向OkHttp注册回调函数可以拿到请求的响应结果,然后将再结果写入到Future中,外部通过调用Future的get方法获取请求的响应结果。
如果请求未接收到响应结果,异步回调接口就不会被调用,那么Future的get就会堵塞调用者线程,直接接收到请求结果,由回调方法调用Future的setResponse方法将阻塞的业务线程唤醒。
假设ip信息查询接口的平均耗时是2ms,而业务服务的处理一次请求耗时是10ms,由于业务的耗时远远大于ip查询的耗时,故可以达到将远程http调用的ip信息查询耗时降为0ms。
Future使用volatile实现可见性,而wait只会阻塞调用者线程,由于一次请求是在一个线程内完成的,每个请求都是持有独立的一个Future,所以,完美避开加锁操作。