8.1背景
负载均衡策略中,我们提到过源地址hash算法,让某些请求固定的落在对应的服务器上。这样可以解决会话信息保留的问题。
同时,标准的hash,如果机器节点数发生变更。那么请求会被重新hash,打破了原始的设计初衷,怎么解决呢?一致性hash上场。
8.2 原理
- 以4台机器为例,一致性hash的算法如下:
- 首先求出各个服务器的哈希值,并将其配置到0~232的圆上
- 然后采用同样的方法求出存储数据的键的哈希值,也映射圆上
- 从数据映射到的位置开始顺时针查找,将数据保存到找到的第一个服务器上
- 如果到最大值仍然找不到,就取第一个。这就是为啥形象的称之为环
添加节点:
删除节点原理类似
8.3 特性
-
单调性(Monotonicity):单调性是指如果已经有一些请求通过哈希分派到了相应的服务器进行处理,又有新的服务器加入到系统中时候,应保证原有的请求可以被映射到原有的或者新的服务器中去,而不会被映射到原来的其它服务器上去。
-
分散性(Spread):分布式环境中,客户端请求时可能只知道其中一部分服务器,那么两个客户端看到不同的部分,并且认为自己看到的都是完整的hash环,那么问题来了,相同的key可能被路由到不同服务器上去。以上图为例,加入client1看到的是1,4;client2看到的是2,3;那么2-4之间的key会被俩客户端重复映射到3,4上去。分散性反应的是这种问题的严重程度。
-
平衡性(Balance):平衡性是指客户端hash后的请求应该能够分散到不同的服务器上去。一致性hash可以做到尽量分散,但是不能保证每个服务器处理的请求的数量完全相同。这种偏差称为hash倾斜。如果节点的分布算法设计不合理,那么平衡性就会收到很大的影响。
4)优化
增加虚拟节点可以优化hash算法,使得切段和分布更细化。即实际有m台机器,但是扩充n倍,在环上放置m*n个,那么均分后,key的段会分布更细化。
8.4 实现
import java.util.Random;
import java.util.SortedMap;
import java.util.TreeMap;
/**
* 一致性Hash算法
*/
public class Hash {
//服务器列表
private static String[] servers = { "192.168.0.1",
"192.168.0.2", "192.168.0.3", "192.168.0.4" };
//key表示服务器的hash值,value表示服务器
private static SortedMap<Integer, String> serverMap = new TreeMap<Integer, String>();
static {
for (int i=0; i<servers.length; i++) {
int hash = getHash(servers[i]);
//理论上,hash环的最大值为2^32
//这里为做实例,将ip末尾作为上限也就是254
//那么服务器是0-4,乘以60后可以均匀分布到 0-254 的环上去
//实际的请求ip到来时,在环上查找即可
hash *= 60;
System.out.println("add " + servers[i] + ", hash=" + hash);
serverMap.put(hash, servers[i]);
}
}
//查找节点
private static String getServer(String key) {
int hash = getHash(key);
//得到大于该Hash值的所有server
SortedMap<Integer, String> subMap = serverMap.tailMap(hash);
if(subMap.isEmpty()){
//如果没有比该key的hash值大的,则从第一个node开始
Integer i = serverMap.firstKey();
//返回对应的服务器
return serverMap.get(i);
}else{
//第一个Key就是顺时针过去离node最近的那个结点
Integer i = subMap.firstKey();
//返回对应的服务器
return subMap.get(i);
}
}
//运算hash值
//该函数可以自由定义,只要做到取值离散即可
//这里取ip地址的最后一节
private static int getHash(String str) {
String last = str.substring(str.lastIndexOf(".")+1,str.length());
return Integer.valueOf(last);
}
public static void main(String[] args) {
//模拟5个随机ip请求
for (int i = 1; i < 8; i++) {
String ip = "192.168.1."+ i*30;
System.out.println(ip +" ---> "+getServer(ip));
}
//将5号服务器加到2-3之间,取中间位置,150
System.out.println("add 192.168.0.5,hash=150");
serverMap.put(150,"192.168.0.5");
//再次发起5个请求
for (int i = 1; i < 8; i++) {
String ip = "192.168.1."+ i*30;
System.out.println(ip +" ---> "+getServer(ip));
}
}
}
-
4台机器加入hash环
-
模拟请求,根据hash值,准确调度到下游节点
-
添加节点5,key取150
-
再次发起请求
9.1网站敏感词过滤
1)场景
敏感词、文字过滤是一个网站必不可少的功能,高效的过滤算法是非常有必要的。针对过滤首先想到的可能是这样:
方案一、使用java里的String contains,逐个遍历敏感词:
String[] s = "广告,广告词,中奖".split(",");
String text = "讨厌的广告词";
boolean flag = false;
for (String s1 : s) {
if (text.contains(s1)){
flag = true;
break;
}
}
System.out.println(flag);
方案二、正则表达式:
System.out.println(text.matches(".*(广告|广告词|中奖).*"));
其实无论采取哪个方法,基本是换汤不换药。都是整体字符匹配,效率值得商榷。
那怎么办呢?DFA算法出场。
2)概述
DFA即Deterministic Finite Automaton,也就是确定有穷自动机,它是是通过event和当前的state得到下一个state,即event+state=nextstate。
对照到以上案例,查找和停止查找是动作,找没找到是状态,每一步的查找和结果决定下一步要不要继续。DFA算法在敏感词上应用的关键是构建敏感词库,如果我们把以上案例翻译成json表达如下:
{
"isEnd": 0,
"广": {
"isEnd": 0,
"告": {
"isEnd": 1,
"词": {
"isEnd": 1
}
}
},
"中": {
"isEnd": 0,
"奖": {
"isEnd": 1
}
}
}
查找过程如下:首先把text按字拆分,逐个字查找词库的key,先从“讨”开始,没有就下一个字“厌”,直到“广”,找到就判断isEnd,如果为1,说明匹配成功包含敏感词,如果为0,那就继续匹配“告”,直到isEnd=1为止。
匹配策略上,有两种。最小和最大匹配。最小则匹配【广告】,最大则需要匹配到底【广告词】
3)java实现
先加入fastjson坐标,查看敏感词库结构要用到
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.70</version>
</dependency>
package com.test.busi;
import com.alibaba.fastjson.JSON;
import java.util.*;
/**
* 敏感词处理DFA算法
*/
public class SensitiveWordUtil {
//短匹配规则,如:敏感词库["广告","广告词"],语句:"我是广告词",匹配结果:我是[广告]
public static final int SHORT_MATCH = 1;
//长匹配规则,如:敏感词库["广告","广告词"],语句:"我是广告词",匹配结果:我是[广告词]
public static final int LONG_MATCH = 2;
/**
* 敏感词库
*/
public static HashMap sensitiveWordMap;
/**
* 初始化敏感词库
* words:敏感词,多个用英文逗号分隔
*/
private static void initSensitiveWordMap(String words) {
String[] w = words.split(",");
sensitiveWordMap = new HashMap(w.length);
Map nowMap;
for (String key : w) {
nowMap = sensitiveWordMap;
for (int i = 0; i < key.length(); i++) {
//转换成char型
char keyChar = key.charAt(i);
//库中获取关键字
Map wordMap = (Map)nowMap.get(keyChar);
//如果不存在新建一个,并加入词库
if (wordMap == null){
wordMap = new HashMap();
wordMap.put("isEnd", "0");
nowMap.put(keyChar, wordMap);
}
nowMap = wordMap;
if (i == key.length() - 1) {
//最后一个
nowMap.put("isEnd", "1");
}
}
}
}
/**
* 判断文字是否包含敏感字符
* @return 若包含返回true,否则返回false
*/
public static boolean contains(String txt, int matchType) {
for (int i = 0; i < txt.length(); i++) {
int matchFlag = checkSensitiveWord(txt, i, matchType); //判断是否包含敏感字符
if (matchFlag > 0) { //大于0存在,返回true
return true;
}
}
return false;
}
/**
* 沿着文本字符挨个往后检索文字中的敏感词
*/
public static Set<String> getSensitiveWord(String txt, int matchType) {
Set<String> sensitiveWordList = new HashSet<>();
for (int i = 0; i < txt.length(); i++) {
//判断是否包含敏感字符
int length = checkSensitiveWord(txt, i, matchType);
if (length > 0) {//存在,加入list中
sensitiveWordList.add(txt.substring(i, i + length));
//指针沿着文本往后移动敏感词的长度
//也就是一旦找到敏感词,加到列表后,越过这个词的字符,继续往下搜索
//但是必须减1,因为for循环会自增,如果不减会造成下次循环跳格而忽略字符
//这会造成严重误差
i = i + length - 1;
}
//如果找不到,i就老老实实一个字一个字的往后移动,作为begin进行下一轮
}
return sensitiveWordList;
}
/**
* 从第beginIndex个字符的位置,往后查找敏感词
* 如果找到,返回敏感词字符的长度,不存在返回0
* 这个长度用于找到后提取敏感词和后移指针,是个性能关注点
*/
private static int checkSensitiveWord(String txt, int beginIndex, int matchType) {
//敏感词结束标识位:用于敏感词只有1位的情况
boolean flag = false;
//匹配到的敏感字的个数,也就是敏感词长度
int length = 0;
char word;
//从根Map开始查找
Map nowMap = sensitiveWordMap;
for (int i = beginIndex; i < txt.length(); i++) {
//被判断语句的第i个字符开始
word = txt.charAt(i);
//获取指定key,并且将敏感库指针指向下级map
nowMap = (Map) nowMap.get(word);
if (nowMap != null) {//存在,则判断是否为最后一个
//找到相应key,匹配长度+1
length++;
//如果为最后一个匹配规则,结束循环,返回匹配标识数
if ("1".equals(nowMap.get("isEnd"))) {
//结束标志位为true
flag = true;
//短匹配,直接返回,长匹配还需继续查找
if (SHORT_MATCH == matchType) {
break;
}
}
} else {
//敏感库不存在,直接中断
break;
}
}
if (length < 2 || !flag) {
//长度必须大于等于1才算是词,字的话就不必这么折腾了
length = 0;
}
return length;
}
public static void main(String[] args) {
//初始化敏感词库
SensitiveWordUtil.initSensitiveWordMap("广告,广告词,中奖");
System.out.println("敏感词库结构:" + JSON.toJSONString(sensitiveWordMap));
String string = "关于中奖广告的广告词筛选";
System.out.println("被检测文本:"+string);
System.out.println("待检测字数:" + string.length());
//是否含有关键字
boolean result = SensitiveWordUtil.contains(string,SensitiveWordUtil.LONG_MATCH);
System.out.println("长匹配:"+result);
result = SensitiveWordUtil.contains(string, SensitiveWordUtil.SHORT_MATCH);
System.out.println("短匹配:"+result);
//获取语句中的敏感词
Set<String> set = SensitiveWordUtil.getSensitiveWord(string,SensitiveWordUtil.LONG_MATCH);
System.out.println("长匹配到:" + set);
set = SensitiveWordUtil.getSensitiveWord(string, SensitiveWordUtil.SHORT_MATCH);
System.out.println("短匹配到:" + set);
}
}
- 敏感词结构初始化后符合预期
- 检测和长短匹配有结果
- 匹配的敏感词列表正确
9.2 最优商品topk
9.2.1 背景
topk是一个典型的业务场景,除了最优商品,包括推荐排名、积分排名所有涉及到排名前k的地方都是该算法的应用场合。
topk即得到一个集合后,筛选里面排名前k个数值。问题看似简单,但是里面的数据结构和算法体现着对解决方案性能的思索和深度挖掘。到底有几种方法,这些方案里蕴含的优化思路究竟是怎么样的?这节来讨论
9.2.2 方案
方案一:
全局排序,将集合整体排序后,取出最大的k个值就是需要的结果。
这种方案最糟糕,我只需要排名前k的元素,其他n-k个的顺序我并不关心,但是运算过程中,都得跟着做了没用的排序操作。
方案二:
局部排序,既然全局没必要,那我只取前k个,后面的就没必要理会了。
冒泡排序在排序算法中可以胜任该操作。我们按最大值往上冒泡为例,只要执行k次冒泡,那前k名就可以确定。但是这种方案依然不是最优办法。因为我们需要的是前k名,那至于这k个,谁大谁小并不需要关心,排序依然是个浪费。
方案三:
最小堆,既然没必要排序,那我们就不排序。
先将前k个元素形成一个最小堆,后面的n-k个元素依次与堆顶比较,小则丢弃大则替换堆顶并调整堆。直到n个全部完成为止。最小堆是topk的经典解决方案。
9.2.3 实现
下面就用最小堆实现topk
import java.util.Arrays;
public class Topk {
//堆元素下沉,形成最小堆,序号从i开始
static void down(int[] nodes,int i) {
//顶点序号遍历,只要到1半即可,时间复杂度为O(log2n)
while ( i << 1 < nodes.length){
//左子,为何左移1位?回顾一下二叉树序号
int left = i<<1;
//右子,左+1即可
int right = left+1;
//标记,指向 本节点,左、右子节点里最小的,一开始取i自己
int flag = i;
//判断左子是否小于本节点
if (nodes[left] < nodes[i]){
flag = left;
}
//判断右子
if (right < nodes.length && nodes[flag] > nodes[right]){
flag = right;
}
//两者中最小的与本节点不相等,则交换
if (flag != i){
int temp = nodes[i];
nodes[i] = nodes[flag];
nodes[flag] = temp;
i = flag;
}else {
//否则相等,堆排序完成,退出循环即可
break;
}
}
}
public static void main(String[] args) {
//原始数据
int[] src={3,6,2,7,4,8,1,9,2,5};
//要取几个
int k = 5;
//堆,为啥是k+1?请注意,最小堆的0是无用的,序号从1开始
int[] nodes = new int[k+1];
//取前k个数,注意这里只是个二叉树,还不满足最小堆的要求
for (int i = 0; i < k; i++) {
nodes[i+1]=src[i];
}
System.out.println("before:"+Arrays.toString(nodes));
//从最底的子树开始,堆顶下沉
//这里才真正的形成最小堆
for (int i = k>>1; i >= 1; i--) {
down(nodes,i);
}
System.out.println("create:"+Arrays.toString(nodes));
//把余下的n-k个数,放到堆顶,依次下沉,topk堆算法的开始
for (int i = src.length - k;i<src.length;i++){
if (nodes[1] < src[i]){
nodes[1] = src[i];
down(nodes,1);
}
}
System.out.println("topk:"+Arrays.toString(nodes));
}
}
9.2.4 结果分析
- 最终获取k个值成功,符合要求
- 中间不涉及排序问题