拉巴力的纸皮箱


  • 首页

  • 标签

  • 归档

  • 关于

  • 搜索

由灾备工作中引发的对“比例”的思考

发表于 2020-07-13

前言

  • 关于文章的题目我想了很久,暂时没想到合适的题目,姑且先这样吧
  • 之前在做灾备工作的时候,经常涉及服务的强弱依赖,超时时间,降级,以及业务折中方面的思考,下面将通过一些例子简要进行描述

场景描述

  • 从服务所依赖的重要程度上来区分有“强依赖”和“弱依赖”。

    1. 通常上,针对“弱依赖”我们会设置较小的超时时间(比如100ms以内),并且在失败率较高是进行降级;
    2. 而针对“强依赖”我们会设置一个较大的超时时间,当接口失败时返回“服务繁忙“,表示暂不可用(当然可以产品层面进行合适的引导)
  • 我们在灾备演练中,通过停其中一个机房,触发以下的问题

    1. 强依赖超时问题(停机房瞬间)
    2. 弱依赖多变长的问题(停机房期间)
  • 从而导致停机房瞬间接口失败率高,停机房期间接口时延上涨严重。由此引发我对“比例“问题的思考。

    1. 强依赖超时时间设长,可以减少失败率,但也有可能导致服务worker线程池被占满而拒绝请求。
    2. 弱依赖虽然每个都设置100ms以内,但是当依赖的服务超过10个时(真实业务真的有),可能导致总时长超过一秒(弱依赖慢但都没熔断,所以没降级;可异步并发的弱依赖的另说)

解决方案

  1. 对强依赖,可设置超时时间的比例(比如只有20%的请求可以超过1s,其他的控制在100ms以内),解决停机房瞬间,线程池占满拒绝请求的问题
  2. 对弱依赖,可控制多个弱依赖的总超时时间,解决停机房期间跨机房调用导致时延一直上涨的问题
    • 单个调用超时时间可分级设置比例;多个调用(可熔断)可聚合在一起设置总超时时间比例
    • 降级优先级排序,控制总时间。熔断线程池,控制比例超时。允许一定的时间超时。流量控制。物理隔离。

扩展

  1. 针对使用回调机制提高接口安全性所讲的,某些网络场景可以考虑针对大金额的请求才回调,减少RPC次数,这其实也是一个“比例”问题。

使用回调机制提高接口安全性

发表于 2020-07-12

前言

  • 接口回调在平常的业务开发中并不罕见。做过渠道推广业务,对接过广告平台接口的同学应该比较熟悉。
  • 在业务的作用主要是两个
    1. 异步通知
    2. 业务查询

本文主要讲的第二种,并通过一个业务例子来展开描述

业务描述

有以下业务场景,用户通过充值加金币:

充值请求----------> 服务A(充值服务)
             [1.验证用户已经支付成功]
             [2.发起加金币请求]------------>服务B(金币服务)
  • 服务B如何验证加金币操作的请求是合法,从而保证资金安全呢?

    • 有一种方法是通过分配业务对应的秘钥和服务端签名对比,验证请求的合法性
  • 通过秘钥和验签的方式无法避免以下问题

    1. 其他业务方误用秘钥
    2. 因误操作而发加币请求
    3. 业务方有bug一次充值多次加币

解决方案

  • 其实以上描述的业务有一个特点:每一次操作都有对应的前置操作,比如
    1. 充值加金币的前置操作是:充值支付成功
    2. 活动类加金币的前置操作是:用户达到活动要求

那么作为底层的金币服务,可以使用定义一种同样的回查机制,由各业务方实现查询逻辑,金币服务通过回查来检验请求的合法性,从而提高安全性。

  • 基础服务(金币服务)接收回调链接地址有两种方式:
  1. 通过后台配置,每个业务配置对应回调链接和相应的参数 (相比可能比较安全可控,但不灵活)
  2. 通过参数获取,如callback参数(灵活,但是要设计完善的校验机制保证安全)
    • 可结合公司的基础服务实现,如可以通过服务发现获取服务对应的地址,只需传uri即可
充值请求----------> 服务A(充值服务)
             [1.验证用户已经支付成功]
             [2.发起加金币请求]------------>服务B(金币服务)
                                <----------[3.回调验证] [4.加金币] < pre>

  • 统一回调接口返回格式
回调接口返回格式
{
    "code": 0,
    "message": "success"
}
0 代表成功;其他:失败

回调用于查询业务订单是否存在,检验是否非法订单
一般实现逻辑建议业务方直接查主库
  • 回调无法绝对解决以上提出的问题,只能说一定程度上提升接口的安全性级别。

扩展

  • 使用非对称加密,是不是可以替换回调验证?(客户端使用公钥解密,服务端使用私钥加密,前提是私钥不泄漏)

分布式限流简单总结

发表于 2020-07-11

基本描述

  • 限流分类

    1. 单机限流
    2. 分布式限流
  • 限流的指标

    • 每秒处理的事务数 (TPS),每秒请求数 (hits per second)
    • 使用 hits per second 作为限流指标
  • 限流规则包含三个部分:时间粒度,接口粒度,最大限流值。

  • 选择单机限流还是分布式限流

    1. 单机限流一般针对服务负载,防止突发流量压垮服务器
    2. 分布式限流一般针对在业务侧做细粒度限流

限流算法

  1. 固定时间窗口
    • 首先需要选定一个时间起点,之后每次接口请求到来都累加计数器,如果在当前时间窗口内,根据限流规则(比如每秒钟最大允许 100 次接口请求),累加访问次数超过限流值,则限流熔断拒绝接口请求。当进入下一个时间窗口之后,计数器清零重新计数。
    • 限流策略过于粗略,无法应对两个时间窗口临界时间内的突发流量。
  2. 滑动时间窗口
    • 滑动时间窗口限流算法可以保证任意时间窗口内接口请求次数都不会超过最大限流值,但是仍然不能防止在细时间粒度上面访问过于集中的问题
  3. 令牌桶算法(Token Bucket)
    • 接口限制 t 秒内最大访问次数为 n,则每隔 t/n 秒会放一个 token 到桶中;桶中最多可以存放 b 个 token
    • 令牌桶大小为 b,所以是可以应对突发流量的
    • 没有提前预热的令牌桶,如果做否决式限流,会导致误杀很多请求
  4. 漏桶算法(Leaky Bucket)
    • 漏桶算法稍微不同与令牌桶算法的一点是:对于取令牌的频率也有限制,要按照 t/n 固定的速度来取令牌,所以可以看出漏桶算法对流量的整形效果更加好,流量更加平滑,任何突发流量都会被限流。

分布式限流

  1. 采用redis+lua的方案做分布式限流 (参考业界实现方案即可,需注意原子性)
  2. 由于使用中心存储计数的方式性能较差,在业务允许的情况下可以考虑将限制的数量分摊到每个服务(服务数通过服务发现接口获取),间接使用单机限流提升性能。

扩展

  1. Google的Guava包中的RateLimiter
    • 令牌桶算法的解决方案,假设1S需要限流5次;也就是1S会往桶里面方5个Token;如果在这1S内桶满了则不再加请求,如果空了则表示达到限制的上线了,会阻塞,直到有数据加入再次处理。
  2. [ratelimiter4j] (https://github.com/wangzheng0822/ratelimiter4j)
    • 分布式限流算法的性能瓶颈主要在中心计数器 Redis,从我们开源的 ratelimiter4j 压测数据来看,在没有做 Redis sharding 的情况下,基于单实例 Redis 的分布式限流算法的性能要远远低于基于内存的单机限流算法,基于我们的压测环境,单机限流算法可以达到 200 万 TPS,而分布式限流算法只能做到 5 万 TPS。所以,在应用分布式限流算法时,一定要考量限流算法的性能是否满足应用场景,如果微服务接口的 TPS 已经超过了限流框架本身的 TPS,则限流功能会成为性能瓶颈影响接口本身的性能。

Reference

  1. 微服务接口限流的设计与思考

分布式锁简单总结

发表于 2020-07-11

实现方式

  1. 数据库的行级排它锁(如select * from x for update);
  2. 基于zookeeper的瞬间顺序节点;
    • 最小节点获得锁
  3. 基于 Redis 的 SETNX 命令。
    • 使用lua脚本保证原子性 (Redisson 有封装实现 )
    • RedLock
      • 红锁并非是一个工具,而是Redis官方提出的一种分布式锁的算法。
      • RedLock作者指出,之所以要用独立的,是避免了redis异步复制造成的锁丢失,比如:主节点没来的及把刚刚set进来这条数据给从节点,就挂了。
      • 红锁算法认为,只要(N/2) + 1个节点加锁成功,那么就认为获取了锁, 解锁时将所有实例解锁。
      • 细说Redis分布式锁
      • Redisson 有封装实现

本文要讲的是第3种方式。

实现原理

  1. 使用setnx创建一个key,如果key不存在,则创建成功返回1,否则返回0。根据是否获得锁决定是否执行业务逻辑,执行完后删除key来实现释放锁。

    • SET resource_name my_random_value NX PX 30000
  2. 为了避免客户端挂了导致其他客户端无法获得锁的情况,为lock_key设置一个过期时间lock timeout

    • 一旦业务逻辑执行时间过长,租约到期,就会引发并发问题。
    • lock timeout 设置合适的时间,一般情况10s内
    • 相对而言,ZooKeeper版本的分布式锁没有这个问题
      • 锁的占用时间限制:redis就有占用时间限制,而ZooKeeper则没有,最主要的原因是redis目前没有办法知道已经获取锁的客户端的状态,是已经挂了呢还是正在执行耗时较长的业务逻辑。而ZooKeeper通过临时节点就能清晰知道,如果临时节点存在说明还在执行业务逻辑,如果临时节点不存在说明已经执行完毕释放锁或者是挂了。
      • 使用ZooKeeper可以主动通知客户端释放锁,Redis则不行
  3. 设置一个随机字符串my_random_value是很有必要的,它保证了一个客户端释放的锁必须是自己持有的那个锁。

    • 释放锁lua脚本
      1
      2
      3
      4
      5
      if redis.call("get",KEYS[1]) == ARGV[1] then
      return redis.call("del",KEYS[1])
      else
      return 0
      end
  4. 可重入锁

    • https://juejin.cn/post/6961380552519712798
    • lua 脚本,需要存储 锁名称lockName、获得该锁的线程id和对应线程的进入次数count
    • 加锁 lock.lua
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
         -- 不存在该key时
      if (redis.call('exists', KEYS[1]) == 0) then
      -- 新增该锁并且hash中该线程id对应的count置1
      redis.call('hincrby', KEYS[1], ARGV[2], 1);
      -- 设置过期时间
      redis.call('pexpire', KEYS[1], ARGV[1]);
      return nil;
      end;

      -- 存在该key 并且 hash中线程id的key也存在
      if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
      -- 线程重入次数++
      redis.call('hincrby', KEYS[1], ARGV[2], 1);
      redis.call('pexpire', KEYS[1], ARGV[1]);
      return nil;
      end;
      return redis.call('pttl', KEYS[1]);
    • 解锁 unlock.lua
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
         -- 不存在key
      if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
      return nil;
      end;
      -- 计数器 -1
      local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
      if (counter > 0) then
      -- 过期时间重设
      redis.call('pexpire', KEYS[1], ARGV[2]);
      return 0;
      else
      -- 删除并发布解锁消息
      redis.call('del', KEYS[1]);
      redis.call('publish', KEYS[2], ARGV[1]);
      return 1;
      end;
      return nil;
  5. 锁续约

    • 延长锁的releaseTime延迟释放锁来直到完成业务期望结果,这种不断延长锁过期时间来保证业务执行完成的操作就是锁续约。
  6. 基于单Redis节点的分布式锁无法解决的安全问题。

    • 假如Redis节点宕机了,那么所有客户端就都无法获得锁了,服务变得不可用。为了提高可用性,我们可以给这个Redis节点挂一个Slave,当Master节点不可用的时候,系统自动切到Slave上(failover)。但由于Redis的主从复制(replication)是异步的,这可能导致在failover过程中丧失锁的安全性。
    • Redlock算法
  7. 使用Redisson可以满足以上所有需求。

    • Redisson 实现分布式锁原理分析
    • Redisson没有设置一个随机值,也可以解决解锁误删的问题。因为Redisson在解决可重入时,已经定义了threadId进行重入计数,通过threadId就可以判断是否是自己之前加的锁。
    • 锁续期:leaseTime 必须是 -1 才会开启 Watch Dog 机制,也就是如果你想开启 Watch Dog 机制必须使用默认的加锁时间为 30s。如果你自己自定义时间,超过这个时间,锁就会自定释放,并不会延长。
    • 锁等待:当锁正在被占用时,等待获取锁的进程并不是通过一个 while(true) 死循环去获取锁,而是利用了 Redis 的发布订阅机制,通过 await 方法阻塞等待锁的进程,有效的解决了无效的锁申请浪费资源的问题。
    • 缺点:Redis Cluster 或者说是 Redis Master-Slave 架构的主从异步复制导致的 Redis 分布式锁的最大缺陷(在 Redis Master 实例宕机的时候,可能导致多个客户端同时完成加锁)
    • 用法:使用Redisson实现分布式锁
  • 个人看法: 分布式锁并不是绝对可靠,只能尽量保证大多数时候可靠,业务应该自行保证一旦锁失效时的逻辑正确性。

Zookeeper和Redis分布式锁的比较

  1. 添加和删除,Reids性能较高
  2. Zookeeper有等待锁队列,大大提升抢锁效率;Redis需要考虑超时,原子性,误删等场景,客户端需要自旋等锁。
  3. 使用 Redis 实现分布式锁在很多企业中非常常见,而且大部分情况下都不会遇到所谓的“极端复杂场景”。所以使用 Redis 作为分布式锁也不失为一种好的方案,最重要的一点是 Redis 的性能很高,可以支撑高并发的获取、释放锁操作。
  4. ZK 天生设计定位就是分布式协调,强一致性。锁的模型健壮、简单易用、适合做分布式锁。
    如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小。
    但是 ZK 也有其缺点:如果有较多的客户端频繁的申请加锁、释放锁,对于 ZK 集群的压力会比较大。

扩展

  • 可以尝试使用Redisson实现分布式锁

  • Redis的作者antirez给出了一个更好的实现,称为Redlock,算是Redis官方对于实现分布式锁的指导规范。Redlock的算法描述就放在Redis的官网上:
    https://redis.io/topics/distlock

  • 举个场景的例子来详细说明:一提到分布式锁问题,大多数人想到的方案是基于Redis的Master-Slave模式来实现。这个实现方案行不行?分布式锁本质是一个CP需求,基于Redis的实现是一个AP需求,乍一看基于Redis的实现是无法满足的。脱离业务场景来谈架构都是耍流氓。
    从技术战略的需求层面来看,如果分布式锁在极端情况下获取锁的不一致,社交业务场景能够接受,那么基于Redis的实现是完全可行的。如果业务是交易场景,分布式锁在极端情况下获取锁的不一致性无法接受,那么基于Redis的实现方案是不可行的。在锁强一致性的场景下,需要采取基于CP模型的etcd等方案来实现。

  • redis-cli提供了EVAL与EVALSHA命令执行Lua脚本:

    1. EVAL
      • EVAL script numkeys key [key …] arg [arg …]
      • key和arg两类参数用于向脚本传递数据, 他们的值可在脚本中使用KEYS和ARGV两个table访问: KEYS表示要操作的键名, ARGV表示非键名参数(并非强制).
    2. EVALSHA
    - EVALSHA命令允许通过脚本的SHA1来执行(节省带宽), Redis在执行EVAL/SCRIPT LOAD后会计算脚本SHA1缓存, EVALSHA根据SHA1取出缓存脚本执行.
    
  • redis一般都是单机房部署,如果要控制多个机房只有一个锁,考虑使用Consul来实现分布式锁。

  • Redis 分布式锁的 10 个坑

    1. 非原子操作(setnx + expire)
      • 如果刚要执行完setnx加锁,正要执行expire设置过期时间时,进程crash或者要重启维护了,那么这个锁就“长生不老”了,别的线程永远获取不到锁了
    2. 被别的客户端请求覆盖( setnx + value为过期时间)
      • Getset 命令用于设置指定 key 的值,并返回 key 的旧值。
    3. 忘记设置过期时间
    4. 业务处理完,忘记释放锁
    5. B的锁被A给释放了
      - 假设在这样的并发场景下:A、B两个线程来尝试给Redis的keylockKey加锁,A线程先拿到锁(假如锁超时时间是3秒后过期)。如果线程A执行的业务逻辑很耗时,超过了3秒还是没有执行完。这时候,Redis会自动释放lockKey锁。刚好这时,线程B过来了,它就能抢到锁了,开始执行它的业务逻辑,恰好这时,线程A执行完逻辑,去释放锁的时候,它就把B的锁给释放掉了。
      - 正确的方式应该是,在用set扩展参数加锁时,放多一个这个线程请求的唯一标记,比如requestId,然后释放锁的时候,判断一下是不是刚刚的请求。
    6. 释放锁时,不是原子性
      • 因为判断是不是当前线程加的锁和释放锁不是一个原子操作。如果调用unlock(lockKey)释放锁的时候,锁已经过期,所以这把锁已经可能已经不属于当前客户端,会解除他人加的锁。
      • 判断和删除是两个操作,不是原子的,有一致性问题。释放锁必须保证原子性,可以使用Redis+Lua脚本来完成
    7. 锁过期释放,业务没执行完
      • 是否可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。
      • 当前开源框架Redisson解决了这个问题: 只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程一还持有锁,那么就会不断的延长锁key的生存时间。
    8. Redis分布式锁和@transactional一起使用失效
      • 正确的实现方法,可以在updateDB方法之前就上锁,即还没有开事务之前就加锁,那么就可以保证线程的安全性.
    9. 锁可重入
      • 前面讨论的Redis分布式锁,都是不可重入的。
      • 不可重入的分布式锁的话,是可以满足绝大多数的业务场景。但是有时候一些业务场景,我们还是需要可重入的分布式锁
      • Redis只要解决这两个问题,就能实现重入锁了:
        • 怎么保存当前持有的线程
        • 怎么维护加锁次数(即重入了多少次)
      • 实现一个可重入的分布式锁,我们可以参考JDK的ReentrantLock的设计思想。实际上,可以直接使用Redisson框架,它是支持可重入锁的。
    10. Redis主从复制导致的坑
      • 如果线程一在Redis的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级为master节点。线程二就可以获取同个key的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。
      • 为了解决这个问题,Redis作者 antirez提出一种高级的分布式锁算法:Redlock。Redlock核心思想是这样的:
        • 搞多个Redis master部署,以保证它们不会同时宕掉。并且这些master节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个master实例上,是与在Redis单实例,使用相同方法来获取和释放锁。
        • 简化下步骤就是:
          1. 按顺序向5个master节点请求加锁
          2. 根据设置的超时时间来判断,是不是要跳过该master节点。
          3. 如果大于等于3个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦。
          4. 如果获取锁失败,解锁!
    11. 个人意见:一般情况下(绝大多数),业务不要强依赖于redis做互斥逻辑

Reference

  1. 分布式锁的实现
  2. 使用Redis作为分布式锁的一些注意点
  3. 如何实现靠谱的分布式锁?
  4. 分布式锁用Redis还是Zookeeper?
  5. 基于Redis的分布式锁到底安全吗(上)?
  6. 80% 人不知道的 Redis 分布式锁的正确实现方式(Java 版)
  7. Redis结合Lua脚本实现高并发原子性操作
  8. 细说Redis分布式锁
  9. 分布式锁用 Redis 好,还是 ZooKeeper 好?
  10. 使用Redis实现分布式锁和ZK实现分布式锁有什么区别,分别有哪些场景?

缓存策略简单总结

发表于 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. 不考虑跨机房幂等问题(主备架构下同个请求落在不同机房概率不高,收益低)
<1…13141516>

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