字节日常实习一面
上次二面挂了之后,晚上另一个北京部门捞了一下,所以有了这次面试,目前已经经历了5次面试,没有当初那份羞涩了,但还是有很多八股没有深入了解,还是有些惭愧的。值得一提的是,这次面试官是个中年大叔,至少是开摄像头面试了,还是有些基础尊重的,之前的字节摄像头都不开。
以下是对问题的记录,希望自己吃一堑长一智。
2min的自我解释,接下来就是八股:
1、Spring中工厂模式和代理模式分别是什么,有什么应用?
🔹 1. 工厂模式(Factory Pattern)
定义:
工厂模式是创建型设计模式,用来封装对象的创建过程,让调用方不用关心具体如何 new
对象。
在 Spring 中的体现:
- BeanFactory / ApplicationContext这是 Spring 的 IoC 容器,本质上就是一个 超级工厂。你只需要告诉 Spring 我要一个某某类型的对象,Spring 工厂就会负责:什么时候创建怎么初始化(依赖注入、配置属性)生命周期管理(单例、多例,初始化回调、销毁回调)
ApplicationContext context = new ClassPathXmlApplicationContext("beans.xml"); UserService userService = context.getBean("userService", UserService.class);
👉 这里 context 就是一个 工厂,你并没有 new UserService()。
- FactoryBean 接口如果某个 Bean 的创建过程很复杂,Spring 提供了 FactoryBean,由它来专门生成复杂 Bean。例如 MyBatis 的 SqlSessionFactoryBean,就是用工厂模式帮我们生成 SqlSessionFactory。
应用场景总结:
- IoC 容器管理对象创建(替代 new)。
- 配合依赖注入,解耦对象之间的依赖。
- 复杂对象生成(如连接池、ORM 会话工厂)。
🔹 2. 代理模式(Proxy Pattern)
定义:
代理模式是结构型设计模式,用来在不改变原始对象代码的情况下,通过一个“代理对象”来控制对目标对象的访问。
在 Spring 中的体现:
- AOP(面向切面编程)AOP 的实现几乎全靠代理。Spring 使用 JDK 动态代理(基于接口)或 CGLIB 动态代理(基于子类继承)来生成代理对象。这样我们就可以在方法调用前后织入逻辑,比如事务管理、日志、权限控制等。
@Service public class OrderService { @Transactional public void createOrder() { // 订单业务逻辑 } }
👉 Spring 会用代理模式包装 OrderService,在调用 createOrder() 前后自动加上事务逻辑。
- 声明式事务当你用 @Transactional 时,Spring 并不是修改了你的业务类,而是创建了一个代理对象,里面增强了事务逻辑。
- 远程调用、缓存代理比如 Dubbo、Feign 客户端,调用的时候其实是代理对象在帮你做远程 RPC 请求。
应用场景总结:
- 日志、监控、性能统计(方法调用增强)。
- 权限校验。
- 缓存、延迟加载。
- 事务管理。
2、动态代理有2种方式,分别讲解一下
动态代理是一种在运行时动态生成代理对象,并在代理对象中增强目标对象方法的技术。它被广泛用于 AOP(面向切面编程)、权限控制、日志记录等场景,使得程序更加灵活、可维护。动态代理可以通过 JDK 原生的 Proxy 机制或 CGLIB 方式实现。
JDK 动态代理基于接口
通过 Proxy.newProxyInstance(classLoader, interfaces[], invocationHandler) 在运行时生成一个实现了给定接口集合的代理类(字节码由 JVM 生成并加载)。
每次调用代理对象的方法,会被转到 InvocationHandler#invoke(Object proxy, Method method, Object[] args),由 invoke 负责调用真实对象(或做其他处理)。
当使用 JDK 动态代理时,主要分为四步,
第一步是定义接口,由于动态代理是基于接口进行代理的,因此目标对象必须实现接口。
第二步是创建并实现 InvocationHandler 接口,并在 invoke 方法中定义增强逻辑。
第三步是生成代理对象,使用 Proxy.newProxyInstance 创建代理对象,代理对象内部会调用 invoke 方法。
第四步是调用代理方法,当调用代理对象的方法时,invoke 方法会被触发,执行增强逻辑,并最终调用目标方法。
优缺点
- 优点:JDK 自带、简单、无需第三方库;适合有接口的场景(接口代理)。
- 缺点:只能代理接口(目标必须实现接口);代理对象不是目标类类型(proxy instanceof ServiceImpl 为 false);内部通过反射 Method.invoke(调用开销,虽 JVM 优化后不再太慢)。
常见坑/注意
- Proxy 生成的代理类实现接口集合,但不是目标类的子类(类型转换要按接口);
- equals/hashCode/toString 的处理通常要在 InvocationHandler 内显式考虑;
- 如果目标类没有接口,JDK 代理不可用(Spring 默认若目标含接口用 JDK 代理)。
CGLIB(Code Generation Library)在运行时生成目标类的子类
并在子类中覆盖(override)可被覆盖的方法,在覆盖方法中插入拦截逻辑,通常使用 MethodInterceptor。
调用代理方法时,拦截器可通过 MethodProxy.invokeSuper(obj, args) 调用父类(即真实方法),比反射更快。
第一步是通过 Enhancer 创建代理对象。
第二步是设置父类,CGLIB 代理基于子类继承,因此代理对象是目标类的子类。
第三步是定义并实现 MethodInterceptor 接口,在 intercept 方法中增强目标方法。
第四步是调用代理方法,当调用代理对象的方法时,intercept 方法会被触发,执行增强逻辑,并最终调用目标方法。
优缺点
- 优点:能代理没有接口的类(类级别代理);方法调用快于反射(invokeSuper);代理对象是目标类的子类,instanceof 为 true。
- 缺点:不能代理 final 类或 final 方法(因为无法重写);生成字节码开销较大(类数量多时要考虑 Metaspace/PermGen),有时需要缓存代理类;创建代理需要目标类可被继承、可访问构造器。
常见坑/注意
- 无法代理 final class 或 final 方法;
- 代理类会在类加载区产生元数据,多次动态生成类会占用 Metaspace,需注意缓存或重用;
- 如果目标类依赖 private 方法(CGLIB 无法拦截 private),这些调用不会被拦截。
3、ArrayList和LinkedList底层有什么区别,是线程安全的吗?
1. ArrayList 的底层原理
- 数据结构:基于 动态数组(Object[])
- 存储方式:元素连续存储,随机访问快(可通过索引直接定位)
- 扩容机制:默认初始容量 10(JDK 8+)扩容时,容量 = 原容量 × 1.5(即 oldCapacity + (oldCapacity >> 1))需要 System.arraycopy() 拷贝到新数组
- 时间复杂度:随机访问(get、set):O(1)插入/删除(尾部 add/remove):摊还 O(1)插入/删除(中间位置):O(n),因为涉及元素搬移
- 适用场景:查询多、增删少
2. LinkedList 的底层原理
- 数据结构:基于 双向链表
- 存储方式:节点(Node)包含三部分:prev、item、next
- 特性:插入/删除不需要移动元素,只需修改前后节点指针不支持随机访问,查找必须从头或尾遍历,时间复杂度 O(n)
- 时间复杂度:插入/删除(头尾):O(1)插入/删除(中间):O(1)(找到节点后)查找(get):O(n)
- 适用场景:增删多、查询少
3. 线程安全性
- ArrayList / LinkedList 默认都是非线程安全的。多线程环境下同时修改(如 add/remove)可能导致数据错乱、ConcurrentModificationException。
- 解决办法:使用 Collections.synchronizedList(new ArrayList<>())使用并发容器 CopyOnWriteArrayList(读多写少场景更适合)
4、ConcurrentHashMap为什么能线程安全,底层是怎么实现的?使用什么锁,锁住的是什么内容?
HashMap 在多线程环境下是不安全的(扩容时可能导致死循环)。
Hashtable 虽然线程安全,但 效率低,因为它对整个 Map 加了 一把大锁(synchronized)。
ConcurrentHashMap 就是为了解决:线程安全 + 高并发性能。
底层实现(JDK 1.7 vs JDK 1.8)
JDK 1.7 实现
- 数据结构:Segment 数组 + HashEntry 数组。
- 每个 Segment 是一个小的 HashTable,相当于把大锁拆分成多把锁(分段锁,默认 16 段)。
- 操作时只锁定对应的 Segment,提高并发度。
类比:把一个超市分成 16 个收银台,每个收银台自己排队,不会互相干扰。
JDK 1.8 实现
- 数据结构:Node 数组 + 链表/红黑树(跟 HashMap 类似)。
- 取消了 Segment 分段锁,改用 CAS + synchronized 锁 Node 节点。
- put 流程:定位桶(数组下标)如果桶为空,用 CAS 直接插入(无锁操作)如果桶不为空,用 synchronized 锁住链表/树的头节点,再插入链表长度大于 8 转为红黑树(查询更快)
类比:每个收银台再细分,如果没人排队就直接走(CAS),有人排队就锁住队伍的入口(synchronized),大家按顺序进。
锁的机制和范围
- 锁的对象:不是整个 Map,也不是整个数组,而是 某个桶(bin)的头节点。
- 锁的方式:当桶为空时使用CAS插入,当桶非空,则使用synchronized锁住头节点再增删改。
- 扩容时:采用分段迁移(多个线程可以一起搬运数据),进一步提升并发性能。
为什么线程安全?
- CAS + volatile:保证写入和读取的可见性与原子性。
- synchronized(锁粒度缩小):只锁桶的头节点,不会锁整个 Map。
- 分段迁移:扩容时多个线程协作,不会阻塞全局。
5、CMS垃圾回收机制讲解一下,如果创建一个对象,一定会存放到新生代吗?
1. CMS 垃圾回收机制
CMS(Concurrent Mark-Sweep)是 JDK 1.5 引入的一种 老年代垃圾回收器,目标是 低延迟。
工作流程(4 步)
- 初始标记(Stop the World)标记 GC Roots 直接关联的对象,速度快。
- 并发标记(和用户线程并发执行)从 GC Roots 出发,遍历对象图,标记可达对象。
- 重新标记(Stop the World)修正并发标记阶段,因用户线程运行而遗漏的对象引用。
- 并发清理(和用户线程并发执行)回收垃圾对象,释放空间。
👉 好处:停顿时间短,适合对 低延迟 要求高的应用(Web 服务)。
👉 缺点:
- 会产生内存碎片(标记-清除,不是压缩算法)。
- CPU 消耗大(GC 线程与用户线程并行竞争)。
2. 对象分配:一定在新生代吗?
不一定! 这是个容易踩坑的地方。默认情况下:
- 大多数新对象 → 新生代(Eden 区)
- 长期存活的对象 → 老年代
- 特殊情况 → 直接进入老年代
规则总结
- 新对象默认分配到 Eden 区JVM 会先尝试在 Eden 分配内存。
- 大对象直接进入老年代比如创建一个很大的数组/字符串,为了避免在新生代频繁复制,直接放老年代。参数:-XX:PretenureSizeThreshold(大于这个值的对象直接进老年代)。
- 长期存活对象晋升老年代对象经历多次 Minor GC 后,年龄达到阈值(默认 15,可以用 -XX:MaxTenuringThreshold 配置),会被晋升老年代。
- 动态年龄判定JVM 会根据 Survivor 区情况动态决定哪些对象晋升。如果某一年龄段的对象总和 > Survivor 区一半,年龄 ≥ 该值的对象直接进老年代。
- 空间担保机制在 Minor GC 前,如果 Survivor 空间不足,JVM 会直接把一些对象放到老年代。
6、脏读、不可重复读、幻读分别是什么?Mysql是如何解决这个问题的?
1️⃣ 三个概念
(1)脏读(Dirty Read)
- 定义:一个事务读到了另一个事务还没提交的数据。
- 风险:如果对方事务回滚了,那你读到的数据就是不存在的“脏数据”。
- 例子:T1:修改账户余额 100 → 50(未提交)。T2:读到账户余额是 50。T1:回滚,余额还是 100。👉 T2读到的 50 就是脏数据。
(2)不可重复读(Non-Repeatable Read)
- 定义:同一个事务中,两次读取同一行记录,结果不一致(因为中途被别的事务修改并提交)。
- 例子:T1:读取余额 = 100。T2:修改余额为 200 并提交。T1:再读取余额 = 200。👉 T1前后两次读到的数据不一样,出现不可重复读。
(3)幻读(Phantom Read)
- 定义:同一个事务中,两次执行相同的查询,结果行数不同(因为中途别的事务插入或删除了数据)。
- 例子:T1:查询 age > 18 的记录,得到 10 行。T2:插入一条新数据 age=20 并提交。T1:再查询 age > 18,得到 11 行。👉 T1发现“凭空多了一条”,就像出现了“幻觉”。
2️⃣ MySQL 如何解决这些问题
MySQL 默认使用 InnoDB 存储引擎,它主要依赖 事务隔离级别 + MVCC + 锁机制 来解决。
四个隔离级别
隔离级别 | 能否脏读 | 能否不可重复读 | 能否幻读 | 实现方式 |
READ UNCOMMITTED(读未提交) | ❌ 有 | ❌ 有 | ❌ 有 | 几乎无隔离 |
READ COMMITTED(读已提交) | ✅ 无 | ❌ 有 | ❌ 有 | 每次读都生成快照 |
REPEATABLE READ(可重复读,MySQL默认) | ✅ 无 | ✅ 无 | ❌ 有(MySQL通过特殊方式解决) | MVCC + 间隙锁 |
SERIALIZABLE(串行化) | ✅ 无 | ✅ 无 | ✅ 无 | 全表锁/行锁,强一致性 |
InnoDB 的具体解决方案
- 脏读:通过 不可见未提交数据(即 MVCC:多版本并发控制)解决。事务只能看到已提交的数据快照。
- 不可重复读:在 REPEATABLE READ 下,事务使用一致性视图(快照)保证同一个事务多次读到的结果一致。
- 幻读:MySQL 在 REPEATABLE READ 下,使用 间隙锁(Gap Lock)+ MVCC 来解决。查询范围数据时,会锁住“范围之间的间隙”,防止其他事务插入新行。比如 SELECT * FROM user WHERE age > 18 FOR UPDATE; 会锁住满足条件的记录和这些记录之间的间隙。
7、MVCC和幻读有什么关系?MVCC底层依靠什么来实现?
1️⃣ MVCC 和幻读的关系
- MVCC(多版本并发控制)主要解决的是 读一致性 问题,即让读操作不用阻塞写操作,也不会读到未提交数据。
- 它能解决:✅ 脏读(读不到未提交的版本)✅ 不可重复读(同一事务里的多次读取保持一致性)
- 但是:❌ 幻读(Phantom Read)MVCC本身解决不了,因为幻读涉及“结果集的行数变化”(新行的插入),而 MVCC 只控制“已存在行的不同版本”,对“新插入的行”是无能为力的。
👉 所以:MVCC ≠ 幻读解决方案,幻读需要依赖 锁机制(间隙锁) 来额外处理。
2️⃣ MVCC 底层依靠什么实现?
InnoDB 的 MVCC 底层依靠了 隐藏字段 + Undo Log + ReadView:
- 隐藏字段(每行数据隐含的元信息):trx_id:最近一次修改该行的事务ID。roll_pointer:指向该行修改前的旧版本(Undo Log链表)。delete_flag:标记是否被删除。
- Undo Log:每次事务修改数据时,都会把旧数据写到 Undo Log 中。回滚时,可以通过 Undo Log 恢复。MVCC 读取时,可以通过 Undo Log 找到“历史版本”。
- ReadView(快照视图):每个事务启动时,会生成一个 一致性视图,记录哪些事务是“可见的”。读取时,InnoDB 会根据 trx_id 和 ReadView 判断某个版本是否对当前事务可见。👉 保证“可重复读”:同一个事务里的多次查询看到的是同一个数据版本。
⚡总结:MVCC = 隐藏字段 + Undo Log + ReadView。
3️⃣ 幻读依靠锁来解决,具体怎么实现?
幻读的核心问题是:别的事务 插入/删除了新行,导致结果集变多/变少。
InnoDB 在 REPEATABLE READ 隔离级别下,用 Next-Key Lock(临键锁 = 记录锁 + 间隙锁) 解决:
- 记录锁(Record Lock):锁住某条已有的记录。
- 间隙锁(Gap Lock):锁住两个记录之间的空隙,防止别人插入。
- 临键锁(Next-Key Lock):记录锁 + 间隙锁的结合,既锁住已有记录,也锁住范围间隙。
8、如果没有缓存,在执行查询语句时,一定会进行回表查询吗?
1️⃣ 什么是回表查询?
- InnoDB 的 二级索引(普通索引) 叶子节点存的不是数据行,而是 主键值。
- 当通过二级索引找到满足条件的主键后,还需要 再回到聚簇索引(主键索引) 取出完整数据,这个过程就叫 回表。
例子:
SELECT name FROM user WHERE age = 20;
- 如果 age 是二级索引:先在二级索引树找到 age=20 对应的主键 id;再回到聚簇索引(主键索引)去拿 name 字段。
2️⃣ 没有缓存时,一定会回表吗?
👉 不一定!
是否回表,取决于 查询语句要的字段 和 索引的覆盖情况:
- 情况 1:查询字段已包含在索引里 → 不需要回表这种情况叫 覆盖索引(Covering Index)。如果语句里只用到了索引里就能提供的字段,就不用回表。例子:
SELECT name FROM user WHERE age = 20;
只查 age,而 age 已经在二级索引里存着,不需要回表。
- 情况 2:查询字段不在索引里 → 必须回表如果语句里需要的列不在二级索引里,就要回表到聚簇索引取完整数据。例子:二级索引只存 age 和 id,name 没有 → 必须回表。
- 情况 3:用到主键索引查询 → 不存在回表因为聚簇索引叶子节点本身就存了整行数据,不需要再查别的地方。
3️⃣ 总结
即使 没有缓存,执行查询时 也不一定会回表,关键看:
- 是否走主键索引(不回表)。
- 是否是覆盖索引(不回表)。
- 是否使用二级索引且查询了额外字段(才需要回表)。
9、spring是如何解决循环依赖的?
1️⃣ 什么是循环依赖?
举个例子:
@Component class A { @Autowired private B b; } @Component class B { @Autowired private A a; }
- Spring 容器启动时,要创建 A,发现需要 B;
- 创建 B 时,又需要 A;
- 如果没有特殊处理,就会死循环,报错 BeanCurrentlyInCreationException。
2️⃣ Spring 解决循环依赖的前提
Spring 只能解决单例作用域(singleton)下的循环依赖,
prototype(原型作用域)无法解决,因为每次都会 new 出一个新对象,容器没法提前暴露“半成品”对象。
3️⃣ Spring 的三级缓存
Spring 用了 三级缓存机制 来解决循环依赖:
- singletonObjects(一级缓存)存放完全创建好的单例 Bean。
- earlySingletonObjects(二级缓存)存放提前暴露的“半成品” Bean(已经实例化,但还没属性注入完成)。
- singletonFactories(三级缓存)存放 Bean 工厂对象,可以生成“半成品 Bean”,支持 AOP 代理。
4️⃣ 解决循环依赖的流程
假设创建 A
需要 B
,而 B
又需要 A
:
- Spring 开始创建 A,先实例化(构造方法执行),此时还没注入属性。把 A 的工厂对象放进 singletonFactories(三级缓存)。
- Spring 发现 A 需要注入 B,就去创建 B。同样,B 实例化后,把 B 的工厂对象放进 singletonFactories。
- B 在属性注入时需要 A,Spring 去容器里找 A。在一级缓存找不到(因为 A 还没完全创建好),在二级缓存找不到(还没放进去),在三级缓存里找到了 A 的工厂,于是通过工厂生成“半成品 A”。把这个“半成品 A”放进二级缓存,同时从三级缓存删除。
- B 成功注入了“半成品 A”,继续完成自己的属性注入和初始化,最后把 B 放进一级缓存。
- 回到 A,注入 B(这时已经是完整体),然后 A 完成初始化,进入一级缓存。
✨ 到这里,循环依赖解决。
5️⃣ 小总结
Spring 解决循环依赖靠的就是 三级缓存提前暴露对象:
- 一级缓存 → 存完整 Bean
- 二级缓存 → 存半成品 Bean
- 三级缓存 → 存工厂对象(解决 AOP 场景,能生成代理对象)
👉 所以:
- 单例作用域 → 能解决循环依赖
- 原型作用域 → 无法解决
- 构造器循环依赖 → 也无法解决(因为构造方法执行时必须先有完整对象)
10、spring自动装配原理是什么,spring boot与spring如何配合启动一个项目?
1️⃣ Spring 自动装配原理
Spring 中的“自动装配”(Autowiring)主要是指 依赖注入(DI) 的过程。它的核心是 IoC 容器 和 BeanDefinition 的管理。
核心流程:
- 扫描 + 注册Spring 通过 @ComponentScan 或 XML 配置扫描 Bean 的定义信息(类名、作用域、依赖等)。扫描到的 Bean 信息会注册到 BeanDefinitionMap 中。
- 实例化 Bean(反射创建对象)Spring 根据 BeanDefinition 用 反射 调用构造器,生成对象。这时对象只是“半成品”,还没有注入依赖。
- 依赖注入如果属性上有 @Autowired、@Resource、@Inject,Spring 会去容器里找匹配的 Bean,赋值到属性中。如果存在循环依赖,触发之前讲过的三级缓存机制。
- 初始化与增强执行 InitializingBean、@PostConstruct 等回调。如果有 AOP 增强,Spring 会在这里生成代理对象。
自动装配的实现原理:
- AutowiredAnnotationBeanPostProcessor:专门处理 @Autowired 注解,完成依赖注入。
- BeanPostProcessor:在 Bean 初始化前后,执行扩展逻辑,比如依赖注入、AOP 代理。
- 依赖查找策略:先按类型(byType)匹配,如果有多个 Bean,再按名称(byName)精确匹配。
2️⃣ Spring Boot 与 Spring 的配合启动流程
Spring Boot 相当于在 Spring 框架的基础上,帮你做了 自动配置 和 简化启动。
Spring Boot 启动原理:
1、入口函数每个 Spring Boot 项目都有一个启动类:
@SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }
2、SpringApplication.run()
创建 SpringApplication 对象,加载各种初始化器(Initializer)、监听器(Listener)。
启动 IoC 容器(默认是 Spring 的 AnnotationConfigApplicationContext 或 SpringApplicationContext)。
3、自动配置(核心亮点)
Spring Boot 会读取 spring.factories 配置文件,加载一堆 @Configuration 类。
这些类里定义了各种默认 Bean(比如 DataSource、DispatcherServlet 等)。
通过 @ConditionalOnMissingBean 判断是否已经存在用户自定义的 Bean,如果没有,就用默认配置。👉 这就是 Spring Boot 的 自动装配原理。
4、项目启动完成
完成 Bean 的创建与依赖注入。
启动 Tomcat(嵌入式容器),监听端口。
对外提供服务。
3️⃣ 总结一句话
- Spring → 提供 IoC 容器 + DI + AOP 等核心能力。
- Spring Boot → 在 Spring 基础上,加入 自动配置(通过 spring.factories + @Conditional),简化开发者的配置工作。
所以一个 Spring Boot 项目能快速跑起来,是因为:
👉 Spring 提供基础容器能力,Spring Boot 提供默认配置 + 自动装配,开发者只需写最少的配置。
11、redis作为数据缓存,假设一个线程在执行写入操作时出现宕机,已经写入redis但没写入到MySQL,接下来会发生什么?
1️⃣ 场景复现
假设我们采用的是 先写缓存,再写数据库 的策略:
- 线程开始执行写操作;
- 第一步:写入 Redis 成功;
- 第二步:准备写 MySQL 时,线程宕机了(比如 JVM 崩溃)。
结果:
- Redis 里已经有了新值;
- MySQL 还是旧值;
- 数据出现不一致。
2️⃣ 会发生什么问题?
- 读请求来了如果后续有读请求,大概率会先读 Redis,得到的是错误的新值。而数据库还是旧值,数据出现“短期污染”。
- 缓存过期后Redis 的这条数据会过期并删除;下次读的时候,缓存失效,去查数据库 → 得到旧值。这时 Redis 和数据库就 重新一致 了。
👉 也就是说,问题虽然不是永久性的,但在缓存没过期前,用户读到的数据是不一致的。
3️⃣ 解决思路
✅ 方式一:改变写顺序(推荐)
- 先写 MySQL,再写 Redis。如果写 MySQL 成功但写 Redis 失败,可以通过 重试机制 或 延迟双删策略 来保证一致性。避免了“只写缓存未写数据库”的情况。
✅ 方式二:写消息队列
- 写操作通过 MQ 投递:先写 MySQL,成功后投递 MQ;消费者异步更新/删除 Redis。
- 即使服务宕机,消息也能保证最终处理,达到 最终一致性。
✅ 方式三:事务机制(分布式)
- 用类似 binlog + canal 的订阅方案:MySQL binlog 作为真实数据源;Redis 通过 canal 订阅 binlog 同步更新。
- 这样即使应用写缓存失败,binlog 同步还能修正。
4️⃣ 面试官想听的点
- 你要能说清:这个问题一定会导致短期数据不一致。
- 然后能提出至少两种解决方案:改写顺序(先 DB 再 Cache);使用 MQ 或 binlog 订阅做最终一致性。
手撕代码很简单,令人迷惑的是,我3次字节面试最后手撕代码都是统计一个字符串中出现次数最多的字符,我怀疑面试官是不是故意考验我看我是不是会主动承认……