首页 文章 精选 留言 我的

精选列表

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

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

前言 本篇文章是我之前系列文章中的一篇,主要讨论了我们在平时的开发过程中,各大系统中都要用到的缓存数据的问题,进一步延伸到数据库和缓存的双写一致性问题,并且给出了所有方案的实现代码方便大家参考。 本篇文章主要内容 数据缓存 为何要使用缓存 哪类数据适合缓存 缓存的利与弊 如何保证缓存和数据库一致性 不更新缓存,而是删除缓存 先操作缓存,还是先操作数据库 非要保证数据库和缓存数据强一致该怎么办 缓存和数据库一致性实战 实战:先删除缓存,再更新数据库 实战:先更新数据库,再删缓存 实战:缓存延时双删 实战:删除缓存重试机制 实战:读取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是可以支持足够多的第三方接入,当然一般我们也就两三个登录就好,太多登录方不仅自身维护成本,界面摆盘也不好看不是。 希望大家能够通过以上学习,能够对于我们多账户登录有一个比较好的认知,这里设计方案不包含分表分库、没有服务化,就是简单直接的设计,当然用户量和需要的不一样,在这个基础上还要加很多东西,谢谢大家阅读!!!

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

面试官问单表数据量大一定要分库分表吗?我们用六个字和十张图回答

欢迎大家关注公众号「JAVA前线」查看更多精彩分享文章,主要包括源码分析、实际应用、架构思维、职场分享、产品思考等等,同时欢迎大家加我个人微信「java_front」一起交流学习 1 文章概述 在业务发展初期单表完全可以满足业务需求,在阿里巴巴开发手册也建议:单表行数超过500万行或者单表容量超过2GB才推荐进行分库分表,如果预计三年后数据量根本达不到这个级别,请不要在创建表时就分库分表。 但是随着业务的发展和深入,单表数据量不断增加,逐渐成为业务系统的瓶颈。这是为什么呢? 从宏观层面分析任何物体都必然有其物理极限。1965年英特尔创始人摩尔预测:集成电路上可容纳的元器件的数目,约每隔24个月增加一倍,性能提升一倍,即计算机性能每两年翻一番。 但是摩尔定律会有终点吗?有些科学家认为摩尔定律是有终点的:半导体芯片单位面积可集成的元件数量是有极限的,因为半导体芯片制程工艺的物理极限为2到3纳米。当然也有科学家不支持这种说法,但是我们可以从中看出物理极限是很难突破的,当单表数据量达到一定规模时必然也达到极限。 从细节层面分析我们将数据保存在数据库,实际上是保存在磁盘中,一次磁盘IO操作需要经历寻道、旋转延时、数据传输三个步骤,那么一次磁盘IO耗时公式如下: 单次IO时间 = 寻道时间 + 旋转延迟 + 传送时间 总体来说上述操作都较为耗时,速度和内存相比有着数量级的差距,当数据量过大磁盘这一瓶颈更加明显。那么应该怎么办?处理单表数据量过大有以下六字口诀:删、换、分、拆、异、热。 删是指删除历史数据并进行归档。换是指不要只使用数据库资源,有些数据可以存储至其它替代资源。分是指读写分离,增加多个读实例应对读多写少的互联网场景。拆是指分库分表,将数据分散至不同的库表中减轻压力。异指数据异构,将一份数据根据不同业务需求保存多份。热是指热点数据,这是一个非常值得注意的问题。 2 删 我们分析这样一个场景:消费者会经常查询一年之前的订单记录吗?答案是一般不会,或者说这种查询需求量很小。根据上述分析那么一年前的数据我们就没有必要放在单表这张业务主表,可以将一年前的数据迁移到历史归档表。 在查询历史数据表时,可以限制查询条件如必须选择日期范围,日期范围不能超过X个月等等从而减轻查询压力。 处理历史存量数据比较简单,因为存量数据一般是静态的,此时状态已经不再改变了。数据处理一般分为以下两个步骤: (1) 迁移一年前数据至历史归档表 (2) 根据主键分批删除主表数据 不能一次性删除所有数据,因为数据量太大可能会引发超时,而是应该根据ID分批删除,例如每次删除500条数据。 第一步查询一年前主键最大值和最小值,这是我们需要删除的数据范围: SELECT MIN(id) AS minId, MAX(id) AS maxId FROM biz_table WHERE create_time < DATE_SUB(now(),INTERVAL 1 YEAR) 第二步删除数据时不能一次性全部删掉,因为很可能会超时,我们可以通过代码动态更新endId进行批量删除: DELETE FROM biz_table WHERE id >= #{minId} AND id <= #{maxId} AND id <= #{endId} LIMIT 500 3 换 换是指换一个存储介质,当然并不是说完全替换,而是用其它存储介质对数据库做一个补充。例如海量流水记录,这类数据量级是巨量的,根本不适合存储在MySQL数据库中,那么这些数据可以存在哪里呢? 现在互联网公司一般都具备与之规模相对应的大数据服务或者平台,那么作为业务开发者要善于应用公司大数据能力,减轻业务数据库压力。 3.1 消息队列 这些海量数据可以存储至Kafka,因为其本质上就是分布式的流数据存储系统。使用Kafka有如下优点: 第一个优点是Kafka社区活跃功能强大,已经成为了一种事实上的工业标准。大数据很多组件都提供了Kafka接入组件,经过生产验证并且对接成本较小,可以为下游业务提供更多选择。 第二个优点是Kafka具有消息队列本身的优点例如解耦、异步和削峰。 假设这些海量数据都已经存储在Kafka,现在我们希望这些数据可以产生业务价值,这涉及到两种数据分析任务:离线任务和实时任务。 离线任务对实时性要求不高,例如每天、每周、每月的数据报表统计分析,我们可以使用基于MapReduce数据仓库工具Hive进行报表统计。 实时任务对实时性要求高,例如根据用户相关行为推荐用户感兴趣的商品,提高用户购买体验和效率,可以使用Flink进行流处理分析。例如运营后台查询分析,可以将数据同步至ES进行检索。 还有一种分类方式是将任务分为批处理任务和流处理任务,我们可以这么理解:离线任务一般使用批处理技术,实时任务一般使用流处理技术。 3.2 API 上一个章节我们使用了Kafka进行海量数据存储,由于其强大兼容性和集成度,可以作为数据中介将数据进行中转和解耦。 当然我们并不是必须使用Kafka进行中转,例如我们直接可以使用相关Java API将数据存入Hive、ES、HBASE等。 但是我并不推荐这种做法,因为将保存流水这样操作耦合进业务代码并不合适,违反了高内聚低耦合的原则,尽量不要使用。 3.3 缓存 从广义上理解换这个字,我们还可以引入Redis远程缓存,把Redis放在MySQL前面,拦下一些高频读请求,但是要注意缓存穿透和击穿问题。 缓存穿透和击穿从最终结果上来说都是流量绕过缓存打到了数据库,可能会导致数据库挂掉或者系统雪崩,但是仔细区分还是有一些不同,我们分析一张业务读取缓存一般流程图。 我们用文字简要描述这张图: (1) 业务查询数据时首先查询缓存,如果缓存存在数据则返回,流程结束 (2) 如果缓存不存在数据则查询数据库,如果数据库不存在数据则返回空数据,流程结束 (3) 如果数据库存在数据则将数据写入缓存并返回数据给业务,流程结束 假设业务方要查询A数据,缓存穿透是指数据库根本不存在A数据,所以根本没有数据可以写入缓存,导致缓存层失去意义,大量请求会频繁访问数据库。 缓存击穿是指请求在查询数据库前,首先查缓存看看是否存在,这是没有问题的。但是并发量太大,导致第一个请求还没有来得及将数据写入缓存,后续大量请求已经开始访问缓存,这是数据在缓存中还是不存在的,所以瞬时大量请求会打到数据库。 我们可以使用分布式锁加上自旋解决这个问题,本文给出一段示例代码,具体原理和代码实现请参看我之前的文章:流程图+源码深入分析:缓存穿透和击穿问题原理以及解决方案 /** * 业务回调 * * @author 今日头条号「JAVA前线」 * */ public interface RedisBizCall { /** * 业务回调方法 * * @return 序列化后数据值 */ String call(); } /** * 安全缓存管理器 * * @author 今天头条号「JAVA前线」 * */ @Service public class SafeRedisManager { @Resource private RedisClient RedisClient; @Resource private RedisLockManager redisLockManager; public String getDataSafe(String key, int lockExpireSeconds, int dataExpireSeconds, RedisBizCall bizCall, boolean alwaysRetry) { boolean getLockSuccess = false; try { while(true) { String value = redisClient.get(key); if (StringUtils.isNotEmpty(value)) { return value; } /** 竞争分布式锁 **/ if (getLockSuccess = redisLockManager.tryLock(key, lockExpireSeconds)) { value = redisClient.get(key); if (StringUtils.isNotEmpty(value)) { return value; } /** 查询数据库 **/ value = bizCall.call(); /** 数据库无数据则返回**/ if (StringUtils.isEmpty(value)) { return null; } /** 数据存入缓存 **/ redisClient.setex(key, dataExpireSeconds, value); return value; } else { if (!alwaysRetry) { logger.warn("竞争分布式锁失败,key={}", key); return null; } Thread.sleep(100L); logger.warn("尝试重新获取数据,key={}", key); } } } catch (Exception ex) { logger.error("getDistributeSafeError", ex); return null; } finally { if (getLockSuccess) { redisLockManager.unLock(key); } } } } 4 分 我们首先看一个概念:读写比。互联网场景中一般是读多写少,例如浏览20次订单列表信息才会进行1次确认收货,此时读写比例就是20:1。面对读多写少这种情况我们可以做什么呢? 我们可以部署多台MySQL读库专门用来接收读请求,主库接收写请求并通过binlog实时同步的方式将数据同步至读库。MySQL官方即提供这种能力,进行简单配置即可。 那么客户端怎么知道访问读库还是写库呢?推荐使用ShardingSphere组件,通过配置将读写请求分别路由至读库或者写库。 5 拆 如果删除了历史数据并采用了其它存储介质,也用了读写分离,但是单表压力还是太大怎么办?这时我们只能拆分数据表,即把单库单表数据迁移到多库多张表中。 假设有一个电商数据库存放订单、商品、支付三张业务表。随着业务量越来越大,这三张业务数据表也越来越大,我们就以这个例子进行分析。 5.1 垂直拆分 垂直拆分就是按照业务拆分,我们将电商数据库拆分成三个库,订单库、商品库。支付库,订单表在订单库,商品表在商品库,支付表在支付库。这样每个库只需要存储本业务数据,物理隔离不会互相影响。 5.2 水平拆分 按照垂直拆分方案,现在我们已经有三个库了,平稳运行了一段时间。但是随着业务增长,每个单库单表的数据量也越来越大,逐渐到达瓶颈。 这时我们就要对数据表进行水平拆分,所谓水平拆分就是根据某种规则将单库单表数据分散到多库多表,从而减小单库单表的压力。 水平拆分策略有很多方案,最重要的一点是选好ShardingKey,也就是按照哪一列进行拆分,怎么分取决于我们访问数据的方式。 5.2.1 范围分片 现在我们要对订单库进行水平拆分,我们选择的ShardingKey是订单创建时间,拆分策略如下: (1) 拆分为四个数据库,分别存储每个季度的数据 (2) 每个库三张表,分别存储每个月的数据 上述方法优点是对范围查询比较友好,例如我们需要统计第一季度的相关数据,查询条件直接输入时间范围即可。 但是这个方案问题是容易产生热点数据。例如双11当天下单量特别大,就会导致11月这张表数据量特别大从而造成访问压力。 5.2.2 查表分片 查表法是根据一张路由表决定ShardingKey路由到哪一张表,每次路由时首先到路由表里查到分片信息,再到这个分片去取数据。 我们分析一个查表法实际案例。Redis官方在3.0版本之后提供了集群方案Redis Cluster,其中引入了哈希槽(slot)这个概念。 一个集群固定有16384个槽,在集群初始化时这些槽会平均分配到Redis集群节点上。每个key请求最终落到哪个槽计算公式是固定的: SLOT = CRC16(key) mod 16384 那么问题来了:一个key请求过来怎么知道去哪台Redis节点获取数据?这就要用到查表法思想。 (1) 客户端连接任意一台Redis节点,假设随机访问到为节点A (2) 节点A根据key计算出slot值 (3) 每个节点都维护着slot和节点映射关系表 (4) 如果节点A查表发现该slot在本节点则直接返回数据给客户端 (5) 如果节点A查表发现该slot不在本节点则返回给客户端一个重定向命令,告诉客户端应该去哪个节点上请求这个key的数据 (6) 客户端再向正确节点发起连接请求 查表法优点是可以灵活制定路由策略,如果我们发现有的分片已经成为热点则修改路由策略。缺点是多一次查询路由表操作增加耗时,而且路由表如果是单点也可能会有单点问题。 5.2.3 哈希分片 现在比较流行的分片方法是哈希分片,相较于范围分片,哈希分片可以较为均匀将数据分散在数据库中。 我们现在将订单库拆分为4个库编号为[0,3],每个库4张表编号为[0,3],如下图如所示: 现在使用orderId作为ShardingKey,那么orderId=100的订单会保存在哪张表?我们来计算一下:由于是分库分表,首先确定路由到哪一个库,取模计算得到序号为0表示路由到db[0] db_index = 100 % 4 = 0 库确定了接着在db[0]进行取模表路由 table_index = 100 % 4 = 0 最终这条数据应该路由至下表 db[0]_table[0] 最终计算结果如下图所示: 在实际开发中最终路由到哪张表,并不需要我们自己算,因为有许多开源框架就可以完成路由功能,例如ShardingSphere、TDDL等等。 6 异 现在数据已经使用哈希分片方法完成了水平拆分,我们选择的ShardingKey是orderId。这时客户端需要查询orderId=111的数据,查询语句很简单如下: SELECT * FROM order WHERE orderId = 111 这个语句没有问题,因为查询条件包含orderId,可以路由到具体的数据表。 现在如果业务想要查询用户维度的数据,希望查询userId=222的数据,现在问题来了:以下这个语句可以查出数据吗? SELECT * FROM order WHERE userId = 222 答案是可以,但是需要扫描所有库的所有表,因为无法根据userId路由到具体某一张表,这样时间成本会非常高,这种场景怎么办呢? 这就要用到数据异构的思想。数据异构核心是用空间换时间,简单一句话就是一份数据按照不同业务需求保存多份,这样做是因为存储硬件成本不是很高,而互联网场景对响应速度要求很高。 对于上述需要使用userId进行查询的场景,我们完全可以新建库和表,数量和结构与订单库表完全一致,唯一不同点是ShardingKey改用userId,这样就可以使用userId查询了。 现在又引出一个新问题,业务不可能每次都将数据写入多个数据源,这样会带来性能问题和数据一致行为。怎么解决老库和新库数据同步问题?我们可以使用阿里开源的canal组件解决这个问题,看一张官网介绍canal架构图: canal组件的主要用途是基于MySQL数据库增量日志解析,提供增量数据订阅和消费服务,工作原理如下: (1) canal伪装成为MySQL slave模拟交互协议向master发送dump协议 (2) master收到canal发送的dump请求,开始推送binlog给canal (3) canal解析binlog并发送到存储目的地,例如MySQL、Kafka、Elasticsearch canal组件下游可以对接很多其它数据源,这样给业务提供了更多选择。我们可以像上述实例中新建用户维度订单表,也可以将数据存在ES中提供运营检索能力等等。 7 热 我们来分析这样一个场景:社交业务有一张用户关系表,主要记录谁关注了谁。其中有一个明星粉丝特别多,如果以userId作为分片,那么其所在分片数据量就会特别大。 不仅分片数据量特别大,而且可以预见这个分片访问频率也会非常高。此时数据量大并且访问频繁,很有可能造成系统压力。 7.1 热点概念 我们将访问行为称为热点行为,将访问对应的数据称为热点数据。我们通过实例来分析。 在电商双11活动中百分之八十的访问量会集中在百分之二十的商品上。用户刷新、添加购物车、下单被称为热点行为,相应商品数据就被称为热点数据。 在微博场景中大V发布一条消息会获得大量访问。用户对这条消息的浏览、点赞、转发、评论被称为热点行为,这条消息数据被称为热点数据。 在秒杀场景中参与秒杀的商品会获得极大的瞬时访问量。用户对这个商品的频繁刷新、点击、下单被称为热点行为,参与秒杀的商品数据被称为热点数据。 我们必须将热点数据进行一些处理,使得热点访问更加流畅,更是为了保护系统免于崩溃。我们从发现热点数据、处理热点数据来展开分析。 7.2 发现热点数据 我们把发现热点数据分为两种方式:静态发现和动态发现。 静态发现:在开始秒杀活动之前,参与商家一定知道哪些商品参与秒杀,那么他们可以提前将这些商品报备告知平台。 在微博场景中,具有影响力的大V一般都很知名,网站运营同学可以提前知道。技术同学还可以通过分析历史数据找出TOP N数据。对于这些可以提前预判的数据,完全可以通过后台系统上报,这样系统可以提前做出预处理。 动态发现:有些商品可能并没有上报为热点商品,但是在实际销售中却非常抢手。在微博场景中,有些话题热度突然升温。这些数据成为事实上的热点数据。对于这些无法提前预判的数据,需要动态进行判断。 我们需要一个热点发现系统去主动发现热点数据。大体思路是首先异步收集访问日志,再统计单位时间内访问频次,当超过一定阈值时可以判断为热点数据。 7.3 处理热点问题 (1) 热点行为 热点行为可以采取高频检测方式,如果发现频率过高则进行限制。或者采用内存队列实现的生产者与消费者这种异步化方式,消费者根据能力处理请求。 (2) 热点数据 处理热点数据核心主要是根据业务形态来进行处理,我一般采用以下方案配合执行: (1) 选择合适ShardingKey进行分库分表 (2) 异构数据至其它适合检索的数据源例如ES (3) 在MySQL之前设置缓存层 (4) 尽量不在MySQL进行耗时操作(例如聚合) 8 文章总结 本文我们详细介绍处理单表数据量过大的六字口诀:删、换、分、拆、异、热。这并不是意味这每次遇到单表数据量过大情况六种方案全部都要使用,例如拆分数据表成本确实比较高,会带来分布式事务、数据难以聚合等问题,如果不分表可以解决那么就不要分表,核心还是根据自身业务情况选择合适的方案。 欢迎大家关注公众号「JAVA前线」查看更多精彩分享文章,主要包括源码分析、实际应用、架构思维、职场分享、产品思考等等,同时欢迎大家加我个人微信「java_front」一起交流学习

资源下载

更多资源
Nacos

Nacos

Nacos /nɑ:kəʊs/ 是 Dynamic Naming and Configuration Service 的首字母简称,一个易于构建 AI Agent 应用的动态服务发现、配置管理和AI智能体管理平台。Nacos 致力于帮助您发现、配置和管理微服务及AI智能体应用。Nacos 提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据、流量管理。Nacos 帮助您更敏捷和容易地构建、交付和管理微服务平台。

Spring

Spring

Spring框架(Spring Framework)是由Rod Johnson于2002年提出的开源Java企业级应用框架,旨在通过使用JavaBean替代传统EJB实现方式降低企业级编程开发的复杂性。该框架基于简单性、可测试性和松耦合性设计理念,提供核心容器、应用上下文、数据访问集成等模块,支持整合Hibernate、Struts等第三方框架,其适用范围不仅限于服务器端开发,绝大多数Java应用均可从中受益。

Rocky Linux

Rocky Linux

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

WebStorm

WebStorm

WebStorm 是jetbrains公司旗下一款JavaScript 开发工具。目前已经被广大中国JS开发者誉为“Web前端开发神器”、“最强大的HTML5编辑器”、“最智能的JavaScript IDE”等。与IntelliJ IDEA同源,继承了IntelliJ IDEA强大的JS部分的功能。

用户登录
用户注册