本站总访问量 Go-Zero框架解析 - Jerry的小站

Jerry Gao

上帝就是真理,真理就是上帝

服务治理

布隆过滤器

关注的问题

  • 布隆过滤器在微服务中能解决什么问题?

  • 布隆过滤器在go-zero中的实现

  • 布隆过滤器在go-zero中如何使用

问题解答

布隆过滤器在微服务中能解决什么问题?

解决缓存穿透的问题。

  • 什么是缓存穿透?

    缓存穿透是指一个缓存系统无法缓存某些查询请求,导致这些请求都需要从数据库或其他服务中获取数据,从而导致缓存系统的压力过大,甚至造成宕机。缓存穿透通常是由于恶意攻击或者查询条件错误等原因造成的。

  • 微服务中为什么存在缓存穿透的问题?

    在微服务架构中,由于服务之间的调用频繁,缓存穿透的问题更加明显,会存在很多查询相同数据的情况。

  • 布隆过滤器为什么能够解决这个问题?

    布隆过滤器是一种快速、高效的数据结构,可以用来判断一个元素是否存在于一个集合中。它可以使用非常小的空间来存储大量的元素,但是会存在一定的误判率。在缓存系统中,我们可以将每个查询条件都作为布隆过滤器中的一个元素,然后在查询时先使用布隆过滤器判断这个查询条件是否存在于布隆过滤器中,如果不存在则直接返回缓存未命中,避免了无效查询请求对缓存系统的影响。

  • 布隆过滤器中为什么会存在误判率?

    布隆过滤器之所以存在误判率,是因为其使用的是一种基于哈希函数的概率算法,它只能判断元素是否“可能存在于集合中”,而不能100%确定元素是否存在于集合中。具体地说,当布隆过滤器将一个元素映射到多个位上时,可能会存在一种情况,即多个元素映射到的位上都已经被标记,从而导致这些元素被误判为存在于集合中。

  • 误判率取决于什么?

    取决于布隆过滤器的大小和哈希函数的数量。

  • 如何选择合适的误判率?

    一般来说,误判率越低,布隆过滤器所需要的空间和时间复杂度就越高。因此,在选择误判率时需要平衡误判率和布隆过滤器的性能。常见的误判率和建议使用的场景:

    • 0.1%:适用于对数据准确性要求非常高的场景,如金融领域的交易系统;

    • 1%:适用于大多数常规应用场景,如网站用户会话管理、垃圾邮件过滤等;

    • 10%:适用于一些不那么关键的应用场景,如网站流量统计、爬虫去重等。

  • 怎么检查一个布隆过滤器的误判率?

    1. 准备测试数据集。测试数据集应该包含一些已知存在于集合中的元素和一些不存在于集合中的元素,可以从真实的数据集中提取,也可以生成一些随机数据。
    2. 使用布隆过滤器对测试数据集进行过滤。将测试数据集中的所有元素都添加到布隆过滤器中,然后再用测试数据集中的所有元素去查询布隆过滤器。如果查询到的元素在布隆过滤器中返回“存在”,否则返回“不存在”。
    3. 统计误判率。对于测试数据集中的所有不存在于集合中的元素,如果布隆过滤器返回了“存在”,则认为发生了误判。通过统计误判数和测试数据集中不存在元素的总数,可以计算出误判率。
  • 布隆过滤器发生误判,会造成什么情况?

    1. 误判率增加:布隆过滤器的误判率是在设计时确定的,如果误判率过高,则可能无法满足实际需求。当发生误判时,误判率会增加,这会降低布隆过滤器的准确性。
    2. 数据丢失:如果布隆过滤器用于缓存系统,当发生误判时,可能会导致一些缓存数据被丢失,从而影响系统的性能和正确性。
    3. 数据错误:如果布隆过滤器用于判断数据的存在性,当发生误判时,可能会导致数据的错误使用,从而影响系统的正确性。
布隆过滤器在go-zero中的实现
1
2
3
4
5
6
7
8
9
10
11
// 对元素进行hash 14次(const maps=14),每次都在元素后追加byte(0-13),然后进行hash.
// 将locations[0-13] 进行取模,最终返回locations.
func (f *BloomFilter) getLocations(data []byte) []uint {
locations := make([]uint, maps)
for i := uint(0); i < maps; i++ {
hashValue := hash.Hash(append(data, byte(i)))
locations[i] = uint(hashValue % uint64(f.bits))
}

return locations
}

对元素进行hash 14次(const maps=14),每次都在元素后追加byte(0-13),然后进行hash.

将locations[0-13] 进行取模,最终返回locations.

布隆过滤器在go-zero中如何使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 初始化 redisBitSet
store := redis.New("redis 地址", func(r *redis.Redis) {
r.Type = redis.NodeType
})
// 声明一个bitSet, key="test_key"名且bits是1024位
bitSet := newRedisBitSet(store, "test_key", 1024)
// 判断第0位bit存不存在
isSetBefore, err := bitSet.check([]uint{0})

// 对第512位设置为1
err = bitSet.set([]uint{512})
// 3600秒后过期
err = bitSet.expire(3600)

// 删除该bitSet
err = bitSet.del()

熔断的原理和实现

关注的问题

  • 熔断器在微服务领域可以解决什么问题?

  • 熔断器的原理

  • go-zero内置的熔断器如何实现

问题解答

熔断器在微服务领域可以解决什么问题?

在微服务中服务间依赖非常常见,比如评论服务依赖审核服务而审核服务又依赖反垃圾服务,当评论服务调用审核服务时,审核服务又调用反垃圾服务,而这时反垃圾服务超时了,由于审核服务依赖反垃圾服务,反垃圾服务超时导致审核服务逻辑一直等待,而这个时候评论服务又在一直调用审核服务,审核服务就有可能因为堆积了大量请求而导致服务宕机。

由此可见,在整个调用链中,中间的某一个环节出现异常就会引起上游调用服务出现一系列的问题,甚至导致整个调用链的服务都宕机,这是非常可怕的。因此一个服务作为调用方调用另一个服务时,为了防止被调用服务出现问题进而导致调用服务出现问题,所以调用服务需要进行自我保护,而保护的常用手段就是 熔断

  • 堆积大量请求为什么会导致服务宕机?

    • 资源耗尽:当服务接收到大量请求时,可能会占用过多的计算资源、内存资源或者网络带宽资源等,导致资源的快速耗尽。

    • 线程阻塞:当服务接收到大量请求时,可能会导致线程池中的线程阻塞。如果阻塞的线程数量过多,那么就会导致新的请求无法被及时处理,从而导致服务宕机。

    • 网络拥塞:当服务接收到大量请求时,可能会导致网络拥塞。如果网络拥塞严重,那么就会导致请求无法及时到达服务端,或者服务端无法及时响应请求,从而导致服务宕机

    • 数据库连接池满:当服务接收到大量请求时,可能会占用过多的数据库连接资源。如果数据库连接池被占满,那么新的请求就无法获得数据库连接,从而导致服务无法响应请求,进而宕机。

熔断器的原理
  • 熔断机制

    指的是在发起服务调用的时候,如果被调用方返回的错误率超过一定的阈值,那么后续将不会真正地发起请求,而是在调用方直接返回错误

    服务调用方为每一个调用服务(调用路径)维护一个状态机,在这个状态机中有三个状态:

    • 关闭:在这种状态下,我们需要一个计数器来记录调用失败的次数和总的请求次数,如果在某个时间窗口内,失败的失败率达到预设的阈值,则切换到断开状态,此时开启一个超时时间,当到达该时间则切换到半关闭状态,该超时时间是给了系统一次机会来修正导致调用失败的错误,以回到正常的工作状态。在关闭状态下,调用错误是基于时间的,在特定的时间间隔内会重置,这能够防止偶然错误导致熔断器进入断开状态

    • 打开:在该状态下,发起请求时会立即返回错误,一般会启动一个超时计时器,当计时器超时后,状态切换到半打开状态,也可以设置一个定时器,定期的探测服务是否恢复

    • 半打开:在该状态下,允许应用程序一定数量的请求发往被调用服务,如果这些调用正常,那么可以认为被调用服务已经恢复正常,此时熔断器切换到关闭状态,同时需要重置计数。如果这部分仍有调用失败的情况,则认为被调用方仍然没有恢复,熔断器会切换到打开状态,然后重置计数器,半打开状态能够有效防止正在恢复中的服务被突然大量请求再次打垮

go-zero内置的熔断器如何实现?
  • zrpc根据Google sre过载保护算法,算法的原理如下:

    • 请求数量:调用方发起的请求数量总和

    • 请求接受数量:被调用方正常处理的请求数量

    正常情况下,两个值相等,异常情况下,请求接受数量小于请求数量,当两者之间到达请求数量 = K * 请求接受数量时,达到阈值,熔断器打开,新的请求在本地就会返回错误。概率的计算公式:

    client_rejection2

    通过修改算法中K的值,可以调整熔断器的敏感度。

    go-zero中对于上述的实现:

    1
    2
    3
    4
    5
    type googleBreaker struct {
    k float64 // 倍值 默认1.5
    stat *collection.RollingWindow // 滑动时间窗口,用来对请求失败和成功计数
    proba *mathx.Proba // 动态概率
    }
  • 自适应熔断算法的实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    func (b *googleBreaker) accept() error {
    accepts, total := b.history() // 请求接受数量和请求总量
    weightedAccepts := b.k * float64(accepts)
    // 计算丢弃请求概率
    dropRatio := math.Max(0, (float64(total-protection)-weightedAccepts)/float64(total+1))
    if dropRatio <= 0 {
    return nil
    }
    // 动态判断是否触发熔断
    if b.proba.TrueOnProba(dropRatio) {
    return ErrServiceUnavailable
    }

    return nil
    }
  • 每次发起请求会调用doReq方法,在这个方法中首先通过accept效验是否触发熔断,acceptable用来判断哪些error会计入失败计数,定义如下:

    1
    2
    3
    4
    5
    6
    7
    8
    func Acceptable(err error) bool {
    switch status.Code(err) {
    case codes.DeadlineExceeded, codes.Internal, codes.Unavailable, codes.DataLoss: // 异常请求错误
    return false
    default:
    return true
    }
    }
  • 如果请求正常则通过markSuccess把请求数量和请求接受数量都加一,如果请求不正常则只有请求数量会加一

    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
    func (b *googleBreaker) doReq(req func() error, fallback func(err error) error, acceptable Acceptable) error {
    // 判断是否触发熔断
    if err := b.accept(); err != nil {
    if fallback != nil {
    return fallback(err)
    } else {
    return err
    }
    }

    defer func() {
    if e := recover(); e != nil {
    b.markFailure()
    panic(e)
    }
    }()

    // 执行真正的调用
    err := req()
    // 正常请求计数
    if acceptable(err) {
    b.markSuccess()
    } else {
    // 异常请求计数
    b.markFailure()
    }

    return err
    }

    watch cicd is_cloud=True image_repo=registry.cn-beijing.aliyuncs.com d8da67f5-d160-433f-b258-99d4e50e9687 | deployByScripts env=dfs-tm-java profile=grayscale image_repo=registry.cn-beijing.aliyuncs.com % deployByScripts env=dfs-console profile=grayscale image_repo=registry.cn-beijing.aliyuncs.com % deployByScripts env=dfs-tcm profile=grayscale image_repo=registry.cn-beijing.aliyuncs.com
    watch cicd is_cloud=True image_repo=registry.cn-beijing.aliyuncs.com 145854b6-6446-4e54-8726-ff74bc876401

原理介绍

TimingWheel

TimingWheel(时间轮)是一种计时器算法,常用于高并发场景中的延时任务调度,如定时任务、超时控制等。它通过将时间轴划分为若干个时刻,并将任务放入对应的时间轮槽中,实现快速的任务检索和触发。

TimingWheel 的核心是一个环形数组,其中每个元素代表一个时间槽,每个时间槽存放着需要在该时刻执行的任务。时间轮的基本单位是时间槽,每个时间槽的时间间隔是相等的,也就是说,整个时间轮可以被看做是一个周期为 $N$ 的定时器。

当有一个任务需要被添加到时间轮中时,首先计算它需要经过多少个时间槽才会被触发。例如,如果任务需要在 5 秒后被触发,而每个时间槽的时间间隔为 1 秒,则该任务需要经过 5 个时间槽才会被触发。接下来,将该任务插入到距离当前时间 $N$ 个时间槽之后的时间槽中,如果插入的位置超过了时间轮的尺寸,则需要对时间轮进行一次轮转。

在每个时间轮的槽中,可以存放多个任务。因此,当时间轮中的一个时间槽被触发时,需要遍历该时间槽中的所有任务,并将它们取出并执行。

TimingWheel 算法的优点是,在时间轮的基础上,可以支持很多高级功能,如分层时间轮、动态调整时间轮的精度、延迟队列等,可以满足不同场景下的需求。但是也存在一些缺点,例如时间轮的精度不高、任务的最大延迟时间有限等。

评论