银河智学后端开发面试(带答案)

我复活了,上周去沉淀技术,看JUC和JVM去了,感觉自己啥也不懂,害

更多面经请查看https://github.com/haandfeng/Mianjin 以后会陆续更新和完善,我会持续引用之前面经的内容,也会根据自己面的公司看之前的面经,然后写上答案。如果大家觉得有用请多多关注,点赞收藏star🥺🥺🥺

实习怎么优化了数据表的查询效率

索引(根据where语句顺序防止失效,创建和Select的选择xx防止回表,来创建联合索引)

  1. 如果可以使用被驱动表的索引,join 语句还是有其优势的;

  2. 不能使用被驱动表的索引,只能使用 Block Nested-Loop Join 算法,这样的语句就尽量不要使用; Join语句的提前优化。参考文章1 参考文章2

  3. 提前用where语句把表缩小,驱动表要比较小(在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与 join 的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表。)

  4. 针对索引区分度不高的自渎阿妈,我提高join_buffer_size的大小

话术:

  1. 首先优化Join语句,使用where语句过滤要Join的表,过滤完成之后,计算参与 join 的各个字段的总数据量,数据量小的那个表,就是“小表”,作为驱动表
  2. 优化索引,针对where语句,建立对应的联合索引,并且会注意到顺序和范围的问题,防止索引失效。根据join的数据,给被驱动表添加索引,提高join的速度。采用覆盖索引的方式简历索引,防止回表。

怎么处理正则表达式

没啥好说的,就过滤,验证过滤,暂时想不出哪里可以吹牛逼

低代码平台怎么用的

。。。就扯吧,毫无技术含量。

注解的工作原理

参考这篇文章 和这个相关的相关的有:动态代理,还有SPI,还有反射,其中动态代理和SPI面试都在后面有问到,我整合到一起

定义: 注解本质是一个继承了Annotation 的特殊接口,其具体实现类是Java 运行时生成的动态代理类。而我们通过反射获取注解时,返回的是Java 运行时生成的动态代理对象$Proxy1。通过代理对象调用自定义注解(接口)的方法,会最终调用AnnotationInvocationHandler 的invoke 方法。该方法会从memberValues 这个Map 中索引出对应的值。而memberValues 的来源是Java 常量池。

我的理解 : Java 中的注解,本质上是一个继承了 Annotation 的特殊 接口。当我们定义一个注解时,实际上是在定义一个接口,所有的注解类型都会隐式继承 java.lang.annotation.Annotation。

当我们在代码中获取某个类、方法或字段的注解时,Java 不会直接创建注解对象,而是会使用 动态代理 机制生成一个 Proxy 类的实例。 假设我们创建了一个MyAnnotation注解, annotation是注解实例 • annotation.getClass() 返回的是 **com.sun.proxy.Proxy1。 • annotation 其实是 Proxy 类的实例,而不是直接的 MyAnnotation 对象。 • 当 annotation.value() 被调用时,Java 代理会去拦截该方法,并调用 invoke() 方法。

💡 深入思考

  1. 为什么 @Override、@SuppressWarnings 不能用反射获取? • 这些是 编译期注解(RetentionPolicy.SOURCE),不会被存储在 .class 文件中,所以运行时无法反射获取。

  2. 为什么 @Transactional、@Autowired 能在 Spring 运行时解析? • 这些是 运行时注解(RetentionPolicy.RUNTIME),可以通过反射动态解析,并结合 AOP 实现动态增强。

聊聊SPI的机制

请参考 不过我觉得有点负责,可以看看我抄的一个自己实现的SPI代码 专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。(制定好规则和接口,让开发者实现自己的功能)

主要分一下几步

  1. 调用load函数读取固定路径下某个文件内所有的class的路径,存入keyClassMap<key, Class对象>中,keyClassMap再存入总的loaderMap<接口名, <key, Class对象>>中。
  2. 调用getInstance,指定自己需要哪个接口下的,哪个实现类。然后再调用keyClassMap获取Class对象,最后初始化Instance。

@Slf4j  
public class SpiLoader {  
  
    /**  
     * 存储已加载的类:接口名 =>(key => 实现类)  
     */  
    private static final Map<String, Map<String, Class<?>>> loaderMap = new ConcurrentHashMap<>();  
  
    /**  
     * 对象实例缓存(避免重复 new),类路径 => 对象实例,单例模式  
     */  
    private static final Map<String, Object> instanceCache = new ConcurrentHashMap<>();  
  
    /**  
     * 系统 SPI 目录  
     */  
    private static final String RPC_SYSTEM_SPI_DIR = "META-INF/rpc/system/";  
  
    /**  
     * 用户自定义 SPI 目录  
     */  
    private static final String RPC_CUSTOM_SPI_DIR = "META-INF/rpc/custom/";  
  
    /**  
     * 扫描路径  
     */  
    private static final String[] SCAN_DIRS = new String[]{RPC_SYSTEM_SPI_DIR, RPC_CUSTOM_SPI_DIR};  
  
    /**  
     * 动态加载的类列表  
     */  
    private static final List<Class<?>> LOAD_CLASS_LIST = Arrays.asList(Serializer.class);  
  
    /**  
     * 获取某个接口的实例  
     *  
     * @param tClass  
     * @param key  
     * @param <T>  
     * @return  
     */  
    public static <T> T getInstance(Class<?> tClass, String key) {  
        String tClassName = tClass.getName();  
        Map<String, Class<?>> keyClassMap = loaderMap.get(tClassName);  
        if (keyClassMap == null) {  
            throw new RuntimeException(String.format("SpiLoader 未加载 %s 类型", tClassName));  
        }  
        if (!keyClassMap.containsKey(key)) {  
            throw new RuntimeException(String.format("SpiLoader 的 %s 不存在 key=%s 的类型", tClassName, key));  
        }  
        // 获取到要加载的实现类型  
        Class<?> implClass = keyClassMap.get(key);  
        // 从实例缓存中加载指定类型的实例  
        String implClassName = implClass.getName();  
        if (!instanceCache.containsKey(implClassName)) {  
            try {  
                instanceCache.put(implClassName, implClass.newInstance());  
            } catch (InstantiationException | IllegalAccessException e) {  
                String errorMsg = String.format("%s 类实例化失败", implClassName);  
                throw new RuntimeException(errorMsg, e);  
            }  
        }  
        return (T) instanceCache.get(implClassName);  
    }  
  
    /**  
     * 加载某个类型  
     *  
     * @param loadClass  
     * @throws IOException  
     */    public static Map<String, Class<?>> load(Class<?> loadClass) {  
        log.info("加载类型为 {} 的 SPI", loadClass.getName());  
        // 扫描路径,用户自定义的 SPI 优先级高于系统 SPI        Map<String, Class<?>> keyClassMap = new HashMap<>();  
        for (String scanDir : SCAN_DIRS) {  
            List<URL> resources = ResourceUtil.getResources(scanDir + loadClass.getName());  
            // 读取每个资源文件  
            for (URL resource : resources) {  
                try {  
                    InputStreamReader inputStreamReader = new InputStreamReader(resource.openStream());  
                    BufferedReader bufferedReader = new BufferedReader(inputStreamReader);  
                    String line;  
                    while ((line = bufferedReader.readLine()) != null) {  
                        String[] strArray = line.split("=");  
                        if (strArray.length > 1) {  
                            String key = strArray[0];  
                            String className = strArray[1];  
                            keyClassMap.put(key, Class.forName(className));  
                        }  
                    }  
                } catch (Exception e) {  
                    log.error("spi resource load error", e);  
                }  
            }  
        }  
        loaderMap.put(loadClass.getName(), keyClassMap);  
        return keyClassMap;  
    }  
  
  
}

聊聊动态代理+工厂模式

请参考这个 我用的是JDK的动态代理

简单来说利用newProxyInstance 这个函数,填入参数:

  1. loader :类加载器,用于加载代理对象。
  2. interfaces : 被代理类实现的一些接口;
  3. h : 实现了 InvocationHandler 接口的对象;

主要的实现在h 要实现动态代理的话,还必须需要实现InvocationHandler 来自定义处理逻辑。 当我们的动态代理对象调用一个方法时,这个方法的调用就会被转发到实现InvocationHandler 接口类的 invoke 方法来调用。

invoke() 方法有下面三个参数:

  1. proxy :动态生成的代理类
  2. method : 与代理类对象调用的方法相对应
  3. args : 当前 method 方法的参数

也就是说:你通过Proxy 类的 newProxyInstance() 创建的代理对象在调用方法的时候,实际会调用到实现InvocationHandler 接口的类的 invoke()方法。 你可以在 invoke() 方法中自定义处理逻辑,比如在方法执行前后做什么事情。

工厂模式

见下面

聊聊你知道的设计模式

这里的笔记主要参考大话设计模式,如果需要电子版书可以私聊我。 ==建议这段还是照着示例代码看,更加清晰,我的太简陋了不如不看==

单例

保证一个类只有一个实例 类构造方法要私有,并提供一个访问他的全局访问点

多线程的情况下,可以用双锁判断(两个null),第一个null判断有无实例,无加锁,再次判断实例是不是null,不是的话创建实例

内部静态类实现

public class Singleton {
    private Singleton() {}

    // 静态内部类,JVM 在类加载时不会立即初始化
    private static class Holder {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}

工厂

简单工厂

简单方法模式: 单独添加一个类来实现创造实例化的过程

代码可以参考大话设计模式第一章,设计计算器,利用简单工厂(Case 判断创建哪一个),创建对应的操作对象(加减乘除)。

工厂模式 vs 简单工厂模式

简单工厂模式的最大优点在于工厂类中包含了必要的逻辑判断,根据客户端的选择条件动态实例化相关的类,对于客户端来说,去除了与具体产品的依赖。但是也违背了开放-封闭原则,让程序员直接对代码进行了修改怒,加了一个case来进行判断

工厂方法模式(Factory Method),定义一个用于创建对象的接口,让子类决定实例化哪一个类。工厂方法使一个类的实例化延迟到其子类。

简单来说,工厂方法模式多了一个接口,其他工厂实现这个接口

工厂方法模式实现时,客户端需要决定实例化哪一个工厂来实现运算类,选择判断的问题还是存在的,也就是说,工厂方法把简单工厂的内部逻辑判断移到了客户端代码来进行。

装饰

装饰模式(Decorator),动态地给一个对象添加一些额外的职责,就增加功能来说,装饰模式比生成子类更为灵活。[DP]

“Component 是定义一个对象接口,可以给这些对象动态地添加职责。ConcreteComponent 是定义了一个具体的对象,也可以给这个对象添加一些职责。Decorator,装饰抽象类,继承Component,从外类来扩展 Component 类的功能,但对于 Component 来说,是无需知道 Decorator 的存在的。至于ConcreteDecorator 就是具体的装饰对象,起到给 Component 添加职责的功能。”

装饰模式是利用 SetComponent 来对对象进行包装的。这样每个装饰对象的实现就和如何使用这个对象分离开了,每个装饰对象只关心自己的功能,不需要关心如何被添加到对象链当中[DPE]。

我的理解是所有方法名要一样(通过继承),不断的设置componet,包装好功能。

代理

代理和被代理对象实现同一个接口-> JDK的动态代理机制,要传入被代理对象的类加载器,被代理对象实现的接口,还有增强对象,最后生成了代理对象

ConcurrentHashMap为什么线程安全

HashMap为什么线程不安全

参考 JDK1.7 及之前版本,在多线程环境下,HashMap 扩容时会造成死循环和数据丢失的问题。 JDK1.7 及之前版本的 HashMap 在多线程环境下扩容操作可能存在死循环问题,这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。 为了解决这个问题,JDK1.8 版本的 HashMap 采用了尾插法而不是头插法来避免链表倒置,使得插入的节点永远都是放在链表的末尾,避免了链表中的环形结构。

数据丢失这个在 JDK1.7 和 JDK 1.8 中都存在JDK 1.8 后,在 HashMap 中,多个键值对可能会被分配到同一个桶(bucket),并以链表或红黑树的形式存储。多个线程对 HashMapput 操作会导致线程不安全,具体来说会有数据覆盖的风险。 =》 可能1. Hash冲突,导致值相互覆盖 可能2. 扩容时中,可能有多个线程同时尝试 rehash,导致数据覆盖 可能3. 线程同时 put 操作导致 size 的值不正确,进而导致数据覆盖的问题

案例1

  • 两个线程 1,2 同时进行 put 操作,并且发生了哈希冲突(hash 函数计算出的插入下标是相同的)。
  • 不同的线程可能在不同的时间片获得 CPU 执行的机会,当前线程 1 执行完哈希冲突判断后,由于时间片耗尽挂起。线程 2 先完成了插入操作。
  • 随后,线程 1 获得时间片,由于之前已经进行过 hash 碰撞的判断,所有此时会直接进行插入,这就导致线程 2 插入的数据被线程 1 覆盖了。
import java.util.HashMap;

public class HashMapThreadUnsafe {
    static HashMap<Integer, String> map = new HashMap<>();

    public static void main(String[] args) {
        // 创建多个线程同时向 HashMap 添加数据
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    map.put(j, Thread.currentThread().getName());
                }
            }).start();
        }

        // 等待一段时间,确保所有线程执行完成
        try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); }

        System.out.println("最终 map 大小: " + map.size()); // 可能小于 10000,数据丢失
    }
}

案例2 这两个线程同时 put 操作导致 size 的值不正确,进而导致数据覆盖的问题:

  1. 线程 1 执行 if(++size > threshold) 判断时,假设获得 size 的值为 10,由于时间片耗尽挂起。
  2. 线程 2 也执行 if(++size > threshold) 判断,获得 size 的值也为 10,并将元素插入到该桶位中,并将 size 的值更新为 11。
  3. 随后,线程 1 获得时间片,它也将元素放入桶位中,并将 size 的值更新为 11。
  4. 线程 1、2 都执行了一次 put 操作,但是 size 的值只增加了 1,也就导致实际上只有一个元素被添加到了 HashMap 中。

ConcurrentHashMap为什么线程安全

1.7之前对Segment加锁 ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成

Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。

一个 ConcurrentHashMap 里包含一个 Segment 数组,Segment 的个数一旦初始化就不能改变Segment 数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。

Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。也就是说,对同一 Segment 的并发写入会被阻塞,不同 Segment 的写入是可以并发执行的。

1.8之后对Node加锁 ConcurrentHashMap 取消了 Segment 分段锁,采用 Node + CAS + synchronized 来保证并发安全。数据结构跟 HashMap 1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。

  1. CAS(Compare-And-Swap)无锁操作: • put() 方法在插入数据时,首先尝试用 CAS 方式 更新数据(原子操作)。 • 如果 CAS 失败(说明有其他线程竞争),再退化为 synchronized 方式。

  2. synchronized 作用于单个桶 • 在 JDK 1.8 及以后,只有在发生并发写入冲突时,才会对单个桶(Node 数组的某个槽位)加锁,而不是整个哈希表。 • 这样可以大幅度减少锁的竞争,提高并发性能。

想了解详情,可以看看B站黑马JUC的视频

我的序列化怎么实现的

懒得看了,感觉不常考。可以说说自己 抄的文档:

1) JSON

优点:

  • 易读性好,可读性强,便于人类理解和调试。
  • 跨语言支持广泛,几乎所有编程语言都有 JSON 的解析和生成库。

缺点:

  • 序列化后的数据量相对较大,因为 JSON 使用文本格式存储数据,需要额外的字符表示键、值和数据结构。
  • 不能很好地处理复杂的数据结构和循环引用,可能导致性能下降或者序列化失败。

2) Hessian

🔗 Hessian 官方网站

优点:

  • 二进制序列化,序列化后的数据量较小,网络传输效率高。

  • 支持跨语言,适用于分布式系统中的服务调用。### 缺点:

  • 性能较 JSON 略低,因为需要将对象转换为二进制格式。

  • 对象必须实现 Serializable 接口,限制了可序列化的对象范围。

3) Kryo

🔗 Kryo GitHub 地址

优点:

  • 高性能,序列化和反序列化速度快。
  • 支持循环引用和自定义序列化器,适用于复杂的对象结构。
  • 无需实现 Serializable 接口,可以序列化任意对象

缺点:

  • 不跨语言,只适用于 Java。
  • 对象的序列化格式不够友好,不易读懂和调试。

4) Protobuf

优点:

  • 高效的二进制序列化,序列化后的数据量极小。
  • 跨语言支持,并且提供了多种语言的实现库。
  • 支持版本化和向前 / 向后兼容性。

缺点:

  • 配置相对复杂,需要先定义数据结构的消息格式。
  • 对象的序列化格式不易读懂,不便于调试。

黑马点评注重用Redis的特性解决常见业务问题,详细说说

  1. 用redis Hash实现缓存,缓存商户信息。提高响应速率
  2. 用redis String实现session功能,解决了传统session不能多服务器共享的问题
  3. 使用redis 实现分布式锁功能,用set实现一人一单,解决超卖问题
  4. 使用redis stream实现消息队列,异步下单,提高响应速度
  5. 使用redis Zset 数据结构实现推模式并按时间顺序分页推送向粉丝推送关注的人的动态。
  6. 使用redis bitmap实现用户签到统计
  7. 使用redis geo数据结构实现查询附近商户

讲讲ThreadLocal 和两重拦截器

第一重拦截器,拦截全部请求: 1.获取请求头中的token 2.基于TOKEN获取redis中的用户 3.把查询到的用于保存到ThreadLocal里面 4.刷新Token有效期 如果没有tokeor没有用户,就直接放行 afterCompletion会移除TreadLocal里的用户,防止内存泄漏

第二重拦截器,拦截需要登陆才能看到的请求

为什么用ThreadLocal,ThreadLocal为什么线程隔离

参考这个 TreadLocal保证了每个线程都能拥有自己独立的变量副本,而不会相互干扰。

线程隔离: 每个 Thread 对象都有一个 ThreadLocalMap,存储 ThreadLocal 变量的值,这个 ThreadLocalMap 以 ThreadLocal 变量作为 key,存储当前线程的变量副本。

当线程调用 ThreadLocal.set(value) ,其他同理时:

  1. 获取当前线程 Thread.currentThread()
  2. 获取当前线程的 ThreadLocalMap(如果不存在,则创建)
  3. 以 ThreadLocal 变量为 key,存入 ThreadLocalMap
线程1 (Thread-1)
  ├── ThreadLocalMap
  │      ├── (ThreadLocal变量1, value1)
  │      ├── (ThreadLocal变量2, value2)

线程2 (Thread-2)
  ├── ThreadLocalMap
  │      ├── (ThreadLocal变量1, value3)
  │      ├── (ThreadLocal变量2, value4)

Maven怎么用

![[英孚#Maven作为一个开发工具,具体有什么用途]]

浏览器输入一个地址到这个页面渲染,中间大概经历什么过程

因为有多个面试都问到我这个问题,我写个详细版。

  1. 解析URL:分析 URL 所需要使用的传输协议和请求的资源路径。如果输入的URL 中的协议或者主机名不合法,将会把地址栏中输入的内容传递给搜索引擎。如果没有问题,浏览器会检查 URL 中是否出现了非法字符,则对非法字符进行转义后在进行下一过程。
  2. 缓存判断:浏览器缓存->系统缓存(hosts 文件)-> 路由器缓存 ->ISP(互联网服务提供商) 的DNS缓存如果其中某个缓存存在,直接返回服务器的IP地址。
  3. DNS解析:如果缓存未命中,浏览器向本地 DNS服务器发起请求,最终可能通过根域名服务器、顶级域名服务器(.com)、权威域名服务器逐级查询,直到获取目标域名的IP 地址。 www.server.com 为例:
    1. 本地 DNS会去问它的根域名服务器:”老大,能告诉我www.server.com 的IP 地址吗?”根域名服务器是最高层次的,它不直接用于域名解析,但能指明一条道路。
    2. 根DNS收到来自本地 DNS的请求后,发现后置是.com,说:“www.server.com 这个域名归.com 区域管理”,我给你.com 顶级域名服务器地址给你,你去问问它吧。”
    3. 本地 DNS收到顶级域名服务器的地址后,发起请求问”老二,你能告诉我 www.server.com 的IP 地址吗?“
    4. 顶级域名服务器说:“我给你负责 www.server.com 区域的权威 DNS服务器的地址,你去问它应该能到”
    5. 本地 DNS于是转向问权威 DNS服务器:"老三,www.server.com对应的IP是啥呀“ server.com 的权威DNS服务器,它是域名解析结果的原出处。为啥叫权威呢?就是我的域名我做主。
    6. 权威 DNS服务器查询后将对应的IP 地址 X.X.X.X告诉本地 DNS。
    7. 本地 DNS再将IP地址返回客户端,客户端和目标建立连接。
  4. 获取MAC地址(可以不提?):当浏览器得到IP 地址后,数据传输还需要知道目的主机 MAC地址,因为应用层下发数据给传输层,TCP 协议会指定源端口号和目的端口号,然后下发给网络层。网络层会将本机地址作为源地址,获取的IP地址作为目的地址。然后将下发给数据链路层,数据链路层的发送需要加入通信双方的MAC地址,本机的MAC地址作为源 MAC地址,目的MAC地址需要分情况处理。通过将IP地址与本机的子网掩码相结合,可以判断是否与请求主机在同一个子网里,如果在同一个子网里,可以使用 ARP 协议获取到目的主机的 MAC地址,如果不在一个子网里,那么请求应该转发给网关,由它代为转发,此时同样可以通过 ARP协议来获取网关的 MAC地址,此时目的主机的MAC地址应该为网关的地址。
  5. 建立TCP连接:浏览器使用HTTP或HTTPS协议建立与目标服务器的TCP连接。对于HTTPS连接,还需要进行SSL/TLS握手以确保安全通信。
  6. 发送HTTP请求:一旦TCP连接建立,浏览器会构建一个HTTP请求,其中包含了请求的方法(GET、POST等)、路径、请求头、可能的请求体(对于POST请求)等信息。这个HTTP请求被发送到目标服务器。
  7. 服务器处理请求: 目标服务器接收到HTTP请求后,会根据请求的内容(例如路径)来处理请求,生成响应。
  8. 服务器发送HTTP响应: 服务器会构建一个HTTP响应,其中包含了响应状态码、响应头、响应体等信息。这个HTTP响应被发送回浏览器。
  9. 浏览器接收响应: 浏览器接收到服务器的HTTP响应后,会根据响应的内容进行处理。这可能包括解析HTML、CSS和JavaScript,渲染页面,以及执行页面上的脚本。
  10. 显示网页内容: 最终,浏览器将解析后的页面内容显示在用户的屏幕上,用户可以与网页进行交互。

包的封装过程: 大概是[这个过程,路由器,网卡,交换机的诞生和区别,我觉得可以参考这篇文章 也可以参考这个](https://xiaolincoding.com/network/1_base/what_happen_url.html#%E5%87%BA%E5%A2%83%E5%A4%A7%E9%97%A8-%E8%B7%AF%E7%94%B1%E5%99%A8)

  1. 应用层:生成HTTP请求信息,请求内容
  2. 传输层:TCP连接确保传输的可靠性(滑动窗口,ACK,校验和),会封装端口号
  3. 网络层:传输到互联网里的哪一个计算机,所以会封装IP地址。IP地址是逻辑地址,主要用来标识属于哪一个子网
  4. 数据链路层/网络层: 封装MAC地址,唯一标识一个网卡,使用ARP协议根据IP地址广播获取。一般找的是下一跳的MAC地址(路由器)
  5. 数据链路层:网卡,封装上报头起始分隔帧,起到分隔的作用
  6. 数据链路层:路由器的端口具有 MAC地址,因此它就能够成为以太网的发送方和接收方;同时还具有IP地址,从这个意义上来说,它和计算机的网卡是一样的。当转发包时,首先路由器端口会接收发给自己的以太网包,然后路由表查询转发目标,再由相应的端口作为发送方将以太网包发送出去。

最终包的样子

常见的请求头和响应头有哪些

一、常见请求头(Request Headers)

请求头字段说明
==Host== 服务器域名和端口号(必须)
==User-Agent== 客户端软件信息(浏览器、设备等)
==Accept== 客户端可接受的响应内容类型(如 text/html, application/json)
==Accept-Encoding== 客户端支持的压缩编码(如 gzip, deflate, br)
Accept-Language 客户端支持的语言(如 zh-CN, en-US)
==Connection== 是否保持连接(如 keep-alive 或 close)
==Content-Type== 请求体数据类型(如 application/json, application/x-www-form-urlencoded)
==Content-Length== 请求体的字节长度
==Authorization== 授权认证信息(如 Bearer token、Basic xxx)
==Cookie== 客户端随请求发送的 cookie 信息
Referer 当前请求页面的来源页面 URL
Origin 请求来源(用于跨域请求控制)
X-Requested-With 通常是 XMLHttpRequest,标识这是 AJAX 请求

二、常见响应头(Response Headers)

响应头字段说明
==Content-Type== 响应体的数据类型(如 text/html, application/json)
==Content-Length== 响应体的字节长度
==Set-Cookie== 设置 Cookie 给客户端
==Cache-Control== 缓存策略(如 no-cache, max-age=3600)
Expires 响应过期时间(配合缓存使用)
Last-Modified 资源上次修改时间
ETag 资源的唯一标识,用于缓存和比对
==Location== 重定向地址(常用于 3xx 响应)
Access-Control-Allow-Origin 跨域资源共享控制,允许哪些来源访问资源
WWW-Authenticate 表示服务器要求认证(通常配合 401 状态码)
==Content-Encoding== 表明响应内容使用了什么压缩方式(如 gzip)
==Server== 服务器软件信息(如 nginx, Apache)
==Connection== 是否关闭连接或保持长连接

场景题:我现在需要做一个一对一的聊天功能,对吧?比如说让你做一个微信是满足用户与用户之间的单点沟通,你可以讲讲这个方案怎么设计吗?比如说从服务端到前端需要怎么设计,都需要考虑哪些点?

问GPT的有点东西,但有些技术我也不懂

一、整体架构设计(高层次视图)

首先明确设计架构: • 客户端(Client):前端用户界面(APP或Web)。 • 服务端(Server): • 提供用户在线状态管理 • 消息的即时推送(WebSocket) • 消息缓存与存储(如Redis、MySQL) • 存储层(Data Storage):持久化消息与用户信息 • 消息队列(Message Queue):用于异步处理,确保服务端稳定性(如Kafka、RabbitMQ)

Client A    <------ WebSocket ------> 服务端 <------ WebSocket ------> Client B
                                         |
                                         | 存取消息记录
                                         V
                                   存储层(Redis/MySQL)

二、服务端设计(关键技术与流程)

(1)通讯协议选择 • 推荐使用 WebSocket(即时消息双向通信,延迟极低) • 备选方案:长轮询(Long polling)、Socket.IO(封装的WebSocket方案) (2)服务端技术栈选择Java技术栈: • Spring Boot / Spring Cloud • Netty + WebSocket (3)用户在线状态管理 • 使用Redis存储用户状态(在线/离线),便于实时查询和推送消息。 • 每次用户连接或断开连接,更新Redis状态。 (4)消息投递与ACK机制 • 用户A发送消息给用户B,服务端即时投递。 • 如果用户B在线,通过WebSocket实时推送,并设置ACK确认机制,确保消息可靠。 • 若用户B离线,则消息存储在服务端,用户上线时自动拉取。 (5)消息队列异步解耦(推荐) • 客户端发消息到服务端,服务端把消息写入消息队列(Kafka、RabbitMQ),再进行异步分发,提升性能和可靠性。

 三、数据存储方案设计

(1)缓存层设计(Redis) • 用于临时存储最近消息(热数据),快速响应前端拉取聊天列表。 • 存储在线状态和未读消息列表。 (2)数据库存储设计(MySQL或MongoDB) • MySQL: • 表结构设计为:

message表:
- message_id(主键)
- from_user_id(发送方)
- to_user_id(接收方)
- content(消息内容)
- create_time(发送时间)
- status(消息状态:未读/已读)

• MongoDB(更适合高并发高频聊天): • 存储文档结构:

{
    "_id": ObjectId,
    "from_user_id": "用户A",
    "to_user_id": "用户B",
    "content": "消息内容",
    "created_at": "时间戳",
    "status": "read/unread"
}

四、前端设计(Web端/APP端)

(1)前端架构选型: • Web端: • React / Vue.js + WebSocket(Socket.IO) • App端: • 原生开发(Android/iOS)或 Flutter/React Native 跨平台开发 (2)前端消息发送与接收:建立连接: • 前端启动后,通过WebSocket连接到服务端,保持长连接。 • 消息发送: • 用户点击发送按钮 → 前端通过WebSocket发送消息JSON到服务端。 • 消息接收: • WebSocket监听服务端推送,前端即时更新聊天界面。 (3)消息可靠性处理: • 实现客户端本地消息缓存(如IndexedDB或SQLite)保证弱网环境下消息不会丢失。 • 实现消息发送失败自动重试逻辑。

 五、安全性与权限控制

认证与鉴权(Authentication & Authorization) • 使用JWT Token,保证API接口的安全访问。 • WebSocket连接时检查Token有效性。 • 数据加密 • 消息传输采用TLS/SSL(HTTPS)。 • 对敏感消息内容加密。 • 防止攻击 • 限流机制、消息长度校验、防止SQL注入。

六、性能优化与扩展性考虑

(1)高并发处理 • 使用Netty、Go语言等高性能框架应对高并发WebSocket连接。 • 异步架构(消息队列解耦)。 (2)负载均衡与扩展 • 部署多个WebSocket节点,通过负载均衡(如Nginx)进行连接分发。 (3)水平扩容与分布式架构 • 服务端无状态设计,支持水平扩容。 • 分布式Redis集群与消息队列集群。

七、其他重要考虑点

消息送达状态(已发送、已送达、已读状态同步) • 离线消息处理(缓存离线消息) • 历史消息拉取与分页(从数据库或缓存层加载历史聊天记录) • 异常情况处理(网络中断、客户端异常断开连接处理) • 监控与报警系统(实时监控服务状态、消息延迟报警)

八、推荐具体技术栈组合举例:

服务端: • Spring Boot + Netty + WebSocket • Redis + MySQL/MongoDB • Kafka/RabbitMQ 异步消息队列 • JWT + HTTPS 加密 • Web端: • React + Redux + WebSocket (Socket.IO) • App端: • Flutter/React Native 原生Socket支持或原生开发

你技术层面遇到过最难的东西是

自己编

你这边生活中用大模型做过一些东西吗

感觉大家挺重视cursor的,好几个人这样问我,但我没用过。。

算法题: 14. 最长公共前缀

#面经##软件开发笔面经##牛客激励计划#
全部评论

相关推荐

04-02 13:46
门头沟学院 Java
美团二面2099人在聊 查看15道真题和解析
点赞 评论 收藏
分享
评论
2
21
分享

创作者周榜

更多
牛客网
牛客企业服务