拉巴力的纸皮箱


  • 首页

  • 标签

  • 归档

  • 关于

  • 搜索

缓存策略简单总结

发表于 2020-07-10

缓存的三座大山

以下内容摘自:翻越缓存的三座大山

  1. 缓存一致性
    • 缓存一致性是指业务在引入分布式缓存系统后,业务对数据的更新除了要更新存储以外还需要同时更新缓存,对两个系统进行数据更新就要先解决分布式系统中的隔离性和原子性难题。
  2. 缓存击穿
    • 缓存击穿是指查询请求没有在缓存层命中而将查询透传到存储 DB 的问题,当大量的请求发生缓存击穿时,将给存储 DB 带来极大的访问压力,甚至导致 DB 过载拒绝服务。
    • 通过以下方式防止缓存击穿:
      1. 通过 bloomfilter 记录 key 是否存在,从而避免无效 Key 的查询;
      2. 在 Redis 缓存不存在的 Key,从而避免无效 Key 的查询;
  3. 缓存雪崩
    • 缓存雪崩是指由于大量的热数据设置了相同或接近的过期时间,导致缓存在某一时刻密集失效,大量请求全部转发到 DB,或者是某个冷数据瞬间涌入大量访问,这些查询在缓存 MISS 后,并发的将请求透传到 DB,DB 瞬时压力过载从而拒绝服务。目前常见的预防缓存雪崩的解决方案,主要是通过对 key 的 TTL 时间加随机数,打散 key 的淘汰时间来尽量规避,但是不能彻底规避。

本文主要讲的是第一个问题:缓存一致性

基本要点

  1. 缓存替换方式
    • 更新缓存VS淘汰缓存
  2. 缓存替换和写库顺序
    • 先替换缓存后写数据库vs先写数据库后替换
  3. 更新缓存数据源是主还是从
    • 主从数据库下(主从延迟),是从还是主读取数据进行缓存替换
  4. 从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案

Cache-Aside pattern

最经典的缓存+数据库读写的模式,就是 Cache Aside Pattern。

  1. 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
  2. 命中:应用程序从cache中取数据,取到后返回。
  3. 更新:先把数据存到数据库中,成功后,再让缓存失效。

大多数业务,使用这样的更新套路即可

解决思路

  1. 以下摘自:高并发下缓存和数据库一致性问题

    • 主从一致性,即修改完立马就要读取到最新的数据(本方案不涉及到缓存的同步,如果涉及可以结合全篇思路去设计) 方案如下:
      1. 半同步复制,理应数据库原生的功能,等从库同步完才返回结果,缺点吞吐量下降
      2. 强制读主库,部分有一致性要求的,代码中强制读取主库,这个时候一定要结合好缓存,提高读性能
      3. 数据库中间件,一般情况数据库中间件把写路由到主,把读路由到从,此处是记录所以写的key,在500ms内读主库,超过500ms后读从库,能保证绝对的一致性,缺点是成本比较高
      4. 缓存记录写key法,发生写操作,把此key记录在缓存里过期时间500ms,key存在表示刚更新过,还没完成同步,强制路由到主库,没有则路由到从库
    • 关于强一致的需求,现实是不多的,本身就使用cache了还要求强一致,貌似本末倒置,但是不排除特殊情况的存在,主要是思路和大家分享。
    • 金钱余额业务有这种强一致需求(用户余额表接近6亿,查询QPS晚高峰4~5k )
  2. 读和写使用分布式锁控制,这样就能保证,先操作(或读或写)数据的先获得结果;写的时候让读流量直接走DB,让更新缓存的操作和写DB的操作串行。

  3. 延时双删策略 (延时减少读到脏数据的概率, 可以异步延时)

    (1)先淘汰缓存
    (2)再写数据库(这两步和原来一样)
    (3)休眠1秒,再次淘汰缓存
    这么做,可以将1秒内所造成的缓存脏数据,再次删除。
    

    延迟删只是减少概率

  4. 只有在读请求比写请求耗时还长的场景下才能产生,实际上这种情况发生的概率会很小

  5. 在读流量走从库的情况下,也有可能会导致缓存不一致。
    由于更新完主库后,binlog还没有同步到从库,这时候DB读到的是旧的值,同样会导致缓存不一致的场景

  6. 缓存更新重试机制:使用MQ或binlog (个人不是很喜欢,除非能很好的抽象成公共组件可以考虑)

  7. 热点数据查主库(同1中的第4点)

    1. 写,更新db,设置热点数据标志(30s(可配置))
  8. 读,判断是否是热点数据。是,直接读主库(主库超过一定qps,读从库),写缓存;否,读缓存

  9. 根据业务id,实时性高的读主库,实时性低的读从库或者缓存

  • 浅谈缓存最终一致性的解决方案
  • 总结: 在解决缓存一致性的过程中,有多种途径可以保证缓存的最终一致性,应该根据场景来设计合适的方案,读多写少的场景下,可以选择采用“ Cache-Aside 结合消费数据库日志做补偿”的方案,写多的场景下,可以选择采用“ Write-Through 结合分布式锁”的方案 ,写多的极端场景下,可以选择采用“ Write-Behind ” 的方案。

其他个人经验

  1. 设置缓存时为了防止穿透,并且具备更新缓存的能力,需要失败时提供默认值,设置较大的过期时间
  2. 那么需要设置:正常数据更新时间R、失败默认数据过期时间DR、数据过期时间E;
  3. 一般E > R > DR
  4. 并且,当缓存已经有数据时, 重新远程获取数据失败时,不应该更新缓存

扩展

  • 太强了,全面解析缓存应用经典问题
    • 缓存的主要存储模式
      1. Cache Aside(旁路缓存)
      2. Read/Write Through(读写穿透)
      3. Write Behind Caching(异步缓存写入)
    • 缓存7大经典问题的常用解决方案
      1. 缓存集中失效
      2. 缓存穿透
      3. 缓存雪崩
      4. 缓存数据不一致
      5. 竞争并发
      6. 热点Key问题
      7. 大Key问题

Reference

  1. 高并发下缓存和数据库一致性问题(更新淘汰缓存不得不注意的细节)
  2. 分布式之数据库和缓存双写一致性方案解析
  3. 翻越缓存的三座大山
  4. 解析分布式系统的缓存设计

财务数据报表的跨天之苦

发表于 2020-07-09

数据报表,尤其是金钱相关的财务报表,对数据的准确性犹为敏感。而服务系统间的处理时间点的存在差异的客观事实(网络时延,失败重试等原因),导致在以天(或月/年)为维度的数据报表中,或对账中,常常出现数据不平的问题。

业务描述

以用户充值加金币的业务为例

充值请求---------------->  充值服务A
                 (支付成功的时间x:2020-07-08 23:59:59)
                 ----------------> 金币服务B
                           (加币成功的时间y:2020-07-09 00:00:01)

现在,有以下数据报表的需求:输出每一天支付金额以及对应的加金币数目。

  • 从上图可以看出,时间x和时间y不在同一天,在出数据报表的时候,这条充值记录应该归为7月8日还是7月9日呢?
    1. 从用户的角度看,充值成功的行为是一个原子操作,用户不关心服务方底层有区分支付时间和加币时间;
    2. 从公司财务的角度看,他们同样不关心1描述的问题,只关心报表是否平帐(支付金币=加币数目)

解决方案

要解决以上问题:需要统一充值请求的业务时间

充值请求---------------->  充值服务A
(生成当前时间addTime并传入)
                 (支付成功的时间x:2020-07-08 23:59:59)
                 (接收addTime参数)
                 ----------------> 金币服务B
                           (加币成功的时间y:2020-07-09 00:00:01)
                           (接收addTime参数)
  • 充值请求在发起时就会生成一个当前时间addTime,并一直透传到底层服务,那么统一以addTime作为充值的时间点,就不会出现跨天差异导致的数据不平问题。

注意事项和监控重跑

  • 上述的方案有一个问题需要解决:addTime 时间的合法性
    1. 传入的addTime大于当前时间
    2. 传入的addTime远远小于当前时间

问题分析

  • 问题1显然是错误的,是不合法的请求,但由于系统间可能存在微小差异,可以在逻辑上拒绝addTime大于当前时间超1分钟的请求
  • 问题2是客观存在的。
    1. addTime是一开始生成的时间,当传到每个服务之后,理论上服务的当前时间必然大于addTime
    2. 由于网络超时,重试等原因,相差的时间可能达到分钟级;而由于链路上下层服务因为故障等原因暂时无法处理,那么相差的时间可能到小时级以上。
    3. 一般情况下,一个充值请求正常情况下,一分钟之内就能完成。所以有一个定时任务会在凌晨生成昨天的报表,以供财务人员第二天查看。
      如果报表已经生成之后,addTime是昨天的充值记录才重试成功,那么将导致该条数据没被统计到!

解决方案

针对上述问题2导致的数据报表错误

  1. (程序阻断)当数据报表已经生成之后,拒绝addTime<=昨天的请求,避免充值数据和数据报表不一致。同时增加告警,支持解除限制和重跑报表。
  2. (告警重跑)当数据报表已经生成之后,接收到addTime<=昨天的请求,且执行成功时告警,通知相关人员重跑报表。

扩展

有人可能会问,上述的解决方案始终依赖告警和人工补偿。有没有更加自动化的手段?

  1. 二八法则:避免将资金、精力和时间花在琐碎的多数问题上。上述的方案其实已经解决了大多数场景的问题,如果要完美的解决,必然需要花费更大的精力(可能花费时间比实现原功能更多)。
    当然可以想办法解决,如果确实有必要的话。
  2. 针对一些不常发生的异常问题:增加限制、增加告警、增加人工补偿,是安全快捷、高效成本低的手段。

《一只特里独行的猪》摘要

发表于 2020-07-09

  • 我的看法也许不值得别人重视,但对自己却很重要。这说明我有自己的好恶、爱憎等等。假如没有这些,做人也没什么味道。

  • 我已经四十岁了,除了这只猪,还没见过谁敢于如此无视对生活的设置。相反,我倒见过很多想要设置别人生活的人,还有对被设置的生活安之若素的人。因为这个缘故,我一直怀念这只特立独行的猪。

  • 真正有出息的人是对名人感兴趣的东西感兴趣,并且在那上面做出成就,而不是仅仅对名人感兴趣。

  • 奇怪的是:错得越厉害就越有人信——这都是因为它让人振奋。

  • 整个人类是一个物种,科学是全人类的事业,它的成就不能为民族所专有,所以它是全人类的光荣;这样就能有一些平常心。有了平常心,也就不容易被人骗。

  • 如罗素所言,科学在“不计利害地追求客观真理”。请扪心自问,你所称的科学,是否如此淳朴和善良。尤瑟纳尔女士说:“当我计算或写作时,就超越了性别,甚至超越了人类。”请扪心自问,你所称的科学,是否是如此崇高的事业。

  • 当年文化知识不能成为饭碗,也不能夸耀于人,但有一些青年对它还是有兴趣,这说明学习本身就可成为一种生活方式。学习文史知识目的在于“温故”,有文史修养的人生活在从过去到现代一个漫长的时间段里。学习科学知识目的在于“知新”,有科学知识的人可以预见将来,他生活在从现在到广阔无垠的未来。假如你什么都不学习,那就只能生活在现时现世的一个小圈子里,狭窄得很

  • 人的存在是一种自然现象,而不是某种意志的产物。这种现象的内容就包括:人和人是不一样的,有性别之分、贤愚之分,还有同性恋和异性恋之分,这都是自然的现象。把属于自然的现象叫做“丑恶”,不是一种郑重的态度。这段话的意思说白了就是这样的:有些事原本就是某个样子,不以人的意志为转移。

  • 据我所知,学化学的研究生也未必能学到李先生的理论;他们还有个罪名是“追星族”,鬼迷心窍,连杨振宁、李政道、李四光是谁都不知道。据我所知,这三位先生的学问实在高深,中学生根本不该懂,不知道学问,死记些名字,有何必要?更何况记下这些名字之后屈指一算,多一半都入了美国籍,这是给孩子灌输些什么

  • 那些与命运斗争的人,那些做接近自己限度的斗争的人,却天生地接近这种失败。

    一个常常在进行着接近自己限度的斗争的人总是会常常失败的,一个想探索自然奥秘的人也常常会失败,一个想改革社会的人更是会常常失败。只有那些安于自己限度之内的生活的人才总是“胜利”,这种“胜利者”之所以常胜不败,只是因为他的对手是早已降伏的,或者说,他根本没有投入斗争。

    在人生的道路上,“失败”这个词还有另外的含义,即是指人失去了继续斗争的信心,放下了手中的武器。人类向限度屈服,这才是真正的失败。而没有放下手中武器,还在继续斗争,继续向限度挑战的人并没有失败。如此看来,老人没有失败。老人从未放下武器,只不过是丧失了武器。老人没有失去信心,因此不应当说他是“失败了的英雄”。

    那些永远不肯或不能越出自己限度的人是平庸的人。

关于幂等的若干问题

发表于 2020-07-09
  1. 请求已经处理成功,当业务调用方重试时,幂等id不变,但其他参数有变化时。
    • 该返回成功的结果吗?或者说应该对所有的参数进行校验,判断和之前的参数是否一致?
    • 与幂等相关的不可变参数组成签名,并保存到数据库(同时保存当时参与签名的生成规则),后续可依据判断参数是否变化?
    • 个人看法最新更新 - 20240528
      • 对于入参是否变化不应该归于苛刻,因为后续迭代加字段是很正常的
      • 说明接口对某些入参幂等,比如订单号
      • 根据业务实际情况,根据需要标注幂等时效性(比如同个订单号一年内幂等,后续可能返回订单错误等)

程序异常时能返回50x吗?

发表于 2020-07-08

问题描述

  1. 内网nginx配置
    proxy_next_upstream error timeout http_500 http_502 http_503 http_504;
    proxy_upstream_tries 5;
    
    表示超时或50x(500,502,503,504),nginx会进行重试,一共5次
  2. 服务的程序有bug,报NullPointException,实际逻辑已经执行成功
  3. 服务使用springboot,默认异常时返回的HTTP状态是500
  4. nginx收到500之后,进行重试
  5. 服务的接口不幂等

解决

  1. 很难保证服务的所有接口都幂等,并且是外网的幂等
  2. 服务的统一异常拦截,最好把状态码设置成200,调用方通过业务错误码判断,避免nginx误重试。

外网请求如何保证幂等

发表于 2020-07-08

常见的外网请求,通常来自网页或app。一个不幂等的接口,可能会导致用户只点击一次,却产生多次点击的效果。

  1. 如果用户的请求只是修改昵称,那么基本没影响
  2. 但如果用户的请求是扣费,比如送礼,那就会产生多扣费的资金问题。

问题的原因

在互联网上,无论是内网还是外网,网络经常会有不稳定的情况。为应对这些网络问题,通常会有各种重试策略。

  1. 内网不稳定的情况,通常会在代理上(如nginx)配置一些超时重试,或50x重试策略
  2. 外网不稳定的情况,客户端会在超时时进行重试或切域名重试等

这些重试策略,导致了用户端只发出一次请求,实际服务端收到多次请求的情况。

总结:“重试”是产生问题的源头

解决问题的关键

解决问题的关键其实很简单,就是要让服务端能识别接收到的“多个”请求是否是“同个”请求,从而确保业务只执行一次。

如何标识请求的唯一性

本质上是请求的参数中,包含由一个或多个参数组合而成的,唯一且不变的标识。

  1. 像修改用户昵称的请求,业务本身就幂等,因为用户id是唯一且不变的
  2. 像用户的扣费请求,那么通常的做法是使用唯一的订单号,以及相应的唯一ID生成策略

唯一ID算法需要考虑哪些

  1. 有序
    • 唯一ID作为数据的索引,保持有序有助于数据库性能提升
  2. 数据库类型
    • 以MySQL为例,选择bigint(20)还是varchar,前者只有64位,像”2017072809364399365840049582“这种订单号只能用varchar存储
  3. 基因
    • 分库需求,详见:唯一ID的基因
  4. 性能

前端唯一ID生成方案

  1. 使用UUID (缺点:无序)
  2. 通过后端接口获取唯一ID(缺点:多一次外网的网络请求)
  3. 按一定的业务规则生成,如:timestamp+ 用户ID(10-11位)+ 随机3位数字(或递增)(优点:有序且基本保证唯一)

后端唯一ID生成方案

  1. 使用snowflake算法实现的唯一ID服务
  2. 其他业界方案

解决方案

  1. 前端唯一ID使用方案3
    • 后端服务对前端生成的ID进行规则校验,防止恶意伪造不规范的唯一ID
  2. 后端唯一ID
  3. 直接使用前端传入的唯一ID
  4. 从唯一ID服务获取(和前端唯一ID进行映射)

扩展

不同业务唯一ID冲突问题

  • 若后端作为一个基础服务,对接上层业务,每个业务使用的唯一ID规则不一样。那么如何避免业务之间唯一ID冲突?
    1. 使用业务ID+业务唯一ID进行订单唯一性区分
    2. 统一唯一ID的生成规则(统一从基础服务获取或由该服务提供订单ID申请接口)

跨机房的服务幂等问题

  • 若服务部署多个机房(通常每个机房有对应的数据库),如何保证幂等,因为外网请求重试,不一定会到达同一个机房。
    1. 把用户按机房分区
    2. 不考虑跨机房幂等问题(主备架构下同个请求落在不同机房概率不高,收益低)

唯一ID的基因

发表于 2020-07-07

在互联网服务中,经常需要使用唯一ID。其中一个常见的应用场景是作为业务中请求的幂等ID。

分布式唯一ID生成方案

  1. uuid
  2. snowflake
  3. 包含业务属性的唯一ID,如 timestamp+ uid(10-11位)+ 随机3位数字(或递增)
  4. 其他方案

优劣不在这里讨论

业务场景分析

结合目前工作中的现状进行分析

  1. 唯一ID的存储方式:MySQL-bigint(20)
  2. 唯一ID生成方式:snowflake-64位:42位时间戳+5位机器码+5位进程worker标识码+12位自增id(42|5|5|12 = 64)
  • 我们把幂等ID作为数据库的唯一键,从而保证幂等;而当数据量越来越大是,我们对数据按月进行分表,提升处理性能。

  • 我们在数据库中除了有幂等ID(orderId)字段之外,还有添加时间字段(addTime);当请求进来之后,根据addTime找到相应的月表。所以实际上要保证请求处理幂等,依赖的是orderId+addTime 不变(或addTime保证始终落在同一个月)

为什么要依赖addTime保证幂等?

  1. 基础组件提供的唯一ID生成服务,对snowflake进行个性化的改造,只能保证ID是唯一的,然而ID的属性并不明显,无法方便的使用唯一ID进行分库(实际上snowflake算法的前n位是时间戳,可以考虑作为分库的属性)

  2. 一些常见唯一ID的例子

    交易单号:4008722001201707283057762612
    商户单号:2017072809364399365840049582
    订单编号: 60310040822721833
    支付宝交易号: 2017092021001001150522558267
    大众点评订单号36611441412777832
    

像“商户单号”和“支付宝交易号”,很明显可以使用日期“基因”来分库,不过这种唯一ID不能使用bigint 存储,因为超过64位了

总结

一个好的唯一ID算法生成的ID,应该具备易用的“基因”。像UUID这种就不是很满足。

如何处理RPC返回的错误码?

发表于 2020-07-07

RPC调用的返回结果

  1. 成功
  2. 失败
  3. 超时

超时情况是不确定的,需要调用方重试或查询等,根据业务情况进行处理

返回结果表示方法

  1. 使用http协议的状态码
  2. 使用业务错误码(在业务处理中比较常见)
    • 0表示成功
    • 1表示失败
    • 其他错误码代表具体的失败业务场景

失败错误码类型

  1. 业务错误(参数错误,或业务场景校验限制,请求已经处理完成)
  2. 处理速度慢(msg:”请求处理中”)
  3. 限流(msg:”你的操作太快了”)
  4. 未知错误
  5. 其他

针对2或4的场景,很可能提交的RPC实际上已经成功,属于不确定的情况(同超时),业务方不能直接当成失败处理;1的场景中“请求已经处理完成”,实际上已经成功,只是调用方的接口返回不幂等。

错误码处理

定义RPC请求中,成功,失败,不确定三种结果对应的错误码集合(最好可配置),业务针对不同的结果进行相应处理。

  1. 成功(错误码:A,B,C ….)
  2. 失败(错误码:E,F,G ….)
  3. 不确定(错误码:H,I,J …. 和 ”超时“)

上线需要做哪些准备?

发表于 2020-07-06

一般常规的上线,需要做哪些准备,确保不会因为遗漏这种低级错误导致线上问题?

  1. 开发中遇到问题标注TODO,避免遗漏,单元测试覆盖业务逻辑
  2. 准备上线checklist,验收方案
  3. 检查上线配置
  4. 是否需要配置定时任务
  5. 重新检查一遍变更的代码

扩展

  1. 涉及app的,需要考虑旧版本兼容和回归测试(重点回归若干个高流量的旧版本)
  2. 若上线后出现问题,先回滚再查问题(适用于大多数场景)
  3. 核心业务要重点测试

使用ThreadContext缓存RPC结果

发表于 2020-07-06

在业务开发中,经常会使用RPC请求获取数据。有时候在同一条逻辑链路中,会多次使用RPC返回的数据。

业务场景

请求----------> 服务A
             [1.methodA]----获取用户数据--->服务B(用户服务)
             [2.mehtodB]----获取用户数据--->服务B(用户服务)

上图中,服务A中同一个逻辑链路中包含methodA和mehtodB,两个都会使用到用户数据,因此会导致重复的RPC调用。

解决方案

  1. 将数据实体作为methodB的参数传入
    • 这种方式可以避免调用多次重复的RPC,但是也有缺点:
      a. 如果有mehtodC,methodD等,每个方法都加个参数,不是很优雅
      b. 如果除了获取用户信息,还要获取商品信息等,那么方法形参将越来越多,影响阅读
请求----------> 服务A
             [1.methodA]----获取用户数据--->服务B(用户服务)
             [2.mehtodB(param1: userInfo)]----获取用户数据--->服务B(用户服务)
             [3.mehtodC(param1: userInfo)]----获取用户数据--->服务B(用户服务)
  1. 使用 ThreadContext 缓存RPC结果
    • 可以使用拦截器统一处理服务的所有ThreadContext(因为使用完之后需要remove)
    • 将RPC结果保存到ThreadContext和从ThreadContext获取RPC结果的逻辑,封装在RPC调用方法中
      请求----------> 服务A
             [1.methodA]----获取用户数据--->服务B(用户服务)
             ->> 将RPC结果保存在ThreadContext
             [2.mehtodB]----获取用户数据--->从ThreadContext获取
             [3.mehtodC]----获取用户数据--->从ThreadContext获取

<1…151617>

169 日志
197 标签
RSS
© 2025 Kingson Wu
由 Hexo 强力驱动
|
主题 — NexT.Pisces v5.1.4