0 引言

context

本文主要谈谈以下几个方面的内容:

  1. context的使用。
  2. context实现原理,哪些是需要注意的地方。
  3. 在实践中遇到的问题,分析问题产生的原因。

1 使用

1.1 核心接口Context

DoneContextDeadlineValue

在请求处理的过程中,会调用各层的函数,每层的函数会创建自己的routine,是一个routine树。所以,context也应该反映并实现成一棵树。

context.Background

之后该怎么创建其它的子孙节点呢?context包为我们提供了以下函数:

这四个函数的第一个参数都是父context,返回一个Context类型的值,这样就层层创建出不同的节点。子节点是从复制父节点得到的,并且根据接收的函数参数保存子节点的一些状态值,然后就可以将它传递给下层的routine了。

WithCancel

调用CancelFunc对象将撤销对应的Context对象,这样父结点的所在的环境中,获得了撤销子节点context的权利,当触发某些条件时,可以调用CancelFunc对象来终止子结点树的所有routine。在子节点的routine中,需要用类似下面的代码来判断何时退出routine:

根据cxt.Done()判断是否结束。当顶层的Request请求处理结束,或者外部取消了这次请求,就可以cancel掉顶层context,从而使整个请求的routine树得以退出。

WithDeadlineWithTimeoutWithCanceldeadline
WithValue

关于更多的使用示例,可参考官方博客。

2 原理

2.1 上下文数据的存储与查询

WithValue()Value()

值得注意的是,context中的上下文数据并不是全局的,它只查询本节点及父节点们的数据,不能查询兄弟节点的数据。

2.2 手动cancel和超时cancel

cancelCtx
cancelCtxchildren子cancelerchildrencancel()cancelCtxdone
timerCtxdeadlinecancel

可以看出,cancelCtx也是一棵树,当触发cancel时,会cancel本结点和其子树的所有cancelCtx

3 遇到的问题

3.1 背景

Context

所有Mysql、MQ、Redis的操作接口的第一个参数都是context,如果这个context(或其父context)被cancel了,则操作会失败。

上线后,遇到一系列的坑......

3.2 Case 1

现象:上线后,5分钟后所有用户登录失败,不断收到报警。

原因:程序中使用localCache,会每5分钟Refresh(调用注册的回调函数)一次所缓存的变量。localCache中保存了一个context,在调用回调函数时会传进去。如果回调函数依赖context,可能会产生意外的结果。

getAppIDAndAlias

第一次localCache.Get(ctx, appKey, appSeret)传的ctx是gRpc call传进来的context,而gRpc在请求结束或失败时会cancel掉context,导致之后cache Refresh()时,执行失败。

解决方法:在Refresh时不使用localCache的context,使用一个不会cancel的context。

3.3 Case 2

现象:上线后,不断收到报警(sys err过多)。看log/etrace产生2种sys err:

  • context canceled
  • sql: Transaction has already been committed or rolled back

3.3.1 背景及原因

Ticket
context canceled
  1. 客户端发送http restful请求。
  2. grpc-gateway与客户端建立连接,接收请求,转换参数,调用后面的grpc-server。
  3. grpc-server处理请求。其中,grpc-server会对每个请求启一个stream,由这个stream创建context。
  4. 客户端连接断开。
  5. grpc-gateway收到连接断开的信号,导致context cancel。grpc client在发送rpc请求后由于外部异常使它的请求终止了(即它的context被cancel),会发一个RST_STREAM。
  6. grpc server收到后,马上终止请求(即grpc server的stream context被cancel)。

可以看出,是因为gRpc handler在处理过程中连接被断开。

sql: Transaction has already been committed or rolled back

程序中使用了官方database包来执行db transaction。其中,在db.BeginTx时,会启一个协程awaitDone:

在context被cancel时,会进行rollback(),而rollback时,会操作原子变量。之后,在另一个协程中tx.Commit()时,会判断原子变量,如果变了,会抛出错误。

3.3.2 解决方法

这两个error都是由连接断开导致的,是正常的。可忽略这两个error。

3.4 Case 3

上线后,每两天左右有1~2次的mysql事务阻塞,导致请求耗时达到120秒。在盘古(内部的mysql运维平台)中查询到所有阻塞的事务在处理同一条记录。

3.4.1 处理过程

1. 初步怀疑是跨机房的多个事务操作同一条记录导致的。由于跨机房操作,耗时会增加,导致阻塞了其他机房执行的db事务。

2. 出现此现象时,暂时将某个接口降级。降低多个事务操作同一记录的概率。

3. 减少事务的个数。

  • 将单条sql的事务去掉
  • 通过业务逻辑的转移减少不必要的事务
innodb_lock_wait_timeoutinnodb_lock_wait_timeout

5. 考虑使用分布式锁来减少操作同一条记录的事务的并发量。但由于时间关系,没做这块的改进。

6. DAL同事发现有事务没提交,查看代码,找到root cause。

原因是golang官方包database/sql会在某种竞态条件下,导致事务既没有commit,也没有rollback。

3.4.2 源码描述

开始事务BeginTxx()时会启一个协程:

tx.rollback(true)

在提交事务Commit()时,会先操作原子变量tx.done,然后判断context是否被cancel了,如果被cancel,则返回;如果没有,则进行commit操作。

如果先进行commit()过程中,先操作原子变量,然后context被cancel,之后另一个协程在进行rollback()会因为原子变量置为1而返回。导致commit()没有执行,rollback()也没有执行。

3.4.3 解决方法

解决方法可以是如下任一个:

不会cancel的contextdatabase/sql

我们之后给Golang提交了patch,修正了此问题(已合入go 1.9.3)。

4 经验教训

接收context的函数