首页 文章 精选 留言 我的

精选列表

搜索[面试],共4916篇文章
优秀的个人博客,低调大师

【LeetCode-面试算法经典-Java实现】【111-Minimum Depth of Binary Tree(二叉树的最小深度)】

原题 Given a binary tree, find its minimum depth. The minimum depth is the number of nodes along the shortest path from the root node down to the nearest leaf node. 题目大意 给定一棵两叉树求树的最小深度。 解题思路 遍历法,对整个树进行遍历,找出最小的深度。 代码实现 树结果定义 public class TreeNode { int val; TreeNode left; TreeNode right; TreeNode(int x) { val = x; } } 1 2 3 4 5 6 算法实现类 public class Solution { private int min = Integer.MAX_VALUE; // 记录树的最小深度 private int cur = 0; // i当前处理的树的尝试 public int minDepth(TreeNode root) { depth(root); return min; } /** * 计算树的深度 * * @param node 当前结点 */ private void depth(TreeNode node) { if (node == null) { min = cur; return; } cur++; // 当前处理的层次加1 // 如果是叶节点,并且路径比记录的最小还小 if (node.left == null && node.right == null && cur < min) { min = cur; // 更新最小值 } // 处理左子树 if (node.left != null) { depth(node.left); } // 处理右子树 if (node.right != null) { depth(node.right); } cur--; // 还原 } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 /** * 计算树的深度 * * @param node 当前结点 */ private void depth (TreeNode node) { if (node == null ) { min = cur; return ; } cur++; // 当前处理的层次加1 // 如果是叶节点,并且路径比记录的最小还小 if (node.left == null && node.right == null && cur < min) { min = cur; // 更新最小值 } // 处理左子树 if (node.left != null ) { depth(node.left); } // 处理右子树 if (node.right != null ) { depth(node.right); } cur--; // 还原 }}

优秀的个人博客,低调大师

面试官:你简历上有熟悉设计模式,那你给我说一下单例模式实现及线程安全吧

云栖号资讯:【点击查看更多行业资讯】在这里您可以找到不同行业的第一手的上云资讯,还在等什么,快来! 前言 单例应用的太广泛,大家应该都用过,本文主要是想聊聊线程安全的单例以及反序列化破坏单例的情况。 1、概念 确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例。 关键点: 私有化构造函数 通过一个静态方法或枚举返回单例类对象 确保单例类的对象有且只有一个,尤其是多线程环境下 确保单例类对象在反序列化时不会重新构建对象 2、实现 2.1、线程安全的单例 2.1.2、饿汉模式 饿汉模式:不管有没有调用getInstance方法,只要类加载了,我就给你new出来(a) public class A { private static final A a = new A(); public static A getInstance() { return a; } private A() {} } 以下两点保证了以上代码的线程安全: 调用一个类的静态方法的时候会触发类的加载(如果类没加载过) 类只会加载(被加载到虚拟机内存的过程,包括5个阶段)一次 static变量在类初始化的时候(类加载过程的最后一个阶段)会去赋值静态变量 2.1.2、懒汉模式 懒汉模式:延迟加载,用到再去new public class B { private static volatile B b; public static synchronized B getInstance() { if (b == null) { b = new B(); } return b; } private B() { } } 要保证线程安全,最简单的方式是加同步锁。synchroized保证了多个线程串行的去调用getInstance(),既然是串行,那就不会存在什么线程安全问题了。但是这实现,每次读都要加锁,其实我们想要做的只是让他写(new)的时候加锁。 2.1.3、Double Check Lock (DCL) public class B { private static volatile B b; public static synchronized B getInstance0() { if (b == null) { synchronized (B.class) { b = new B(); } } return b; } public static B getInstance() { if (b == null) { synchronized (B.class) { if (b == null) { b = new B(); } } } return b; } private B() { } } 为了解决懒汉模式的效率问题,我们改造成getInstance0(): 但还有个问题 X、Y 两个线程同时进入if (b == null), X先进同步代码块,new了一个B,返回。Y等到X释放锁之后,它也进了同步代码块,也会new一个B。 getInstance0()解决了效率问题,但它不是线程安全的。我们有进行了一次改造: getInstance(): getInstance在同步块里面,又做了一次if (b == null)的判断,确保了Y线程不会再new B,保证了线程安全。 getInstance() 也正是所谓的双重检查锁定(double checked locking)。 这里还有一个关键点:private static volatile B b;b是用volatile修饰的。 这个主要是因为new 并不是原子的。 B b = new B(); 可以简单的分解成一下步骤: 分配对象内存 初始化对象 设置引用指向分配的内存地址 2,3 直接可能发生指令重排序,就是说对象还未初始化完成,就让b指向了一块内存地址,这时候b就不是null了。 2.1.4、静态内部类单例模式 public class C { private C() {} public static C getInstance() { return CHolder.c; } private static class CHolder { private static final C c = new C(); } } 静态内部类的线程安全也是由jvm保证的,在调用Cholder.c的时候,去加载CHolder类,new 了一个c。 总的来说,这个方式比DCL还是高点的,因为DCL加了volatile,效率上还是略微有些些影响。 上面介绍的3种线程安全的单例,在有种极端的情况,单例模式有可能被破坏:反序列化 Java序列化就是指把Java对象转换为字节序列的过程Java反序列化就是指把字节序列恢复为Java对象的过程。 反序列化的时候,会重新构造一个对象,破坏单例模式。我们看下代码验证下: public class C1 implements Serializable { private C1() { System.out.println("构造方法"); } public static C1 getInstance() { return CHolder.c; } private static class CHolder { private static final C1 c = new C1(); } // 注意这块被注释的代码 // private Object readResolve(){ // System.out.println("read resolve"); // return CHolder.c; // } public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { C1 c = C1.getInstance(); System.out.println(c.toString()); try { ObjectOutputStream o = new ObjectOutputStream( new FileOutputStream("d:/tmp/c.out")); o.writeObject(c); o.close(); } catch(Exception e) { e.printStackTrace(); } C1 c1 = null, c2 = null; try { ObjectInputStream in =new ObjectInputStream( new FileInputStream("d:/tmp/c.out")); c1 = (C1)in.readObject(); in.close(); } catch(Exception e) { e.printStackTrace(); } try { ObjectInputStream in =new ObjectInputStream( new FileInputStream("d:/tmp/c.out")); c2 = (C1)in.readObject(); in.close(); } catch(Exception e) { e.printStackTrace(); } System.out.println("c1.equals(c2) : " + c1.equals(c2)); System.out.println("c1 == c2 : " + (c1 == c2)); System.out.println(c1); System.out.println(c2); } } 结果: 构造方法 me.hhy.designpattern.singletonpattern.C1@1540e19d c1.equals(c2) : false c1 == c2 : false me.hhy.designpattern.singletonpattern.C1@135fbaa4 me.hhy.designpattern.singletonpattern.C1@45ee12a7 放开注释的代码 构造方法 me.hhy.designpattern.singletonpattern.C1@1540e19d read resolve read resolve c1.equals(c2) : true c1 == c2 : true me.hhy.designpattern.singletonpattern.C1@1540e19d me.hhy.designpattern.singletonpattern.C1@1540e19d 正如我们看到的那样,加上readResolve就解决了反序列化单例被破坏的问题。 当然,如果没实现Serializable接口,也就不会有这个被破坏的问题… 还是看场景。 关于readResolve的介绍,感兴趣的同学们可以看java.io.ObjectInputStream#readUnshared方法上的注释(博主看了,看得不是很明白,一知半解,就不误人子弟了) 而我们下面要介绍的枚举单例,并不会有这个问题。 2.1.5、枚举单例 public enum DEnum { INSTANCE; private D d; DEnum() { d = new D(); } public D getInstance() { return d; } } public class D {} 线程安全的保证: 枚举只能拥有私有的构造器 枚举类实际上是一个继承Enum的一个final类 上面的INSTANCE实际是被static final 修饰的 序列化不破坏单例的保证: 在序列化的时候Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过java.lang.Enum的valueOf方法来根据名字查找枚举对象。同时,编译器是不允许任何对这种序列化机制的定制的,因此禁用了writeObject、readObject、readObjectNoData、writeReplace和readResolve等方法。 2.2 线程不安全的单例 2.2.1、懒汉模式 不过多介绍了,这个其实在线程安全的单例部分,我们介绍的比较详细了。 public class B { private static volatile B b; public static B getInstance() { if (b == null) { b = new B(); } return b; } private B() { } } 3. 总结 单例的应用实在是太多了,也没必要再去找源码种的经典使用(因为基本上大家用过)。 枚举单例构造方法还是public,并不是防止外部直接去new它。个人认为如果一个类要开放给外部使用,用内部类的形式实现单例是最合适的。 【云栖号在线课堂】每天都有产品技术专家分享!课程地址:https://yqh.aliyun.com/live 立即加入社群,与专家面对面,及时了解课程最新动态!【云栖号在线课堂 社群】https://c.tb.cn/F3.Z8gvnK 原文发布时间:2020-08-04本文作者:程序员伟杰本文来自:“掘金”,了解相关信息可以关注“掘金”

优秀的个人博客,低调大师

[雪峰磁针石博客]Python经典面试题: 用3种方法实现堆栈和队列并示例实际应用场景

介绍 数据结构在计算机中组织存储,以便我们可以有效地访问和更改数据。 堆栈和队列是计算机科学中定义的最早的数据结构。 堆栈 遵循后进先出 (Last-in-First-Out LIFO)原则。 push - 在堆栈顶部添加元素: pop - 删除堆栈顶部的元素: 队列 遵循先入先出(FIFO:First-in-First-Out)原则。 enqueue - 在队列的开头添加元素: dequeue - 删除队列开头的元素: 使用列表实现堆栈和队列 Python的内置List数据结构k堆栈和队列操作的方法。 堆栈 letters = [] # Let's push some letters into our list letters.append('c') letters.append('a') letters.append('t') letters.append('g') # Now let's pop letters, we should get 'g' last_item = letters.pop() print(last_item) # If we pop again we'll get 't' last_item = letters.pop() print(last_item) # 'c' and 'a' remain print(letters) # ['c', 'a'] 执行结果 g t ['c', 'a'] 队列 fruits = [] # Let's enqueue some fruits into our list fruits.append('banana') fruits.append('grapes') fruits.append('mango') fruits.append('orange') # Now let's dequeue our fruits, we should get 'banana' first_item = fruits.pop(0) print(first_item) # If we dequeue again we'll get 'grapes' first_item = fruits.pop(0) print(first_item) # 'mango' and 'orange' remain print(fruits) # ['c', 'a'] 执行结果 banana grapes ['mango', 'orange'] 使用Deque库的堆栈和队列 deque是Double Ended Queue的缩写 - 可以获取存储的第一个或最后一个元素的通用队列,下面我们使用Deque库的堆栈和队列: from collections import deque # you can initialize a deque with a list numbers = deque() # Use append like before to add elements numbers.append(99) numbers.append(15) numbers.append(82) numbers.append(50) numbers.append(47) # You can pop like a stack last_item = numbers.pop() print(last_item) # 47 print(numbers) # deque([99, 15, 82, 50]) # You can dequeue like a queue first_item = numbers.popleft() print(first_item) # 99 print(numbers) # deque([15, 82, 50]) 执行结果 47 deque([99, 15, 82, 50]) 99 deque([15, 82, 50]) 参考资料 本文最新版本地址 本文涉及的python测试开发库 谢谢点赞! 本文相关海量书籍下载 python工具书籍下载-持续更新 python GUI工具书籍下载-持续更新 更严格的实现 创建撤消功能 - 允许用户回溯他们的操作,直到会话开始。堆栈是这种情况的理想选择。 我们可以通过将其推送到堆栈来记录用户所采取的每个操作。 当用户想要撤消操作时,他们将从堆栈中弹出它。 游戏中,每次按下按钮,都会触发输入事件。 测试人员注意到,如果按钮按下得太快,游戏只处理第一个按钮,特殊动作将无效!可以使用队列修复它。 我们可以将所有输入事件排入队列。 #!/usr/bin/python3 # -*- coding: utf-8 -*- # 项目实战讨论QQ群630011153 144081101 # python测试开发库汇总: https://github.com/china-testing/python-api-tesing/ # 本文最佳板式地址: https://www.jianshu.com/p/c990427ca608 # A simple class stack that only allows pop and push operations class Stack: def __init__(self): self.stack = [] def pop(self): if len(self.stack) < 1: return None return self.stack.pop() def push(self, item): self.stack.append(item) def size(self): return len(self.stack) # And a queue that only has enqueue and dequeue operations class Queue: def __init__(self): self.queue = [] def enqueue(self, item): self.queue.append(item) def dequeue(self): if len(self.queue) < 1: return None return self.queue.pop(0) def size(self): return len(self.queue) document_actions = Stack() # The first enters the title of the document document_actions.push('action: enter; text_id: 1; text: This is my favourite document') # Next they center the text document_actions.push('action: format; text_id: 1; alignment: center') # As with most writers, the user is unhappy with the first draft and undoes the center alignment document_actions.pop() # The title is better on the left with bold font document_actions.push('action: format; text_id: 1; style: bold') input_queue = Queue() # The player wants to get the upper hand so pressing the right combination of buttons quickly input_queue.enqueue('DOWN') input_queue.enqueue('RIGHT') input_queue.enqueue('B') # Now we can process each item in the queue by dequeueing them key_pressed = input_queue.dequeue() # 'DOWN' # We'll probably change our player position key_pressed = input_queue.dequeue() # 'RIGHT' # We'll change the player's position again and keep track of a potential special move to perform key_pressed = input_queue.dequeue() # 'B' # This can do the act, but the game's logic will know to do the special move

优秀的个人博客,低调大师

程序员吐槽自己阿里p7面试微软被拒,网友:你就是高级一点的码农

我想一提起阿里巴巴,我们就互相到马云这位大佬。然而阿里巴巴也是我国巨头企业霸主之一,在国际上也十分具有知名度。众所周知阿里员工的待遇和福利是非常优渥的,因此也吸引了很多年轻人的目光。但是阿里和国际知名企业如谷歌、微软等相较于技术来说还是有着一定的差距。 就有一位阿里工作的程序员想去微软养老。他说自己是P7级了,工作累想去微软养老,想要一个principal(微软的内部级别,类似于国内大厂的项目经理)。结果被拒了... 就楼主的吐槽而言,一位亚马逊工作的员工说出了自己的看法,在美国,他相信阿里巴巴对微软来说仍然有很大的差距,尤其是在科研方面,阿里的P7是不超过一个更高层次的码农而已,怎么能对标微软的principal了。事实就是如此,国内的阿里、百度却是离微软谷歌等国际性的大企业还存在差距。 基本上大部分网友认为楼主被拒才是正常的,阿里P7的程序员对标微软principal这一水平,阿里巴巴的很多业务P7也不是很强,在阿里你绩效好就可以拿很多钱,但这并不等同于你可以为其他公司获得相同的薪水。 不知道这楼主的吐槽你们怎么看,观众之中不乏人才,说下你们的看法吧! 想要学习Java高架构、分布式架构、高可扩展、高性能、高并发、性能优化、Spring boot、Redis、ActiveMQ、Nginx、Mycat、Netty、Jvm大型分布式项目实战学习架构师视频免费获取 架构群;468947140

优秀的个人博客,低调大师

面试官问:缓存与数据库一致性如何解决?先操作数据库,还是缓存?

前言 本篇文章是我之前系列文章中的一篇,主要讨论了我们在平时的开发过程中,各大系统中都要用到的缓存数据的问题,进一步延伸到数据库和缓存的双写一致性问题,并且给出了所有方案的实现代码方便大家参考。 本篇文章主要内容 数据缓存 为何要使用缓存 哪类数据适合缓存 缓存的利与弊 如何保证缓存和数据库一致性 不更新缓存,而是删除缓存 先操作缓存,还是先操作数据库 非要保证数据库和缓存数据强一致该怎么办 缓存和数据库一致性实战 实战:先删除缓存,再更新数据库 实战:先更新数据库,再删缓存 实战:缓存延时双删 实战:删除缓存重试机制 实战:读取binlog异步删除缓存 码字不易,只求关注,欢迎关注我的原创技术公众号:后端技术漫谈(二维码见文章底部) 项目源码在这里 https://github.com/qqxx6661/miaosha 数据缓存 在我们实际的业务场景中,一定有很多需要做数据缓存的场景,比如售卖商品的页面,包括了许多并发访问量很大的数据,它们可以称作是是“热点”数据,这些数据有一个特点,就是更新频率低,读取频率高,这些数据应该尽量被缓存,从而减少请求打到数据库上的机会,减轻数据库的压力。 为何要使用缓存 缓存是为了追求“快”而存在的。我们用代码举一个例子。 我在自己的Demo代码仓库中增加了两个查询库存的接口getStockByDB和getStockByCache,分别表示从数据库和缓存查询某商品的库存量。 随后我们用JMeter进行并发请求测试。(JMeter的使用请参考我之前写的文章:点击这里) 需要声明的是,我的测试并不严谨,只是作对比测试,不要作为实际服务性能的参考。 这是两个接口的代码: /** * 查询库存:通过数据库查询库存 * @param sid * @return */ @RequestMapping("/getStockByDB/{sid}") @ResponseBody public String getStockByDB(@PathVariable int sid) { int count; try { count = stockService.getStockCountByDB(sid); } catch (Exception e) { LOGGER.error("查询库存失败:[{}]", e.getMessage()); return "查询库存失败"; } LOGGER.info("商品Id: [{}] 剩余库存为: [{}]", sid, count); return String.format("商品Id: %d 剩余库存为:%d", sid, count); } /** * 查询库存:通过缓存查询库存 * 缓存命中:返回库存 * 缓存未命中:查询数据库写入缓存并返回 * @param sid * @return */ @RequestMapping("/getStockByCache/{sid}") @ResponseBody public String getStockByCache(@PathVariable int sid) { Integer count; try { count = stockService.getStockCountByCache(sid); if (count == null) { count = stockService.getStockCountByDB(sid); LOGGER.info("缓存未命中,查询数据库,并写入缓存"); stockService.setStockCountToCache(sid, count); } } catch (Exception e) { LOGGER.error("查询库存失败:[{}]", e.getMessage()); return "查询库存失败"; } LOGGER.info("商品Id: [{}] 剩余库存为: [{}]", sid, count); return String.format("商品Id: %d 剩余库存为:%d", sid, count); } 首先设置为10000个并发请求的情况下,运行JMeter,结果首先出现了大量的报错,10000个请求中98%的请求都直接失败了。让人很慌张~ 打开日志,报错如下: SpringBoot内置的Tomcat最大并发数搞的鬼,其默认值为200,对于10000的并发,单机服务实在是力不从心。当然,你可以修改这里的并发数设置,但是你的小机器仍然可能会扛不住。 将其修改为如下配置后,我的小机器才在通过缓存拿库存的情况下,保证了10000个并发的100%返回请求: server.tomcat.max-threads=10000 server.tomcat.max-connections=10000 可以看到,不使用缓存的情况下,吞吐量为668个请求每秒: 使用缓存的情况下,吞吐量为2177个请求每秒: 在这种“十分不严谨”的对比下,有缓存对于一台单机,性能提升了3倍多,如果在多台机器,更多并发的情况下,由于数据库有了更大的压力,缓存的性能优势应该会更加明显。 测完了这个小实验,我看了眼我挂着MySql的小水管腾讯云服务器,生怕他被这么高流量搞挂。这种突发的流量,指不定会被检测为异常攻击流量呢~ 哪类数据适合缓存 缓存量大但又不常变化的数据,比如详情,评论等。对于那些经常变化的数据,其实并不适合缓存,一方面会增加系统的复杂性(缓存的更新,缓存脏数据),另一方面也给系统带来一定的不稳定性(缓存系统的维护)。 但一些极端情况下,你需要将一些会变动的数据进行缓存,比如想要页面显示准实时的库存数,或者其他一些特殊业务场景。这时候你需要保证缓存不能(一直)有脏数据,这就需要再深入讨论一下。 缓存的利与弊 我们到底该不该上缓存的,这其实也是个trade-off(权衡)的问题。 上缓存的优点: 能够缩短服务的响应时间,给用户带来更好的体验。 能够增大系统的吞吐量,依然能够提升用户体验。 减轻数据库的压力,防止高峰期数据库被压垮,导致整个线上服务BOOM! 上了缓存,也会引入很多额外的问题: 缓存有多种选型,是内存缓存,memcached还是redis,你是否都熟悉,如果不熟悉,无疑增加了维护的难度(本来是个纯洁的数据库系统)。 缓存系统也要考虑分布式,比如redis的分布式缓存还会有很多坑,无疑增加了系统的复杂性。 在特殊场景下,如果对缓存的准确性有非常高的要求,就必须考虑缓存和数据库的一致性问题。 本文想要重点讨论的,就是缓存和数据库的一致性问题,各位看官且往下看。 如何保证缓存和数据库一致性 说了这么多缓存的必要性,那么使用缓存是不是就是一个很简单的事情了呢,我之前也一直是这么觉得的,直到遇到了需要缓存与数据库保持强一致的场景,才知道让数据库数据和缓存数据保持一致性是一门很高深的学问。 从远古的硬件缓存,操作系统缓存开始,缓存就是一门独特的学问。这个问题也被业界探讨了非常久,争论至今。我翻阅了很多资料,发现其实这是一个权衡的问题。值得好好讲讲。 以下的讨论会引入几方观点,我会跟着观点来写代码验证所提到的问题。 不更新缓存,而是删除缓存 大部分观点认为,做缓存不应该是去更新缓存,而是应该删除缓存,然后由下个请求去去缓存,发现不存在后再读取数据库,写入缓存。 观点引用:《分布式之数据库和缓存双写一致性方案解析》孤独烟 原因一:线程安全角度 同时有请求A和请求B进行更新操作,那么会出现 (1)线程A更新了数据库 (2)线程B更新了数据库 (3)线程B更新了缓存 (4)线程A更新了缓存 这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。 原因二:业务场景角度 有如下两点: (1)如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。 (2)如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。 其实如果业务非常简单,只是去数据库拿一个值,写入缓存,那么更新缓存也是可以的。但是,淘汰缓存操作简单,并且带来的副作用只是增加了一次cache miss,建议作为通用的处理方式。 先操作缓存,还是先操作数据库 那么问题就来了,我们是先删除缓存,然后再更新数据库,还是先更新数据库,再删缓存呢? 先来看看大佬们怎么说。 《【58沈剑架构系列】缓存架构设计细节二三事》58沈剑: 对于一个不能保证事务性的操作,一定涉及“哪个任务先做,哪个任务后做”的问题,解决这个问题的方向是:如果出现不一致,谁先做对业务的影响较小,就谁先执行。 假设先淘汰缓存,再写数据库:第一步淘汰缓存成功,第二步写数据库失败,则只会引发一次Cache miss。 假设先写数据库,再淘汰缓存:第一步写数据库操作成功,第二步淘汰缓存失败,则会出现DB中是新数据,Cache中是旧数据,数据不一致。 沈剑老师说的没有问题,不过没完全考虑好并发请求时的数据脏读问题,让我们再来看看孤独烟老师《分布式之数据库和缓存双写一致性方案解析》: 先删缓存,再更新数据库 该方案会导致请求数据不一致 同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形: (1)请求A进行写操作,删除缓存 (2)请求B查询发现缓存不存在 (3)请求B去数据库查询得到旧值 (4)请求B将旧值写入缓存 (5)请求A将新值写入数据库 上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。 所以先删缓存,再更新数据库并不是一劳永逸的解决方案,再看看先更新数据库,再删缓存这种方案怎么样? 先更新数据库,再删缓存这种情况不存在并发问题么? 不是的。假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生 (1)缓存刚好失效 (2)请求A查询数据库,得一个旧值 (3)请求B将新值写入数据库 (4)请求B删除缓存 (5)请求A将查到的旧值写入缓存 ok,如果发生上述情况,确实是会发生脏数据。 然而,发生这种情况的概率又有多少呢? 发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。可是,大家想想,数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。 先更新数据库,再删缓存依然会有问题,不过,问题出现的可能性会因为上面说的原因,变得比较低! 所以,如果你想实现基础的缓存数据库双写一致的逻辑,那么在大多数情况下,在不想做过多设计,增加太大工作量的情况下,请先更新数据库,再删缓存! 我非要数据库和缓存数据强一致怎么办 那么,如果我非要保证绝对一致性怎么办,先给出结论: 没有办法做到绝对的一致性,这是由CAP理论决定的,缓存系统适用的场景就是非强一致性的场景,所以它属于CAP中的AP。 所以,我们得委曲求全,可以去做到BASE理论中说的最终一致性。 最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性 大佬们给出了到达最终一致性的解决思路,主要是针对上面两种双写策略(先删缓存,再更新数据库/先更新数据库,再删缓存)导致的脏数据问题,进行相应的处理,来保证最终一致性。 缓存延时双删 问:先删除缓存,再更新数据库中避免脏数据? 答案:采用延时双删策略。 上文我们提到,在先删除缓存,再更新数据库的情况下,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。 那么延时双删怎么解决这个问题呢? (1)先淘汰缓存 (2)再写数据库(这两步和原来一样) (3)休眠1秒,再次淘汰缓存 这么做,可以将1秒内所造成的缓存脏数据,再次删除。 那么,这个1秒怎么确定的,具体该休眠多久呢? 针对上面的情形,读者应该自行评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。 如果你用了mysql的读写分离架构怎么办? ok,在这种情况下,造成数据不一致的原因如下,还是两个请求,一个请求A进行更新操作,另一个请求B进行查询操作。 (1)请求A进行写操作,删除缓存 (2)请求A将数据写入数据库了, (3)请求B查询缓存发现,缓存没有值 (4)请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值 (5)请求B将旧值写入缓存 (6)数据库完成主从同步,从库变为新值 上述情形,就是数据不一致的原因。还是使用双删延时策略。只是,睡眠时间修改为在主从同步的延时时间基础上,加几百ms。 采用这种同步淘汰策略,吞吐量降低怎么办? ok,那就将第二次删除作为异步的。自己起一个线程,异步删除。这样,写的请求就不用沉睡一段时间后了,再返回。这么做,加大吞吐量。 所以在先删除缓存,再更新数据库的情况下,可以使用延时双删的策略,来保证脏数据只会存活一段时间,就会被准确的数据覆盖。 在先更新数据库,再删缓存的情况下,缓存出现脏数据的情况虽然可能性极小,但也会出现。我们依然可以用延时双删策略,在请求A对缓存写入了脏的旧值之后,再次删除缓存。来保证去掉脏缓存。 删缓存失败了怎么办:重试机制 看似问题都已经解决了,但其实,还有一个问题没有考虑到,那就是删除缓存的操作,失败了怎么办?比如延时双删的时候,第二次缓存删除失败了,那不还是没有清除脏数据吗? 解决方案就是再加上一个重试机制,保证删除缓存成功。 参考孤独烟老师给的方案图: 方案一: 流程如下所示 (1)更新数据库数据; (2)缓存因为种种问题删除失败 (3)将需要删除的key发送至消息队列 (4)自己消费消息,获得需要删除的key (5)继续重试删除操作,直到成功 然而,该方案有一个缺点,对业务线代码造成大量的侵入。于是有了方案二,在方案二中,启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。 方案二: 流程如下图所示: (1)更新数据库数据 (2)数据库会将操作信息写入binlog日志当中 (3)订阅程序提取出所需要的数据以及key (4)另起一段非业务代码,获得该信息 (5)尝试删除缓存操作,发现删除失败 (6)将这些信息发送至消息队列 (7)重新从消息队列中获得该数据,重试操作。 而读取binlog的中间件,可以采用阿里开源的canal 好了,到这里我们已经把缓存双写一致性的思路彻底梳理了一遍,下面就是我对这几种思路徒手写的实战代码,方便有需要的朋友参考。 缓存和数据库一致性实战 实战:先删除缓存,再更新数据库 终于到了实战,我们在秒杀项目的代码上增加接口:先删除缓存,再更新数据库 OrderController中新增: /** * 下单接口:先删除缓存,再更新数据库 * @param sid * @return */ @RequestMapping("/createOrderWithCacheV1/{sid}") @ResponseBody public String createOrderWithCacheV1(@PathVariable int sid) { int count = 0; try { // 删除库存缓存 stockService.delStockCountCache(sid); // 完成扣库存下单事务 orderService.createPessimisticOrder(sid); } catch (Exception e) { LOGGER.error("购买失败:[{}]", e.getMessage()); return "购买失败,库存不足"; } LOGGER.info("购买成功,剩余库存为: [{}]", count); return String.format("购买成功,剩余库存为:%d", count); } stockService中新增: @Override public void delStockCountCache(int id) { String hashKey = CacheKey.STOCK_COUNT.getKey() + "_" + id; stringRedisTemplate.delete(hashKey); LOGGER.info("删除商品id:[{}] 缓存", id); } 其他涉及的代码都在之前三篇文章中有介绍,并且可以直接去Github拿到项目源码,就不在这里重复贴了。 实战:先更新数据库,再删缓存 如果是先更新数据库,再删缓存,那么代码只是在业务顺序上颠倒了一下,这里就只贴OrderController中新增: /** * 下单接口:先更新数据库,再删缓存 * @param sid * @return */ @RequestMapping("/createOrderWithCacheV2/{sid}") @ResponseBody public String createOrderWithCacheV2(@PathVariable int sid) { int count = 0; try { // 完成扣库存下单事务 orderService.createPessimisticOrder(sid); // 删除库存缓存 stockService.delStockCountCache(sid); } catch (Exception e) { LOGGER.error("购买失败:[{}]", e.getMessage()); return "购买失败,库存不足"; } LOGGER.info("购买成功,剩余库存为: [{}]", count); return String.format("购买成功,剩余库存为:%d", count); } 实战:缓存延时双删 如何做延时双删呢,最好的方法是开设一个线程池,在线程中删除key,而不是使用Thread.sleep进行等待,这样会阻塞用户的请求。 更新前先删除缓存,然后更新数据,再延时删除缓存。 OrderController中新增接口: // 延时时间:预估读数据库数据业务逻辑的耗时,用来做缓存再删除 private static final int DELAY_MILLSECONDS = 1000; /** * 下单接口:先删除缓存,再更新数据库,缓存延时双删 * @param sid * @return */ @RequestMapping("/createOrderWithCacheV3/{sid}") @ResponseBody public String createOrderWithCacheV3(@PathVariable int sid) { int count; try { // 删除库存缓存 stockService.delStockCountCache(sid); // 完成扣库存下单事务 count = orderService.createPessimisticOrder(sid); // 延时指定时间后再次删除缓存 cachedThreadPool.execute(new delCacheByThread(sid)); } catch (Exception e) { LOGGER.error("购买失败:[{}]", e.getMessage()); return "购买失败,库存不足"; } LOGGER.info("购买成功,剩余库存为: [{}]", count); return String.format("购买成功,剩余库存为:%d", count); } OrderController中新增线程池: // 延时双删线程池 private static ExecutorService cachedThreadPool = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>()); /** * 缓存再删除线程 */ private class delCacheByThread implements Runnable { private int sid; public delCacheByThread(int sid) { this.sid = sid; } public void run() { try { LOGGER.info("异步执行缓存再删除,商品id:[{}], 首先休眠:[{}] 毫秒", sid, DELAY_MILLSECONDS); Thread.sleep(DELAY_MILLSECONDS); stockService.delStockCountCache(sid); LOGGER.info("再次删除商品id:[{}] 缓存", sid); } catch (Exception e) { LOGGER.error("delCacheByThread执行出错", e); } } } 来试验一下,请求接口createOrderWithCacheV3: 日志中,做到了两次删除: 实战:删除缓存重试机制 上文提到了,要解决删除失败的问题,需要用到消息队列,进行删除操作的重试。这里我们为了达到效果,接入了RabbitMq,并且需要在接口中写发送消息,并且需要消费者常驻来消费消息。Spring整合RabbitMq还是比较简单的,我把简单的整合代码也贴出来。 pom.xml新增RabbitMq的依赖: <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency> 写一个RabbitMqConfig: @Configuration public class RabbitMqConfig { @Bean public Queue delCacheQueue() { return new Queue("delCache"); } } 添加一个消费者: @Component @RabbitListener(queues = "delCache") public class DelCacheReceiver { private static final Logger LOGGER = LoggerFactory.getLogger(DelCacheReceiver.class); @Autowired private StockService stockService; @RabbitHandler public void process(String message) { LOGGER.info("DelCacheReceiver收到消息: " + message); LOGGER.info("DelCacheReceiver开始删除缓存: " + message); stockService.delStockCountCache(Integer.parseInt(message)); } } OrderController中新增接口: /** * 下单接口:先更新数据库,再删缓存,删除缓存重试机制 * @param sid * @return */ @RequestMapping("/createOrderWithCacheV4/{sid}") @ResponseBody public String createOrderWithCacheV4(@PathVariable int sid) { int count; try { // 完成扣库存下单事务 count = orderService.createPessimisticOrder(sid); // 删除库存缓存 stockService.delStockCountCache(sid); // 延时指定时间后再次删除缓存 // cachedThreadPool.execute(new delCacheByThread(sid)); // 假设上述再次删除缓存没成功,通知消息队列进行删除缓存 sendDelCache(String.valueOf(sid)); } catch (Exception e) { LOGGER.error("购买失败:[{}]", e.getMessage()); return "购买失败,库存不足"; } LOGGER.info("购买成功,剩余库存为: [{}]", count); return String.format("购买成功,剩余库存为:%d", count); } 访问createOrderWithCacheV4: 可以看到,我们先完成了下单,然后删除了缓存,并且假设延迟删除缓存失败了,发送给消息队列重试的消息,消息队列收到消息后再去删除缓存。 实战:读取binlog异步删除缓存 我们需要用到阿里开源的canal来读取binlog进行缓存的异步删除。 我写了一篇Canal的入门文章,其中用的入门例子就是读取binlog删除缓存。大家可以直接跳转到这里:阿里开源MySQL中间件Canal快速入门 扩展阅读 更新缓存的的Design Pattern有四种: Cache aside Read through Write through Write behind caching,这里有陈皓的总结文章可以进行学习。 https://coolshell.cn/articles/17416.html 小结 引用陈浩《缓存更新的套路》最后的总结语作为小结: 分布式系统里要么通过2PC或是Paxos协议保证一致性,要么就是拼命的降低并发时脏数据的概率 缓存系统适用的场景就是非强一致性的场景,所以它属于CAP中的AP,BASE理论。 异构数据库本来就没办法强一致,只是尽可能减少时间窗口,达到最终一致性。 还有别忘了设置过期时间,这是个兜底方案 结束语 本文总结并探讨了缓存数据库双写一致性问题。 文章内容大致可以总结为如下几点: 对于读多写少的数据,请使用缓存。 为了保持数据库和缓存的一致性,会导致系统吞吐量的下降。 为了保持数据库和缓存的一致性,会导致业务代码逻辑复杂。 缓存做不到绝对一致性,但可以做到最终一致性。 对于需要保证缓存数据库数据一致的情况,请尽量考虑对一致性到底有多高要求,选定合适的方案,避免过度设计。 作者水平有限,写文章过程中难免出现错误和疏漏,请理性讨论与指正。 码字不易,只求关注,欢迎关注我的原创技术公众号:后端技术漫谈(二维码见文章底部) 参考 https://cloud.tencent.com/developer/article/1574827 https://www.jianshu.com/p/2936a5c65e6b https://www.cnblogs.com/rjzheng/p/9041659.html https://www.cnblogs.com/codeon/p/8287563.html https://www.jianshu.com/p/0275ecca2438 https://www.jianshu.com/p/dc1e5091a0d8 https://coolshell.cn/articles/17416.html 作者:蛮三刀把刀 链接:https://www.jianshu.com/p/93f16ca6b396 来源:简书 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

优秀的个人博客,低调大师

面试官问我:如何设计 QQ、微信等第三方账号登陆 ?还要我说出数据库表设计!

名称解释 这里的多账户区别于系统级别的,我们讲的多账户系统是指,在我们互联网应用当中,我们的应用会使用多个第三方账号进行登录,比如现在常用的APP:网易、微信、QQ等等。 内容 通过这一篇文章: 可以学到:多用户下面的技术方案细节,以及相应的表设计,流程设计。 不可以学到:与其他文章一样,我这里不会有具体代码实现细节,方案做的对,代码咋写都不会太烂。 架构演进 创业初期 归结为创业初期是因为这个时候用户量比较少,甚至还没有接入上面所说的其他第三方的账户系统,只是自建的体系就可以满足,自建体系的话,目前常用的有 用户名密码注册登陆 这种方式在很多初期网站建设会使用,先注册,再进行登录,在老一点的cms中都能找到这个影子。 流程图: 流程说明: 前端将用户名、密码发送到服务器,服务器进行常规的判断,判断用户名、密码长度是否满足,用户名是否重复等条件,条件不通过直接返回对应错误码给到前端,这里密码字段,为了防止传输过程中被截胡,建议加密再上传,我们的传输密码默认都是会进行一个md5加密,然后记录到数据库再进行一层加密,就算是脱库也没事,密码不要明文存储。 校验通过后,就将用户名密码写入数据库,并进行后面积分发放等操作,这里不展开。 现在进行登录,前端将用户名,密码发送给到服务端,服务端首先会校验登录次数是否超过设置的阈值,如果超过只能继续等待被关小黑屋。 如果未超过继续登录逻辑,判断用户名、密码是否正确,不正确密码则进行阈值的判断,如果超过则关小黑屋,记住小黑屋必须设置过期时间,要不然就会永久关上了,这个可以用redis的过期来做。 登录成功后进行后续的一切后置逻辑,比如加积分。。。等操作。 手机号注册登陆 流程图: 流程说明: 首先输入手机号,然后发送到服务端,服务端将手机号记录在我们数据库中,然后生成随机验证码,并将手机号和验证码绑定到一个redis里面,然后记录过期时间,这个过期时间一般是10分钟左右,这就是我们一般手机验证码的有效期。 手机接收到手机短信后,那么就在界面填写验证码发送服务端,服务端收到验证码后就会在redis里面查询到这个手机号对应的验证码,失败就返回错误码。 成功后就进行登录操作。 这里看起来没有明确的注册登录操作,其实在发送手机号码就可以认为是一个常规的注册,然后后面的验证码输入就是一个登陆操作, 问:那我要密码咋办? 答:在后续产品里面增加一个 手机号码密码补录的功能 即可,这也是现在很常规的手法,但是现在移动互联网大爆炸时代,密码已经显得不是那么重要了,反正我从来记不住密码,如果手机号码能操作的app,绝对不用密码来操作。 数据库设计 表结构 : 说明 : 这里只是单纯说明需要用到的数据,没有扩展具体场景,这个表结构能够满足上面两个方案的设计。 引入第三方账户方案 这里是以QQ-SDK的登录逻辑, 我们先来一波时序图 说明: 客户端自己调起登录的界面,进行输入用户名、密码,这里的是第三方的用户名,密码,登录成功后,会返回access_token openid expire_in,这过程会使用到oauth2.0,不过在sdk里面进行内置回调获取了,后面我们会说明我们自身实现的oauth2.0 客户端拿到access_token、openid、login_type(qq、wechat...)请求应用服务器,应用服务器拿到这些数据后就会根据对应的login_type去对应的用户中心进行access_token和openid进行校验。校验不通过则返回对应错误码 校验通过后就会判断本地是否有这个login_type和openid是否存在,不存在则进行获取远程的用户名、头像等基础信息来作为本地基础数据,并且返回code值 如果已经存在,那就是进行登录操作,返回code值。 客户端拿到code值后进行token值的换取,这个完全遵照oauth2.0的协议来走的,后续每次请求必须带上token,token值在服务端的时间比较久,因为我们想要做的是那种永不下线的操作,所以每次请求我们都将token过期时间进行累加。 数据库设计 根据部分小伙伴的的建议,我这里做一下数据库的整理: 说明 users表只是单纯针对我们业务侧的登录,主要是做自身业务的oauth2.0业务, user_local_auth是做自己用户名、密码登录,手机号码登录信息记录, user_third_auth是我们第三方用户体系的数据记录, user_auth_rel是用来关联我们users表与user_local_auth、user_third_auth。 整个设计理念就是将自建用户与第三方在存储上区分,这在架构演进上也是合乎情理的,开始用户体系大多自建,而后才是对外接入。 总结 总的来讲,第三方用户的接入技术上来讲是比较简单的,这里设计多一个user_thirds是可以支持足够多的第三方接入,当然一般我们也就两三个登录就好,太多登录方不仅自身维护成本,界面摆盘也不好看不是。 希望大家能够通过以上学习,能够对于我们多账户登录有一个比较好的认知,这里设计方案不包含分表分库、没有服务化,就是简单直接的设计,当然用户量和需要的不一样,在这个基础上还要加很多东西,谢谢大家阅读!!!

资源下载

更多资源
优质分享App

优质分享App

近一个月的开发和优化,本站点的第一个app全新上线。该app采用极致压缩,本体才4.36MB。系统里面做了大量数据访问、缓存优化。方便用户在手机上查看文章。后续会推出HarmonyOS的适配版本。

Mario

Mario

马里奥是站在游戏界顶峰的超人气多面角色。马里奥靠吃蘑菇成长,特征是大鼻子、头戴帽子、身穿背带裤,还留着胡子。与他的双胞胎兄弟路易基一起,长年担任任天堂的招牌角色。

腾讯云软件源

腾讯云软件源

为解决软件依赖安装时官方源访问速度慢的问题,腾讯云为一些软件搭建了缓存服务。您可以通过使用腾讯云软件源站来提升依赖包的安装速度。为了方便用户自由搭建服务架构,目前腾讯云软件源站支持公网访问和内网访问。

Rocky Linux

Rocky Linux

Rocky Linux(中文名:洛基)是由Gregory Kurtzer于2020年12月发起的企业级Linux发行版,作为CentOS稳定版停止维护后与RHEL(Red Hat Enterprise Linux)完全兼容的开源替代方案,由社区拥有并管理,支持x86_64、aarch64等架构。其通过重新编译RHEL源代码提供长期稳定性,采用模块化包装和SELinux安全架构,默认包含GNOME桌面环境及XFS文件系统,支持十年生命周期更新。