从0到1设计简版singleflight

210 阅读7分钟

背景

在微服务架构或分布式系统中,经常会遇到对同一个资源的并发请求。如果每个请求都独立处理,会导致资源的过度使用,甚至服务崩溃。singleflight是一种编程模式,用于确保对于特定的键值(key),在同一时刻只有一个请求会去获取数据,其他相同的请求会等待这个请求结束后,直接获取其结果,从而减少对下游系统或资源的压力。

需求分析

场景分析

场景详情
开发人员开启多个gouroutine, 执行一个Do操作,传入相同的key和要执行的函数如果在第一个执行函数执行完毕之前执行的Do操作会等待,等第一个执行函数执行完毕复用结果。如果第一个函数执行完毕之后,Do操作才提交的执行函数,那么这个执行函数会和第一个执行函数维持一样的逻辑
开发人员开启多个gouroutine, 执行一个Do操作,传入不同的key和要执行的函数不同的key对应的函数并发执行,并且能拿到结果
                                       |

| 开发人员开启多个gouroutine, 执行一个DoChan操作,传入相同的key和要执行的函数, 返回一个channel | 用户可以监听这个channel拿到结果

功能性需求

功能点详情
Do在第一个函数执行期间提交的函数,会等待第一个函数执行结束,并且复用第一个函数执行的结果
DoChan返回一个Channel, 当第一个函数执行完结果的时候能返回

非功能性需求

暂无

分析

设计数据结构

在设计 "singleflight" 组件时,我们的主要目标是确保对于同一键的请求,在同一时间只会有一个正在进行,其余的请求都在等待结果。这样的需求告诉我们,我们需要一种方式来跟踪哪个键正在被处理,以及请求是否已经完成。

确定主要数据结构:首先,我们知道我们需要一个容器来保存所有的 keys 和它们关联的信息。因此,我们创建了一个名为 SingleFlight 的结构体,其中包含一个 map 其中的 key 是字符串类型(我们的请求 key),Value是请求相关信息。由于我们的组件可能会在并发环境下被使用,我们还需要一个互斥锁 (mutex) 来保护这个 map 的读写操作是线程安全的。

设计关联数据结构: 针对场景和功能需求,找出相关名词,进行筛选和整合。这个过程前期可以在纸上写下来,等熟练之后在头脑里就可以完成。我们寻找到的名词如下:相关信息的Value、用户提交的函数、函数结果、Channel。有了这些名词之后我们就可以去设计次要的数据结构了。其中,函数结果包含函数正常的结果和错误信息,因此可以考虑使用一个结构体 Result 来保存。用户提交的函数需要符合预设的规范,这里用 type 定义一个函数类型 Handler。最后,定义一个 call 结构体,表示一次函数调用,其中包含 Result 和待执行的函数 Handler。

type Handler func() (interface{}, error)

type Singleflight struct {
	mux   sync.Mutex
	calls map[string]*call
}

type Result struct {
	Val interface{}
	Err error
}

type call struct {
	result *Result
	handler Handler
}

虽然目前的结构体属性已经设计出来了,但可能还有一些不完善的地方。在接下来设计方法的过程中,我们应该根据每个方法的实现需要,考虑是否需要添加新的字段。具体来说,我们需要思考哪些信息是必须的,也就是哪些内容需要被存储起来。然后,我们还要考虑是否需要其他辅助字段来帮助我们更高效地实现特定的方法。这样,我们可以迭代并优化我们的结构体设计,以更好地满足需求。

设计数据结构的方法

在引入数据结构之后,重要的是要界定每种数据结构应包含的方法。从使用者的角度看,这些方法大致可以分为两大类:一类是提供给外部代码用于交互的公开方法;另一类是内部方法,主要用于支持数据结构的功能实现。不同数据结构间的相互作用主要通过这些方法互相调用,以实现复杂的功能和流程。

在确定每个数据结构应该具备哪些方法之后,我们还需要详细分析每个方法的逻辑,即明确方法内部应完成哪些操作。需要注意的是,思考每个数据结构的方法并不是一个独立的过程。在考虑一个方法的实现时,可能会发现需要依赖其他数据结构的新方法,这就可能引出更多未预见的新方法。

Singleflight对象方法

我们需要提供一种方式让外部代码与我们的组件进行交互,即实现 "Do" 方法。该方法需接收一个 key 和一个函数。若 key 首次出现,则立即执行对应函数并保存结果;若 key 对应的请求已在处理中,则等待请求完成并返回结果。为保证线程安全,在操作 map 时我们需要进行加锁,而在执行函数或等待过程中,应解锁以避免阻塞其他 goroutine。

在这其中,函数的执行应作为 call 结构体的职责,所以我们可以为 call 结构体添加一个名为 doCall 的方法,该方法负责执行 Handler 并返回结果。另外,对于同时发起调用且目标同一 key 的多个 "Do",仅首个 "Do" 被接收处理,其余的则需等待。要实现多个 goroutine 等待单个 goroutine 完成的功能,常见的方案包括:

  • Channel
  • sync.WaitGroup

在此我们选择使用 sync.WaitGroup。那么如何管理这个 WaitGroup 对象呢?由于同一个 key 的并发等待 goroutine 所对应的只有一个 call 对象,因此我们可以将 WaitGroup 对象作为 call 对象的属性。

另外,我们还需要实现一个DoChan方法,这个方法返回一个Channel, 此时Singleflight对象调用call对象的doCall 方法应该是异步的。另外,因为可能有多个goroutine同时等待一个结果,所以这里我们对每个等待的goroutine都创建一个channel对象,当call内部的Handler执行结束之后,我们等待这个call的所有channel中存放结果。所以,这里多个channel对象我们也放在call结构体中,让这个call对象管理等待她调用结果的所有channel。于是,此时的 call 结构体如下:

type call struct {  
    result *Result  
    handler Handler  
    wait *sync.WaitGroup  
    chans []chan<- Result
}

call对象方法

由上面的内容我们知道call对象需要有一个doCall方法,这个方法很简单,就是调用外部传入的函数Handler,然后返回结果。

package singleflight

import (
	"bytes"
	"fmt"
	"runtime/debug"
	"sync"
)

type panicError struct {
	value interface{}
	stack []byte
}

func (p *panicError) Error() string {
	return fmt.Sprintf("%v\n\n%s", p.value, p.stack)
}

func newPanicError(v interface{}) error {
	stack := debug.Stack()

	if line := bytes.IndexByte(stack[:], '\n'); line >= 0 {
		stack = stack[line+1:]
	}
	return &panicError{value: v, stack: stack}
}

type Handler func() (interface{}, error)

type Singleflight struct {
	mux   sync.Mutex
	calls map[string]*call
}

type Result struct {
	Val interface{}
	Err error
}

type call struct {
	result  *Result
	handler Handler
	wait    *sync.WaitGroup
	chans   []chan<- Result
}

func (c *call) doCall() *Result {
	res, err := c.handler()
	c.result = &Result{
		Val: res,
		Err: err,
	}
	return c.result
}

func (s *Singleflight) Do(key string, handler Handler) (interface{}, error) {
	s.mux.Lock()
	if s.calls == nil {
		s.calls = make(map[string]*call)
	}
	c, ok := s.calls[key]
	if ok {
		s.mux.Unlock()
		c.wait.Wait()
		return c.result.Val, c.result.Err
	}

	c = &call{
		result:  &Result{},
		handler: handler,
		wait:    &sync.WaitGroup{},
	}

	s.calls[key] = c
	c.wait.Add(1)
	s.mux.Unlock()
	s.doCall(key, c)
	return c.result.Val, c.result.Err
}

func (s *Singleflight) DoChan(key string, handler Handler) chan<- Result {
	ch := make(chan Result, 1)

	s.mux.Lock()
	if s.calls == nil {
		s.calls = make(map[string]*call)
	}
	c, ok := s.calls[key]
	if ok {
		c.chans = append(c.chans, ch)
		s.mux.Unlock()
		return ch
	}

	c = &call{
		result:  &Result{},
		handler: handler,
		wait:    &sync.WaitGroup{},
		chans:   []chan<- Result{ch},
	}
	c.wait.Add(1)
	s.mux.Unlock()

	go s.doCall(key, c)
	return ch
}

func (s *Singleflight) doCall(key string, c *call) {
	defer func() {
		if r := recover(); r != nil {
			c.result.Err = newPanicError(r)
		}
		s.mux.Lock()
		defer s.mux.Unlock()
		c.wait.Done()
		if s.calls[key] == c {
			delete(s.calls, key)
		}
		for _, ch := range c.chans {
			ch <- *c.result
		}
	}()
	c.doCall()
}