常见的应用级算法(下)

常见的应用级算法

  • 1.负载均衡算法
    • 1.轮询(RR)
    • 2.随机
    • 3.源地址哈希
    • 4.加权轮询
    • 5.加权随机
    • 6.应用案例
  • 2.加密算法的应用
    • 1.散列
    • 2. 对称
    • 3.非对称
  • 3.一致性hash及其应用
    • 1.背景
    • 2. 原理
    • 3.特性
    • 4.实现
    • 5.验证
  • 4.典型业务场景应用
    • 1.网站敏感词过滤
    • 2.最优商品topk

1.负载均衡算法

1.轮询(RR)

1.概述:轮询即排好队,一个接一个。前面调度算法中用到的时间片轮转,就是一种典型的轮询。但是前面使用数组和下标轮询实现。这里尝试手动写一个双向链表形式实现服务器列表的请求轮询算法

2.代码实现

package com.andy.load;

/**
 * @author Andy
 * @version 0.0.1
 * @since 2023-09-15 15:58
 */
public class RR {
    class Server {
        Server prev;
        Server next;
        String name;

        public Server(String name) {
            this.name = name;
        }
    }


    // 当前服务节点
    Server current;

    // 初始化轮询类,多个服务器ip用逗号隔开
    public RR(String serverName) {
        System.out.println("init server list : " + serverName);
        String[] names = serverName.split(",");
        for (int i = 0; i < names.length; i++) {
            Server server = new Server(names[i]);
            if (current == null) {
                // 如果当前服务器为空,说明是第一台机器,current就指向新创建的server
                this.current = server;
                // 同时,server的前后均指向自己。
                current.prev = current;
                current.next = current;
            } else {
                // 否则说明已经有机器了,按新加处理。
                addServer(names[i]);
            }
        }

    }

    // 添加机器
    void addServer(String serverName) {
        System.out.println("add server : " + serverName);
        Server server = new Server(serverName);
        Server next = this.current.next;
        // 在当前节点后插入新节点
        this.current.next = server;
        server.prev = this.current;

        // 修改下一节点的prev指针
        server.next = next;
        next.prev = server;
    }

    // 将当前服务器移除,同时修改前后节点的指针,让其直接关联
    // 移除的current会被回收器回收掉
    void remove() {
        System.out.println("remove current = " + current.name);
        this.current.prev.next = this.current.next;
        this.current.next.prev = this.current.prev;
        this.current = current.next;
    }

    // 请求。由当前节点处理即可
    // 注意:处理完成后,current指针后移
    void request() {
        System.out.println(this.current.name);
        this.current = current.next;
    }

    public static void main(String[] args) throws InterruptedException {
        // 初始化两台机器
        RR rr = new RR("192.168.0.1,192.168.0.2");
        // 启动一个额外线程,模拟不停的请求
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    rr.request();
                }
            }
        }).start();

        // 3s后,3号机器加入清单
        Thread.currentThread().sleep(3000);
        rr.addServer("192.168.0.3");

        // 3s后,当前服务节点被移除
        Thread.currentThread().sleep(3000);
        rr.remove();

    }

}

3.结果分析

  • 初始化后,只有1,2,两者轮询
  • 3加入后,1,2,3,三者轮询
  • 移除2后,只剩1和3轮询

4.优缺点

  • 实现简单,机器列表可以自由加减,且时间复杂度为o(1)
  • 无法针对节点做偏向性定制,节点处理能力的强弱无法区分对待

2.随机

1.概述:从可服务的列表中随机取一个提供响应。随机存取的场景下,适合使用数组更高效的实现下标随机读取

2.实现

package com.andy.load;

import java.util.ArrayList;
import java.util.Random;

/**
 * @author Andy
 * @version 0.0.1
 * @since 2023-09-15 16:11
 */
public class Rand {
    ArrayList<String> ips;

    public Rand(String nodeNames) {
        System.out.println("init list : " + nodeNames);
        String[] nodes = nodeNames.split(",");
        // 初始化服务器列表,长度取机器数
        ips = new ArrayList<>(nodes.length);
        for (String node : nodes) {
            ips.add(node);
        }
    }

    // 请求
    void request() {
        // 下标,随机数,注意因子
        int i = new Random().nextInt(ips.size());
        System.out.println(ips.get(i));
    }

    // 添加节点,注意,添加节点会造成内部数组扩容
    // 可以根据实际情况初始化时预留一定空间
    void addnode(String nodeName) {
        System.out.println("add node : " + nodeName);
        ips.add(nodeName);
    }

    // 移除
    void remove(String nodeName) {
        System.out.println("remove node : " + nodeName);
        ips.remove(nodeName);
    }


    public static void main(String[] args) throws InterruptedException {
        Rand rd = new Rand("192.168.0.1,192.168.0.2");

        // 启动一个额外线程,模拟不停的请求
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    rd.request();
                }
            }
        }).start();

        // 3s后,3号机器加入清单
        Thread.currentThread().sleep(3000);
        rd.addnode("192.168.0.3");

        // 3s后,当前服务节点被移除
        Thread.currentThread().sleep(3000);
        rd.remove("192.168.0.2");
    }
}

3.结果分析

  • 初始化为1,2,两者不按顺序轮询,而是随机出现
  • 3加入服务节点列表
  • 移除2后,只剩1,3,依然是两者随机,无序

3.源地址哈希

1.概述:对当前访问的ip地址做一个hash值,相同的key被路由到同一台机器去。场景常见于分布式集群环境下,用户登录时的请求路由和会话保持。

2.实现

package com.andy.load;

import java.util.ArrayList;

/**
 * @author Andy
 * @version 0.0.1
 * @since 2023-09-15 16:18
 * 源地址哈希
 */
public class Hash {

    ArrayList<String> ips;

    public Hash(String nodeNames) {
        System.out.println("init list : " + nodeNames);
        String[] nodes = nodeNames.split(",");
        // 初始化服务器列表,长度取机器数
        ips = new ArrayList<>(nodes.length);
        for (String node : nodes) {
            ips.add(node);
        }
    }

    // 添加节点,注意,添加节点会造成内部Hash重排,思考为什么呢???
    // 这是个问题!在一致性hash中会进入详细探讨
    void addnode(String nodeName) {
        System.out.println("add node : " + nodeName);
        ips.add(nodeName);
    }

    // 移除
    void remove(String nodeName) {
        System.out.println("remove node : " + nodeName);
        ips.remove(nodeName);
    }

    // 映射到key的算法,这里取余数做下标
    private int hash(String ip) {
        int last = Integer.valueOf(ip.substring(ip.lastIndexOf(".") + 1, ip.length()));
        return last % ips.size();
    }

    // 请求
    // 注意,这里和来访ip是有关系的,采用一个参数,表示当前的来访ip
    void request(String ip) {
        // 下标
        int i = hash(ip);
        System.out.println(ip + "-->" + ips.get(i));
    }

    public static void main(String[] args) {
        Hash hash = new Hash("192.168.0.1,192.168.0.2");
        for (int i = 1; i < 10; i++) {
            // 模拟请求的来源ip
            String ip = "192.168.1." + i;
            hash.request(ip);
        }

        hash.addnode("192.168.0.3");
        for (int i = 1; i < 10; i++) {
            // 模拟请求的来源ip
            String ip = "192.168.1." + i;
            hash.request(ip);
        }
    }

}

3.结果分析

  • 初始化后,只有1,2,下标为末尾ip取余数,多次运行,响应的机器不变,实现了会话保持
  • 3加入后,重新hash,机器分布发生变化
  • 2被移除后,原来hash到2的请求被重新定位给3响应

4.加权轮询

1.概述:WeightRoundRobin,轮询只是机械的旋转,加权轮询弥补了所有机器一视同仁的缺点。在轮询的基础上,初始化时,机器携带一个比重

2.实现:维护一个链表,每个机器根据权重不同,占据的个数不同。轮询时权重大的,个数多,自然取到的次数变大。举个例子:a,b,c 三台机器,权重分别为4,2,1,排位后会是a,a,a,a,b,b,c,每次请求时,从列表中依次取节点,下次请求再取下一个。到末尾时,再从头开始

  • 但是这样有一个问题:机器分布不够均匀,扎堆出现了…

  • 解决:为解决机器平滑出现的问题,nginx的源码中使用了一种平滑的加权轮询的算法,规则如下:

  • 每个节点两个权重,weight和currentWeight,weight永远不变是配置时的值,current不停变化

  • 变化规律如下:选择前所有current+=weight,选current最大的响应,响应后让它的current-=total

次数 响应前 被选中 响应后
1 4,2,1 a -3,2,1
2 1,4,2 b 1,-3,2
3 5,-1,3 a -2,-1,3
4 2,1,4 c 2,1,-3
5 6,3,-2 a -1,3,-2
6 3,5,-1 b 3,-2,-1
7 7,0,0 a 0,0,0

统计:a=4,b=2,c=1 且分布平滑均衡

package com.andy.load;

import java.util.ArrayList;

/**
 * @author Andy
 * @version 0.0.1
 * @since 2023-09-15 16:29
 */
public class WRR {
    class Node {
        int weight, currentWeight;
        String name;

        public Node(String name, int weight) {
            this.name = name;
            this.weight = weight;
            this.currentWeight = 0;
        }

        @Override
        public String toString() {
            return String.valueOf(currentWeight);
        }
    }


    // 所有节点的列表
    ArrayList<Node> list;
    // 总权重
    int total;

    // 初始化节点列表,格式:a#4,b#2,c#1
    public WRR(String nodes) {
        String[] ns = nodes.split(",");
        list = new ArrayList<>(ns.length);
        for (String n : ns) {
            String[] n1 = n.split("#");
            int weight = Integer.valueOf(n1[1]);
            list.add(new Node(n1[0], weight));
            total += weight;
        }
    }

    // 获取当前节点
    Node getCurrent() {
        // 执行前,current加权重
        for (Node node : list) {
            node.currentWeight += node.weight;
        }

        // 遍历,取权重最高的返回
        Node current = list.get(0);
        int i = 0;
        for (Node node : list) {
            if (node.currentWeight > i) {
                i = node.currentWeight;
                current = node;
            }
        }
        return current;
    }

    // 响应
    void request() {
        // 获取当前节点
        Node node = this.getCurrent();
        // 第一列,执行前的current
        System.out.print(list.toString() + "---");
        // 第二列,选中的节点开始响应
        System.out.print(node.name + "---");
        // 响应后,current减掉total
        node.currentWeight -= total;
        // 第三列,执行后的current
        System.out.println(list);
    }

    public static void main(String[] args) {
        WRR wrr = new WRR("a#4,b#2,c#1");
        // 7次执行请求,看结果
        for (int i = 0; i < 7; i++) {
            wrr.request();
        }
    }

}

5.加权随机

1.概述:WeightRandom,机器随机被筛选,但是做一组加权值,根据权值不同,选中的概率不同。在这个概念上,可以认为随机是一种等权值的特殊情况

2.实现:设计思路依然相同,根据权值大小,生成不同数量的节点,节点排队后,随机获取。这里的数据结构主要涉及到随机的读取,所以优选为数组。与随机相同的是,同样为数组随机筛选,不同在于,随机只是每台机器1个,加权后变为多个。

package com.andy.load;

import java.util.ArrayList;
import java.util.Random;

/**
 * @author Andy
 * @version 0.0.1
 * @since 2023-09-15 16:32
 */
public class WR {
    // 所有节点的列表
    ArrayList<String> list;

    // 初始化节点列表
    public WR(String nodes) {
        String[] ns = nodes.split(",");
        list = new ArrayList<>();
        for (String n : ns) {
            String[] n1 = n.split("#");
            int weight = Integer.valueOf(n1[1]);
            for (int i = 0; i < weight; i++) {
                list.add(n1[0]);
            }
        }
    }

    void request() {
        // 下标,随机数,注意因子
        int i = new Random().nextInt(list.size());
        System.out.println(list.get(i));
    }

    public static void main(String[] args) {
        WR wr = new WR("a#2,b#1");
        for (int i = 0; i < 9; i++) {
            wr.request();
        }
    }
}

3.结果分析

  • 运行9次,a,b交替出现,a=6,b=3,满足2:1比例
  • 注意!既然是随机,就存在随机性,不见得每次执行都会严格比例。样本趋向无穷时,比例约准确

6.应用案例

1.nginx upstream

upstream frontend {
    #源地址hash
    ip_hash;
    server 192.168.0.1:8081;
    server 192.168.0.2:8082 weight=1 down;
    server 192.168.0.3:8083 weight=2;
    server 192.168.0.4:8084 weight=3 backup;
    server 192.168.0.5:8085 weight=4 max_fails=3 fail_timeout=30s;
}

  • ip_hash:即源地址hash算法
  • down:表示当前的server暂时不参与负载
  • weight:即加权算法,默认为1,weight越大,负载的权重就越大。
  • backup:备份机器,只有其它所有的非backup机器down或者忙的时候,再请求+ backup机器
  • max_fails:最大失败次数,默认值为1,这里为3,也就是最多进行3次尝试
  • fail_timeout:超时时间为30秒,默认值是10s。
  • 注意!weight和backup不能和ip_hash关键字一起使用。

2.springcloud ribbon IRule

#设置负载均衡策略 eureka-application-service为调用的服务的名称
eureka-application-service.ribbon.NFLoadBalancerRuleClassName=com.netflix.loadbalancer.RandomRule
  • RoundRobinRule:轮询

  • RandomRule:随机

  • AvailabilityFilteringRule:先过滤掉由于多次访问故障而处于断路器跳闸状态的服务,还有并发的连接数量超过阈值的服务,然后对剩余的服务轮询

  • WeightedResponseTimeRule:根据平均响应时间计算所有服务的权重,响应时间越快服务权重越大。刚启动时如果统计信息不足,则使用RoundRobinRule策略,等统计信息足够,会切换到该策略

  • RetryRule:先按照RoundRobinRule的策略,如果获取服务失败则在指定时间内重试,获取可用的服务

  • BestAvailableRule:会先过滤掉由于多次访问故障而处于断路器跳闸状态的服务,然后选择一个并发量最小的服务

  • ZoneAvoidanceRule:默认规则,综合判断server所在区域的性能和server的可用性

3.dubbo负载均衡

@Service(loadbalance = "roundrobin",weight = 100) 
  • RandomLoadBalance: 随机,这种方式是dubbo默认的负载均衡策略
  • RoundRobinLoadBalance:轮询
  • LeastActiveLoadBalance:最少活跃次数,dubbo框架自定义了一个Filter,用于计算服务被调用的次数
  • ConsistentHashLoadBalance:一致性hash

2.加密算法的应用

1.散列

1.概述:严格来讲这不算是一种加密,而应该叫做信息摘要算法。该算法使用散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来。通过数据打乱混合,重新创建一个叫做 散列值
2.常见算法:MD5、SHA(128、256)系列
常见的应用级算法(下)_第1张图片
3.应用:

  • 常用于密码存储,或文件指纹校验
  • 网站用户注册后,密码经过MD5加密后的值,存储进DB。再次登录时,将用户输入的密码按同样的方式加密,与数据库中的密文比对。这样即使数据库被破解,或者开发人员可见,基于MD5的不可逆性,仍然不知道密码是什么
  • 其次是文件校验场景。例如从某站下载的文件(尤其是大文件,比如系统镜像iso),官方网站都会放置一个签名(可能是MD5,或者SHA),当用户拿到文件后,可以本地执行散列算法与官网签名比对是否一致,来判断文件是否被篡改

4.实现
添加commons坐标


<dependency>
    <groupId>commons-codecgroupId>
    <artifactId>commons-codecartifactId>
    <version>1.15version>
dependency>

代码:

package com.andy.encrypt;

import org.apache.commons.codec.digest.DigestUtils;

import java.math.BigInteger;
import java.security.MessageDigest;

/**
 * @author Andy
 * @version 0.0.1
 * @since 2023-09-15 17:03
 * 散列
 */
public class Hash {
    /**
     * jdk的security实现md5
     * 也可以借助commons-codec包
     */
    public static String md5(String src) {
        byte[] pwd = null;
        try {
            pwd = MessageDigest.getInstance("md5").digest(src.getBytes("utf-8"));
        } catch (Exception e) {
            e.printStackTrace();
        }
        String code = new BigInteger(1, pwd).toString(16);
        for (int i = 0; i < 32 - code.length(); i++) {
            code = "0" + code;
        }
        return code;
    }

    public static String commonsMd5(String src) {
        return DigestUtils.md5Hex(src);
    }

    /**
     * jdk实现sha算法
     * 也可以借助commons-codec包
     */
    public static String sha(String src) throws Exception {
        MessageDigest sha = MessageDigest.getInstance("sha");
        byte[] shaByte = sha.digest(src.getBytes("utf-8"));
        StringBuffer code = new StringBuffer();
        for (int i = 0; i < shaByte.length; i++) {
            int val = ((int) shaByte[i]) & 0xff;
            if (val < 16) {
                code.append("0");
            }
            code.append(Integer.toHexString(val));
        }
        return code.toString();
    }

    public static String commonsSha(String src) throws Exception {
        return DigestUtils.sha1Hex(src);
    }


    public static void main(String[] args) throws Exception {
        String name = "Andy测试算法";
        System.out.println(name);
        System.out.println(md5(name));
        System.out.println(commonsMd5(name));
        System.out.println(sha(name));
        System.out.println(commonsSha(name));
    }
}

4.结果分析
常见的应用级算法(下)_第2张图片

  • jdk与commons均生成了相同的散列值
  • 多次运行,依然生成固定值
  • commons-codec还有很多可用方法,如:sha256,sha512…

2. 对称

1.概述:加密与解密用的都是同一个秘钥,性能比非对称加密高很多。

2.常见算法:常见的对称加密算法有 DES、3DES、AES

  • DES算法在POS、ATM、磁卡及智能卡(IC卡)、加油站、高速公路收费站等领域被广泛应用,以此来实现关键数据的保密,如信用卡持卡人的PIN的加密传输,IC卡与POS间的双向认证、金融交易数据包的MAC校验等

  • 3DES是DES加密算法的一种模式,是DES的一个更安全的变形。从DES向AES的过渡算法

  • AES,是下一代的加密算法标准,速度快,安全级别更高。
    常见的应用级算法(下)_第3张图片

3.应用:常用于对效率要求较高的实时数据加密通信

4.实现:

package com.andy.encrypt;

import org.apache.commons.codec.binary.Base64;

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.spec.SecretKeySpec;
import java.security.Key;

/**
 * @author Andy
 * @version 0.0.1
 * @since 2023-09-15 17:27
 */
public class AES {

    public static void main(String[] args) throws Exception {
        // 生成KEY
        KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
        keyGenerator.init(128);
        // key转换
        Key key = new SecretKeySpec(keyGenerator.generateKey().getEncoded(), "AES");

        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");

        String src = "Andy测试AES";
        System.out.println("明文:" + src);
        // 加密
        cipher.init(Cipher.ENCRYPT_MODE, key);
        byte[] result = cipher.doFinal(src.getBytes());
        System.out.println("加密:" + Base64.encodeBase64String(result));

        // 解密
        cipher.init(Cipher.DECRYPT_MODE, key);
        result = cipher.doFinal(result);
        System.out.println("解密:" + new String(result));
    }
}

5.运行结果分析:加密成功,且解密后明文一致
常见的应用级算法(下)_第4张图片

3.非对称

1.概述:非对称即加密与解密不是同一把钥匙,而是分成公钥和私钥。私钥在个人手里,公钥公开。这一对钥匙一个用于加密,另一个用于解密。使用其中一个加密后,则原始明文只能用对应的另一个密钥解密,即使最初用于加密的密钥也不能用作解密。正是因为这种特性,所以称为非对称加密
2.常见算法:RSA、ElGamal、背包算法、Rabin(RSA的特例)、迪菲-赫尔曼密钥交换协议中的公钥加密算法、椭圆曲线加密算法(英语:Elliptic Curve Cryptography, ECC)。使用最广泛的是RSA算法

常见的应用级算法(下)_第5张图片
3.应用

  • 最常见的,两点:https和数字签名。

  • 严格意义上讲,https并非所有请求都使用非对称。基于性能考虑,https先使用非对称约定一个key,后期使用该key进行对称加密和数据传输
    常见的应用级算法(下)_第6张图片

数字签名则是用于验证报文是否为服务器发出的,用于防伪和认证。过程如下:

签发:
服务器外发布公钥,私钥保密
服务器对消息M计算摘要(如MD5等公开算法),得到摘要D
服务器使用私钥对D进行签名,得到签名S
将M和S一起发给客户

验证:
客户端对M使用同一摘要算法计算摘要,得到摘要D
使用服务器公钥对S进行解密,得到摘要D’
如果D和D’相同,那么证明M确实是服务器发出的

4.代码:


package com.andy.encrypt;

import org.apache.commons.codec.binary.Base64;

import javax.crypto.Cipher;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;

/**
 * @author Andy
 * @version 0.0.1
 * @since 2023-09-15 17:32
 */
public class RSAUtil {
    static String privKey;
    static String publicKey;

    public static void main(String[] args) throws Exception {
        // 生成公钥和私钥
        genKeyPair();
        // 加密字符串
        String message = "Andy测试RES";
        System.out.println("明文:" + message);
        System.out.println("随机公钥为:" + publicKey);
        System.out.println("随机私钥为:" + privKey);

        String messageEn = encrypt(message, publicKey);
        System.out.println("公钥加密:" + messageEn);
        String messageDe = decrypt(messageEn, privKey);
        System.out.println("私钥解密:" + messageDe);

    }

    /**
     * 随机生成密钥对
     */
    public static void genKeyPair() throws NoSuchAlgorithmException {
        // KeyPairGenerator类用于生成公钥和私钥对,基于RSA算法生成对象
        KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA");
        // 初始化密钥对生成器,密钥大小为96-1024位
        keyPairGen.initialize(1024, new SecureRandom());
        // 生成一个密钥对,保存在keyPair中
        KeyPair keyPair = keyPairGen.generateKeyPair();

        privKey = new String(Base64.encodeBase64((keyPair.getPrivate().getEncoded())));
        publicKey = new String(Base64.encodeBase64(keyPair.getPublic().getEncoded()));

    }

    /**
     * RSA公钥加密
     */
    public static String encrypt(String str, String publicKey) throws Exception {
        // base64编码的公钥
        byte[] decoded = Base64.decodeBase64(publicKey);
        RSAPublicKey pubKey = (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(decoded));
        // RSA加密
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.ENCRYPT_MODE, pubKey);
        String outStr = Base64.encodeBase64String(cipher.doFinal(str.getBytes("UTF-8")));
        return outStr;
    }

    /**
     * RSA私钥解密
     */
    public static String decrypt(String str, String privateKey) throws Exception {
        // 64位解码加密后的字符串
        byte[] inputByte = Base64.decodeBase64(str.getBytes("UTF-8"));
        byte[] decoded = Base64.decodeBase64(privateKey);
        RSAPrivateKey priKey = (RSAPrivateKey) KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(decoded));
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.DECRYPT_MODE, priKey);
        return new String(cipher.doFinal(inputByte));
    }
}

5.结果分析
常见的应用级算法(下)_第7张图片

  • 加密解密实现完整还原
  • 必须用另一把钥匙解密,如果用公钥加密后再使用公钥解密,则失败

3.一致性hash及其应用

1.背景

负载均衡策略中,我们提到过源地址hash算法,让某些请求固定的落在对应的服务器上。这样可以解决会话信息保留的问题。同时,标准的hash,如果机器节点数发生变更。那么请求会被重新hash,打破了原始的设计初衷,怎么解决呢?一致性hash上场。

2. 原理

  • 以4台机器为例,一致性hash的算法如下:
  • 首先求出各个服务器的哈希值,并将其配置到0~232的圆上
  • 然后采用同样的方法求出存储数据的键的哈希值,也映射圆上
  • 从数据映射到的位置开始顺时针查找,将数据保存到找到的第一个服务器上
  • 如果到最大值仍然找不到,就取第一个。这就是为啥形象的称之为环

常见的应用级算法(下)_第8张图片
常见的应用级算法(下)_第9张图片

3.特性

  • 单调性(Monotonicity):单调性是指如果已经有一些请求通过哈希分派到了相应的服务器进行处理,又有新的服务器加入到系统中时候,应保证原有的请求可以被映射到原有的或者新的服务器中去,而不会被映射到原来的其它服务器上去。

  • 分散性(Spread):分布式环境中,客户端请求时可能只知道其中一部分服务器,那么两个客户端看到不同的部分,并且认为自己看到的都是完整的hash环,那么问题来了,相同的key可能被路由到不同服务器上去。以上图为例,加入client1看到的是1,4;client2看到的是2,3;那么2-4之间的key会被俩客户端重复映射到3,4上去。分散性反应的是这种问题的严重程度。

  • 平衡性(Balance):平衡性是指客户端hash后的请求应该能够分散到不同的服务器上去。一致性hash可以做到尽量分散,但是不能保证每个服务器处理的请求的数量完全相同。这种偏差称为hash倾斜。如果节点的分布算法设计不合理,那么平衡性就会收到很大的影响。

优化:增加虚拟节点可以优化hash算法,使得切段和分布更细化。即实际有m台机器,但是扩充n倍,在环上放置m*n个,那么均分后,key的段会分布更细化

常见的应用级算法(下)_第10张图片

4.实现

package com.andy.hash;

import java.util.SortedMap;
import java.util.TreeMap;

/**
 * @author Andy
 * @version 0.0.1
 * @since 2023-09-15 17:41
 * 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));
        }
    }

}

5.验证

常见的应用级算法(下)_第11张图片

  • 4台机器加入hash环

  • 模拟请求,根据hash值,准确调度到下游节点

  • 添加节点5,key取150

  • 再次发起请求

4.典型业务场景应用

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(".*(广告|广告词|中奖).*"));

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实现:

package com.andy.app;

import com.alibaba.fastjson2.JSON;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

/**
 * @author Andy
 * @version 0.0.1
 * @since 2023-09-15 17:49
 * 敏感词处理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);

    }

}

4.结果分析
常见的应用级算法(下)_第12张图片

  • 敏感词结构初始化后符合预期
  • 检测和长短匹配有结果
  • 匹配的敏感词列表正确

2.最优商品topk

1.背景:topk是一个典型的业务场景,除了最优商品,包括推荐排名、积分排名所有涉及到排名前k的地方都是该算法的应用场合

2.方案;

  • 方案一
    • 全局排序,将集合整体排序后,取出最大的k个值就是需要的结果
    • 这种方案最糟糕,我只需要排名前k的元素,其他n-k个的顺序我并不关心,但是运算过程中,都得跟着做了没用的排序操作
  • 方案二
    • 局部排序,既然全局没必要,那我只取前k个,后面的就没必要理会了
    • 冒泡排序在排序算法中可以胜任该操作。我们按最大值往上冒泡为例,只要执行k次冒泡,那前k名就可以确定。但是这种方案依然不是最优办法。因为我们需要的是前k名,那至于这k个,谁大谁小并不需要关心,排序依然是个浪费
  • 方案三
    • 最小堆,既然没必要排序,那我们就不排序
    • 先将前k个元素形成一个最小堆,后面的n-k个元素依次与堆顶比较,小则丢弃大则替换堆顶并调整堆。直到n个全部完成为止。最小堆是topk的经典解决方案

3.代码:

package com.andy.app;

import java.util.Arrays;

/**
 * @author Andy
 * @version 0.0.1
 * @since 2023-09-15 18:00
 */
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));
    }

}

常见的应用级算法(下)_第13张图片

4.结果分析

  • 最终获取k个值成功,符合要求
  • 中间不涉及排序问题

你可能感兴趣的:(算法,windows)