作为一个服务端开发,熟悉我们服务的业务至关重要,但我们换工作的时候,也经常会换到别的业务场景下。比方说,我在成人教育的行业工作了2年,跳槽到了支付的行业。这么看的话,业务并不能成为服务端工程师的求职门槛。但我们可能会有被评价过缺少产品 sense,限制我们始终工作在一线。
抛开业务的特殊场景,我们所开发的功能其实大同小异,面临的问题也有很高的重复性。当遇到之前解决过的问题,我经常会回忆之前写过的代码,代码本身可能很烂,但总觉得它经过了线上的考验,是信的过的。但很严肃的结果是,自己完全回忆不起来。
这篇文章就是想细数一些之前写过的代码,或者,遇到的写的比较好的代码(博客中的、或者三方库中的),也可能是罗列一些代码的问题。总之,就是不成体系的代码片段。
Go协程封装
下面的代码展示的是启用协程的常规操作,为了处理协程内可能的 panic,在方法体第一行总需要 defer recover 这样的组合,当捕获异常时,还需要日志打印调用堆栈的信息。
这种写法其实非常不美观,对于有代码洁癖的人来说,如果在同一个函数体内赋值拷贝2次以上,他就会感觉很崩溃。有些没有代码洁癖的人,可能就是按需拷贝N次了,这对于有代码洁癖的人而言,会是更加崩溃的事情。毕竟,一个项目都是多个人共同在维护的,不是每个人都有代码洁癖。
go func() {
defer func() {
if r := recover(); r != nil {
// 打印堆栈信息
}
}()
// 异步逻辑处理
}()
所以,很多人就开始想把 go defer recover 封装起来,下面就是其中的一种,异步调用 fun 函数,并明确传递需要的 args 参数,方法内部也做了简单的参数判断,最后,通过反射函数 Call 执行函数 fun。代码为了做到兼容,引入了反射,但也因为反射,会损失掉一部分性能。
这个封装除了性能,当发生 panic 时,打印堆栈日志的部分可能需要特别注意。一般来说,我们的日志都会输出打印日志的文件名和行号,但通过这样的封装,发生错误的文件和行号都变成了 GoFunc 所在的文件和行号。如果要变得更完善,还需要调整堆栈的层级。
func GoFunc(fun interface{}, args ...interface{}) (err error) {
v := reflect.ValueOf(fun)
go func(err error) {
defer func() {
if r := recover(); r != nil {
// 打印堆栈日志
}
}()
switch v.Kind() {
case reflect.Func:
pps := make([]reflect.Value, 0, len(args))
for _, arg := range args {
pps = append(pps, reflect.ValueOf(arg))
}
v.Call(pps)
default:
err = errors.New(fmt.Sprintf("func is not func, type=%v", v.Kind().String()))
}
}(err)
return
}
IN 分批查询
SQL 查询时可能会遇到 IN 查询的情况,IN 中参数多少一般是由具体的业务请求来决定的,一般用户可能 IN 后面跟小于 5 个索引,特殊用户 IN 后面可能多一些。考虑一些极端的场景,如果 SQL 的 IN 中包含了成百上千个索引,我们要不要去查询底层数据?直接查询数据会引起什么问题吗?
引申个题外话,对于用户的任意请求,我们要做好防守,防守一些极端 case。 SQL 语句 LIMIT 就是一个很好的防守例子,我们给 LIMIT 设置一个预期内的最大值,如果用户的查询超过了这个最大值限制,就直接替换为这个预期内的最大值。
回归正题,IN 参数特别多会导致底层数据库的负载压力不均衡,命中极端 IN 查询的服务 CPU 可能会瞬间拉高,影响服务的其他查询。和大 KEY 的场景差不多,存储大 KEY 或者热 KEY 的服务,负载就会比其他服务高一些。
解决的思路也很简单,在调用端采用分批请求的方式,人为的将一次 IN 查询拆分成多次 IN 查询,关键点在于解决条件的拆分,以及结果集的合并。将一次请求拆分成并发的多次请求,最后将各路请求的结果进行合并,就是我们的工作。
下面展示了拆分的具体代码实现,注意,代码只是一个拆分模式,并不能直接运行。而且,代码还存在其他缺陷,没有支持上泛型、分批查询的数量限制也不能动态设置,但我觉问题不大,看关键点就够了。
func PatchQuery(ctx context.Context, keys []string,
query func(ctx context.Context, keys []string, result *sync.Map)) *sync.Map {
var result sync.Map
var wg sync.WaitGroup
patchSize := 64
length := len(keys)
for i := 0; i < length; {
if i+patchSize >= length {
patch := keys[i:]
query(ctx, patch, &result)
break
}
wg.Add(1)
go func(index int) {
defer wg.Done()
patch := keys[index : index+patchSize]
query(ctx, patch, &result)
}(i)
i += patchSize
}
wg.Wait()
return &result
}
因为内部存在并发结果的合并,简单的引入了 sync Map 来解决并发。用 map 还有一个原因,就是 key 值可以通过 IN 的索引进行构建,通过 key 来查询到具体的结果。无论 IN 的条件是否是唯一索引,我们都可以正确的处理返回结果。
正确的使用切片是这里的重点,切片中的 [a:b] 是半闭半开的区间范围,一定不要混淆。另外,切片的最后一次查询是否要启用新的协程也值得考量,万事没绝对,我倒是觉得,不用是比较适当的。
耗时方法统计
经常看到使用 defer 统计代码块的耗时,相比较直来直往的统计,defer 看起来高级不少,也更能体现一些技术技巧。还是循序渐进地来看代码:
st := time.Now()
// 代码块
cost := time.Since(st).Milliseconds()
这是最基本的原型代码,所有的高级代码都围绕上述两行代码展开。经常会看到有部分人会借用匿名函数做一层耗时统计封装,怎么评价这个封装呢?
所有的方法都需要调用 Rt,在 Rt 中创建一个匿名函数,匿名函数中执行我们的业务代码。无法透出被包裹函数的返回值是一个比较大的缺陷,换一个描述角度,就是做到无法细粒度得统计方法的耗时。
func Rt(process func()) {
st := time.Now()
process()
cost := time.Since(st).Milliseconds()
fmt.Printf("cost:%dms\n", cost)
}
目前来看,最优的耗时统计方法当属下面的代码封装。利用 defer 函数在 return 之后执行的特性,我们可以将耗时的统计都封装在 defer 执行的函数中。
func StaticRt() func() {
st := time.Now()
return func() {
cost := time.Since(st)
// log
// fmt.Println(cost)
}
}
使用起来非常便捷,如下所示。我们只需要在函数体的第一行执行 defer StaticRt
函数,StaticRt 代码注释的 log 部分需要结合业务来填充。
特别需要注意的一点,defer 后执行 StaticRt 方法最末尾必须跟一个括号,因为 defer 实际要执行的是 StaticRt 函数返回的方法,而非 StaticRt 函数本身。另外,为了保证 StaticRt 函数会被编译器执行,也不可以使用一个匿名函数来包裹 StaticRt 函数。
func main() {
defer StaticRt()()
time.Sleep(time.Second)
}
代码的工作原理特别简单,StaticRt 充分利用闭包的方式,记录下程序开始执行的时间。在函数 return 之后,defer 函数开始执行,用当前的时间减去 st 中保存的开始时间就是函数的执行耗时。文章来源:https://www.toymoban.com/news/detail-496636.html
这里说到闭包,本质上就是影响了函数的传值方式,我们通过简单的举例,来看看闭包的功效。左图传递给 increment 的时候参数 i 发生了值拷贝,右图参数 i 操作的还是外层声明的变量 i 本身。文章来源地址https://www.toymoban.com/news/detail-496636.html
打印结果:13 | 打印结果:14 |
---|---|
到了这里,关于Go代码片段品鉴的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!