(粉丝福利局)java面试-场景题汇总

作为9年经验的开发,现在的面试据说已经不仅仅是八股文了,本身到这个阶段对我而言,单纯的技术已经没有意义,还是要为业务去选择合适的技术,摘录一些我认为高频或者实用的场景设计题;【只开放7天哦】
个人的微信公众号:印象人生;【后面打算同步文章到微信公众号,欢迎来踩】

1、消息推送中的已读/未读消息设计

“站内信”有两个基本功能:
点到点的消息传送。用户给用户发送站内信,管理员给用户发送站内信。
点到面的消息传送。管理员给用户(指定满足某一条件的用户群)群发消息
这两个功能实现起来也很简单:
(粉丝福利局)java面试-场景题汇总_第1张图片

只需要设计一个消息内容表和一个用户通知表,当创建一条系统通知后,数据插入到消息内容表。消息内容包含了发送渠道,根据发送渠道决定后续动作。
如果是站内渠道,在插入消息内容后异步的插入记录到用户通知表。

这个方案看起来没什么问题,但实际上,我们把所有用户通知的消息全部放在一个表里面,如果有 10W 个用户,那么同样的消息需要存储 10W 条。

很明显,会带来两个问题:

  • 随着用户量的增加,发送一次消息需要插入到数据库中的数据量会越来越大,导致耗时会越来越长;
  • 用户通知表的数据量会非常大,对未读消息的查询效率会严重下降;

随着用户量的增加,发送一次消息需要插入到数据库中的数据量会越来越大,导致耗时会越来越长;
用户通知表的数据量会非常大,对未读消息的查询效率会严重下降;
所以上面这种方案很明显行不通,要解决这两个问题,我有两个参考解决思路。
(粉丝福利局)java面试-场景题汇总_第2张图片

  • 第一个方式(如图),先取消用户通知表, 避免在发送平台消息的时候插入大量重复数据问题。
    其次增加一个“message_offset”站内消息进度表,每个用户维护一个消息消费的进度Offset。
    每个用户去获取未读消息的时候,只需要查询大于当前维护的 msg_id_offset 的数据即可。
    在这种设计方式中,即便我们发送给 10W 人,也只需要在消息内容表里面插入一条记录即可。在性能上和数据量上都有较大的提升。
  • 第二种方式,和第一种方式类似,使用 Redis 中的Set 集合来保存已经读取过的消息id
    使用 userid_read_message 作为key,这样就可以为每个用户保存已经读取过的所有 消息的id
    当用户读取了未读消息后, 就直接在redis 的已读消息id 的set 中新增一条记录。这样,在已经得知到已读消息的数量和具体消息 id 的情况下,我们可以直接使用消息 id 来查询没有消费过的数据。

2、分布式ID策略

1.为什么需要它

分布式ID是确保系统中的每个数据项都具有全局唯一的标识,满足系统需求并提高开发效率的关键。
在分布式系统中,数据可能分布在多个服务器、数据库或其他存储设备上。为了确保系统中的每个数据项都具有全局唯一的标识,需要使用分布式ID。随着系统的数据量越来越大,单机MySQL已经无法满足需求,需要进行分库分表。数据库的自增主键已经无法满足生成的主键唯一性,此时就需要生成分布式ID。使用分布式ID可以解决数据分布和唯一标识的问题,满足系统需求并提高开发效率。

分布式ID需要满足的要求包括【重要】

  • 全局唯一性:ID在整个系统中是唯一的,不会出现重复的情况。
  • 趋势递增:有利于保证写入的效率,尤其是在使用像MySQL数据库的InnoDB存储引擎时,其使用聚集索引和有序的主键ID。
  • 单调递增:保证下一个ID大于上一个ID,这种情况可以保证事务版本号、排序等特殊需求实现。
  • 信息安全:ID递增但是不规则是最好的,如果ID是连续的,容易被恶意爬取数据。
  • 数量够用:根据公司的具体业务来评估在一定时间范围内会消耗多少个ID。
  • 安全性:分布式ID生成的算法应该是安全的,防止恶意用户预测或推断出其他ID,甚至恶意篡改。
  • 有序性:通常建议分布式ID具备有序性,不过需要根据实际的业务来决定。 常见的分布式ID生成方案包括以下几个;

UUID、数据库自增、号段模式、Redis实现、雪花算法(SnowFlake)、百度Uidgenerator、美团Leaf、滴滴TinyID等。
每种方案都有其优缺点,选择最适合的方案需要结合具体的业务场景和需求。
最终分布式ID肯定是要做DB存储的,这就意味着要满足索引或主键的一些要求

2.mysql的页分裂

分布式ID最终是要落库的,InnoDB存储引擎使用聚簇索引来组织表中的数据。
聚簇索引定义了数据在磁盘上的物理存储顺序。数据实际上是存储在索引的叶子节点上的。这意味着,当你通过主键查询数据时,InnoDB可以直接在索引中找到相应的数据,而无需进行额外的磁盘I/O操作。这就是所谓的“覆盖索引”查询,它可以大大提高查询性能。
然而,聚簇索引的一个潜在缺点是它可能导致数据页分裂。

那什么是数据分页呢? lnnoDB 不是按行来操作数据的,它可操作的最小单位是页,页加载进内存后才会通过扫描页来获取行记录比如查询 id=1,是获取 1所在的数据页,加载进内存后取出1这一行。

页的默认大小为16KB,64个连续的数据页称为一个extent(区),64个页组成一个区,所以区的大小为1MB(16*64=1024),连续的256个数据区称为一组数据区。

数据页分裂是一个相对昂贵的操作,因为它涉及到数据的移动和可能的磁盘I/O操作。

当一个数据页中的数据行太多放不下的时候就会生成一个新的数据页来存储, 同时使用双向链表来相连; 使用索引时,一个最基本的条件是后面数据中的数据行的主键值要大于前一个数据页中数据行的主键值。

如果我们设置的主键是乱序的, 就有可能会导致数据页中的主键值大小不能满足索引使用条件。所以就会要求主键必须有序。
如果值有序,但是插入的数据不是递增的,此时就会产生页分裂, 如下图的数据页:
(粉丝福利局)java面试-场景题汇总_第3张图片
可以发现后面数据页里的主键值比前一个数据页的主键值小, 里面的数据就会进行数据挪动,那这就是我们所说的页分裂。(粉丝福利局)java面试-场景题汇总_第4张图片
通过页分裂,我们只要将主键为2的数据行与主键值为4的数据行互相挪动一下位置,就可以保证后面一个数据页的主键值比前一个数据页中的主键值大了,

为了更清晰地理解页分裂,我们可以将其步骤概括为:

检查空间:当InnoDB尝试插入新的数据时,它首先会检查当前数据页是否有足够的空间来容纳新数据。
分裂决策:如果当前页没有足够的空间,InnoDB就会决定进行页分裂。它会创建一个新的数据页,并将原数据页中的一部分数据(通常是中位数附近的数据)移动到新页中,以确保新插入的数据可以放在合适的位置。
数据移动:实际的数据移动过程涉及将原数据页中的一部分行复制到新页中,并更新相关的索引和指针以反映这种变化。这可能涉及到多个数据页的调整,以确保数据的连续性和索引的正确性。
更新链接:InnoDB会更新数据页之间的双向链表指针,以确保分裂后的数据页仍然按照正确的顺序链接在一起。同时,它也会更新索引结构以反映新数据页的存在和位置。
插入新数据:一旦页分裂完成,InnoDB就可以在新的位置插入新数据了。这通常是在分裂后留下的空间中进行的。
需要注意的是,页分裂不仅发生在插入操作中。当更新操作导致行的大小增加,使得当前页无法容纳时,也可能发生页分裂。同样地,删除操作可能导致页的合并,以释放空间并提高存储效率。

为了减少页分裂的频率和提高写入性能,可以采取以下策略:

  • 有序插入:如您所述,通过保持插入数据的顺序性(如使用自增主键),可以减少页分裂的次数。这是因为有序插入可以使得新数据总是被添加到索引的末尾,从而避免了在中间位置插入数据所需的复杂操作。
  • 批量插入:将多个插入操作组合成一个批量插入操作可以减少单个插入操作的开销,并提高整体的写入性能。这可以通过使用InnoDB的批量插入优化来实现。
  • 调整页大小:虽然InnoDB的默认页大小是16KB,但在某些情况下,调整页大小可能有助于优化性能。然而,这需要谨慎操作,因为页大小的更改会影响到整个数据库的存储和性能特性。
  • 优化索引设计:通过合理设计索引和使用覆盖索引等技术,可以减少不必要的数据页访问和I/O操作,从而提高写入性能并减少页分裂的可能性。

所以,其结论就是主键值最好是有序的, 不仅可以不用页分裂,还能充分使用到索引。否则必须进行页分裂来保证索引的使用。

3.根据场景选择

(粉丝福利局)java面试-场景题汇总_第5张图片

2.1 UUID

UUID (Universally Unique Identifier),通用唯一识别码的缩写。UUID的标准型式包含32个16进制数字,标准格式为:8-4-4-4-12,总长度为36个字符(包括4个-号)
优点
(1)技术实现简单,一行代码即可。
(2)本地即可生成,出错率低。
(3)ID生成性能高。

缺点
(1)无序,影响数据库的数据写入性能。
(2)存储成本高,就算去掉4个“-”,长度也是32。
(3)可读性差。

场景
分布式链路追踪ID

千万不要忽视索引重建带来的问题,看下我们公司之前的表,200w+的核心表,单字段索引+联合索引有14个,造成的结果是索引物理存储几乎是数据的5倍,如果采用的分布式id做索引或者主键,其带来的索引重建,随机IO和频繁的页分裂,肯定是不小
在这里插入图片描述

2.2 数据库自增id

基于数据库的auto_increment自增ID完全可以充当分布式ID:
优点

  • 实现简单,ID单调自增,数值类型查询速度快

缺点

  • DB单点存在宕机风险,无法扛住高并发场景
  • 单调增长,存在被攻击的情况
  • 扩展性问题,随着业务量的增长,如果采用分库分表这个模式做改造就很麻烦

场景
单库低业务量场景

2.3 数据库号段模式

CREATE TABLE id_generator (
  `id` int(10) NOT NULL,
  `max_id` bigint(20) NOT NULL COMMENT '当前最大id',
  `step` int(20) NOT NULL COMMENT '号段的步长',
  `biz_type`    int(20) NOT NULL COMMENT '业务类型',
  `version` int(20) NOT NULL COMMENT '版本号乐观锁',
  PRIMARY KEY (`id`)
)

等ID都用了,再去数据库获取,然后更改最大值

update id_generator set max_id = #{max_id+step}, version = version + 1 where version = # {version} and biz_type = XXX

优点

  • 容灾性高,虽然强依赖于数据库,但是分布式ID发号器可以内存级别缓存一定的号段,即使数据库宕机也能提供一段时间的服务。(建议号段的步长step设置为QPS的600倍,数据库宕机后也可以用10min)。
  • 有比较成熟的方案,像百度Uidgenerator,美团Leaf

缺点

  • 强依赖于数据库实现
  • ID号码不够随机,能够泄露发号数量的信息,不太安全。不适合做订单的ID
  • 当号段使用完后,需要向数据库请求新的号段,性能会卡在数据库的IO性能上
  • DB宕机或者发生主从切换,会导致一段时间的服务不可用

场景
优缺点明显,偏向业务复杂的场景,感觉使用这个模式的公司,都是在业务发展前期设计的,后续打了很多的补丁,使用上和运营依旧会遇见很多不可控的问题;

2.4 redis自增

Redis分布式ID实现主要是通过提供像INCR 和 INCRBY 这样的自增原子命令,由于Redis单线程的特点,可以保证ID的唯一性和有序性;
这种实现方式,如果并发请求量上来后,就需要集群,不过集群后,又要和传统数据库一样,设置分段和步长;

优点

  • Redis性能相对比较好,又可以保证唯一性和有序性

缺点

  • 需要依赖Redis来实现,系统需要引进redis组件(对于微服务来说不是问题)
  • 单调增长的特性,id存在被攻击的风险

场景
还真没见有人用过这个做分布式环境的发号器

2.5 雪花算法

组成结构

(粉丝福利局)java面试-场景题汇总_第6张图片

位置 取值 说明
1bit 固定0 代表正整数
41bit时间戳 2^41-1 1个数=1毫秒=>69年
10bit机器id 2^10=1024 机房+机器一共10位=>1024个节点
12bit序列号 2^12-1=4095 单节点1毫秒可产生4095个ID序号

速记口诀:一是一,要灵要饿【141,1012】

从上面的组成可以看出来,雪花算法基础逻辑上可以保证以下两个特点:

  1. 不依赖于数据库,完全在内存中生成,速度快
  2. id是按照时间趋势递增的,但是1ms带来的id变化量很客观,暴力攻击不太可能
  3. 从整个分布式系统内部而言,不会产生重复的id(机房id+机器id)
场景实战

在了解其组成结构以后,我结合网上的资料,改造了一个工具类:

@Slf4j
@Data
public class DistributeIdGenerator {
    /**
     * workerId部分1:数据中心占用的位数 原默认5
     */
    private int dataCenterBitNum = 0;// 此处我设置的0

    /**
     * workerId部分2:机器码占用的位数,原默认5
     */
    private int machineBitNum;

    /**
     * 随机数的占位
     */
    private int sequenceBitNum;

    /**
     * 起始的时间戳
     */
    private static final long TIMESTAMP = 1716981729000L;

    /**
     * 用于进行末尾sequence随机数产生
     */
    private static final Random RANDOM = new Random();

    /**
     * datacenter编号 我们不用,默认值为0
     */
    private long datacenterId = 0;

    /**
     * 机器编号
     */
    private volatile long machineId = -1;

    /**
     * 上次生成id的最新时间戳
     */
    private long lastStamp = -1L;
    /**
     * 时间戳偏移位数:机房位+机器位+序列位
     */
    private int timestampLeftShift;

    /**
     * datacenter偏移位数:机器位+序列位
     */
    private int datacenterIdShift;

    /**
     * 机器id偏移位数:序列位
     */
    private int machineIdShift;

    /**
     * 每秒产生的最大序列值:~(-1L << sequenceBitNum)
     * 假如sequenceBitNum=12
     * 第一步:11111111111111111111111111111111(32位)左移12位得到11111111111111111111000000000000
     * 第二部:使用按位非操作 ~,即取反,每个 1 变成 0,每个 0 变成 1。所以,上面的二进制串取反后变为:00000000000000000000111111111111
     * 第三步:二进制转10进制,其实就是2的12次方-1的值,即4095
     */
    private Long maxSequenceValue;

    /**
     * 根据上面的规则初始化偏移量
     */
    private void initShiftOffset() {
        timestampLeftShift = this.dataCenterBitNum + this.machineBitNum + this.sequenceBitNum;
        datacenterIdShift = this.machineBitNum + this.sequenceBitNum;
        machineIdShift = this.machineBitNum;
        maxSequenceValue = ~(-1L << sequenceBitNum);
    }

    /**
     * 私有化构造,禁止new
     */
    private DistributeIdGenerator() {
    }

    /**
     * 产生下一个ID
     */
    public synchronized long nextId() {
        long currentStamp = System.currentTimeMillis();
        if (currentStamp < this.lastStamp) {
            long backMills = lastStamp - currentStamp;
            if (backMills <= 5) {
                try {
                    wait(backMills << 1);
                    currentStamp = System.currentTimeMillis();
                    if (currentStamp < this.lastStamp) {

                    }
                } catch (InterruptedException e) {
                    // doNothing
                    throw new RuntimeException("服务节点时钟发回拔if (currentStamp < this.lastStamp) {
            long backMills = lastStamp - currentStamp;
            if (backMills <= 5) {
                try {
                    wait(backMills << 1);
                    currentStamp = System.currentTimeMillis();
                    if (currentStamp < this.lastStamp) {
                        throw new RuntimeException("服务节点时钟发回拔,请重点关注!");
                    }
                } catch (InterruptedException e) {
                    // doNothing
                    throw new RuntimeException("服务节点时钟发回拔系统异常,请重点关注!");
                }
            }
        },请重点关注!");
                }
            }
        }
        long sequence = 0;
        if (currentStamp == this.lastStamp) {
            sequence = (sequence + 1) & this.maxSequenceValue;// 位运算保证始终就是在4096这个范围内
            // 1毫秒内自增数超过设置的最大值,等待1ms,防止进位导致id重复
            if (sequence > this.maxSequenceValue) {
                sequence = 0L;// 此处不采用随机数是考虑到现有的都不够用了,就别在随机了,搞不好随机个值就更少了
                currentStamp = getNextMills();
            }
        } else {
            // 时间不相同时,默认sequence归0,容易导致大部分尾数雷同,采用随机数可以做到数据存储的分布均衡
            sequence = RANDOM.nextInt(100);
        }

        // 维护最后一次生成的时间
        this.lastStamp = currentStamp;

        // 先按照雪花算法的结构对各part的值进行位移,然后做各个part的或运算就可以拿到最终的结果
        long ts = (currentStamp - TIMESTAMP) << this.timestampLeftShift;
        long dc = this.datacenterId << this.datacenterIdShift;
        long work = this.machineId << this.machineIdShift;
        return ts | dc | work | sequence;
    }

    /**
     * 判断是否在1毫秒内
     * - 是:阻塞,直到下一毫秒,返回下一毫秒的时间戳
     * - 否:返回当前时间戳
     */
    private long getNextMills() {
        long timestamp = System.currentTimeMillis();
        while (timestamp <= lastStamp) {
            timestamp = System.currentTimeMillis();
        }
        return timestamp;
    }

    /**
     * 静态工厂
     *
     * @param machineId      机器/节点id[0,1024]
     * @param machineBitNum  机器码占用位数
     * @param sequenceBitNum 随机数长度
     * @return 生成器
     */
    public static DistributeIdGenerator getInstance(Integer machineId, Integer machineBitNum, Integer sequenceBitNum) {
        if (machineId < 0 || machineId > (~(-1 << machineBitNum))) {
            throw new RuntimeException("机器id越界,请重新设置");
        }

        DistributeIdGenerator generator = new DistributeIdGenerator();
        generator.setMachineBitNum(machineBitNum);
        generator.setSequenceBitNum(sequenceBitNum);
        generator.setMachineId(machineId);
        // 初始化偏移量
        generator.initShiftOffset();
        return generator;
    }

    public static void main(String args[]) {
        try {
            DistributeIdGenerator instance = DistributeIdGenerator.getInstance(1, 10, 12);
            System.out.println("id:" + instance.nextId());
            Thread.sleep(2000);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
实战总结
  • 在了解清楚结构之后,这些运算和计算的逻辑,其实也很简单
  • 数据中心一般实际情况下,设置0就行,把空间留给机器id(总体10位,1024个节点)
  • 1毫秒生成的id数,理论上最多4095个,要注意边界的情况,发号发完了,就while等1毫秒
  • 时间不相同的情况下,序列号sequence的起始值可以采用随机数处理,这可以保证数据在存储的时候,如果有场景是用的value取模做的存储,可以保证存储的均匀性
  • 雪花算法能从我设置的TIMESTAMP值开始,用69年
  • 时钟回波的问题也不可怕,个人认为再多的方案都不如抛异常靠谱,我的case里面的代码参考美团的leaf做了一次尝试,如果发生回拔的时间在网络波动范围内,则做一次retry;如果还是失败,则抛异常;这种策略至少不要修数据,远比id重复带来的数据混淆更加可控,让它崩;【人比机器靠谱,可以增加回拔的容错性,牺牲一点点性能,也就几ms】,在抛出异常后,最好和OP进行联动,比如自动删除或者断网有问题的服务即可

10 参考资料

  • https://blog.csdn.net/cxyxysam/article/details/136656172
  • https://zhuanlan.zhihu.com/p/682656066
  • https://www.elecfans.com/d/2313649.html
  • https://blog.csdn.net/tang_huan_11/article/details/136526916【强推】
  • https://www.jianshu.com/p/2a27fbd9e71a【强推】

3、限流策略

在考虑限流时,需要对服务的上下链路,以及流量从前到后涉及的中间件有大概的链路;
举一个我们公司的例子:
http请求->DNS服务器->四层负载->七层负载->入口网关->k8s服务集群->节点前置nginx->机器节点->API接口
这是我们公司一个完整的http请求的链路经过的完整链路;
限流的对象,以及限流的目的要知悉,在整个链路从前到后的过程中,很多的中间件都有机制对流量做控制,关键还是看是技术性限制 OR 业务性限制,下面主要说的是业务要求的限制方案,比如做活动的时候,需要考虑到限流的问题;

考虑因素

1.流量预估

在考虑限流之前,需要和PM进行流量的预估,虽然不能准确的预估出大概,但是相对于开发,PM肯定要对设计的场景有一个大概的判断,这一点很关键,需要和PM对齐;

  • 服务集群的压力值
    流量分散到单台机器的量大概是多少,当前机器的配置堆栈内存是否足够;是否存在需要扩缩容的可能性,如果可以提前扩缩容,应对机制会更灵活;
  • 下游服务的压力值
    流量自上而下,进行链路传导,涉及到的下有接口和对应的服务,也要做到提前沟通;以免关键时刻摇不到人;
  • 关联中间件的压力值
    中间件这一块的话,主要是读写压力的预估,大部分情况下都可以支撑,但是也要考虑极端的情况,对缓存方面需要左一定的考量;
  • 服务熔断、服务降级、接口限流
    通用的降级(快速失败),还是直接丢弃+后置结果查询,各方对齐方案即可;

熔断、降级、限流都是一种策略,cloud和dubbo都有机制,包括第三方组件sentential的引入去做,这块只是方案上要去做完善,面试的时候不需要回答的太细,更侧重于全局的角度考虑问题,尤其要结合现有公司的基础架构和微服务生态;

2.限流位置

  • 服务调用层面
    像cloud和dubbo,是有对应的限流和降级机制的,我们是否可以通过框架的层面去做限制,而且这两种微服务通信机制,都是流量可配置、策略可定制化。【开发量小】
  • 接口调用层面
    如果是接口层面做限流,势必要做代码开发,如果项目沉淀足够长,一般都有通用的注解或者公共方法来做限制,但是这种相对于服务层面做控制,这是有开发量的,适合业务要求相对灵活的场景;【有一定开发量】

有的公司有自己的业务路由配置网关,也可以做限流,但其实和服务调用层面是一个道理,只不过一个是集中控制,一个是分散到单集群控制;

常用策略

提一嘴,限流和防重复提交分布式锁要分清楚

目前成熟的限流算法策略大概有下面几种,渗透到一些中间件的配置化限流机制和sdk中,了解清楚会更让我们选择合适的算法去适配场景;

1.计数器算法

计数器算法是最简单的限流算法之一,在一段时间间隔内(时间窗/时间区间),处理请求的最大数量固定,超过部分不做处理。
简单粗暴,比如指定线程池大小,指定数据库连接池大小、nginx连接数等,这都属于计数器算法。

public class CounterLimiter {
    private long timeStamp = System.currentTimeMillis();
    private int reqCount = 0;
    // 时间窗口内最大请求数
    private final int limit = 100;
    // 时间窗口ms
    private final long interval = 1000;

    public boolean tryAcquire() {
        long now = System.currentTimeMillis();
        if (now - timeStamp < interval) {
            reqCount++;
            return reqCount <= limit;
        } else {
            timeStamp = now;
            reqCount = 1;
            return true;
        }
    }
}

缺点:涉及到窗口的临界问题,容易被攻击者利用漏洞,直接打满接口请求次,导致后面的时间段都无法恢复正常访问

滑动窗口【增强】

计数器算法的临界问题,可以使用滑动窗口算法来进行增强;
相对于固定窗口,滑动窗口除了需要引入计数器之外还需要记录时间窗口内每个请求到达的时间点,因此对内存的占用会比较多。
规则如下,假设时间窗口为 1 秒:

记录每次请求的时间
统计每次请求的时间 至 往前推1秒这个时间窗口内请求数,并且 1 秒前的数据可以删除。
统计的请求数小于阈值就记录这个请求的时间,并允许通过,反之拒绝。
(粉丝福利局)java面试-场景题汇总_第7张图片
相当于用一个map或者数组,进行秒级别的访问次数存储;
但是滑动窗口和固定窗口都无法解决短时间之内集中流量的突击。

import java.util.concurrent.atomic.AtomicInteger;

public class SlidingWindowLimiter {
    private AtomicInteger[] timeSlots;
    private int timeSlotSize;
    private int slotIndex = 0;
    private long lastTime;
    private final int limit;

    public SlidingWindowLimiter(int timeSlotSize, int limit) {
        this.timeSlots = new AtomicInteger[timeSlotSize];
        this.timeSlotSize = timeSlotSize;
        this.lastTime = System.currentTimeMillis();
        this.limit = limit;
        for (int i = 0; i < timeSlotSize; ++i) {
            timeSlots[i] = new AtomicInteger(0);
        }
    }

    public boolean tryAcquire() {
        long now = System.currentTimeMillis();
        synchronized (this) {
            if (now - lastTime > timeSlotSize) {
                timeSlots[slotIndex].set(0);
                slotIndex = (slotIndex + 1) % timeSlotSize;
                lastTime = now;
            }
            int count = 0;
            for (AtomicInteger slot : timeSlots) {
                count += slot.get();
            }
            if (count < limit) {
                timeSlots[slotIndex].getAndIncrement();
                return true;
            } else {
                return false;
            }
        }
    }
}

2.漏桶算法

可以解决计数器时间窗口和流量突发的问题;漏桶算法将请求视作流入到漏桶里的水,水漏出的速率是固定的,如果漏桶满了,新流入的水将被溢出(即请求被丢弃)。

  • 请求来了放入桶中
  • 桶内请求量满了拒绝请求
  • 服务定速从桶内拿请求处理
public class LeakyBucketLimiter {
    // 桶的容量
    private long capacity = 10;
    // 漏桶流出的速率
    private long rate = 1;
    // 当前水量(当前累积请求数)
    private long water = 0;
    // 上一次漏水时间
    private long lastTime = System.currentTimeMillis();

    public synchronized boolean tryAcquire() {
        long now = System.currentTimeMillis();
        water = Math.max(0, water - (now - lastTime) * rate);
        lastTime = now;
        if ((water + 1) < capacity) {
            water++;
            return true;
        } else {
            return false;
        }
    }
}

主要关注的点是桶的流速是否超过容量

3.令牌桶算法【常用】

guava的RateLimiter就是其中的一个应用
该算法通过以恒定的速度向桶中添加令牌,并且每当有请求来时,需要从桶中取出一个或多个令牌才能继续执行。如果桶中没有足够的令牌,请求将被限流,即延迟处理或拒绝服务。(粉丝福利局)java面试-场景题汇总_第8张图片

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

public class TokenBucketLimiter {
    private final long maxBucketSize;
    private final long refillRate;
    private AtomicLong currentBucketSize;
    private AtomicLong lastRefillTimestamp;

    public TokenBucketLimiter(long maxBucketSize, long refillRate) {
        this.maxBucketSize = maxBucketSize;
        this.refillRate = refillRate;
        this.currentBucketSize = new AtomicLong(0);
        this.lastRefillTimestamp = new AtomicLong(System.nanoTime());
    }

    public boolean tryAcquire() {
        refill();
        if (currentBucketSize.get() > 0) {
            currentBucketSize.decrementAndGet();
            return true;
        } else {
            return false;
        }
    }

    private void refill() {
        long now = System.nanoTime();
        long refillTokens = (now - lastRefillTimestamp.get()) / 1_000_000_000 * refillRate;
        if (refillTokens > 0) {
            long adjustedRefill = Math.min(refillTokens, maxBucketSize - currentBucketSize.get());
            currentBucketSize.addAndGet(adjustedRefill);
            lastRefillTimestamp.set(now);
        }
    }
}

和漏桶算法的区别就在于一个是加法,一个是减法。
可看出令牌桶在应对突发流量的时候,桶内假如有 100 个令牌,那么这 100 个令牌可以马上被取走,而不像漏桶那样匀速的消费。所以在应对突发流量的时候令牌桶表现更佳。

guava包限流
@Service
public class AccessLimitService {
 
    //每秒只发出5个令牌
    RateLimiter rateLimiter = RateLimiter.create(5.0);
 
    /**
     * 尝试获取令牌
     * @return
     */
    public boolean tryAcquire(){
        return rateLimiter.tryAcquire();
    }
}
@Controller
public class HelloController {
 
    private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
 
    @Autowired
    private AccessLimitService accessLimitService;
 
    @RequestMapping("/access")
    @ResponseBody
    public String access(){
        //尝试获取令牌
        if(accessLimitService.tryAcquire()){
            //模拟业务执行500毫秒
            try {
                Thread.sleep(500);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
            return "aceess success [" + sdf.format(new Date()) + "]";
        }else{
            return "aceess limit [" + sdf.format(new Date()) + "]";
        }
    }
}

4、RBAC权限模型

经典的ERP数据库权限模型,直接上图
(粉丝福利局)java面试-场景题汇总_第9张图片

5、短链系统的核心思路

短网址现在可以说是随处可见,很多短信内部都会包含短网址,点击短网址链接可以直接跳到对应的长链接地址,背后的原理其实很简单,服务端会存储短链和长链的对应关系,当接受到短链请求后,程序会去程序中查找对应的长链然后给浏览器返回给浏览器并且让浏览器重定向到长链地址,这就是整个流程;

短链的长度

短网址的长度该设计为多少呢? 当前互联网上的网页总数大概是 45亿(参考 http://www.worldwidewebsize.com),超过了 232=4294967296,那么用一个64位整数足够了。
一个64位整数如何转化为字符串呢?,假设我们只是用大小写字母加数字,那么可以看做是62进制数,log62(264−1)=10.7,即字符串最长11就足够了。

实际生产中,还可以再短一点,比如新浪微博采用的长度就是7,因为 627=3521614606208,这个量级远远超过互联网上的URL总数了,绝对够用了。

现代的web服务器(例如Apache, Nginx)大部分都区分URL里的大小写了,所以用大小写字母来区分不同的URL是没问题的。

因此,正确答案:长度不超过7的字符串,由大小写字母加数字共62个字母组成

短链的算法选择

短链算法:
最容易想到的办法是哈希,先hash得到一个64位整数,将它转化为62进制整,截取低7位即可。但是哈希算法会有冲突,如何处理冲突呢,又是一个麻烦。这个方法只是转移了矛盾,没有解决矛盾,抛弃。【拥抱雪花算法】

短链转码:Base62 算法是一种将数据转换为由 62 个字符组成的字符串的编码方式,这 62 个字符通常包括 26 个大写英文字母、26 个小写英文字母和 10 个数字,以下是其相关介绍:

基本思想

Base62 算法的基本原理是将数据按照 62 进制进行编码。它把要编码的数据看作是一个大的整数,然后将这个整数转换为 62 进制的表示形式,每一位对应 62 个字符中的一个。

编码过程

以对数字进行 Base62 编码为例,首先确定数字对应的十进制值,然后用这个十进制数除以 62,得到商和余数。余数就是对应 62 进制下的一位数字,其范围是 0 到 61,将余数映射到对应的字符。接着,用商继续进行上述除法操作,直到商为 0。最后,将得到的所有字符按照从后往前的顺序排列,就得到了 Base62 编码后的字符串。

解码过程

与编码过程相反,解码时是将 Base62 编码的字符串中的每个字符映射回对应的数字,然后将这些数字按照 62 进制转换为十进制数,从而还原出原始数据。

应用场景

  • 缩短 URL:在网址缩短服务中,Base62 算法可以将长 URL 转换为更短的字符串。例如,原始的长 URL 可能很难记忆和分享,通过 Base62 编码后,可以生成一个简短且唯一的字符串作为替代,用户访问这个短字符串时,服务器再将其解码并重定向到原始的长 URL。
  • 唯一标识符生成:在生成唯一标识符时,如短链接、订单号、验证码等。使用 Base62 算法可以在相对较短的字符串长度内生成大量的唯一组合,满足系统对唯一性和简洁性的要求。以订单号为例,通过 Base62 编码生成的订单号既简短又能保证足够的唯一性,方便系统处理和用户识别。
  • 数据存储和传输优化:在某些数据存储和传输场景中,使用 Base62 编码可以减少数据的存储空间和传输带宽。因为它可以用较少的字符表示较大范围的数据,提高了数据存储和传输的效率。

答案:雪花算法+ Base62算法

如何存储

存储到数据库,这样短链映射的长链数据参数,方便后续对数据统计和分析

应对高并发的策略

布隆过滤器或者redis缓存

预防攻击

IP单日/秒限流

代码实战【部分】

controller类

@Autowired
    private UrlMapMapper urlMapMapper;

    /**
     * 根据短链查询实际的长链 & 重定向
     */
    @GetMapping("/get")
    public void longUrl2Short(@RequestParam String shortUrl, HttpServletResponse response) throws IOException {
        // DB中查询
        QueryWrapper<UrlMap> wrapper = new QueryWrapper<>();
        wrapper.eq("short_url", shortUrl);
        UrlMap urlMap = urlMapMapper.selectOne(wrapper);
        if (urlMap == null) {
            throw new IllegalArgumentException("短链不存在");
        }
        log.info("短链存在且重定向:{}", JSON.toJSONString(urlMap));
        // 重定向
        sendRedirect(urlMap.getLongUrl(), response);
    }

    /**
     * 根据长链生成短链
     */
    @GetMapping("/shorten")
    public ResponseEntity<String> createShortUrlV3(@RequestParam String longUrl) {
        // 1、先查询缓存里面是否对应的短链。缓存中有这个短链,直接返回

        // 2、再查询数据库里面是否有长链 对应的短链
        QueryWrapper<UrlMap> wrapper = new QueryWrapper<>();
        wrapper.eq("long_url", longUrl);
        UrlMap urlMap = urlMapMapper.selectOne(wrapper);
        String shortUrl = urlMap != null ? urlMap.getShortUrl() : null;
        if (shortUrl != null && !shortUrl.isEmpty()) {
            // 数据库中有这个短链,直接返回,可以顺便保存到缓存中
            return ResponseEntity.ok(shortUrl);
        }

        // 3、没找到, 那就利用雪花算法生成ID
        Long id = DistributeIdGenerator.getInstance(1, 10, 12).nextId();
        // 利用base62算法,生成短链
        shortUrl = Base62.convertToShortUrl(id);

        // 4.存储到数据库
        // 4.1、将短链保存到布隆过滤器中
        // 4.2、保存到缓存中, 以便下次查询
        // 4.3、保存到数据库
        urlMapMapper.insert(new UrlMap(longUrl, shortUrl));

        return ResponseEntity.ok(shortUrl);
    }

    // 进行重定向的函数
    public void sendRedirect(String longUrl, HttpServletResponse response) throws IOException {
        response.sendRedirect(longUrl);
        response.setHeader("Location", longUrl);
        response.setHeader("Connection", "close");
        response.setHeader("Content-Type", "text/html; charset=utf-8");
        response.setHeader("Cache-Control", "no-cache");
        response.setHeader("Pragma", "no-cache");
        response.setHeader("Expires", "0");
    }

短链工具类

package com.example.sharding.config;

public class Base62 {
    /**
     * ASCII编码
     * Value Encoding  Value Encoding  Value Encoding  Value Encoding
     * 0 a            17 r            34 I            51 Z
     * 1 b            18 s            35 J            52 0
     * 2 c            19 t            36 K            53 1
     * 3 d            20 u            37 L            54 2
     * 4 e            21 v            38 M            55 3
     * 5 f            22 w            39 N            56 4
     * 6 g            23 x            40 O            57 5
     * 7 h            24 y            41 P            58 6
     * 8 i            25 z            42 Q            59 7
     * 9 j            26 A            43 R            60 8
     * 10 k           27 B            44 S            61 9
     * 11 l           28 C            45 T
     * 12 m           29 D            46 U
     * 13 n           30 E            47 V
     * 14 o           31 F            48 W
     * 15 p           32 G            49 X
     * 16 q           33 H            50 Y
     */
    // 定义 62 进制字符集
    private static final String CHARSET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
    private static final int BASE = CHARSET.length();
    private static final int SHORT_URL_LENGTH = 7;// 短链长度

    /**
     * 将雪花算法生成的 ID 转换为 7 位短链【哈希算法有冲突,加盐或者其他加密只是减缓了冲突机率】
     *
     * @param snowflakeId 雪花算法生成的 ID
     * @return 7 位短链
     */
    public static String convertToShortUrl(long snowflakeId) {
        StringBuilder shortUrl = new StringBuilder();
        // 进行进制转换
        while (snowflakeId > 0) {
            int remainder = (int) (snowflakeId % BASE);
            shortUrl.insert(0, CHARSET.charAt(remainder));
            snowflakeId /= BASE;
        }
        // 不足 7 位时在前面补 0
        while (shortUrl.length() < SHORT_URL_LENGTH) {
            shortUrl.insert(0, CHARSET.charAt(0));
        }
        return shortUrl.toString();
    }

    public static void main(String[] args) {
        // 示例雪花算法生成的 ID
        long snowflakeId = 1234567890123L;
        String shortUrl = convertToShortUrl(snowflakeId);
        System.out.println("雪花算法 ID: " + snowflakeId);
        System.out.println("生成的 " + SHORT_URL_LENGTH + " 位短链: " + shortUrl);
    }
}

参考资料

  • https://blog.csdn.net/weixin_43844521/article/details/136148854【熔断、降级、限流】
  • https://zhuanlan.zhihu.com/p/697392499【限流算法】
  • https://www.jianshu.com/p/40d3f44122b2
  • https://cloud.tencent.com/developer/article/2398554
  • https://blog.csdn.net/qq_32907195/article/details/133931740[guava限流工具]
  • https://cn.soulmachine.me/2017-04-10-how-to-design-tinyurl/
  • https://www.jianshu.com/p/7189521b5cfb

你可能感兴趣的:(java面试,java,面试)