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

一 限流算法与应用

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

1.1 计数器

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

2. 实现

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

image

2. 实现

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. 结果分析

image

4. 优缺点
有效的挡住了外部的请求,保护了内部的服务不会过载内部服务匀速执行,无 法应对流量洪峰,无法做到弹性处理突发任务任务超时溢出时被丢弃。现实中 可能需要缓存队列辅助保持一段时间。
5. 应用
nginx中的限流是漏桶算法的典型应用,配置案例如下:

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. 概述

image

2. 实现

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. 结果分析

image

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

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分钟内请求数小于上 限,而且要求每个片段请求数也要小于上限。相当于将原来的计数周期做了多 个片段拆分。更为精细。

image

2. 实现
image

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. 结果分析

image

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

二 定时算法与应用

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

2.1 最小堆

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

2. 案例
介绍优先级队列+最小堆算法的实现原理:

class Task extends TimerTask {
    @Override
    public void run() {
        System.out.println("running...");
    }
}
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到队列

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

//最小堆排序
    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循环

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天为例,将任务的执行时间%12,根据得到的数值,放 置在时间轮上,小时指针沿着轮盘扫描,扫到的点取出任务执行:

image

image

  1. 实现
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();
    }
}
  1. 结果分析


    image

三 负载均衡算法

负载均衡,英文名称为Load Balance,其含义就是指将负载(工作任务)进行 平衡、分摊到多个操作单元上进行运行,例如FTP服务器、Web服务器、企业核 心应用服务器和其它主要任务服务器等,从而协同完成工作任务。既然涉及到 多个机器,就涉及到任务如何分发,这就是负载均衡算法问题。

3.1 轮询(RoundRobin)

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

2. 实现

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. 结果分析

image

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

3.2 随机(Random)

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

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

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. 结果分析

image

3.1 源地址哈希(Hash)

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

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

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. 结果分析

image

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
统计

image

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. 结果分析

image

3.5 加权随机(WR)

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

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

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. 结果分析


    image

3.6 最小连接数(LC)

1. 概述
LeastConnections,即统计当前机器的连接数,选最少的去响应新的请求。前 面的算法是站在请求维度,而最小连接数是站在机器的维度。
2. 实现
定义一个链接表记录机器的节点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. 结果分析

image

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

image

2. springcloud ribbon IRule

#设置负载均衡策略 eureka‐application‐service为调用的服务的名称
eureka‐applicationservice.
ribbon.NFLoadBalancerRuleClassName=com.netflix.loadbalancer.RandomRule

3. dubbo负载均衡
使用Service注解

@Service(loadbalance = "roundrobin",weight = 100)
image

四 加密算法与应用

4.1 散列

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

image

3. 应用(常用于密码存储,或文件指纹校验。)
网站用户注册后,密码经过MD5加密后的值,存储进DB。再次登录时,将用户 输入的密码按同样的方式加密,与数据库中的密文比对。这样即使数据库被破 解,或者开发人员可见,基于MD5的不可逆性,仍然不知道密码是什么。

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

image

4. 实现

<dependency>
  <groupId>commons‐codec</groupId>
  <artifactId>commons‐codec</artifactId>
  <version>1.14</version>
</dependency>
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. 结果分析

image

4.2 对称

1. 概述
加密与解密用的都是同一个秘钥,性能比非对称加密高很多。
2. 常见算法
常见的对称加密算法有 DES、3DES、AES
DES算法在POS、ATM、磁卡及智能卡(IC卡)、加油站、高速公路收费站等领域被广泛应用,以此来实现关键数
据的保密,如信用卡持卡人的PIN的加密传输,IC卡与POS间的双向认证、金融交易数据包的MAC校验等
3DES是DES加密算法的一种模式,是DES的一个更安全的变形。从DES向AES的过渡算法

image

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

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. 结果分析

image

4,3 非对称

1. 概述
非对称即加密与解密不是同一把钥匙,而是分成公钥和私钥。私钥在个人手 里,公钥公开。这一对钥匙一个用于加密,另一个用于解密。使用其中一个加 密后,则原始明文只能用对应的另一个密钥解密,即使最初用于加密的密钥
也不能用作解密。正是因为这种特性,所以称为非对称加密。

2. 常见算法
RSA、ElGamal、背包算法、Rabin(RSA的特例)、迪菲-赫尔曼密钥交换协议 中的公钥加密算法、椭圆曲线加密算法(英语:Elliptic Curve Cryptography, ECC)。使用最广泛的是RSA算法(发明者Rivest、Shmir和
Adleman姓氏首字母缩写)

image

3. 应用
最常见的,两点:https和数字签名。
严格意义上讲,https并非所有请求都使用非对称。基于性能考虑,https先使 用非对称约定一个key,后期使用该key进行对称加密和数据传输。

image

image

4. 实现

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. 结果分析

image

五 一致性hash及应用

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

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

2. 原理

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

image

image

3. 特性
image

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

5. 实现

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. 验证

image

六 典型业务场景应用

6.1 网站敏感词过滤

1. 场景
敏感词、文字过滤是一个网站必不可少的功能,高效的过滤算法是非常有必要 的。针对过滤首先想到的可能是这样:
2. 方案
方案一、使用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算法出场。
3. 概述
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为止。

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

4. java实现
先加入fastjson坐标,查看敏感词库结构要用到

<dependency>
  <groupId>com.alibaba</groupId>
  <artifactId>fastjson</artifactId>
  <version>1.2.70</version>
</dependency>
/**
 * 敏感词处理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. 方案

image

3. 实现
下面就用最小堆实现topk

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));
    }
}
  1. 结果分析


    image

相关文章

基于数据结构和算法的业务应用一

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,636评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,890评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,680评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,766评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,665评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,045评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,515评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,182评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,334评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,274评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,319评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,002评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,599评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,675评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,917评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,309评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,885评论 2 341

推荐阅读更多精彩内容