基于数据结构和算法的业务应用(二)

2020-12-08 10:23:11 浏览数 (1)

一 限流算法与应用

限流是对系统的一种保护措施。即限制流量请求的频率(每秒处理多少个请 求)。一般来说,当请求流量超过系统的瓶颈,则丢弃掉多余的请求流量,保 证系统的可用性。即要么不放进来,放进来的就保证提供服务。

1.1 计数器

1. 概述

计数器采用简单的计数操作,到一段时间节点后自动清零。

2. 实现

代码语言:txt复制
public class Counter {
    public static void main(String[] args) {
        //计数器,这里用信号量实现
        final Semaphore semaphore = new Semaphore(3);
        //定时器,到点清零
        ScheduledExecutorService service = Executors.newScheduledThreadPool(1);
        service.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                semaphore.release(3);
            }
        }, 3000, 3000, TimeUnit.MILLISECONDS);
        //模拟无数个请求从天而降
        while (true) {
            try {
                //判断计数器
                semaphore.acquire();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //如果准许响应,打印一个ok
            System.out.println("ok");
        }
    }
}

3. 结果分析

3个ok一组呈现,到下一个计数周期之前被阻断

4. 优缺点

实现起来非常简单。控制力度太过于简略,假如1s内限制3次,那么如果3次在 前100ms内已经用完,后面的900ms将只能处于阻塞状态,白白浪费掉。

5. 应用

使用计数器限流的场景较少,因为它的处理逻辑不够灵活。最常见的可能在 web的登录密码验证,输入错误次数冻结一段时间的场景。如果网站请求使用 计数器,那么恶意攻击者前100ms吃掉流量计数,使得后续正常的请求被全部 阻断,整个服务很容易被搞垮。

1.2 漏桶算法

1. 概述

漏桶算法将请求缓存在桶中,服务流程匀速处理。超出桶容量的部分丢弃。漏 桶算法主要用于保护内部的处理业务,保障其稳定有节奏的处理请求,但是无 法根据流量的波动弹性调整响应能力。现实中,类似容纳人数有限的服务大厅 开启了固定的服务窗口。

2. 实现

代码语言:txt复制
public class Barrel {
    public static void main(String[] args) {
//桶,用阻塞队列实现,容量为3
        final LinkedBlockingQueue<Integer> que = new LinkedBlockingQueue(3);
//定时器,相当于服务的窗口,2s处理一个
        ScheduledExecutorService service = Executors.newScheduledThreadPool(1);
        service.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                int v = que.poll();
                System.out.println("处理:"   v);
            }
        }, 2000, 2000, TimeUnit.MILLISECONDS);
//无数个请求,i 可以理解为请求的编号
        int i = 0;
        while (true) {
            i  ;
            try {
                System.out.println("put:"   i);
//如果是put,会一直等待桶中有空闲位置,不会丢弃
// que.put(i);
//等待1s如果进不了桶,就溢出丢弃
                que.offer(i, 1000, TimeUnit.MILLISECONDS);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

3. 结果分析

4. 优缺点

有效的挡住了外部的请求,保护了内部的服务不会过载内部服务匀速执行,无 法应对流量洪峰,无法做到弹性处理突发任务任务超时溢出时被丢弃。现实中 可能需要缓存队列辅助保持一段时间。

5. 应用

nginx中的限流是漏桶算法的典型应用,配置案例如下:

代码语言:txt复制
http {
    #$binary_remote_addr 表示通过remote_addr这个标识来做key,也就是限制同一客户端ip地址。
    #zone=one:10m 表示生成一个大小为10M,名字为one的内存区域,用来存储访问的频次信息。
    #rate=1r/s 表示允许相同标识的客户端每秒1次访问
    limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
    server {
        location /limited/ {
        #zone=one 与上面limit_req_zone 里的name对应。
        #burst=5 缓冲区,超过了访问频次限制的请求可以先放到这个缓冲区内,类似代码中的队列长度。
        #nodelay 如果设置,超过访问频次而且缓冲区也满了的时候就会直接返回503,如果没有设置,则所有请求
        会等待排队,类似代码中的put还是offer。
        limit_req zone=one burst=5 nodelay;
    }
}

1.3 令牌桶

1. 概述

2. 实现

代码语言:txt复制
public class Token {
    public static void main(String[] args) throws InterruptedException {
//令牌桶,信号量实现,容量为3
        final Semaphore semaphore = new Semaphore(3);
//定时器,1s一个,匀速颁发令牌
        ScheduledExecutorService service = Executors.newScheduledThreadPool(1);
        service.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                if (semaphore.availablePermits() < 3) {
                    semaphore.release();
                }
// System.out.println("令牌数:" semaphore.availablePermits());
            }
        }, 1000, 1000, TimeUnit.MILLISECONDS);
//等待,等候令牌桶储存
        Thread.sleep(5);
//模拟洪峰5个请求,前3个迅速响应,后两个排队
        for (int i = 0; i < 5; i  ) {
            semaphore.acquire();
            System.out.println("洪峰:"   i);
        }
//模拟日常请求,2s一个
        for (int i = 0; i < 3; i  ) {
            Thread.sleep(1000);
            semaphore.acquire();
            System.out.println("日常:"   i);
            Thread.sleep(1000);
        }
//再次洪峰
        for (int i = 0; i < 5; i  ) {
            semaphore.acquire();
            System.out.println("洪峰:"   i);
        }
//检查令牌桶的数量
        for (int i = 0; i < 5; i  ) {
            Thread.sleep(2000);
            System.out.println("令牌剩余:"   semaphore.availablePermits());
        }
    }
}

3. 结果分析

4. 应用

springcloud中gateway可以配置令牌桶实现限流控制,案例如下:

代码语言:txt复制
cloud:
  gateway:
    routes:
    ‐ id: limit_route
      uri: http://localhost:8080/test
      filters:
      ‐ name: RequestRateLimiter
        args:
          #限流的key,ipKeyResolver为spring中托管的Bean,需要扩展KeyResolver接口
          key‐resolver: '#{@ipResolver}'
          #令牌桶每秒填充平均速率,相当于代码中的发放频率
          redis‐rate‐limiter.replenishRate: 1
          #令牌桶总容量,相当于代码中,信号量的容量
          redis‐rate‐limiter.burstCapacity: 3

1.4 滑动窗口

1. 概述

滑动窗口可以理解为细分之后的计数器,计数器粗暴的限定1分钟内的访问次 数,而滑动窗口限流将1分钟拆为多个段,不但要求整个1分钟内请求数小于上 限,而且要求每个片段请求数也要小于上限。相当于将原来的计数周期做了多 个片段拆分。更为精细。

2. 实现

代码语言:txt复制
package com.yungtay.mpu.util;

public class Window {
    //整个窗口的流量上限,超出会被限流
    final int totalMax = 5;
    //每片的流量上限,超出同样会被拒绝,可以设置不同的值
    final int sliceMax = 5;
    //分多少片
    final int slice = 3;
    //窗口,分3段,每段1s,也就是总长度3s
    final LinkedList<Long> linkedList = new LinkedList<>();
    //计数器,每片一个key,可以使用HashMap,这里为了控制台保持有序性和可读性,采用TreeMap
    Map<Long, AtomicInteger> map = new TreeMap();
    //心跳,每1s跳动1次,滑动窗口向前滑动一步,实际业务中可能需要手动控制滑动窗口的时机。
    ScheduledExecutorService service = Executors.newScheduledThreadPool(1);

    //获取key值,这里即是时间戳(秒)
    private Long getKey() {
        return System.currentTimeMillis() / 1000;
    }

    public Window() {
//初始化窗口,当前时间指向的是最末端,前两片其实是过去的2s
        Long key = getKey();
        for (int i = 0; i < slice; i  ) {
            linkedList.addFirst(key‐i);
            map.put(key‐i, new AtomicInteger(0));
        }
//启动心跳任务,窗口根据时间,自动向前滑动,每秒1步
        service.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                Long key = getKey();
//队尾添加最新的片
                linkedList.addLast(key);
                map.put(key, new AtomicInteger());
//将最老的片移除
                map.remove(linkedList.getFirst());
                linkedList.removeFirst();
                System.out.println("step:"   key   ":"   map);
                ;
            }
        }, 1000, 1000, TimeUnit.MILLISECONDS);
    }

    //检查当前时间所在的片是否达到上限
    public boolean checkCurrentSlice() {
        long key = getKey();
        AtomicInteger integer = map.get(key);
        if (integer != null) {
            return integer.get() < sliceMax;
        }
//默认允许访问
        return true;
    }

    //检查整个窗口所有片的计数之和是否达到上限
    public boolean checkAllCount() {
        return map.values().stream().mapToInt(value ‐ > value.get()).sum() < totalMax;
    }

    //请求来临....
    public void req() {
        Long key = getKey();
//如果时间窗口未到达当前时间片,稍微等待一下
//其实是一个保护措施,放置心跳对滑动窗口的推动滞后于当前请求
        while (linkedList.getLast() < key) {
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
//开始检查,如果未达到上限,返回ok,计数器增加1
//如果任意一项达到上限,拒绝请求,达到限流的目的
//这里是直接拒绝。现实中可能会设置缓冲池,将请求放入缓冲队列暂存
        if (checkCurrentSlice() && checkAllCount()) {
            map.get(key).incrementAndGet();
            System.out.println(key   "=ok:"   map);
        } else {
            System.out.println(key   "=reject:"   map);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Window window = new Window();
//模拟10个离散的请求,相对之间有200ms间隔。会造成总数达到上限而被限流
        for (int i = 0; i < 10; i  ) {
            Thread.sleep(200);
            window.req();
        }
//等待一下窗口滑动,让各个片的计数器都置零
        Thread.sleep(3000);
//模拟突发请求,单个片的计数器达到上限而被限流
        System.out.println("‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐");
        for (int i = 0; i < 10; i  ) {
            window.req();
        }
    }
}

3. 结果分析

4. 结果应用

滑动窗口算法,在tcp协议发包过程中被使用。在web现实场景中,可以将流量 控制做更细化处理,解决计数器模型控制力度太粗暴的问题。

二 定时算法与应用

系统或者项目中难免会遇到各种需要自动去执行的任务,实现这些任务的手段 也多种多样,如操作系统的crontab,spring框架的quartz,java的Timer和 ScheduledThreadPool都是定时任务中的典型手段。

2.1 最小堆

1. 概述

Timer是java中最典型的基于优先级队列 最小堆实现的定时器,内部维护一个 存放定时任务的优先级队列,该优先级队列使用了最小堆排序。当我们调用 schedule方法的时候,一个新的任务被加入queue,堆重排,始终保持堆顶是 执行时间最小(即最近马上要执行)的。同时,内部相当于起了一个线程不断 扫描队列,从队列中依次获取堆顶元素执行,任务得到调度。

2. 案例

介绍优先级队列 最小堆算法的实现原理:

代码语言:txt复制
class Task extends TimerTask {
    @Override
    public void run() {
        System.out.println("running...");
    }
}
代码语言:txt复制
public class TimerDemo {
    public static void main(String[] args) {
        Timer t = new Timer();
//在1秒后执行,以后每2秒跑一次
        t.schedule(new Task(), 1000, 2000);
    }
}

3. 源码分析

新加任务时,t.schedule方法会add到队列

代码语言:txt复制
void add(TimerTask task) {
// Grow backing store if necessary
        if (size   1 == queue.length)
            queue = Arrays.copyOf(queue, 2 * queue.length);
        queue[  size] = task;
        fixUp(size);
    }

add实现了容量维护,不足时扩容,同时将新任务追加到队列队尾,触发堆排 序,始终保持堆顶元素最小线。

代码语言:txt复制
//最小堆排序
    private void fixUp(int k) {
        while (k > 1) {
//k指针指向当前新加入的节点,也就是队列的末尾节点,j为其父节点
            int j = k >> 1;
//如果新加入的执行时间比父节点晚,那不需要动
            if (queue[j].nextExecutionTime <= queue[k].nextExecutionTime)
                break;
//如果大于其父节点,父子交换
            TimerTask tmp = queue[j]; queue[j] = queue[k]; queue[k] = tmp;
//交换后,当前指针继续指向新加入的节点,继续循环,知道堆重排合格
            k = j;
        }
    }

线程调度中的run,主要调用内部mainLoop()方法,使用while循环

代码语言:txt复制
private void mainLoop() {
        while (true) {
            try {
                TimerTask task;
                boolean taskFired;
                synchronized(queue) {
//...
// Queue nonempty; look at first evt and do the right thing
                    long currentTime, executionTime;
                    task = queue.getMin();
                    synchronized(task.lock) {
//...
//当前时间
                        currentTime = System.currentTimeMillis();
//要执行的时间
                        executionTime = task.nextExecutionTime;
//判断是否到了执行时间
                        if (taskFired = (executionTime<=currentTime)) {
//判断下一次执行时间,单次的执行完移除
//循环的修改下次执行时间
                            if (task.period == 0) { // Non‐repeating, remove
                                queue.removeMin();
                                task.state = TimerTask.EXECUTED;
                            } else { // Repeating task, reschedule
//下次时间的计算有两种策略
//1.period是负数,那下一次的执行时间就是当前时间‐period
//2.period是正数,那下一次就是该任务本次的执行时间 period
//注意!这两种策略大不相同。因为Timer是单线程的
//如果是1,那么currentTime是当前时间,就受任务执行长短影响
//如果是2,那么executionTime是绝对时间戳,与任务长短无关
                                queue.rescheduleMin(
                                        task.period<0 ? currentTime ‐ task.period
: executionTime   task.period);
                            }
                        }
                    }
//不到执行时间,等待
                    if (!taskFired) // Task hasn't yet fired; wait
                        queue.wait(executionTime ‐ currentTime);
                }
//到达执行时间,run!
                if (taskFired) // Task fired; run it, holding no locks
                    task.run();
            } catch(InterruptedException e) {
            }
        }
    }
  1. 应用本节使用Timer为了介绍算法原理,但是Timer已过时,实际应用中推荐使用 ScheduledThreadPoolExecutor(同样内部使用DelayedWorkQueue和最小堆排序) Timer是单线程,一旦一个失败或出现异常,将打断全部任务队列,线程池不会 Timer在jdk1.3 ,而线程池需要jdk1.5 2.2 时间轮1. 概述 时间轮是一种更为常见的定时调度算法,各种操作系统的定时任务调度, linux crontab,基于java的通信框架Netty等。其灵感来源于我们生活中的时 钟。轮盘实际上是一个头尾相接的环状数组,数组的个数即是插槽数,每个插 槽中可以放置任务。以1天为例,将任务的执行时间,根据得到的数值,放 置在时间轮上,小时指针沿着轮盘扫描,扫到的点取出任务执行:undefined undefined2. 实现public class RoundTask { //延迟多少秒后执行 int delay; //加入的序列号,只是标记一下加入的顺序 int index; public RoundTask(int index, int delay) { this.index = index; this.delay = delay; } void run() { System.out.println("task " index " start , delay = " delay); } @Override public String toString() { return String.valueOf(index "=" delay); } }时间轮算法:public class RoundDemo { //小轮槽数 int size1 = 10; //大轮槽数 int size2 = 5; //小轮,数组,每个元素是一个链表 LinkedList<RoundTask>[] t1 = new LinkedList[size1]; //大轮 LinkedList<RoundTask>[] t2 = new LinkedList[size2]; //小轮计数器,指针跳动的格数,每秒加1 final AtomicInteger flag1 = new AtomicInteger(0); //大轮计数器,指针跳动个格数,即每10s加1 final AtomicInteger flag2 = new AtomicInteger(0); //调度器,拖动指针跳动 ScheduledExecutorService service = Executors.newScheduledThreadPool(2); public RoundDemo() { //初始化时间轮 for (int i = 0; i < size1; i ) { t1[i] = new LinkedList<>(); } for (int i = 0; i < size2; i ) { t2[i] = new LinkedList<>(); } } //打印时间轮的结构,数组 链表 void print() { System.out.println("t1:"); for (int i = 0; i < t1.length; i ) { System.out.println(t1[i]); } System.out.println("t2:"); for (int i = 0; i < t2.length; i ) { System.out.println(t2[i]); } } //添加任务到时间轮 void add(RoundTask task) { int delay = task.delay; if (delay < size1) { //10以内的,在小轮 t1[delay].addLast(task); } else { //超过小轮的放入大轮,槽除以小轮的长度 t2[delay / size1].addLast(task); } } void startT1() { //每秒执行一次,推动时间轮旋转,取到任务立马执行 service.scheduleAtFixedRate(new Runnable() { @Override public void run() { int point = flag1.getAndIncrement() % size1; System.out.println("t1 ‐‐‐‐‐> slot " point); LinkedList<RoundTask> list = t1[point]; if (!list.isEmpty()) { //如果当前槽内有任务,取出来,依次执行,执行完移除 while (list.size() != 0) { list.getFirst().run(); list.removeFirst(); } } } }, 0, 1, TimeUnit.SECONDS); } void startT2() { //每10秒执行一次,推动时间轮旋转,取到任务下方到t1 service.scheduleAtFixedRate(new Runnable() { @Override public void run() { int point = flag2.getAndIncrement() % size2; System.out.println("t2 =====> slot " point); LinkedList<RoundTask> list = t2[point]; if (!list.isEmpty()) { //如果当前槽内有任务,取出,放到定义的小轮 while (list.size() != 0) { RoundTask task = list.getFirst(); //放入小轮哪个槽呢?小轮的槽按10取余数 t1[task.delay % size1].addLast(task); //从大轮中移除 list.removeFirst(); } } } }, 0, 10, TimeUnit.SECONDS); } public static void main(String[] args) { RoundDemo roundDemo = new RoundDemo(); //生成100个任务,每个任务的延迟时间随机 for (int i = 0; i < 100; i ) { roundDemo.add(new RoundTask(i, new Random().nextInt(50))); } //打印,查看时间轮任务布局 roundDemo.print(); //启动大轮 roundDemo.startT2(); //小轮启动 roundDemo.startT1(); } }
  2. 结果分析 三 负载均衡算法负载均衡,英文名称为Load Balance,其含义就是指将负载(工作任务)进行 平衡、分摊到多个操作单元上进行运行,例如FTP服务器、Web服务器、企业核 心应用服务器和其它主要任务服务器等,从而协同完成工作任务。既然涉及到 多个机器,就涉及到任务如何分发,这就是负载均衡算法问题。3.1 轮询(RoundRobin)1. 概述undefined轮询即排好队,一个接一个。前面调度算法中用到的时间片轮转,就是一种典 型的轮询。但是前面使用数组和下标轮询实现。这里尝试手动写一个双向链表 形式实现服务器列表的请求轮询算法。

2. 实现

代码语言:txt复制
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. 结果分析

4. 优缺点

实现简单,机器列表可以自由加减,且时间复杂度为o(1)

无法针对节点做偏向性定制,节点处理能力的强弱无法区分对待

3.2 随机(Random)

1. 概述

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

2. 实现

定义一个数组,在数组长度内取随机数,作为其下标即可。非常简单

代码语言:txt复制
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. 结果分析

3.1 源地址哈希(Hash)

1. 概述

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

2. 实现

使用HashMap可以实现请求值到对应节点的服务,其查找时的时间复杂度为 o(1)。固定一种算法,将请求映射到key上即可。举例,将请求的来源ip末 尾,按机器数取余作为key:

代码语言:txt复制
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. 结果分析

2.4 加权轮询(WRR)

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

统计

代码语言:txt复制
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();
        }
    }
}

3. 结果分析

3.5 加权随机(WR)

1. 概述

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

2. 实现

设计思路依然相同,根据权值大小,生成不同数量的节点,节点排队后,随机获取。这里的数据结构主要涉及到随

机的读取,所以优选为数组。

与随机相同的是,同样为数组随机筛选,不同在于,随机只是每台机器1个,加权后变为多个。

代码语言:txt复制
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();
        }
    }
}
  1. 结果分析 3.6 最小连接数(LC)1. 概述undefinedLeastConnections,即统计当前机器的连接数,选最少的去响应新的请求。前 面的算法是站在请求维度,而最小连接数是站在机器的维度。 2. 实现undefined定义一个链接表记录机器的节点id和机器连接数量的计数器。内部采用最小堆 做排序处理,响应时取堆顶节点即是最小连接数。public class LC { //节点列表 Node[] nodes; //初始化节点,创建堆 // 因为开始时各节点连接数都为0,所以直接填充数组即可 LC(String ns) { String[] ns1 = ns.split(","); nodes = new Node[ns1.length 1]; for (int i = 0; i < ns1.length; i ) { nodes[i 1] = new Node(ns1[i]); } } //节点下沉,与左右子节点比对,选里面最小的交换 //目的是始终保持最小堆的顶点元素值最小 //i:要下沉的顶点序号 void down(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].get() < nodes[i].get()) { flag = left; } //判断右子 if (right < nodes.length && nodes[flag].get() > nodes[right].get()) { flag = right; } //两者中最小的与本节点不相等,则交换 if (flag != i) { Node temp = nodes[i]; nodes[i] = nodes[flag]; nodes[flag] = temp; i = flag; } else { //否则相等,堆排序完成,退出循环即可 break; } } } //请求。非常简单,直接取最小堆的堆顶元素就是连接数最少的机器 void request() { System.out.println("‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐"); //取堆顶元素响应请求 Node node = nodes[1]; System.out.println(node.name " accept"); //连接数加1 node.inc(); //排序前的堆 System.out.println("before:" Arrays.toString(nodes)); //堆顶下沉 down(1); //排序后的堆 System.out.println("after:" Arrays.toString(nodes)); } public static void main(String[] args) { //假设有7台机器 LC lc = new LC("a,b,c,d,e,f,g"); //模拟10个请求连接 for (int i = 0; i < 10; i ) { lc.request(); } } class Node { //节点标识 String name; //计数器 AtomicInteger count = new AtomicInteger(0); public Node(String name) { this.name = name; } //计数器增加 public void inc() { count.getAndIncrement(); } //获取连接数 public int get() { return count.get(); } @Override public String toString() { return name "=" count; } } }3. 结果分析undefined 3.7 应用案例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; }undefined2. springcloud ribbon IRule #设置负载均衡策略 eureka‐application‐service为调用的服务的名称 eureka‐applicationservice. ribbon.NFLoadBalancerRuleClassName=com.netflix.loadbalancer.RandomRule3. dubbo负载均衡undefined使用Service注解@Service(loadbalance = "roundrobin",weight = 100) 四 加密算法与应用4.1 散列1. 概述undefined严格来讲这不算是一种加密,而应该叫做信息摘要算法。该算法使用散列函数 把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来。通过数 据打乱混合,重新创建一个叫做 散列值undefined2. 常见算法undefinedundefined3. 应用(常用于密码存储,或文件指纹校验。)undefined网站用户注册后,密码经过MD5加密后的值,存储进DB。再次登录时,将用户 输入的密码按同样的方式加密,与数据库中的密文比对。这样即使数据库被破 解,或者开发人员可见,基于MD5的不可逆性,仍然不知道密码是什么。

其次是文件校验场景。例如从某站下载的文件(尤其是大文件,比如系统镜像 iso),官方网站都会放置一个签名(可能是MD5,或者SHA),当用户拿到文 件后,可以本地执行散列算法与官网签名比对是否一致,来判断文件是否被篡 改。如ubuntu20.04的镜像:

4. 实现

代码语言:txt复制
<dependency>
  <groupId>commons‐codec</groupId>
  <artifactId>commons‐codec</artifactId>
  <version>1.14</version>
</dependency>
代码语言:txt复制
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 = "架构师训练营";
        System.out.println(name);
        System.out.println(md5(name));
        System.out.println(commonsMd5(name));
        System.out.println(sha(name));
        System.out.println(commonsSha(name));
    }
}

5. 结果分析

4.2 对称

1. 概述

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

2. 常见算法

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

DES算法在POS、ATM、磁卡及智能卡(IC卡)、加油站、高速公路收费站等领域被广泛应用,以此来实现关键数

据的保密,如信用卡持卡人的PIN的加密传输,IC卡与POS间的双向认证、金融交易数据包的MAC校验等

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

3. 应用

常用于对效率要求较高的实时数据加密通信。

4. 实现

代码语言:txt复制
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 = "架构师训练营";
        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算法(发明者Rivest、Shmir和

Adleman姓氏首字母缩写)

3. 应用

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

严格意义上讲,https并非所有请求都使用非对称。基于性能考虑,https先使 用非对称约定一个key,后期使用该key进行对称加密和数据传输。

4. 实现

代码语言:txt复制
public class RSAUtil {
    static String privKey;
    static String publicKey;

    public static void main(String[] args) throws Exception {
//生成公钥和私钥
        genKeyPair();
//加密字符串
        String message = "架构师训练营";
        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. 结果分析

五 一致性hash及应用

1. 背景

负载均衡策略中,我们提到过源地址hash算法,让某些请求固定的落在对应的 服务器上。这样可以解决会话信息保留的问题。

同时,标准的hash,如果机器节点数发生变更。那么请求会被重新hash,打破 了原始的设计初衷,怎么解决呢?一致性hash上场。

2. 原理

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

3. 特性

4. 优化

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

5. 实现

代码语言:txt复制
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());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. 验证

六 典型业务场景应用

6.1 网站敏感词过滤

1. 场景

敏感词、文字过滤是一个网站必不可少的功能,高效的过滤算法是非常有必要 的。针对过滤首先想到的可能是这样:

2. 方案

方案一、使用java里的String contains,逐个遍历敏感词:

代码语言:txt复制
String[] s = "广告,广告词,中奖".split(",");
String text = "讨厌的广告词";
boolean flag = false;
for (String s1 : s) {
  if (text.contains(s1)){
    flag = true;
    break;
  }
}
System.out.println(flag);

方案二、正则表达式:

代码语言:txt复制
System.out.println(text.matches(".*(广告|广告词|中奖).*"));

其实无论采取哪个方法,基本是换汤不换药。都是整体字符匹配,效率值得商 榷。那怎么办呢?DFA算法出场。

3. 概述

DFA即Deterministic Finite Automaton,也就是确定有穷自动机,它是是通 过event和当前的state得到下一个state,即event state=nextstate。

对照到以上案例,查找和停止查找是动作,找没找到是状态,每一步的查找和 结果决定下一步要不要继续。DFA算法在敏感词上应用的关键是构建敏感词 库,如果我们把以上案例翻译成json表达如下:

代码语言:txt复制
{
    "isEnd": 0,
    "广": {
    "isEnd": 0,
    "告": {
    "isEnd": 1,
    "词": {
    "isEnd": 1
    }
    }
    },
    "中": {
    "isEnd": 0,
    "奖": {
    "isEnd": 1
    }
    }
}

查找过程如下:首先把text按字拆分,逐个字查找词库的key,先从“讨”开 始,没有就下一个字“厌”,直到“广”,找到就判断isEnd,如果为1,说明匹配 成功包含敏感词,如果为0,那就继续匹配“告”,直到isEnd=1为止。

匹配策略上,有两种。最小和最大匹配。最小则匹配【广告】,最大则需要匹 配到底【广告词】

4. java实现

先加入fastjson坐标,查看敏感词库结构要用到

代码语言:txt复制
<dependency>
  <groupId>com.alibaba</groupId>
  <artifactId>fastjson</artifactId>
  <version>1.2.70</version>
</dependency>
代码语言:txt复制
/**
 * 敏感词处理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);
    }
}

6.2 最优商品topk

1. 背景

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

topk即得到一个集合后,筛选里面排名前k个数值。问题看似简单,但是里面 的数据结构和算法体现着对解决方案性能的思索和深度挖掘。到底有几种方 法,这些方案里蕴含的优化思路究竟是怎么样的?这节来讨论

2. 方案

3. 实现

下面就用最小堆实现topk

代码语言:txt复制
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));
    }
}

4. 结果分析

0 人点赞