异常传递
异常(error)如何在分布式系统中传递,问题最终如何呈现。
应该强制处理调用栈上关键点的异常,但系统控制流中忽视异常仍常见。
出现异常意味着系统进入了一个无法满足用户操作的状态,系统需要传达几个关键信息:
-
发生什么
- 异常事件的描述,如“磁盘已满”。 发生时间、发生位置
- 完整的栈轨迹信息(启动及出错位置),内部运行的上下文信息(分布式系统中识别发生异常机器的字段等),对应机器UTC时间。 用户友好信息
- 自定义用户异常信息,简单概述,最好一行内文本。 如何获得更多信息
- 提供ID,查询具体故障,详细日志。含异常完整信息:发生时间,完整堆栈调用。
异常分类:
- Bug。
系统未定义异常,或原生异常(极少遇到的异常)。 - 已知信息(网络连接断开,磁盘读写失败等)。
以组件边界为例,所有传入异常信息都必须重新格式化。
func PostReport(id string) error {
result, err := lowlevel.DoWork()
if err != nil {
if _, ok := err.(lowlevel.Error); ok {
err = WrapErr(err, "cannot post report with id %q", id)
}
}
// ...
}
最初异常包含太多底层信息(产生根源,goroutine,机器,栈轨迹等),必要时应当在模块边界转换为模块相关信息,可以清晰地划分异常类型。
当不规范的异常或bug传递时,应记录异常,并显示友好信息;应包含日志ID,用于查询更多信息。
创建格式良好的异常类型。
type MyError struct {
Inner error
Message string
StackTrace string
Misc map[string]interface{}
}
func wrapError(err error, messagef string, msgArgs ...interface{}) MyError {
return MyError{
Inner: err,
Message: fmt.Sprintf(messagef, msgArgs...),
StackTrace: string(debug.Stack())
Misc: make(map[string]interface{}),
}
}
func (err MyError) Error() string {
return err.Message
}
创建底层模块。
type LowLevelErr struct {
error
}
func isGloballyExec(path string) (bool, error) {
info, err := os.Stat(path)
if err != nil {
return false, LowLevelErr{wrapError(err, err.Error())}
}
return info.Mode().Perm()&0100, nil
中间模块,调用底层模块。
type IntermediateErr struct {
error
}
func runJob(id string) error {
const jobBinPath = "/bad/job/binary"
isExecutable, err := isGloballyExec(jobBinPath)
if err != nil {
return err
}else if isExecutable == false {
return wrapError(nil, "job binary is not executable")
}
return exec.Command(jobBinPath, "--id="+id).Run()
}
外层main函数调用中间层。
func handleError(key int, err error, message string) {
log.SetPrefix(fmt.Sprintf("[logID: %v]", key))
lgo.Printf("%$v", err)
fmt.Printf("[%v] %v", key, message)
}
func main() {
log.SetOutput(os.Stdout)
log.SetFlags(log.Ltime|log.LUTC)
err := runJob("1")
if err != nil {
msg := "There was an unexpected issue; please report this as a bug."
if _, ok := err.(IntermediateErr); ok {
msg = err.Error()
}
handleError(1, err, msg)
}
}
中间层封装lowlevel模块的异常。
type IntermediateErr struct {
error
}
func runJob(id string) error {
const jobBinPath = "/bad/job/binary"
isExecutable, err := isGloballyExec(jobBinPath)
if err != nil {
return IntermediateErr{wrapError(err, "cannot run job %q: requisite binaries are not available", id)}
}else if isExecutable == false {
return wrapError(nil, "cannot run job %q: requisite binaries are not executable", id)
}
return exec.Command(jobBinPath, "--id="+id).Run()
}
超时和取消
Timeouts和Cancellation
并发进程支持超时原因:
- 系统饱和
系统处理请求的能力刚好,超出请求返回超时,而不是长时间等待响应。何时超时的一般性指导:请求不太可能重复;无资源存储请求;系统响应或请求发送数据有时效性要求。 - 陈旧数据
并发进程处理数据的时间,超过数据窗口期(必须先处理更多相关数据或处理需求已过期),想返回超时并取消进程。context.WithDeadline或context.WithTimeout,context.WithCancel。 - 试图防止死锁
分布式等大型系统中,有时难以理解数据流动的方式,或者可能出现异常,为了保证系统不会发生死锁,建议所有并发操作中增加超时处理。
并发进程可能被取消原因:
- 超时
- 用户干预
当用户使用并发程序时,有时允许用户取消他们已经开始的操作。 - 父进程取消
父进程停止,子进程也将被取消。 - 复制请求
数据发送到多个并发进程,以尝试获得更快响应,当第一个回来时,取消其余的进程。
并发进程的可抢占性。两个longCalculation间可以抢占。
reallyLongCalculation := func(done <-chan interface{}, value interface{}) interface{} {
intermediateResult := longCalculation(value)
select {
case <-done:
return nil
default:
}
return longCalculation(intermediateResult)
}
定义并发进程可抢占的周期,确保运行周期比抢占周期长的功能本身都是可抢占的。简单方法是将goroutine代码段分解成小段。
reallyLongCalculation := func(done <-chan interface{}, value interface{}) interface{} {
intermediateResult := longCalculation(done, value)
return longCalculation(done, intermediateResult)
}
goroutine代码段分解成小段的潜在问题,当goroutine恰好修改共享状态(数据库、文件、内存数据结构等),goroutine取消时发生什么?如何回滚?。
修改三次状态
result := add(1, 2, 3)
writeTallyToState(result)
result = add(result, 4, 5, 6)
writeTallyToState(result)
result = add(result, 7, 8, 9)
writeTallyToState(result)
修改一次状态
result := add(1, 2, 3, 4, 5, 6, 7, 8, 9)
writeTallyToState(result)
重复消息。假设一个pipeline有三个阶段:生成阶段,阶段A和阶段B。生成阶段通过记录上一次channel被读取的时间,来监控阶段A持续的时间。若当前实例不正常,则产生新实例A2,阶段B可能收到重复消息。
避免发送重复消息的方法。最简单的方法是让一个父goroutine在子goroutine已经发送完结果之后发送一个取消信号,需要各阶段间的双向通信。其他方法:
- 接收到的第一个或最后一个消息
算法允许或并发进程幂等(可重复调用,在调用方多次调用的情况下,最终得到的结果是一致的。),可以允许下游进程存在重复消息,并从接收到的第一个或最后一个消息中挑选一个处理。 - 向父goroutine确认权限
与父goroutine使用双向通信来确认发送消息的权限,类似心跳。明确请求允许在B的channel上执行写入操作,比心跳更安全,也更复杂。
心跳
心跳是并发进程向外界发出信号的一种方式。
两种类型心跳:
- 在一段时间间隔内发出的心跳
- 在工作单元开始时发出的心跳
在一段时间间隔内发出的心跳对于处于等待事件触发状态的goroutine很有用,它告诉监听程序一切安好。
doWork := func(done <-chan interface{}, pulseInterval time.Time) (<-chan interface{}, <-chan time.Time) {
heartbeat := make(chan interface{})
results := make(chan time.Time)
go func() {
defer close(heartbeat)
defer close(results)
pulse := time.Tick(pulseInterval)
workGen := time.Tick(2*pulseInterval)
sendPulse := func() {
select {
case heartbeat <-struct{}{}:
default:
}
}
sendResult := func(r time.Time) {
for {
select {
case <-done:
return
case <-pulse:
sendPulse()
case results <- r:
return
}
}
}
for {
select {
case <-done:
return
case <-pulse:
sendPulse()
case r := <-workGen:
sendResult(r)
}
}
}()
return heartbeat, results
}
done := make(chan interface{})
time.AfterFunc(10*time.Second, func(){close(done)})
const timeout = 2*time.Second
heartbeat, results := doWork(done, timeout/2)
for {
select {
case _, ok := <-heartbeat:
if ok == false {
return
}
fmt.Println("pulse")
case r, ok := <-results:
if ok == false {
return
}
fmt.Printf("results %v\n", r.Second())
case <-time.After(timeout):
return
}
}
心跳可以用来收集关于空闲时间的统计数据。心跳让我们知道长时间运行的goroutine依然正常工作着,但需要时间运行,计算出值并发送给channel。
不关闭任何channel,模拟产生异常的goroutine,通过心跳避免了死锁,且不需要依赖更长的超时时间来保持确定性。
doWork := func(done <-chan interface{}, pulseInterval time.Time) (<-chan interface{}, <-chan time.Time) {
heartbeat := make(chan interface{})
results := make(chan time.Time)
go func() {
//defer close(heartbeat)
//defer close(results)
pulse := time.Tick(pulseInterval)
workGen := time.Tick(2*pulseInterval)
sendPulse := func() {
select {
case heartbeat <-struct{}{}:
default:
}
}
sendResult := func(r time.Time) {
for {
select {
//case <-done:return
case <-pulse:
sendPulse()
case results <- r:
return
}
}
}
for i := 0; i<2; i++ {
select {
case <-done:
return
case <-pulse:
sendPulse()
case r := <-workGen:
sendResult(r)
}
}
}()
return heartbeat, results
}
done := make(chan interface{})
time.AfterFunc(10*time.Second, func(){close(done)})
const timeout = 2*time.Second
heartbeat, results := doWork(done, timeout/2)
for {
select {
case _, ok := <-heartbeat:
if ok == false {
return
}
fmt.Println("pulse")
case r, ok := <-results:
if ok == false {
return
}
fmt.Printf("results %v\n", r.Second())
case <-time.After(timeout):
fmt.Printfln("worker goroutine is not healthy!")
return
}
}
一个工作单元开始时发出的心跳,对于测试来说非常有效。
package main
import (
"fmt"
"math/rand"
)
func main() {
doWork := func(done <-chan interface{}) (<-chan interface{}, <-chan int) {
heartbeatStream := make(chan interface{}, 1) // <1>
workStream := make(chan int)
go func() {
defer close(heartbeatStream)
defer close(workStream)
for i := 0; i < 10; i++ {
select { // <2>
case heartbeatStream <- struct{}{}:
default: // <3>
}
select {
case <-done:
return
case workStream <- rand.Intn(10):
}
}
}()
return heartbeatStream, workStream
}
done := make(chan interface{})
defer close(done)
heartbeat, results := doWork(done)
for {
select {
case _, ok := <-heartbeat:
if ok {
fmt.Println("pulse")
} else {
return
}
case r, ok := <-results:
if ok {
fmt.Printf("results %v\n", r)
} else {
return
}
}
}
}
不那么好的测试样例,删除time.Sleep的话,测试有时会通过,有时失败,非确定性。一些外部因素(CPU负载过高、磁盘抢占、网络延迟等)会导致goroutine花费更长时间进行第一次迭代。
package main
import (
"testing"
"time"
)
func DoWork(
done <-chan interface{},
nums ...int,
) (<-chan interface{}, <-chan int) {
heartbeat := make(chan interface{}, 1)
intStream := make(chan int)
go func() {
defer close(heartbeat)
defer close(intStream)
time.Sleep(2 * time.Second) // 模拟goroutine开始前的某种延迟。
for _, n := range nums {
select {
case heartbeat <- struct{}{}:
default:
}
select {
case <-done:
return
case intStream <- n:
}
}
}()
return heartbeat, intStream
}
func TestDoWork_GeneratesAllNumbers(t *testing.T) {
done := make(chan interface{})
defer close(done)
intSlice := []int{0, 1, 2, 3, 5}
_, results := DoWork(done, intSlice...)
for i, expected := range intSlice {
select {
case r := <-results:
if r != expected {
t.Errorf(
"index %v: expected %v, but received %v,",
i,
expected,
r,
)
}
case <-time.After(1 * time.Second): // <1>
t.Fatal("test timed out")
}
}
}
好的测试样例。有了心跳,可以安全的编写测试而不需要加入超时机制。
package main
import (
"testing"
"time"
)
func DoWork(
done <-chan interface{},
pulseInterval time.Duration,
nums ...int,
) (<-chan interface{}, <-chan int) {
heartbeat := make(chan interface{}, 1)
intStream := make(chan int)
go func() {
defer close(heartbeat)
defer close(intStream)
time.Sleep(2 * time.Second)
pulse := time.Tick(pulseInterval)
numLoop: // <2>
for _, n := range nums {
for { // <1>
select {
case <-done:
return
case <-pulse:
select {
case heartbeat <- struct{}{}:
default:
}
case intStream <- n:
continue numLoop // <3>
}
}
}
}()
return heartbeat, intStream
}
func TestDoWork_GeneratesAllNumbers(t *testing.T) {
done := make(chan interface{})
defer close(done)
intSlice := []int{0, 1, 2, 3, 5}
const timeout = 2 * time.Second
heartbeat, results := DoWork(done, timeout/2, intSlice...)
<-heartbeat // <4>
i := 0
for {
select {
case r, ok := <-results:
if ok == false {
return
} else if expected := intSlice[i]; r != expected {
t.Errorf("index %v: expected %v, but received %v,", i, expected, r)
}
i++
case <-heartbeat: // <5>
case <-time.After(timeout):
t.Fatal("test timed out")
}
}
}
复制请求
将请求分发到多个处理程序(goroutine,进程,服务器等),返回响应最快的结果,消耗更多资源。
package main
import (
"fmt"
"sync"
"time"
"math/rand"
)
func main() {
doWork := func(
done <-chan interface{},
id int,
wg *sync.WaitGroup,
result chan<- int,
) {
defer wg.Done()
started := time.Now()
simulatedLoadTime := time.Duration(1+rand.Intn(5)) * time.Second
select {
case <-done:
case <-time.After(simulatedLoadTime):
}
select {
case <-done:
case result <- id:
}
took := time.Since(started)
if took<simulatedLoadTime{
took = simulatedLoadTime
}
fmt.Printf("%v took %v\n", id, took)
}
done := make(chan interface{})
result := make(chan int)
var wg sync.WaitGroup
wg.Add(10)
for i := 0; i < 10; i++ {
go doWork(done, i, &wg, result)
}
firstReturned := <-result
close(done)
wg.Wait()
fmt.Printf("Received an an answer from #%v\n", firstReturned)
}
速率限制
它限制了某种资源(API连接、磁盘读写、网络包、异常等)在某段时间内被访问的次数。
系统限速,可以避免系统被攻击。合法用户,大量级执行操作或运行代码异常,也可能会降低系统的可用性。用户队系统的访问应当被沙盒化。
速率限制允许你将系统的性能和稳定性平衡在可控范围内。在大量测试和等待后,可以以可控的方式扩大限制。
大多数限速基于令牌桶算法。