专栏名称: 阿里开发者
阿里巴巴官方技术号,关于阿里的技术创新均将呈现于此
目录
相关文章推荐
阿里开发者  ·  MySQL 是怎么做并发控制的? ·  2 天前  
白鲸出海  ·  出海五年,猿辅导交出怎样的成绩单? ·  1 周前  
51好读  ›  专栏  ›  阿里开发者

那些年,我们在Go中间件上踩过的坑

阿里开发者  · 公众号  · 科技公司  · 2024-10-11 08:30

正文

阿里妹导读


作者总结了过去在Go中间件上踩过的坑,这些坑也促进了阿里内部Go中间件的完善,希望大家学习本文后,不犯同样的错误。

背景

为什么要写这些?因为过去这些踩过的坑,促进了Go中间件的完善,有一些,从中间件层面直接规避了,不会再出现。但还有一些坑,在未来,应该还会有人不断踩。了解这些问题,让我们规避掉其中一些坑,也能了解到一些中间件的原理,从而更少地犯错。

利用他人踩过的坑,进行学习,不犯同样的错误,这应该算一种高级的智慧,希望本文能帮助到大家。

一、VipServer 请求不均匀问题(rand)

现象:压测的时候,流量会打偏,某些机器的请求非常多,某些机器非常少,各机器收到的请求 非常不均匀。

原因:调查发现,业务代码使用了 rand.Seed(time.Now().Unix()),影响了中间件获取随机浮点数的逻辑。

压测流量较大,在某一秒,由于业务反复执行 rand.Seed(秒级时间戳),导致 go vipserver client中间件中,获取到的随机数就会一样(Go的标签库实现是仿随机数),再进一步用随机数计算权重,选取 IP,就会导致请求到一连串相同的机器。

下面是一个简单的复现示例:

package main
import (  "fmt"  "math/rand")
func main() {  for i := 0; i < 3; i++ {    rand.Seed(1234567890)    fmt.Println(rand.Float64())    fmt.Println(rand.Float64())    fmt.Println(rand.Float64())  }}
上面的示例,由于 Seed 一样,会发现,3轮循环,得到的随机数是一样的。

如果用随机数来计算选取 IP,就会导致取到的一直是对应的 3 台固定的机器。

那如何解决这个问题呢,新版本使用 golang.org/x/exp/rand 包,创建了一个独立的 globalRand,不再使用全局共用的 rand.Float(),在启动的时候执行 :
globalRand.Seed(uint64(time.Now().UnixNano()))

不会再受业务 rand.Seed(x) 的影响。

package rand
import (    "time"
   "golang.org/x/exp/rand")
var globalRand = rand.New(new(rand.LockedSource))
func init() {    globalRand.Seed(uint64(time.Now().UnixNano()))}
// Uint64 returns a pseudo-random 64-bit value as a uint64// from the default Source.func Uint64() uint64 { return globalRand.Uint64() }
// Float64 returns, as a float64, a pseudo-random number in [0.0,1.0)// from the default Source.func Float64() float64 { return globalRand.Float64() }
另外 go sdk v1.22.0 新加了 rand/v2,这个包修复了 Seed 互相影响的问题,全局的 rand/v2 不允许自己 Seed(更合理)。

原来的 math/rand.Seed 也标为废弃方法了,如果用新版本的 go ,推荐用 rand/v2,性能更好(8ns -> 4ns)。

二、VipServer 只打到一个机房(部分机器)问题

现象:应用方反馈,观察监控,流量只打到了一个机房,让排查原因。

不均匀存在一定的风险,比如单机限流可能被触发,导致部分机器负载过高。

分析发现,A服务请求B服务,B的机器比较多,但A服务单机请求B服务的流量少。

由于 vipserver client 是每 30s 刷新一次 vipserver key 对应的 IP,刷新后又从第一个开始请求。之前 vipserver go 采用的是 smooth weighted 算法,如果一个个 IP 请求,由于机房 IP 较多,单机的请求量没那么大,要请求完一个机房的所有 IP,需要的时间大于 30s,所以在 B服务 的一个机房机器还没有遍历完,就又到了下一轮。看到的现象就是只打到了一个机房。

为了解决这个问题,新版本 vipserver,采用了完全随机数的方案,然后使用 二分法,从所有 IP 中随机选取,解决了此问题,性能也要明显优于之前的版本提供的算法。

三、VipServer CPU、内存泄露问题

通过 pprof 发现,是 ticker 的泄露,这个问题在开源的软件中也很常见,在 go sdk 1.23.0 之前的版本,得等时间到了,对应的 ticker 才被释放,在释放前,资源会一直占用着。

我们来了解下 go 里面 timer/ticker 的实现。

time.Ticker 不能忘记 Stop,否则会造成泄露,另外 time.After 也会造成短暂的内存泄露(go 1.23之前的版本)。

Go1.23 新特性:花了近 10 年,time.After 终于不泄漏了!相关的 commit

https://segmentfault.com/a/1190000045127185

https://github.com/golang/go/issues/61542

https://github.com/golang/go/commit/508bb17edd04479622fad263cd702deac1c49157

time: garbage collect unstopped Tickers and Timers

另外补充:timer 的一些使用注意事项。

上面的示例,使用了 time.Sleep(x),它不能被context感知且无法被中断。

改成 time.After,这种方法简单而直接,但并不完美。

  • 我们每次循环,都会分配一个新的 channel (time.After 里面的实现)。
  • time.After 可能会导致短期内存泄漏问题。

如果在倒计时之前因为 ctx.Done() 触发结束,那么 time.After 将一直存在,直到时间到期。

上面封装的 Sleep 可以感知 ctx,可以被中断。

四、VipServer 预热问题

现象:应用进程启动后,从来没请求过该 vipserver,到某个时候,突然开始请求,发现业务请求接口有报警 404。

原因:由于没有请求过,业务进程就不会监听 vs 的变动,首次调用时,用的是之前的快照,可能已经过期了,里面的一些 ip 不再属于这个 vs,所以就报错了。

vipserver 如何更新某个 dom 的?

有两种更新机制,第一种是通过 udp协议推送到 client,第二种是前面提到的每30s会刷新一下。

vipserver 缓存的数据在哪里?

默认保存在目录 /home/admin/vipsrv-cache

日志文件在 /home/admin/vipsrv-logs

那为什么不默认监听?

因为 vipserver go 不知道你会请求哪些 vipserver key,总不能提前监听阿里所有的 vipserver key 吧。

有些同学说可以扫描快照目录,确实是一个办法,但如果一个 vipserver key 你后面再也不会调用了,不就会造成资源浪费么?

那业务怎样才能监听?

启动的时候要调用 SrvHost(vipServerKey) 方法一下,就可以了,需要应用方自行实现,启动的时候调一下,这样就会“实时”更新了。

五、Diamond 读取到旧配置

这个是使用的问题,用户使用的优先使用的本地快照,业务逻辑在拉取到服务配置之前就运行了。


配置读取顺序(一般推荐采用 1,而不是 2)

  1. 获取数据的顺序:容灾文件-> 服务器 -> 本地缓存

  2. 获取数据的顺序:容灾文件-> 本地缓存 -> 服务器
  3. 获取数据的顺序:容灾文件-> 服务器

如果服务长时间没有启动,在下次启动的时候,本地缓存的配置,可能比服务上的配置旧,导致此问题。

内部也提供了相应的包,实现了 拉取配置 + 监听配置 + 缓存提升性能 的最佳实践。

六、Diamond 配置改错崩掉的问题

json 反序列化问题,如果在 diamond 上发布的新内容,是一个不合法的 json,可能会导致应用崩溃,因为应用读取不到配置报错!

xconfig 已优化此场景,会继续使用之前合法的 json 配置,直到下次启动的时候,会报错(如果有预热,可以做成不允许启动)。

这样可以降低操作错误,对业务的影响。当然这个还是得开发人员自己检查,或者 diamond平台保存的时候,支持核验是否合法。

七、MySQL 事务 tx 传 db 问题

业务方在代码中,事务里面,报错之后,忘记 rollback,导致数据库连接不释放,最近所有接口挂掉。

另外,也有 tx 和 db 传错的问题,如果在 MySQL 事务里面,你还自己用 db 对象,而不是 tx,你以为是在一个事务里面,实际上不是。为如何解决这些问题呢?有没有最佳实践呢?


MySQL事务简介

典型地,事务流程如下:
  1. 开启一个事务。
  2. 完成一系列数据库操作。
  3. 结束事务
  1. 如果没有错误发生,提交事务。
  2. 发生错误,回滚事务。


Go事务简介

  1. 开启一个事务。db.BeginTx()

  2. 完成一系列数据库操作。

  1. INSERT, UPDATE, DELETE操作,使用  ExecContext
  2. 查询数据,使用 QueryContextQueryRowContext

  1. 结束事务
  1. 如果没有错误发生,提交事务。tx.Commit()
  2. 发生错误,回滚事务。tx.Rollback()

  1. 即使回滚失败,事务不再有效,也不可再提交。


存在问题

  1. 整个流程中,手动编码,用户很容易写错,到底是使用 db,还是使用 tx 。

  2. 代码复杂时,容易忘写 Commit 或 Rollback(上面使用 defer 可以避免该问题,但不是所有人都这么写)
  3. 没法控制事务的提交,祼用 *sql.Tx 很危险,无法控制啥时候提交。尤其是事务嵌套时,里层的事务可能会 Commit 掉,导致出问题。
  4. 现在生成的 dao 层,*sql.DB 和 *sql.Tx 都满足,传错无法知道,可能带来严重问题。


如何解决

使用 gokit 生成代码,依赖 xsql 的 dao 和 WithTransaction 综合实现。

  1. 问题 1,2,3 使用 WithTransaction 解决。

package dao
import (  "context"  "database/sql")
type txCtxKey struct{}
// SqlTxFunc is a function that will be called with an initialized 'DbTx' object// that can be used for executing statements and queries against a database.type SqlTxFunc[T any] func(ctx context.Context, tx *sql.Tx) (T, error)
// WithTransaction creates a new transaction and handles rollback/commit based on the// error object returned by the 'SqlTxFunc', ignore the return value.func WithTransaction(ctx context.Context, db *sql.DB, txFn func(ctx context.Context, tx *sql.Tx) error) (err error) {  _, err = WithTransactionRet(ctx, db, func(ctx context.Context, tx *sql.Tx) (any, error) {    return nil, txFn(ctx, tx)  })  return err}
// WithTransactionRet creates a new transaction and handles rollback/commit based on the// error object returned by the 'SqlTxFunc', with the return value.func WithTransactionRet[T any](ctx context.Context, db *sql.DB, txFn SqlTxFunc[T]) (ret T, err error) {  // https://go.dev/doc/database/execute-transactions  // Get a Tx for making transaction requests.  tx, err := db.BeginTx(ctx, nil)  if err != nil {    Errorf(ctx, "db beginTx error, %v", err)    return  }  // Defer a rollback in case anything fails.  defer func() { _ = tx.Rollback() }()
 // Transaction Context, used to check db  ctx = context.WithValue(ctx, txCtxKey{}, 1)  if ret, err = txFn(ctx, tx); err != nil {    Errorf(ctx, "txFn error, %v", err)    return ret, err  }
 // Commit the transaction.  if err = tx.Commit(); err != nil {    Errorf(ctx, "tx commit error, %v", err)  }  return ret, err}
2. 问题 4,添加校验,如果传入 *sql.DB 或 *sql.Tx 与预期不一致,则 Panic。

preCheck(ctx) 方法校验传入的是不是正确。

var (  ErrNonTransaction = errors.New("xsql: underlying type is not a transaction")  ErrIsTransaction  = errors.New("xsql: underlying type is transaction"))
func (t *Table[T]) preCheck(ctx context.Context) {  _tx, _ := ctx.Value(txCtxKey{}).(int)  _, isDB := t.db.(*sql.DB)  _, isTx := t.db.(*sql.Tx)  if !isDB && !isTx { // Can't check    return  }  if _tx == 1 && isDB {    panic(ErrIsTransaction)  }  if isTx && _tx == 0 {    panic(ErrNonTransaction)  }}
// Insert inserts an array of data into tablefunc (t *Table[T]) Insert(ctx context.Context, data []map[string]any) (int64, error) {  t.preCheck(ctx)  cond, vals, err := builder.BuildInsertWithContext(ctx, t.table, data)  if err != nil {    return 0, err  }  result, err := t.db.ExecContext(ctx, cond, vals...)  if err != nil || result == nil {    return 0, err  }  return result.LastInsertId()}
在内部,我们使用 gokit 工具生成 mysql dao 文件,轻松实现增删改查,事务最佳实践。

八、corona(drds)中间件不支持 uint?

同时使用 mysql 和 corona 时, 如果结构体中使用的 uintXX 类型, 升级 mysql 版本可能会有问题。

报错示例:

{"code":2,"message":"Failure.((Error 4998: [178e7db845000000][11.123.38.52:3306][XXX_DB]ERR-CODE: [TDDL-4998][ERR_NOT_SUPPORT] parameter of mysqltype:-32760 fullblob:false encode:utf-8 not support yet!))","timestamp":1708595077,"version":"3.0-2000.01.01.release","gsId":"-","data":null,"result":false}

go-sql-driver 1.4.1 升级到 1.6.0,在 1.5.0 版本 go-sql-driver 值转换做了变动,从 int64 改成了uint64。

而 corona 对这一变化表现不兼容,造成了报错,要么不升级,要么业务方暂时放弃 unit 类型的使用。

后续我们又切换到了原生的 tddl go 中间件,从而解决了这个问题。

九、corona 切换 TDDL 应用有报错

现象:从 corona 切换到 TDDLX,应用发现有报错。

原因:用户传入的是 int 类型的值,corona 转换成 SQL 的时候,会把 value 变成 varchar,而 tddlx 是透传!

不报错的SQL(java corona)

select SUM(`amount`)from `xxx_activity`WHERE JSON_EXTRACT(`ext_info`,'$.act_id') = 0 AND `adcode` = '110000' AND `t_code` = '1234' AND `act_id` = '6789';
报错的SQL (go tddlx):
select SUM(`amount`)from `xxx_activity`WHERE (JSON_EXTRACT(`ext_info`,'$.act_id')=0 AND adcode=110000 AND t_code=1234 AND act_id=6789);
Java把查询参数里面的值都转成了 string 了 !!!!这就是不报错的原因。

表结构如下:

CREATE TABLE `xxx_activity` (  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,  `act_id` bigint(20) unsigned NOT NULL DEFAULT '0',  `adcode` varchar(6) NOT NULL DEFAULT '',  `t_code` varchar(6) NOT NULL DEFAULT '',  `amount` bigint(20) NOT NULL DEFAULT '0',  `ext_info` varchar(256) NOT NULL DEFAULT '',  PRIMARY KEY (`id`),  KEY `idx_record` (`act_id`,`adcode`,`t_code`),) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;
但这是什么操作呢?为什么 act_id 字段不是 varchar 也转了呢?

a)一定要注意SQL条件的类型,可能会导致不走索引。

数据库字段是字符串,查询的时候传的是数字,一般都不走索引!

如果数据库字段是整数,你传字符串和整数,都可以正常走索引。



`request_id` 类型是 varchar(64),传入 0x3 的时候,走索引,官方文档的解释:

  • Hexadecimal values are treated as binary strings if not compared to a number.

b)corona(java tddl 应用)为啥将整数转换成字符串?

java这么做,确实不透明,那么go版本的 tddlx 要不要也类似方式实现呢?要不要提供一个corona兼容模式出来,尽量兼容原来的,方便大家迁移?

十、corona 切换 TDDL 索引选择问题

现象:从 corona 切换到 TDDLX,凌晨2点,定时任务扫表,索引选择错误,导致数据库挂掉!

通过执行的SQL记录,可以发现扫描行数出现了非常大的差异。

/* ///9bd81b6a/ */ SELECT * FROM `xxx_card_record_0083` WHERE `is_delete` = 0 AND `status` = 1 AND `has_bill` = 0 AND `id` > 0 AND `gmt_modified` < '2024-08-31' AND `gmt_modified` >= '2024-08-30' ORDER BY `id` LIMIT 100;
-- 从历史慢SQL中看,扫描行数 981w
/* 2104ad0d17248682261294374d1e93/9// *//*DRDS /11.12.42.110/1880fae51e800001/ */SELECT * FROM `xxx_card_record_0083` AS `xxx_card_record` WHERE `is_delete` = 0 AND `status` = 1 AND `has_bill` = 0 AND `id` > 0 AND `gmt_modified` < '2024-08-29' AND `gmt_modified` >= '2024-08-28' ORDER BY `id` LIMIT 0, 100;
-- 从历史慢SQL中看,扫描行数 35w
数据库表结构如下:
CREATE TABLE `xxx_card_record_0083` (  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',  `status` tinyint(4) NOT NULL,  `has_bill` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '0未同步,1 已同步',  `is_delete` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否删除,0未删除',  -- 省略无关字段  `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',  `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',  PRIMARY KEY (`id`),  KEY `idx_gmt_modified` (`gmt_modified`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

和上一个例子还不太一样,has_bill、status、is_delete 等 corona 和 tddlx 传值类型都一样。

两个 SQL 有区别么,确实有区别,有三点:

  1. SQL的备注信息不太一样,corona 的含有 DRDS,tddlx 的只有简短的 trace id。
  2. corona 的SQL中表名有 as 别名,tddlx 生成的没有。
  3. LIMIT 语句,corona 是 LIMIT 0, 100,而 tddlx 的是 LIMIT 100。

按道理,这些区别,在 mysql 中执行的物理 SQL 语义无关的,但为什么性能会出现如此大的差异呢?

DBA的解释说,SQL模板哪怕出现了微小的差异,多了一个空格,sql id 就会发生变化,会影响到执行计划是否使用缓存。

tddlx 生成的新的语句,执行计划重新评估,可能会选择一个和之前不一样的的索引。where 跟 order by 选择索引不一致,where 里面的 gmt_modified 会选择 idx_gmt_modified 索引,但 order by id 会选择 PRIMARY,最终评估代价最小是用 PRIMARY 索引。

因此切换 tddlx 之后出问题了。

这张表,数据表的记录到了 981万的样子,猜测应该是慢慢积累,到一定程度了。DBA也说见过很多 where 和 order by 里面索引选择不一致,到了某一天就开始报错了,这种也没办法提前发现,只能出了问题优化下。

那为什么切回 corona 之后又恢复了呢?是因为执行计划的缓存?

2024-09-01的凌晨1点多,做了两个测试:

  1. 将 SQL 的 `is_delete` = 0 移到 WHERE 的最后,测试发现使用 corona 和 tddlx 都会报错。
  2. 将 order by id 改成 order by id, gmt_modified,无论 corona 还是 tddlx 都正常了。

总之,这个sql语句本身问题,与CORONA切换tddlx关系不大,重点是要优化SQL语句,要么强制走索引,要么将 order by id 改成 order by id, gmt_modified,问题解决。

这个问题整好发生在 corona 切换到 tddlx 的当天,非常的凑巧,都碰到一起了!

十一、tddlx 路由算错

非常早期的一个 tddl go 版本,存在的一个问题,现在最新的 tddlx 已无此问题。

说白了就是 int 的 hash code ,在 java 里面是返回这个 int 本身。

由于实现的时候,翻译成 go 没有考虑周全,是把 int 转 string 再算 hash code,导致算错了。

十二、metaq 一个消费者订阅多个topic

订阅一致性问题,metaq 4.x,一个 consumer group 不能订阅多个 topic。

要订阅多个 topic,需要新建多个 CID 来进行。

详见 :

https://help.aliyun.com/zh/apsaramq-for-rocketmq/cloud-message-queue-rocketmq-4-x-series/use-cases/subscription-consistency

这个并不是中间件的问题导致的,是研发对原理了解不够,使用不当造成的。

十三、metaq新扩容机器启动panic

新扩容的机器,信息可能还没有同步到 cmdb 等,导致启动的时候,jmenv nsaddr 查询不到机器信息,从而不知道对应的单元标等,无法给出 name server 信息。

新的中间件,在请求的时候,添加了 lables 参数,把机器的环境,IP,用途等信息都在参数中携带,解决了此问题。内部的其它的组件,也都进行了相应的改造。

MetaQ遇到的一些别的问题:

1. producer send msg timeout option does not take effect #1110

https://github.com/apache/rocketmq-client-go/issues/1110

2. [ISSUE #1112] feat: optimize producer send async #1111

https://github.com/apache/rocketmq-client-go/issues/1111

3. [ISSUE #1114] fix: response future should close channel before callback #1113

https://github.com/apache/rocketmq-client-go/pull/1113

4. fix: ignore name server connection read timeout log (#1117)

https://github.com/apache/rocketmq-client-go/pull/1117

5. feat: connection close add debug log #1118

https://github.com/apache/rocketmq-client-go/pull/1118

十四、hsf go 连接池用完

hsf是长连接,两台机器之间,只会建一个连接(全双工,hsf请求带编号,可以根据编号知道对应的请求和响应)。

补充:需要区别的是 mysql,redis 等,是连接池中,取连接,独占,结束后再交还。

某一天,应用突然报错,日志显示:

ERROR [XxxConsumer.queryPassengerXxx:86] [TraceId=x] - queryPassengerXxx exception! amapXxxId: x, cpAmapOrderId: x
com.taobao.hsf.exception.HSFException: 82

error message : Invalid call is removed because connection has been closed suddenly

排查发现,总体连接数的限制比较少,只有 700,后来改成了 10000(实际上推荐再调大一些),解决了此问题。

十五、hsf 引用开源库导致的坑

第三方库的一个坑:

这个包有潜在问题,用户自己升了这个,或者依赖的其它的库有用这个,就有问题。

strcase.ToLowerCamel("getAwardByActIDList")

版本 v0.2.0 结果=> getAwardByActIDList

版本 v0.3.0 结果=> getAwardByActIdList 

这个行为,可能会导致 hsf 服务方法名,和之前不兼容!

建议使用 github.com/iancoleman/strcase v0.2.0

除非升级前你就用的 v0.3.0。

面对这种诱惑,自己忍一下,要保持定力!

最后总结

从19年开始,曾经有无数的同学为此做出过贡献,还有不少坑,这里没有办法全部都写出来。

在无数的坑的基础上,在阿里内部,才有了今天比较完善的Go中间件,并且还在不断迭代和完善中。

希望大家能利用他人踩过的坑,进行学习,不犯同样的错误,希望本文能帮助到大家。