最近在开发一款C端产品,研究了一下Java服务端缓存框架,发现阿里的jetcache不错,有二级缓存,既可以做本地缓存也可以做远程缓存,兼容springboot,使用起来很方便。
使用二级缓存,可以很有效的分摊一个缓存的负载。我们的系统是基于springcloud的分布式系统,有了本地+远程缓存,那么就要考虑缓存一致性的问题了。远程缓存暂且不谈,毕竟同一个服务(下面说的服务大概都代表同一个服务的不同节点)都是用同一个Redis的库。服务之间本地缓存怎么同步呢?
设想一个场景,如果服务a1从现在时刻缓存数据集d0,服务a2从t时刻缓存数据集d0,在t+n时(假设数据t<本地缓存时间<(t+n)),数据集修改为d1,此时正好请求到服务a1,此时a1缓存的数据集为d1,那么,在接下来一段时间内,由于负载均衡的原因,请求会去轮询请求服务a1、a2,那么获取到的数据集就会是d0和d1不一样的结果。这不是客户端想要的结果呀。实际上客户端想要的是请求保持幂等性。
说到缓存一致,先想到的当然是从缓存本身入手,让每台机子的缓存都同步成一样的数据集。沿着这个思路,要么让数据变更时通知各个缓存数据服务,让其来同步数据,要么就是数据变更时统一刷新缓存,一个主动一个被动,但是都有同一个问题,就是几乎很难知道都有谁在使用这份数据。
同步缓存不行,那么换个思路,前面说过,请求是因为负载均衡才路由到不同的服务的,那么就让同一个客户端的请求都路由到同一个服务不就行了。但是这样还是有些问题:
用什么来固定客户端,至少要保证同一个客户端,每次都标识都是一致的,才能保证根据标识来负载均衡是一致的?
这个问题,可能只有IP才是“不变”,用户ID不行,没登录的用户用不了,客户端唯一key不行,这个web端没这个东西,好像就没有了吧?对于IP来说,有可能移动端或者连接WiFi的时候会在短时间内IP变化,但这种变化怎么说呢,可以不考虑的。
负载均衡算法怎么保证一致性?
首先,需要用到负载均衡的,在我现在的系统中,只有springcloud gateway和feignclient两个框架用到了,只需要重写其负载均衡的方法即可。
其次,使用什么算法保证每个客户端都能路由到同一个服务中去。前面说到了唯一标识一个客户端的值,就是客户端的IP,那么可以用IP来映射服务,其实算法也就呼之欲出了,没错,就是一致性hash。
Talk is cheap. Show me the code.
需要依赖:
com.google.guava
guava
23.0
来源于streametry/jumphash,没有虚拟节点的一致性hash算法。
package com.wuhenjian.util.hash;
import com.google.common.primitives.UnsignedLong;
/**
* 一致性hash算法工具类
* @author 無痕剑
* @date 2019/7/17 18:59
*/
public class ConsistentHashUtil {
private ConsistentHashUtil() {}
private static UnsignedLong KEY_TIMES = UnsignedLong.valueOf(2862933555777941757L);
private static long CONSTANT = 1L << 31;
/**
* 使用对象的hashCode方法计算选择的hash桶值
* @param buckets 桶数量
* @param object 对象(最好是重写过hashCode方法的对象)
* @return 选择的桶值
*/
public static int jump(int buckets, Object object) {
return jump(buckets, object.hashCode());
}
/**
* 计算keyHash值最近的桶值
* @param buckets 桶数量
* @param keyHash hash值
* @return 选择的桶值
*/
public static int jump(int buckets, long keyHash) {
UnsignedLong key = UnsignedLong.fromLongBits(keyHash);
long b = -1, j = 0;
while (j < buckets) {
b = j;
key = key.times(KEY_TIMES).plus(UnsignedLong.ONE);
UnsignedLong keyShift = UnsignedLong.fromLongBits(key.longValue() >>> 33).plus(UnsignedLong.ONE);
j = (long) ((b + 1) * (CONSTANT / keyShift.doubleValue()));
}
return (int) b;
}
}
feignclient没有找到对应的官方文档说明,这个有点坑,但是自己对feign还是比较属性,之前写过一个SpringCloud微服务Zuul网关动态路由,里面有说明怎么配置才能在feign中使用RequestContextHolder.getRequestAttributes()
获取到原请求。这里获取到原请求其实就是获取请求中携带的源IP,用于负载均衡。
说明一个feign比较坑的地方,负载均衡选择服务
com.netflix.loadbalancer.IRule#choose(Object key)
的参数我真的是想尽了办法都没法把这个参数传给我自定义的类,没办法,所以只好在方法里面获取原请求来获取参数了。
package com.wuhenjian.api.config;
import com.netflix.client.config.IClientConfig;
import com.netflix.loadbalancer.AbstractLoadBalancerRule;
import com.netflix.loadbalancer.ILoadBalancer;
import com.netflix.loadbalancer.Server;
import com.wuhenjian.model.gateway.balance.BalanceConstant;
import com.wuhenjian.util.hash.ConsistentHashUtil;
import com.wuhenjian.util.string.StringUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.CollectionUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
/**
* IP-一致性HASH负载均衡算法实现
* @author 無痕剑
* @date 2019/7/17 9:19
*/
@Slf4j
public class IpConsistentHashRule111 extends AbstractLoadBalancerRule {
@Override
public void initWithNiwsConfig(IClientConfig clientConfig) {
}
@Override
public Server choose(Object key) {
String clientRealIp;
// key为空,则使用RequestContextHolder获取请求源,再获取其中的客户端真实IP
if (key == null) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
log.error("负载均衡未获取到原请求");
return null;
}
HttpServletRequest request = attributes.getRequest();
// 通过源请求获取到客户端真实IP,这个值必须存在,才能进行IP一致性HASH负载均衡
clientRealIp = request.getHeader(BalanceConstant.CLIENT_REAL_IP);
if (StringUtil.isBlank(clientRealIp)) {
log.error("在请求[{} {}]中未获取到客户端真实IP", request.getMethod(), request.getRequestURI());
return null;
}
} else {
clientRealIp = (String) key;
}
ILoadBalancer loadBalancer = super.getLoadBalancer();
if (loadBalancer == null) {
log.error("未获取到负载均衡操作接口对象");
return null;
}
// 获取可用服务
List reachableServerList = loadBalancer.getReachableServers();
if (CollectionUtils.isEmpty(reachableServerList)) {
log.error("负载均衡没有可用服务");
return null;
}
// 使用一致性hash算法计算路由到的服务序号
int jump = ConsistentHashUtil.jump(reachableServerList.size(), clientRealIp);
// 返回选择的服务
return reachableServerList.get(jump);
}
}
这个官网说的就很清楚了,负载均衡默认是由这个类实现的,需要的就是重写这里org.springframework.cloud.gateway.filter.LoadBalancerClientFilter#choose
。看重写的逻辑,实际上就是重写了org.springframework.cloud.netflix.ribbon.RibbonLoadBalancerClient#getServer(com.netflix.loadbalancer.ILoadBalancer)
方法中的return
语句,把原来传的固定值default改为了客户端真实IP。
package com.wuhenjian.gateway.pe.filter;
import com.netflix.client.config.IClientConfig;
import com.netflix.loadbalancer.ILoadBalancer;
import com.netflix.loadbalancer.Server;
import com.wuhenjian.model.gateway.balance.BalanceConstant;
import com.wuhenjian.util.string.StringUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.LoadBalancerClient;
import org.springframework.cloud.gateway.filter.LoadBalancerClientFilter;
import org.springframework.cloud.gateway.support.NotFoundException;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.cloud.netflix.ribbon.*;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import java.net.URI;
/**
* IP一致性HASH负载均衡
* @author 無痕剑
* @date 2019/7/18 11:55
*/
@Slf4j
@Component
public class IpHashLoadBalancerClientFilter1111 extends LoadBalancerClientFilter {
private final SpringClientFactory clientFactory;
public IpHashLoadBalancerClientFilter1111(LoadBalancerClient loadBalancer,
SpringClientFactory clientFactory) {
super(loadBalancer);
this.clientFactory = clientFactory;
}
@Override
protected ServiceInstance choose(ServerWebExchange exchange) {
URI uri = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR);
if (uri == null) {
log.error("负载均衡拦截器获取前置拦截器[ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR]属性失败");
throw new NotFoundException("负载均衡拦截器获取前置拦截器[ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR]属性失败");
}
String serviceId = uri.getHost();
// 获取负载均衡操作接口对象
ILoadBalancer loadBalancer = clientFactory.getLoadBalancer(serviceId);
if (loadBalancer == null) {
log.error("未获取到负载均衡操作接口对象");
throw new NotFoundException("未获取到负载均衡操作接口对象");
}
ServerHttpRequest request = exchange.getRequest();
// 获取请求头中的x_forwarded_for参数
String clientRealIp = request.getHeaders().getFirst(BalanceConstant.CLIENT_REAL_IP);
if (StringUtil.isBlank(clientRealIp)) {
log.error("在请求[{} {}]中未获取到客户端真实IP", request.getMethod(), request.getURI().getPath());
throw new NotFoundException("在请求中未获取到客户端真实IP");
}
// 根据请求源IP进行负载均衡
Server server = loadBalancer.chooseServer(clientRealIp);
return new RibbonLoadBalancerClient.RibbonServer(
serviceId,
server,
isSecure(server, serviceId),
serverIntrospector(serviceId).getMetadata(server)
);
}
private ServerIntrospector serverIntrospector(String serviceId) {
ServerIntrospector serverIntrospector = this.clientFactory.getInstance(serviceId,
ServerIntrospector.class);
if (serverIntrospector == null) {
serverIntrospector = new DefaultServerIntrospector();
}
return serverIntrospector;
}
private boolean isSecure(Server server, String serviceId) {
IClientConfig config = this.clientFactory.getClientConfig(serviceId);
ServerIntrospector serverIntrospector = serverIntrospector(serviceId);
return RibbonUtils.isSecure(config, serverIntrospector, server);
}
}
/**
* 设置负载均衡算法为IP-一致性HASH算法
*/
@Bean
public IRule IpHashRule() {
return new IpConsistentHashRule();
}
后续会把负载均衡方法再补上去掉已失效的服务,重新请求这个功能,让它成为一个通用且能应用于生产的方法。