由分布式本地缓存到一致性hash

由分布式本地缓存到一致性hash

背景

最近在开发一款C端产品,研究了一下Java服务端缓存框架,发现阿里的jetcache不错,有二级缓存,既可以做本地缓存也可以做远程缓存,兼容springboot,使用起来很方便。

使用二级缓存,可以很有效的分摊一个缓存的负载。我们的系统是基于springcloud的分布式系统,有了本地+远程缓存,那么就要考虑缓存一致性的问题了。远程缓存暂且不谈,毕竟同一个服务(下面说的服务大概都代表同一个服务的不同节点)都是用同一个Redis的库。服务之间本地缓存怎么同步呢?

设想一个场景,如果服务a1从现在时刻缓存数据集d0,服务a2从t时刻缓存数据集d0,在t+n时(假设数据t<本地缓存时间<(t+n)),数据集修改为d1,此时正好请求到服务a1,此时a1缓存的数据集为d1,那么,在接下来一段时间内,由于负载均衡的原因,请求会去轮询请求服务a1a2,那么获取到的数据集就会是d0d1不一样的结果。这不是客户端想要的结果呀。实际上客户端想要的是请求保持幂等性

解决方案

同步缓存

说到缓存一致,先想到的当然是从缓存本身入手,让每台机子的缓存都同步成一样的数据集。沿着这个思路,要么让数据变更时通知各个缓存数据服务,让其来同步数据,要么就是数据变更时统一刷新缓存,一个主动一个被动,但是都有同一个问题,就是几乎很难知道都有谁在使用这份数据。

负载均衡

同步缓存不行,那么换个思路,前面说过,请求是因为负载均衡才路由到不同的服务的,那么就让同一个客户端的请求都路由到同一个服务不就行了。但是这样还是有些问题:

  • 用什么来固定客户端,至少要保证同一个客户端,每次都标识都是一致的,才能保证根据标识来负载均衡是一致的?

    这个问题,可能只有IP才是“不变”,用户ID不行,没登录的用户用不了,客户端唯一key不行,这个web端没这个东西,好像就没有了吧?对于IP来说,有可能移动端或者连接WiFi的时候会在短时间内IP变化,但这种变化怎么说呢,可以不考虑的。

  • 负载均衡算法怎么保证一致性?

    • 首先,需要用到负载均衡的,在我现在的系统中,只有springcloud gateway和feignclient两个框架用到了,只需要重写其负载均衡的方法即可。

    • 其次,使用什么算法保证每个客户端都能路由到同一个服务中去。前面说到了唯一标识一个客户端的值,就是客户端的IP,那么可以用IP来映射服务,其实算法也就呼之欲出了,没错,就是一致性hash

Talk is cheap. Show me the code.

一致性hash算法

需要依赖:


    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

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);
	}
}

springcloud gateway

这个官网说的就很清楚了,负载均衡默认是由这个类实现的,需要的就是重写这里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);
	}
}

创建bean

/**
 * 设置负载均衡算法为IP-一致性HASH算法
 */
@Bean
public IRule IpHashRule() {
	return new IpConsistentHashRule();
}

最后

后续会把负载均衡方法再补上去掉已失效的服务,重新请求这个功能,让它成为一个通用且能应用于生产的方法。

你可能感兴趣的:(Java,技术方案,SpringCloud)