asong

asong 查看完整档案

深圳编辑  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑

欢迎关注公众号:Golang梦工厂

个人动态

asong 发布了文章 · 1月23日

源码剖析panic与recover,看不懂你打我好了!

前言

哈喽,大家好,我是asong,今天与大家来聊一聊go语言中的"throw、try.....catch{}"。如果你之前是一名java程序员,我相信你一定吐槽过go语言错误处理方式,但是这篇文章不是来讨论好坏的,我们本文的重点是带着大家看一看panicrecover是如何实现的。上一文我们讲解了defer是如何实现的,但是没有讲解与defer紧密相连的recover,想搞懂panicrecover的实现也没那么简单,就放到这一篇来讲解了。废话不多说,直接开整。

什么是panicrecover

Go 语言中 panic 关键字主要用于主动抛出异常,类似 java 等语言中的 throw 关键字。panic 能够改变程序的控制流,调用 panic 后会立刻停止执行当前函数的剩余代码,并在当前 Goroutine 中递归执行调用方的 defer

Go 语言中 recover 关键字主要用于捕获异常,让程序回到正常状态,类似 java 等语言中的 try ... catchrecover 可以中止 panic 造成的程序崩溃。它是一个只能在 defer 中发挥作用的函数,在其他作用域中调用不会发挥作用;

recover只能在defer中使用这个在标准库的注释中已经写明白了,我们可以看一下:

// The recover built-in function allows a program to manage behavior of a
// panicking goroutine. Executing a call to recover inside a deferred
// function (but not any function called by it) stops the panicking sequence
// by restoring normal execution and retrieves the error value passed to the
// call of panic. If recover is called outside the deferred function it will
// not stop a panicking sequence. In this case, or when the goroutine is not
// panicking, or if the argument supplied to panic was nil, recover returns
// nil. Thus the return value from recover reports whether the goroutine is
// panicking.
func recover() interface{}

这里有一个要注意的点就是recover必须要要在defer函数中使用,否则无法阻止panic。最好的验证方法是先写两个例子:

func main()  {
    example1()
    example2()
}

func example1()  {
    defer func() {
        if err := recover(); err !=nil{
            fmt.Println(string(Stack()))
        }
    }()
    panic("unknown")
}

func example2()  {
    defer recover()
    panic("unknown")
}

func Stack() []byte {
    buf := make([]byte, 1024)
    for {
        n := runtime.Stack(buf, false)
        if n < len(buf) {
            return buf[:n]
        }
        buf = make([]byte, 2*len(buf))
    }
}

运行我们会发现example2()方法的panic是没有被recover住的,导致整个程序直接crash了。这里大家肯定会有疑问,为什么直接写recover()就不能阻止panic了呢。我们在详解defer实现机制(附上三道面试题,我不信你们都能做对)讲解了defer实现原理,一个重要的知识点defer将语句放入到栈中时,也会将相关的值拷贝同时入栈。所以defer recover()这种写法在放入defer栈中时就已经被执行过了,panic是发生在之后,所以根本无法阻止住panic

特性

上面我们简单的介绍了一下什么是panicrecover,下面我一起来看看他们有什么特性,避免我们踩坑。

  • recover只有在defer函数中使用才有效,上面已经举例说明了,这里就不在赘述了。
  • panic允许在defer中嵌套多次调用.程序多次调用 panic 也不会影响 defer 函数的正常执行,所以使用 defer 进行收尾工作一般来说都是安全的。写个例子验证一下:
func example3()  {
    defer fmt.Println("this is a example3 for defer use panic")
    defer func() {
        defer func() {
            panic("panic defer 2")
        }()
        panic("panic defer 1")
    }()
    panic("panic example3")
}
// 运行结果
this is a example3 for defer use panic
panic: panic example3
        panic: panic defer 1
        panic: panic defer 2
.......... 省略

通过运行结果可以看出panic不会影响defer函数的使用,所以他是安全的。

  • panic只会对当前Goroutinedefer有效,还记得我们上一文分析的deferproc函数吗?在newdefer中分配_defer结构体对象的时,会把分配到的对象链入当前 goroutine_defer 链表的表头,也就是把延迟调用函数与调用方所在的Goroutine进行关联。因此当程序发生panic时只会调用当前 Goroutine 的延迟调用函数是没有问题的。写个例子验证一下:
func main()  {
    go example4()
    go example5()
    time.Sleep(10 * time.Second)
}

func example4()  {
    fmt.Println("goroutine example4")
    defer func() {
        fmt.Println("test defer")
    }()
    panic("unknown")
}

func example5()  {

    defer fmt.Println("goroutine example5")
    time.Sleep(5 * time.Second)
}
// 运行结果
goroutine example4
test defer
panic: unknown
............. 省略部分代码

这里我开了两个协程,一个协程会发生panic,导致程序崩溃,但是只会执行自己所在Goroutine的延迟函数,所以正好验证了多个 Goroutine 之间没有太多的关联,一个 Goroutinepanic 时也不应该执行其他 Goroutine 的延迟函数。

典型应用

其实我们在实际项目开发中,经常会遇到panic问题, Go 的 runtime 代码中很多地方都调用了 panic 函数,对于不了解 Go 底层实现的新人来说,这无疑是挖了一堆深坑。我们在实际生产环境中总会出现panic,但是我们的程序仍能正常运行,这是因为我们的框架已经做了recover,他已经为我们兜住底,比如gin,我们看一看他是怎么做的。

先看代码部分吧:

func Default() *Engine {
    debugPrintWARNINGDefault()
    engine := New()
    engine.Use(Logger(), Recovery())
    return engine
}
// Recovery returns a middleware that recovers from any panics and writes a 500 if there was one.
func Recovery() HandlerFunc {
    return RecoveryWithWriter(DefaultErrorWriter)
}

// RecoveryWithWriter returns a middleware for a given writer that recovers from any panics and writes a 500 if there was one.
func RecoveryWithWriter(out io.Writer) HandlerFunc {
    var logger *log.Logger
    if out != nil {
        logger = log.New(out, "\n\n\x1b[31m", log.LstdFlags)
    }
    return func(c *Context) {
        defer func() {
            if err := recover(); err != nil {
                // Check for a broken connection, as it is not really a
                // condition that warrants a panic stack trace.
            ...................// 省略
            }
        }()
        c.Next()
    }
}

我们在使用gin时,第一步会初始化一个Engine实例,调用Default方法会把recovery middleware附上,recovery中使用了defer函数,通过recover来阻止panic,当发生panic时,会返回500错误码。这里有一个需要注意的点是只有主程序中的panic是会被自动recover的,协程中出现panic会导致整个程序crash。还记得我们上面讲的第三个特性嘛,一个协程会发生panic,导致程序崩溃,但是只会执行自己所在Goroutine的延迟函数,所以正好验证了多个 Goroutine 之间没有太多的关联,一个 Goroutinepanic 时也不应该执行其他 Goroutine 的延迟函数。 这就能解释通了吧, 所以为了程序健壮性,我们应该自己主动检查我们的协程程序,在我们的协程函数中添加recover是很有必要的,比如这样:

func main()  {
        r := gin.Default()
        r.GET("/asong/test/go-panic", func(ctx *gin.Context) {
            go func() {
                defer func() {
                    if err := recover();err != nil{
                        fmt.Println(err)
                    }
                }()
                panic("panic")
            }()
        })
        r.Run()
}

如果使用的Gin框架,切记要检查协程中是否会出现panic,否则线上将付出沉重的代价。非常危险!!!

源码解析

go-version: 1.15.3

我们先来写个简单的代码,看看他的汇编调用:

func main()  {
    defer func() {
        if err:= recover();err != nil{
            fmt.Println(err)
        }
    }()
    panic("unknown")
}

执行go tool compile -N -l -S main.go就可以看到对应的汇编码了,我们截取部分片段分析:

image

上面重点部分就是画红线的三处,第一步调用runtime.deferprocStack创建defer对象,这一步大家可能会有疑惑,我上一文忘记讲个这个了,这里先简单概括一下,defer总共有三种模型,编译一个函数里只会有一种defer模式

  • 第一种,堆上分配(deferproc),基本是依赖运行时来分配"_defer"对象并加入延迟参数。在函数的尾部插入deferreturn方法来消费deferlink。
  • 第二种,栈上分配(deferprocStack),基本上跟堆差不多,只是分配方式改为在栈上分配,压入的函数调用栈存有_defer记录,编译器在ssa过程中会预留defer空间。
  • 第三种,开放编码模式(open coded),不过是有条件的,默认open-coded最多支持8个defer,超过则取消。在构建ssa时如发现gcflags有N禁止优化的参数 或者 return数量 * defer数量超过了 15不适用open-coded模式。并不能处于循环中。

按理说我们的版本是1.15+,应该使用开放编码模式呀,但是这里怎么还会在栈上分配?注意看呀,伙计们,我在汇编处理时禁止了编译优化,那肯定不会走开放编码模式呀,这个不是重点,我们接着分析上面的汇编。

第二个红线在程序发生panic时会调用runtime.gopanic,现在程序处于panic状态,在函数返回时调用runtime.deferreturn,也就是调用延迟函数处理。上面这一步是主程序执行部分,下面我们在看一下延迟函数中的执行:

image

这里最重点的就只有一个,调用runtime.gorecover,也就是在这一步,对主程序中的panic进行了恢复了,这就是panicrecover的执行过程,接下来我们就仔细分析一下runtime.gopanicruntime.gorecover这两个方法是如何实现的!

_panic结构

在讲defer实现机制时,我们一起看过defer的结构,其中有一个字段就是_panic,是触发defer的作用,我们来看看的panic的结构:

type _panic struct {
    argp      unsafe.Pointer // pointer to arguments of deferred call run during panic; cannot move - known to liblink
    arg       interface{}    // argument to panic
    link      *_panic        // link to earlier panic
    pc        uintptr        // where to return to in runtime if this panic is bypassed
    sp        unsafe.Pointer // where to return to in runtime if this panic is bypassed
    recovered bool           // whether this panic is over
    aborted   bool           // the panic was aborted
    goexit    bool
}

简单介绍一下上面的字段:

  • argp是指向defer调用时参数的指针。
  • arg是我们调用panic时传入的参数
  • link指向的是更早调用runtime._panic结构,也就是说painc可以被连续调用,他们之间形成链表
  • recovered 表示当前runtime._panic是否被recover恢复
  • aborted表示当前的panic是否被强行终止

上面的pcspgoexit我们单独讲一下,runtime包中有一个Goexit方法,Goext能够终止调用它的goroutine,其他的goroutine是不受影响的,goexit也会在终止goroutine之前运行所有延迟调用函数,Goexit不是一个panic,所以这些延迟函数中的任何recover调用都将返回nil。如果我们在主函数中调用了Goexit会终止该goroutine但不会返回func main。由于func main没有返回,因此程序将继续执行其他gorountine,直到所有其他goroutine退出,程序才会crash。写个简单的例子:

func main()  {
    go func() {
        defer func() {
            if err := recover(); err != nil {
                fmt.Println(err)
            }
        }()
        runtime.Goexit()
    }()
    go func() {
        for true {
            fmt.Println("test")
        }
    }()
    runtime.Goexit()
    fmt.Println("main")
    select {

    }
}

运行上面的例子你就会发现,即使在主goroutine中调用了runtime.Goexit,其他goroutine 是没有任何影响的。所以结构中的pcspgoexit三个字段都是为了修复runtime.Goexit,这三个字段就是为了保证该函数的一定会生效,因为如果在defer中发生panic,那么goexit函数就会被取消,所以才有了这三个字段做保护。看这个例子:

func main()  {
    maybeGoexit()
}
func maybeGoexit() {
    defer func() {
        fmt.Println(recover())
    }()
    defer panic("cancelled Goexit!")
    runtime.Goexit()
}

英语好的可以看一看这个:https://github.com/golang/go/...,这就是上面的一个例子,这里就不过多解释了,了解就好。

下面就开始我们的重点吧~。

gopanic

gopanic的代码有点长,我们一点一点来分析:

  • 第一部分,判断panic类型:
gp := getg()
    if gp.m.curg != gp {
        print("panic: ")
        printany(e)
        print("\n")
        throw("panic on system stack")
    }

    if gp.m.mallocing != 0 {
        print("panic: ")
        printany(e)
        print("\n")
        throw("panic during malloc")
    }
    if gp.m.preemptoff != "" {
        print("panic: ")
        printany(e)
        print("\n")
        print("preempt off reason: ")
        print(gp.m.preemptoff)
        print("\n")
        throw("panic during preemptoff")
    }
    if gp.m.locks != 0 {
        print("panic: ")
        printany(e)
        print("\n")
        throw("panic holding locks")
    }

根据不同的类型判断当前发生panic错误,这里没什么多说的,接着往下看。

  • 第二部分,确保每个recover都试图恢复当前协程中最新产生的且尚未恢复的panic
var p _panic // 声明一个panic结构
    p.arg = e // 把panic传入的值赋给`arg`
    p.link = gp._panic // 指向runtime.panic结构
    gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

    atomic.Xadd(&runningPanicDefers, 1)

    // By calculating getcallerpc/getcallersp here, we avoid scanning the
    // gopanic frame (stack scanning is slow...)
    addOneOpenDeferFrame(gp, getcallerpc(), unsafe.Pointer(getcallersp()))

    for {
        d := gp._defer // 获取当前gorourine的 defer
        if d == nil {
            break // 如果没有defer直接退出了
        }

        // If defer was started by earlier panic or Goexit (and, since we're back here, that triggered a new panic),
        // take defer off list. An earlier panic will not continue running, but we will make sure below that an
        // earlier Goexit does continue running.
        if d.started {
            if d._panic != nil {
                d._panic.aborted = true
            }
            d._panic = nil
            if !d.openDefer {
                // For open-coded defers, we need to process the
                // defer again, in case there are any other defers
                // to call in the frame (not including the defer
                // call that caused the panic).
                d.fn = nil
                gp._defer = d.link
                freedefer(d)
                continue
            }
        }

        // Mark defer as started, but keep on list, so that traceback
        // can find and update the defer's argument frame if stack growth
        // or a garbage collection happens before reflectcall starts executing d.fn.
        d.started = true
    // Record the panic that is running the defer.
        // If there is a new panic during the deferred call, that panic
        // will find d in the list and will mark d._panic (this panic) aborted.
        d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

上面的代码不太好说的部分,我添加了注释,就不在这解释一遍了,直接看 d.Started部分,这里的意思是如果defer是由先前的panicGoexit启动的(循环处理回到这里,这触发了新的panic),将defer从列表中删除。早期的panic将不会继续运行,但我们将确保早期的Goexit会继续运行,代码中的if d._panic != nil{d._panic.aborted =true}就是确保将先前的panic终止掉,将aborted设置为true,在下面执行recover时保证goexit不会被取消。

  • 第三部分,defer内联优化调用性能
    if !d.openDefer {
                // For open-coded defers, we need to process the
                // defer again, in case there are any other defers
                // to call in the frame (not including the defer
                // call that caused the panic).
                d.fn = nil
                gp._defer = d.link
                freedefer(d)
                continue
            }

        done := true
        if d.openDefer {
            done = runOpenDeferFrame(gp, d)
            if done && !d._panic.recovered {
                addOneOpenDeferFrame(gp, 0, nil)
            }
        } else {
            p.argp = unsafe.Pointer(getargp(0))
            reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz))
        }

上面的代码都是截图片段,这些部分都是为了判断当前defer是否可以使用开发编码模式,具体怎么操作的就不展开了。

  • 第四部分,gopanic中执行程序恢复

在第三部分进行defer内联优化选择时会执行调用延迟函数(reflectcall就是这个作用),也就是会调用runtime.gorecoverrecoverd = true,具体这个函数的操作留在下面讲,因为runtime.gorecover函数并不包含恢复程序的逻辑,程序的恢复是在gopanic中执行的。先看一下代码:

        if p.recovered { // 在runtime.gorecover中设置为true
            gp._panic = p.link 
            if gp._panic != nil && gp._panic.goexit && gp._panic.aborted { 
                // A normal recover would bypass/abort the Goexit.  Instead,
                // we return to the processing loop of the Goexit.
                gp.sigcode0 = uintptr(gp._panic.sp)
                gp.sigcode1 = uintptr(gp._panic.pc)
                mcall(recovery)
                throw("bypassed recovery failed") // mcall should not return
            }
            atomic.Xadd(&runningPanicDefers, -1)

            if done {
                // Remove any remaining non-started, open-coded
                // defer entries after a recover, since the
                // corresponding defers will be executed normally
                // (inline). Any such entry will become stale once
                // we run the corresponding defers inline and exit
                // the associated stack frame.
                d := gp._defer
                var prev *_defer
                for d != nil {
                    if d.openDefer {
                        if d.started {
                            // This defer is started but we
                            // are in the middle of a
                            // defer-panic-recover inside of
                            // it, so don't remove it or any
                            // further defer entries
                            break
                        }
                        if prev == nil {
                            gp._defer = d.link
                        } else {
                            prev.link = d.link
                        }
                        newd := d.link
                        freedefer(d)
                        d = newd
                    } else {
                        prev = d
                        d = d.link
                    }
                }
            }

            gp._panic = p.link
            // Aborted panics are marked but remain on the g.panic list.
            // Remove them from the list.
            for gp._panic != nil && gp._panic.aborted {
                gp._panic = gp._panic.link
            }
            if gp._panic == nil { // must be done with signal
                gp.sig = 0
            }
            // Pass information about recovering frame to recovery.
            gp.sigcode0 = uintptr(sp)
            gp.sigcode1 = pc
            mcall(recovery)
            throw("recovery failed") // mcall should not return
        }

这段代码有点长,主要就是分为两部分:

第一部分主要是这个判断if gp._panic != nil && gp._panic.goexit && gp._panic.aborted { ... },正常recover是会绕过Goexit的,所以为了解决这个,添加了这个判断,这样就可以保证Goexit也会被recover住,这里是通过从runtime._panic中取出了程序计数器pc和栈指针sp并且调用runtime.recovery函数触发goroutine的调度,调度之前会准备好 sppc 以及函数的返回值。

第二部分主要是做panicrecover,这也与上面的流程基本差不多,他是从runtime._defer中取出了程序计数器pc栈指针sp并调用recovery函数触发Goroutine,跳转到recovery函数是通过runtime.call进行的,我们看一下其源码(src/runtime/asm_amd64.s 289行):

// func mcall(fn func(*g))
// Switch to m->g0's stack, call fn(g).
// Fn must never return. It should gogo(&g->sched)
// to keep running g.
TEXT runtime·mcall(SB), NOSPLIT, $0-8
    MOVQ    fn+0(FP), DI

    get_tls(CX)
    MOVQ    g(CX), AX    // save state in g->sched
    MOVQ    0(SP), BX    // caller's PC
    MOVQ    BX, (g_sched+gobuf_pc)(AX)
    LEAQ    fn+0(FP), BX    // caller's SP
    MOVQ    BX, (g_sched+gobuf_sp)(AX)
    MOVQ    AX, (g_sched+gobuf_g)(AX)
    MOVQ    BP, (g_sched+gobuf_bp)(AX)

    // switch to m->g0 & its stack, call fn
    MOVQ    g(CX), BX
    MOVQ    g_m(BX), BX
    MOVQ    m_g0(BX), SI
    CMPQ    SI, AX    // if g == m->g0 call badmcall
    JNE    3(PC)
    MOVQ    $runtime·badmcall(SB), AX
    JMP    AX
    MOVQ    SI, g(CX)    // g = m->g0
    MOVQ    (g_sched+gobuf_sp)(SI), SP    // sp = m->g0->sched.sp
    PUSHQ    AX
    MOVQ    DI, DX
    MOVQ    0(DI), DI
    CALL    DI
    POPQ    AX
    MOVQ    $runtime·badmcall2(SB), AX
    JMP    AX
    RET

因为go语言中的runtime环境是有自己的堆栈和goroutinerecovery函数也是在runtime环境执行的,所以要调度到m->g0来执行recovery函数,我们在看一下recovery函数:

// Unwind the stack after a deferred function calls recover
// after a panic. Then arrange to continue running as though
// the caller of the deferred function returned normally.
func recovery(gp *g) {
    // Info about defer passed in G struct.
    sp := gp.sigcode0
    pc := gp.sigcode1

    // d's arguments need to be in the stack.
    if sp != 0 && (sp < gp.stack.lo || gp.stack.hi < sp) {
        print("recover: ", hex(sp), " not in [", hex(gp.stack.lo), ", ", hex(gp.stack.hi), "]\n")
        throw("bad recovery")
    }

    // Make the deferproc for this d return again,
    // this time returning 1. The calling function will
    // jump to the standard return epilogue.
    gp.sched.sp = sp
    gp.sched.pc = pc
    gp.sched.lr = 0
    gp.sched.ret = 1
    gogo(&gp.sched)
}

recovery 函数中,利用 g 中的两个状态码回溯栈指针 sp 并恢复程序计数器 pc 到调度器中,并调用 gogo 重新调度 g ,将 g 恢复到调用 recover 函数的位置, goroutine 继续执行,recovery在调度过程中会将函数的返回值设置为1。这个有什么作用呢? 在deferproc函数中找到了答案:

//go:nosplit
func deferproc(siz int32, fn *funcval) { // arguments of fn follow fn
  ............ 省略
// deferproc returns 0 normally.
    // a deferred func that stops a panic
    // makes the deferproc return 1.
    // the code the compiler generates always
    // checks the return value and jumps to the
    // end of the function if deferproc returns != 0.
    return0()
    // No code can go here - the C return register has
    // been set and must not be clobbered.
}

当延迟函数中recover了一个panic时,就会返回1,当 runtime.deferproc 函数的返回值是 1 时,编译器生成的代码会直接跳转到调用方函数返回之前并执行 runtime.deferreturn,跳转到runtime.deferturn函数之后,程序就已经从panic恢复了正常的逻辑。

  • 第五部分,如果没有遇到runtime.gorecover就会依次遍历所有的runtime._defer,在最后调用fatalpanic中止程序,并打印panic参数返回错误码2。
// fatalpanic implements an unrecoverable panic. It is like fatalthrow, except
// that if msgs != nil, fatalpanic also prints panic messages and decrements
// runningPanicDefers once main is blocked from exiting.
//
//go:nosplit
func fatalpanic(msgs *_panic) {
    pc := getcallerpc()
    sp := getcallersp()
    gp := getg()
    var docrash bool
    // Switch to the system stack to avoid any stack growth, which
    // may make things worse if the runtime is in a bad state.
    systemstack(func() {
        if startpanic_m() && msgs != nil {
            // There were panic messages and startpanic_m
            // says it's okay to try to print them.

            // startpanic_m set panicking, which will
            // block main from exiting, so now OK to
            // decrement runningPanicDefers.
            atomic.Xadd(&runningPanicDefers, -1)

            printpanics(msgs)
        }

        docrash = dopanic_m(gp, pc, sp)
    })

    if docrash {
        // By crashing outside the above systemstack call, debuggers
        // will not be confused when generating a backtrace.
        // Function crash is marked nosplit to avoid stack growth.
        crash()
    }

    systemstack(func() {
        exit(2)
    })

    *(*int)(nil) = 0 // not reached
}

在这里runtime.fatalpanic实现了无法被恢复的程序崩溃,它在中止程序之前会通过 runtime.printpanics 打印出全部的 panic 消息以及调用时传入的参数。

好啦,至此整个gopanic方法就全部看完了,接下来我们再来看一看gorecover方法。

gorecover

这个函数就简单很多了,代码量比较少,先看一下代码吧:

// The implementation of the predeclared function recover.
// Cannot split the stack because it needs to reliably
// find the stack segment of its caller.
//
// TODO(rsc): Once we commit to CopyStackAlways,
// this doesn't need to be nosplit.
//go:nosplit
func gorecover(argp uintptr) interface{} {
    // Must be in a function running as part of a deferred call during the panic.
    // Must be called from the topmost function of the call
    // (the function used in the defer statement).
    // p.argp is the argument pointer of that topmost deferred function call.
    // Compare against argp reported by caller.
    // If they match, the caller is the one who can recover.
    gp := getg()
    p := gp._panic
    if p != nil && !p.goexit && !p.recovered && argp == uintptr(p.argp) {
        p.recovered = true
        return p.arg
    }
    return nil
}

首先获取当前所在的Goroutine,如果当前Goroutine没有调用panic,那么该函数会直接返回nil,是否能recover住该panic的判断条件必须四个都吻合,p.Goexit判断当前是否是goexit触发的,如果是则无法revocer住,上面讲过会在gopanic中执行进行recoverargp是是最顶层延迟函数调用的实参指针,与调用者的argp进行比较,如果匹配说明调用者是可以recover,直接将recovered字段设置为true就可以了。这里主要的作用就是判断当前panic是否可以recover,具体的恢复逻辑还是由gopanic函数负责的。

流程总结

上面看了一篇源码,肯定也是一脸懵逼吧~。这正常,毕竟文字诉说,只能到这个程度了,还是要自己结合带去去看,这里只是起一个辅助作用,最后做一个流程总结吧。

  • 在程序执行过程中如果遇到panic,那么会调用runtime.gopanic,然后取当前Goroutinedefer链表依次执行。
  • 在调用defer函数是如果有recover就会调用runtime.gorecover,在gorecover中会把runtime._panic中的recoved标记为true,这里只是标记的作用,恢复逻辑仍在runtime.panic中。
  • gopanic中会执行defer内联优化、程序恢复逻辑。在程序恢复逻辑中,会进行判断,如果是触发是runtime.Goexit,也会进行recoverypanic也会进行recovery,主要逻辑是runtime.gopanic会从runtime._defer结构体中取出程序计数器pc和栈指针sp并调用runtime.recovery函数恢复程序。runtime.recvoery函数中会根据传入的 pcspgogo中跳转回runtime.deferproc,如果返回值为1,就会调用runtime.deferreturn恢复正常流程。
  • gopanic执行完所有的_defer并且也没有遇到recover,那么就会执行runtime.fatalpanic终止程序,并返回错误码2.

这就是这个逻辑流程,累死我了。。。。

小彩蛋

结尾给大家发一个小福利,哈哈,这个福利就是如果避免出现panic,要注意这些:

  • 数组/切片下标越界,对于go这种静态语言来说,下标越界是致命问题。
  • 不要访问未初始化的指针或nil指针
  • 不要往已经closechan里发送数据
  • map不是线程安全的,不要并发读写map

这几个是比较典型的,还有很多会发生panic的地方,交给你们自行学习吧~。

总结

好啦,这篇文章就到这里啦,素质三连(分享、点赞、在看)都是笔者持续创作更多优质内容的动力!

创建了一个Golang学习交流群,欢迎各位大佬们踊跃入群,我们一起学习交流。入群方式:加我vx拉你入群,或者公众号获取入群二维码

结尾给大家发一个小福利吧,最近我在看[微服务架构设计模式]这一本书,讲的很好,自己也收集了一本PDF,有需要的小伙可以到自行下载。获取方式:关注公众号:[Golang梦工厂],后台回复:[微服务],即可获取。

我翻译了一份GIN中文文档,会定期进行维护,有需要的小伙伴后台回复[gin]即可下载。

翻译了一份Machinery中文文档,会定期进行维护,有需要的小伙伴们后台回复[machinery]即可获取。

我是asong,一名普普通通的程序猿,让我们一起慢慢变强吧。欢迎各位的关注,我们下期见~~~

推荐往期文章:

查看原文

赞 0 收藏 0 评论 0

asong 发布了文章 · 1月17日

踩坑日志之elasticSearch

前言

上周六马上就下班了,正兴高采烈的想着下班吃什么呢!突然QA找到我,说我们的DB与es无法同步数据了,真是令人头皮发秃,好不容易休一天,啊啊啊,难受呀,没办法,还是赶紧找bug吧。下面我就把我这次的bug原因分享给大家,避免踩坑~。

bug原因之bulk隐藏错误信息

第一时间,我去看了一下错误日志,竟然没有错误日志,很是神奇,既然这样,那我们就DEBUG一下吧,DEBUG之前我先贴一段代码:

func (es *UserES) batchAdd(ctx context.Context, user []*model.UserEs) error {
    req := es.client.Bulk().Index(es.index)
    for _, u := range user {
        u.UpdateTime = uint64(time.Now().UnixNano()) / uint64(time.Millisecond)
        u.CreateTime = uint64(time.Now().UnixNano()) / uint64(time.Millisecond)
        doc := elastic.NewBulkIndexRequest().Id(strconv.FormatUint(u.ID, 10)).Doc(u)
        req.Add(doc)
    }
    if req.NumberOfActions() < 0 {
        return nil
    }
    if _, err := req.Do(ctx); err != nil {
        return err
    }
    return nil
}

就是上面这段代码,使用esbulk批量操作,经过DEBUG仍然没有发现任何问题,卧槽!!!没有头绪了,那就看一看es源码吧,里面是不是有什么隐藏的点没有注意到。还真被我找到了,我们先看一下req.Do(ctx)的实现:

// Do sends the batched requests to Elasticsearch. Note that, when successful,
// you can reuse the BulkService for the next batch as the list of bulk
// requests is cleared on success.
func (s *BulkService) Do(ctx context.Context) (*BulkResponse, error) {
    /**
    ...... 省略部分代码
  **/
    // Get response
    res, err := s.client.PerformRequest(ctx, PerformRequestOptions{
        Method:      "POST",
        Path:        path,
        Params:      params,
        Body:        body,
        ContentType: "application/x-ndjson",
        Retrier:     s.retrier,
        Headers:     s.headers,
    })
    if err != nil {
        return nil, err
    }

    // Return results
    ret := new(BulkResponse)
    if err := s.client.decoder.Decode(res.Body, ret); err != nil {
        return nil, err
    }

    // Reset so the request can be reused
    s.Reset()

    return ret, nil
}

我只把重要部分代码贴出来,看这一段就好了,我来解释一下:

  • 首先构建Http请求
  • 发送Http请求并分析,并解析response
  • 重置request可以重复使用

这里的重点就是ret := new(BulkResponse)new了一个BulkResponse结构,他的结构如下:

type BulkResponse struct {
    Took   int                            `json:"took,omitempty"`
    Errors bool                           `json:"errors,omitempty"`
    Items  []map[string]*BulkResponseItem `json:"items,omitempty"`
}
// BulkResponseItem is the result of a single bulk request.
type BulkResponseItem struct {
    Index         string        `json:"_index,omitempty"`
    Type          string        `json:"_type,omitempty"`
    Id            string        `json:"_id,omitempty"`
    Version       int64         `json:"_version,omitempty"`
    Result        string        `json:"result,omitempty"`
    Shards        *ShardsInfo   `json:"_shards,omitempty"`
    SeqNo         int64         `json:"_seq_no,omitempty"`
    PrimaryTerm   int64         `json:"_primary_term,omitempty"`
    Status        int           `json:"status,omitempty"`
    ForcedRefresh bool          `json:"forced_refresh,omitempty"`
    Error         *ErrorDetails `json:"error,omitempty"`
    GetResult     *GetResult    `json:"get,omitempty"`
}

先来解释一个每个字段的意思:

  • took:总共耗费了多长时间,单位是毫秒
  • Errors:如果其中任何子请求失败,该 errors 标志被设置为 true ,并且在相应的请求报告出错误明细(看下面的Items解释)
  • Items:这个里就是存储每一个子请求的response,这里的Error存储的是详细的错误信息

现在我想大家应该知道为什么我们的代码没有报err信息了,bulk的每个请求都是独立的执行,因此某个子请求的失败不会对其他子请求的成功与否造成影响,所以其中某一条出现错误我们需要从BulkResponse解出来。现在我们把代码改正确:

func (es *UserES) batchAdd(ctx context.Context, user []*model.UserEs) error {
    req := es.client.Bulk().Index(es.index)
    for _, u := range user {
        u.UpdateTime = uint64(time.Now().UnixNano()) / uint64(time.Millisecond)
        u.CreateTime = uint64(time.Now().UnixNano()) / uint64(time.Millisecond)
        doc := elastic.NewBulkIndexRequest().Id(strconv.FormatUint(u.ID, 10)).Doc(u)
        req.Add(doc)
    }
    if req.NumberOfActions() < 0 {
        return nil
    }
    res, err := req.Do(ctx)
    if err != nil {
        return err
    }
    // 任何子请求失败,该 `errors` 标志被设置为 `true` ,并且在相应的请求报告出错误明细
    // 所以如果没有出错,说明全部成功了,直接返回即可
    if !res.Errors {
        return nil
    }
    for _, it := range res.Failed() {
        if it.Error == nil {
            continue
        }
        return &elastic.Error{
            Status:  it.Status,
            Details: it.Error,
        }
    }
    return nil
}

这里再解释一下res.Failed方法,这里会把itemsbulk response带错误的返回,所以在这里面找错误信息就可以了。

至此,这个bug原因终于被我找到了,接下来可以看下一个bug了,我们先简单总结一下:

bulk API 允许在单个步骤中进行多次 createindexupdatedelete 请求,每个子请求都是独立执行,因此某个子请求的失败不会对其他子请求的成功与否造成影响。bulk的response结构中Erros字段,如果其中任何子请求失败,该 errors 标志被设置为 true ,并且在相应的请求报告出错误明细,items字段是一个数组,,这个数组的内容是以请求的顺序列出来的每个请求的结果。所以在使用bulk时一定要从response中判断是否有err

bug原因之数值范围越界

这里完全是自己使用不当造成,但还是想说一说es的映射数字类型范围的问题:

数字类型有如下分类:

类型说明
byte有符号的8位整数, 范围: [-128 ~ 127]
short有符号的16位整数, 范围: [-32768 ~ 32767]
integer有符号的32位整数, 范围: [−231−231 ~ 231231-1]
long有符号的64位整数, 范围: [−263−263 ~ 263263-1]
float32位单精度浮点数
double64位双精度浮点数
half_float16位半精度IEEE 754浮点类型
scaled_float缩放类型的的浮点数, 比如price字段只需精确到分, 57.34缩放因子为100, 存储结果为5734

这里都是有符号类型的,无符号在es7.10.1版本才开始支持,有兴趣的同学戳这里

这里把这些数字类型及范围列出来就是方便说我的bug原因,这里直接解释一下:

我在DB设置字段的类型是tinyint unsignedtinyint是一个字节存储,无符号的话范围是0-255,而我在es中映射类型选择的是byte,范围是-128~127,当DB中数值超过这个范围是,在进行同步时就会出现这个问题,这里需要大家注意一下数值范围的问题,不要像我一样,因为这个还排查了好久的bug,有些空间没必要省,反正也占不了多少空间。

总结

这篇文章就是简单总结一下我在工作中遇到的问题,发表出来就是给大家提个醒,有人踩过的坑,就不要在踩了,浪费时间!!!!

好啦,这篇文章就到这里啦,素质三连(分享、点赞、在看)都是笔者持续创作更多优质内容的动力!

结尾给大家发一个小福利吧,最近我在看[微服务架构设计模式]这一本书,讲的很好,自己也收集了一本PDF,有需要的小伙可以到自行下载。获取方式:关注公众号:[Golang梦工厂],后台回复:[微服务],即可获取。

我翻译了一份GIN中文文档,会定期进行维护,有需要的小伙伴后台回复[gin]即可下载。

翻译了一份Machinery中文文档,会定期进行维护,有需要的小伙伴们后台回复[machinery]即可获取。

我是asong,一名普普通通的程序猿,让gi我一起慢慢变强吧。欢迎各位的关注,我们下期见~~~

推荐往期文章:

查看原文

赞 0 收藏 0 评论 0

asong 发布了文章 · 1月10日

详解defer实现机制(附上三道面试题)

前言

嗨,大家好,我是asong,鸽了好久,其实元旦就想写一下这篇文章,但是因为喝酒喝断片了,养了三天才缓过来,就推迟到这个周末了,不过多追溯了,有点丢人。今天与大家来聊一聊go中的关键字defer,目前很多编程语言中都有defer关键字,而go语言的defer用于资源的释放,会在函数返回之前进行调用,它会经常被用于关闭文件描述符、关闭数据库连接以及解锁资源。下面我们就深入Go语言源码介绍defer关键字的实现原理。
文末尾给你们留了三道题,检测一下学习成果吧~

基本使用

我们首先来看一看defer关键字是怎么使用的,一个经典的场景就是我们在使用事务时,发生错误需要回滚,这时我们就可以用使用defer来保证程序退出时保证事务回滚,示例代码如下:

// 代码摘自之前写的 Leaf-segment数据库获取ID方案:https://github.com/asong2020/go-algorithm/blob/master/leaf/dao/leaf_dao.go
func (l *LeafDao) NextSegment(ctx context.Context, bizTag string) (*model.Leaf, error) {
    // 开启事务
    tx, err := l.sql.Begin()
    defer func() {
        if err != nil {
            l.rollback(tx)
        }
    }()
    if err = l.checkError(err); err != nil {
        return nil, err
    }
    err = l.db.UpdateMaxID(ctx, bizTag, tx)
    if err = l.checkError(err); err != nil {
        return nil, err
    }
    leaf, err := l.db.Get(ctx, bizTag, tx)
    if err = l.checkError(err); err != nil {
        return nil, err
    }
    // 提交事务
    err = tx.Commit()
    if err = l.checkError(err); err != nil {
        return nil, err
    }
    return leaf, nil
}

上面只是一个简单的应用,defer还有一些特性,如果你不知道,使用起来可能会踩到一些坑,尤其是跟带命名的返回参数一起使用时。下面我们我先来带大家踩踩坑。

defer的注意事项和细节

defer调用顺序

我们先来看一道题,你能说他的答案吗?

func main() {
    fmt.Println("reciprocal")

    for i := 0; i < 10; i++ {
        defer fmt.Println(i)
    }
}

答案:

reciprocal
9
8
7
6
5
4
3
2
1
0

看到答案,你是不是产生了疑问?这就对了,我最开始学golang时也有这个疑问,这个跟栈一样,即"先进后出"特性,越后面的defer表达式越先被调用。所以这里大家关闭依赖资源时一定要注意defer调用顺序。

defer拷贝

我们先来看这样一段代码,你能说出defernum1num2的值是多少吗?

func main() {
    fmt.Println(Sum(1, 2))
}

func Sum(num1, num2 int) int {
    defer fmt.Println("num1:", num1)
    defer fmt.Println("num2:", num2)
    num1++
    num2++
    return num1 + num2
}

聪明的你一定会说:"这也太简单了,答案就是num1等于2,num2等于3"。很遗憾的告诉你,错了,正确的答案是num11,num2为2,这两个变量并不受num1++、num2++的影响,因为defer将语句放入到栈中时,也会将相关的值拷贝同时入栈。

deferreturn的返回时机

这里我先说结论,总结一下就是,函数的整个返回过程应该是:

  1. return 对返回变量赋值,如果是匿名返回值就先声明再赋值;
  2. 执行 defer 函数;
  3. return 携带返回值返回。

下面我们来看两道题,你知道他们的返回值是多少吗?

  • 匿名返回值函数
// 匿名函数
func Anonymous() int {
    var i int
    defer func() {
        i++
        fmt.Println("defer2 value is ", i)
    }()

    defer func() {
        i++
        fmt.Println("defer1 in value is ", i)
    }()

    return i
}
  • 命名返回值的函数
func HasName() (j int) {
    defer func() {
        j++
        fmt.Println("defer2 in value", j)
    }()

    defer func() {
        j++
        fmt.Println("defer1 in value", j)
    }()

    return j
}

先来公布一下答案吧:

1. Anonymous()的返回值为0
2. HasName()的返回值为2

从这我们可以看出命名返回值的函数的返回值被 defer 修改了。这里想必大家跟我一样,都很疑惑,带着疑惑我查阅了一下go官方文档,文档指出,defer的执行顺序有以下三个规则:

  1. A deferred function’s arguments are evaluated when the defer statement is evaluated.
  2. Deferred function calls are executed in Last In First Out order after the surrounding function returns.
  3. Deferred functions may read and assign to the returning function’s named return values.

规则3就可以印证为什么命名返回值的函数的返回值被更改了,其实在函数最终返回前,defer 函数就已经执行了,在命名返回值的函数 中,由于返回值已经被提前声明,所以 defer 函数能够在 return 语句对返回值赋值之后,继续对返回值进行操作,操作的是同一个变量,而匿名返回值函数中return先返回,已经进行了一次值拷贝r=i,defer函数中再次对变量i的操作并不会影响返回值。

这里可能有些小伙伴还不是很懂,我在讲一下return返回步骤,相信你们会豁然开朗。

  • 函数在返回时,首先函数返回时会自动创建一个返回变量假设为ret(如果是命名返回值的函数则不会创建),函数返回时要将变量i赋值给ret,即有ret = i。
  • 然后检查函数中是否有defer存在,若有则执行defer中部分。
  • 最后返回ret

现在你们应该知道上面是什么原因了吧~。

解密defer源码

写在开头:go版本1.15.3

我们先来写一段代码,查看一下汇编代码:

func main() {
    defer func() {
        fmt.Println("asong 真帅")
    }()
}

执行如下指令:go tool compile -N -l -S main.go,截取部分汇编指令如下:

image

我们可以看出来,从执行流程来看首先会调用deferproc来创建defer,然后在函数返回时插入了指令CALL runtime.deferreturn。知道了defer在流程中是通过这两个方法是调用的,接下来我们来看一看defer的结构:

// go/src/runtime/runtime2.go
type _defer struct {
    siz     int32 // includes both arguments and results
    started bool
    heap    bool
    // openDefer indicates that this _defer is for a frame with open-coded
    // defers. We have only one defer record for the entire frame (which may
    // currently have 0, 1, or more defers active).
    openDefer bool
    sp        uintptr  // sp at time of defer
    pc        uintptr  // pc at time of defer
    fn        *funcval // can be nil for open-coded defers
    _panic    *_panic  // panic that is running defer
    link      *_defer

    // If openDefer is true, the fields below record values about the stack
    // frame and associated function that has the open-coded defer(s). sp
    // above will be the sp for the frame, and pc will be address of the
    // deferreturn call in the function.
    fd   unsafe.Pointer // funcdata for the function associated with the frame
    varp uintptr        // value of varp for the stack frame
    // framepc is the current pc associated with the stack frame. Together,
    // with sp above (which is the sp associated with the stack frame),
    // framepc/sp can be used as pc/sp pair to continue a stack trace via
    // gentraceback().
    framepc uintptr
}

这里简单介绍一下runtime._defer结构体中的几个字段:

  • siz代表的是参数和结果的内存大小
  • sppc分别代表栈指针和调用方的程序计数器
  • fn代表的是defer关键字中传入的函数
  • _panic是触发延迟调用的结构体,可能为空
  • openDefer表示的是当前defer是否已经开放编码优化(1.14版本新增)
  • link所有runtime._defer结构体都通过该字段串联成链表

先来我们也知道了defer关键字的数据结构了,下面我们就来重点分析一下deferprocdeferreturn函数是如何调用。

deferproc函数

deferproc函数也不长,我先贴出来代码;

// proc/panic.go
// Create a new deferred function fn with siz bytes of arguments.
// The compiler turns a defer statement into a call to this.
//go:nosplit
func deferproc(siz int32, fn *funcval) { // arguments of fn follow fn
    gp := getg()
    if gp.m.curg != gp {
        // go code on the system stack can't defer
        throw("defer on system stack")
    }

    // the arguments of fn are in a perilous state. The stack map
    // for deferproc does not describe them. So we can't let garbage
    // collection or stack copying trigger until we've copied them out
    // to somewhere safe. The memmove below does that.
    // Until the copy completes, we can only call nosplit routines.
    sp := getcallersp()
    argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn)
    callerpc := getcallerpc()

    d := newdefer(siz)
    if d._panic != nil {
        throw("deferproc: d.panic != nil after newdefer")
    }
    d.link = gp._defer
    gp._defer = d
    d.fn = fn
    d.pc = callerpc
    d.sp = sp
    switch siz {
    case 0:
        // Do nothing.
    case sys.PtrSize:
        *(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp))
    default:
        memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz))
    }

    // deferproc returns 0 normally.
    // a deferred func that stops a panic
    // makes the deferproc return 1.
    // the code the compiler generates always
    // checks the return value and jumps to the
    // end of the function if deferproc returns != 0.
    return0()
    // No code can go here - the C return register has
    // been set and must not be clobbered.
}

上面介绍了rumtiem._defer结构想必这里的入参是什么意思就不用我介绍了吧。

deferproc的函数流程很清晰,首先他会通过newdefer函数分配一个_defer结构对象,然后把需要延迟执行的函数以及该函数需要用到的参数、调用deferproc函数时的rps寄存器的值以及deferproc函数的返回地址保存在_defer结构体对象中,最后通过return0()设置rax寄存器的值为0隐性的给调用者返回一个0值。deferproc主要是靠newdefer来分配_defer结构体对象的,下面我们一起来看看newdefer实现,代码有点长:

// proc/panic.go
// Allocate a Defer, usually using per-P pool.
// Each defer must be released with freedefer.  The defer is not
// added to any defer chain yet.
//
// This must not grow the stack because there may be a frame without
// stack map information when this is called.
//
//go:nosplit
func newdefer(siz int32) *_defer {
    var d *_defer
    sc := deferclass(uintptr(siz))
    gp := getg()//获取当前goroutine的g结构体对象
    if sc < uintptr(len(p{}.deferpool)) {
        pp := gp.m.p.ptr() //与当前工作线程绑定的p
        if len(pp.deferpool[sc]) == 0 && sched.deferpool[sc] != nil {
            // Take the slow path on the system stack so
            // we don't grow newdefer's stack.
            systemstack(func() {
                lock(&sched.deferlock) 
         //把新分配出来的d放入当前goroutine的_defer链表头
                for len(pp.deferpool[sc]) < cap(pp.deferpool[sc])/2 && sched.deferpool[sc] != nil {
                    d := sched.deferpool[sc]
                    sched.deferpool[sc] = d.link
                    d.link = nil
                    pp.deferpool[sc] = append(pp.deferpool[sc], d)
                }
                unlock(&sched.deferlock)
            })
        }
        if n := len(pp.deferpool[sc]); n > 0 {
            d = pp.deferpool[sc][n-1]
            pp.deferpool[sc][n-1] = nil
            pp.deferpool[sc] = pp.deferpool[sc][:n-1]
        }
    }
    if d == nil {
    //如果p的缓存中没有可用的_defer结构体对象则从堆上分配
         // Allocate new defer+args.
         //因为roundupsize以及mallocgc函数都不会处理扩栈,所以需要切换到系统栈执行
        // Allocate new defer+args.
        systemstack(func() {
            total := roundupsize(totaldefersize(uintptr(siz)))
            d = (*_defer)(mallocgc(total, deferType, true))
        })
        if debugCachedWork {
            // Duplicate the tail below so if there's a
            // crash in checkPut we can tell if d was just
            // allocated or came from the pool.
            d.siz = siz
       //把新分配出来的d放入当前goroutine的_defer链表头
            d.link = gp._defer
            gp._defer = d
            return d
        }
    }
    d.siz = siz
    d.heap = true
    return d
}

newdefer函数首先会尝试从当前工作线程绑定的p _defer 对象池和全局对象池中获取一个满足大小要求(sizeof(_defer) + siz向上取整至16的倍数) _defer 结构体对象,如果没有能够满足要求的空闲 _defer 对象则从堆上分一个,最后把分配到的对象链入当前 goroutine _defer 链表的表头。

到此deferproc函数就分析完了,你们懂了吗? 没懂不要紧,我们再来总结一下这个过程:

  • 首先编译器把defer语句翻译成对应的deferproc函数的调用
  • 然后deferproc函数通过newdefer函数分配一个_defer结构体对象并放入当前的goroutine_defer链表的表头;
  • 在 _defer 结构体对象中保存被延迟执行的函数 fn 的地址以及 fn 所需的参数
  • 返回到调用 deferproc 的函数继续执行后面的代码。

deferreturn函数

// Run a deferred function if there is one.
// The compiler inserts a call to this at the end of any
// function which calls defer.
// If there is a deferred function, this will call runtime·jmpdefer,
// which will jump to the deferred function such that it appears
// to have been called by the caller of deferreturn at the point
// just before deferreturn was called. The effect is that deferreturn
// is called again and again until there are no more deferred functions.
//
// Declared as nosplit, because the function should not be preempted once we start
// modifying the caller's frame in order to reuse the frame to call the deferred
// function.
//
// The single argument isn't actually used - it just has its address
// taken so it can be matched against pending defers.
//go:nosplit
func deferreturn(arg0 uintptr) {
    gp := getg() //获取当前goroutine对应的g结构体对象
    d := gp._defer //获取当前goroutine对应的g结构体对象
    if d == nil {
    //没有需要执行的函数直接返回,deferreturn和deferproc是配对使用的
         //为什么这里d可能为nil?因为deferreturn其实是一个递归调用,这个是递归结束条件之一
        return
    }
    sp := getcallersp() //获取调用deferreturn时的栈顶位置
    if d.sp != sp { // 递归结束条件
    //如果保存在_defer对象中的sp值与调用deferretuen时的栈顶位置不一样,直接返回
        //因为sp不一样表示d代表的是在其他函数中通过defer注册的延迟调用函数,比如:
        //a()->b()->c()它们都通过defer注册了延迟函数,那么当c()执行完时只能执行在c中注册的函数
        return
    }
    if d.openDefer {
        done := runOpenDeferFrame(gp, d)
        if !done {
            throw("unfinished open-coded defers in deferreturn")
        }
        gp._defer = d.link
        freedefer(d)
        return
    }

    // Moving arguments around.
    //
    // Everything called after this point must be recursively
    // nosplit because the garbage collector won't know the form
    // of the arguments until the jmpdefer can flip the PC over to
    // fn.
      //把保存在_defer对象中的fn函数需要用到的参数拷贝到栈上,准备调用fn
    //注意fn的参数放在了调用调用者的栈帧中,而不是此函数的栈帧中
    switch d.siz {
    case 0:
        // Do nothing.
    case sys.PtrSize:
        *(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d))
    default:
        memmove(unsafe.Pointer(&arg0), deferArgs(d), uintptr(d.siz))
    }
    fn := d.fn
    d.fn = nil
    gp._defer = d.link // 使gp._defer指向下一个_defer结构体对象
    //因为需要调用的函数d.fn已经保存在了fn变量中,它的参数也已经拷贝到了栈上,所以释放_defer结构体对象
    freedefer(d)
    // If the defer function pointer is nil, force the seg fault to happen
    // here rather than in jmpdefer. gentraceback() throws an error if it is
    // called with a callback on an LR architecture and jmpdefer is on the
    // stack, because the stack trace can be incorrect in that case - see
    // issue #8153).
    _ = fn.fn
    jmpdefer(fn, uintptr(unsafe.Pointer(&arg0)))
}

deferreturn函数主要流程还是简单一些的,我们来分析一下:

  • 首先我们通过当前goroutine对应的g结构体对象的_defer链表判断是否有需要执行的defered函数,如果没有则返回;这里的没有是指g._defer== nil 或者defered函数不是在deferteturncaller函数中注册的函数。
  • 然后我们在从_defer对象中把defered函数需要的参数拷贝到栈上,并释放_defer的结构体对象。
  • 最红调用jmpderfer函数调用defered函数,也就是defer关键字中传入的函数.

jmpdefer函数实现挺优雅的,我们一起来看看他是如何实现的:

// runtime/asm_amd64.s : 581
// func jmpdefer(fv *funcval, argp uintptr)
// argp is a caller SP.
// called from deferreturn.
// 1. pop the caller
// 2. sub 5 bytes from the callers return
// 3. jmp to the argument
TEXT runtime·jmpdefer(SB), NOSPLIT, $0-16
    MOVQ    fv+0(FP), DX    // fn
    MOVQ    argp+8(FP), BX    // caller sp
    LEAQ    -8(BX), SP    // caller sp after CALL
    MOVQ    -8(SP), BP    // restore BP as if deferreturn returned (harmless if framepointers not in use)
    SUBQ    $5, (SP)    // return to CALL again
    MOVQ    0(DX), BX
    JMP    BX    // but first run the deferred function

这里都是汇编,大家可能看不懂,没关系,我只是简单介绍一下这里,有兴趣的同学可以去查阅一下相关知识再来深入了解。

  • MOVQ fv+0(FP), DX这条指令会把jmpdefer的第一个参数也就是结构体对象fn的地址存放入DX寄存器,之后的代码就可以通过寄存器访问到fnfn就可以拿到defer关键字中传入的函数,对应上面的例子就是匿名函数func(){}().
  • MOVQ argp+8(FP), BX这条指令就是把jmpdefer的第二个参数放入BX寄存器,该参数是一个指针,他指向defer关键字中传入的函数的第一个参数.
  • LEAQ -8(BX), SP这条指令的作用是让 SP 寄存器指向 deferreturn 函数的返回地址所在的栈内存单元.
  • MOVQ -8(SP), BP这条指令的作用是调整 BP 寄存器的值,此时SP_8的位置存放的是defer关键字当前所在的函数的rbp寄存器的值,所以这条指令在调整rbp寄存器的值使其指向当前所在函数的栈帧的适当位置.
  • SUBQ $5, (SP)这里的作用是完成defer函数的参数以及执行完函数后返回地址在栈上的构造.因为在执行这条指令时,rsp寄存器指向的是deferreturn函数的返回地址.
  • MOVQ 0(DX), BXJMP BX放到一起说吧,目的是跳转到对应defer函数去执行,完成defer函数的调用.

总结

大概分析了一下defer的实现机制,但还是有点蒙圈,最后在总结一下这里:

  • 首先编译器会把defer语句翻译成对deferproc函数的调用。
  • 然后deferproc函数会负责调用newdefer函数分配一个_defer结构体对象并放入当前的goroutine_defer链表的表头;
  • 然后编译起会在defer所在函数的结尾处插入对deferreturn的调用,deferreturn负责递归的调用某函数(defer语句所在函数)通过defer语句注册的函数。

总体来说就是这三个步骤,go语言对defer的实现机制就是这样啦,你明白了吗?

小试牛刀

上面我们也细致学习了一下defer,下面出几道题吧,看看你们真的学会了吗?

问题1

// 测试1
func Test1() (r int) {
    i := 1
    defer func() {
        i = i + 1
    }()
    return i
}

返回结果是什么?

问题2

func Test2() (r int) {
    defer func(r int) {
        r = r + 2
    }(r)
    return 2
}

返回结果是什么?

如果改成这样呢?

func Test3() (r int) {
    defer func(r *int) {
        *r = *r + 2
    }(&r)
    return 2
}

问题3

func main(){
  e1()
  e2()
  e3()
}
func e1() {
    var err error
    defer fmt.Println(err)
    err = errors.New("e1 defer err")
}

func e2() {
    var err error
    defer func() {
        fmt.Println(err)
    }()
    err = errors.New("e2 defer err")
}

func e3() {
    var err error
    defer func(err error) {
        fmt.Println(err)
    }(err)
    err = errors.New("e3 defer err")
}

这个返回结果又是什么呢?

总结

最后这三道题这里就当作思考题吧,自己运行一下,看看你们想的和运行结果是否一致呢?

如果有问题欢迎来找我讨论,因为目前没有留言功能,大家可以加入我的交流群,我们一起交流成长。因为是微信群,会有过期时间,所以大家关注我的公众号,获取入群二维码,或者添加我的个人微信,我拉你入群。

好啦,这篇文章就到这里啦,素质三连(分享、点赞、在看)都是笔者持续创作更多优质内容的动力!

结尾给大家发一个小福利吧,最近我在看[微服务架构设计模式]这一本书,讲的很好,自己也收集了一本PDF,有需要的小伙可以到自行下载。获取方式:关注公众号:[Golang梦工厂],后台回复:[微服务],即可获取。

我翻译了一份GIN中文文档,会定期进行维护,有需要的小伙伴后台回复[gin]即可下载。

翻译了一份Machinery中文文档,会定期进行维护,有需要的小伙伴们后台回复[machinery]即可获取。

我是asong,一名普普通通的程序猿,让gi我一起慢慢变强吧。欢迎各位的关注,我们下期见~~~

推荐往期文章:

查看原文

赞 0 收藏 0 评论 0

asong 发布了文章 · 2020-12-27

动态sql工具之gendry

前言

哈喽,我是asong

今天给大家推荐一个第三方库gendry,这个库是用于辅助操作数据库的Go包。其是基于go-sql-driver/mysql,它提供了一系列的方法来为你调用标准库database/sql中的方法准备参数。对于我这种不喜欢是使用orm框架的选手,真的是爱不释手,即使不使用orm框架,也可以写出动态sql。下面我就带大家看一看这个库怎么使用!

github地址:https://github.com/didi/gendry

初始化连接

既然要使用数据库,那么第一步我们就来进行数据库连接,我们先来看一下直接使用标准库进行连接库是怎样写的:

func NewMysqlClient(conf *config.Server) *sql.DB {
    connInfo := fmt.Sprintf("%s:%s@(%s)/%s?charset=utf8&parseTime=True&loc=Local", conf.Mysql.Username, conf.Mysql.Password, conf.Mysql.Host, conf.Mysql.Db)
    var err error
    db, err := sql.Open("mysql", connInfo)
    if err != nil {
        fmt.Printf("init mysql err %v\n", err)
    }
    err = db.Ping()
    if err != nil {
        fmt.Printf("ping mysql err: %v", err)
    }
    db.SetMaxIdleConns(conf.Mysql.Conn.MaxIdle)
    db.SetMaxOpenConns(conf.Mysql.Conn.Maxopen)
    db.SetConnMaxLifetime(5 * time.Minute)
    fmt.Println("init mysql successc")
    return db
}

从上面的代码可以看出,我们需要自己拼接连接参数,这就需要我们时刻记住连接参数(对于我这种记忆白痴,每回都要去度娘一下,很难受)。Gendry为我们提供了一个manager库,主要用来初始化连接池,设置其各种参数,你可以设置任何go-sql-driver/mysql驱动支持的参数,所以我们的初始化代码可以这样写:

func MysqlClient(conf *config.Mysql) *sql.DB {

    db, err := manager.
        New(conf.Db,conf.Username,conf.Password,conf.Host).Set(
        manager.SetCharset("utf8"),
        manager.SetAllowCleartextPasswords(true),
        manager.SetInterpolateParams(true),
        manager.SetTimeout(1 * time.Second),
        manager.SetReadTimeout(1 * time.Second),
            ).Port(conf.Port).Open(true)

    if err != nil {
        fmt.Printf("init mysql err %v\n", err)
    }
    err = db.Ping()
    if err != nil {
        fmt.Printf("ping mysql err: %v", err)
    }
    db.SetMaxIdleConns(conf.Conn.MaxIdle)
    db.SetMaxOpenConns(conf.Conn.Maxopen)
    db.SetConnMaxLifetime(5 * time.Minute)
    //scanner.SetTagName("json")  // 全局设置,只允许设置一次
    fmt.Println("init mysql successc")
    return db
}

manager做的事情就是帮我们生成datasourceName,并且它支持了几乎所有该驱动支持的参数设置,我们完全不需要管datasourceName的格式是怎样的,只管配置参数就可以了。

如何使用?

下面我就带着大家一起来几个demo学习,更多使用方法可以看源代码解锁(之所以没说看官方文档解决的原因:文档不是很详细,还不过看源码来的实在)。

数据库准备

既然是写示例代码,那么一定要先有一个数据表来提供测试呀,测试数据表如下:

create table users
(
    id       bigint unsigned auto_increment
        primary key,
    username varchar(64)  default '' not null,
    nickname varchar(255) default '' null,
    password varchar(256) default '' not null,
    salt     varchar(48)  default '' not null,
    avatar   varchar(128)            null,
    uptime   bigint       default 0  not null,
    constraint username
        unique (username)
)
    charset = utf8mb4;

好了数据表也有了,下面就开始展示吧,以下按照增删改查的顺序依次展示~。

插入数据

gendry提供了三种方法帮助你构造插入sql,分别是:

// BuildInsert work as its name says
func BuildInsert(table string, data []map[string]interface{}) (string, []interface{}, error) {
    return buildInsert(table, data, commonInsert)
}

// BuildInsertIgnore work as its name says
func BuildInsertIgnore(table string, data []map[string]interface{}) (string, []interface{}, error) {
    return buildInsert(table, data, ignoreInsert)
}

// BuildReplaceInsert work as its name says
func BuildReplaceInsert(table string, data []map[string]interface{}) (string, []interface{}, error) {
    return buildInsert(table, data, replaceInsert)
}

// BuildInsertOnDuplicateKey builds an INSERT ... ON DUPLICATE KEY UPDATE clause.
func BuildInsertOnDuplicate(table string, data []map[string]interface{}, update map[string]interface{}) (string, []interface{}, error) {
    return buildInsertOnDuplicate(table, data, update)
}

看命名想必大家就已经知道他们代表的是什么意思了吧,这里就不一一解释了,这里我们以buildInsert为示例,写一个小demo:

func (db *UserDB) Add(ctx context.Context,cond map[string]interface{}) (int64,error) {
    sqlStr,values,err := builder.BuildInsert(tplTable,[]map[string]interface{}{cond})
    if err != nil{
        return 0,err
    }
    // TODO:DEBUG
    fmt.Println(sqlStr,values)
    res,err := db.cli.ExecContext(ctx,sqlStr,values...)
    if err != nil{
        return 0,err
    }
    return res.LastInsertId()
}
// 单元测试如下:
func (u *UserDBTest) Test_Add()  {
    cond := map[string]interface{}{
        "username": "test_add",
        "nickname": "asong",
        "password": "123456",
        "salt": "oooo",
        "avatar": "http://www.baidu.com",
        "uptime": 123,
    }
    s,err := u.db.Add(context.Background(),cond)
    u.Nil(err)
    u.T().Log(s)
}

我们把要插入的数据放到map结构中,key就是要字段,value就是我们要插入的值,其他都交给 builder.BuildInsert就好了,我们的代码大大减少。大家肯定很好奇这个方法是怎样实现的呢?别着急,后面我们一起解密。

删除数据

我最喜欢删数据了,不知道为什么,删完数据总有一种快感。。。。

删除数据可以直接调用 builder.BuildDelete方法,比如我们现在我们要删除刚才插入的那条数据:

func (db *UserDB)Delete(ctx context.Context,where map[string]interface{}) error {
    sqlStr,values,err := builder.BuildDelete(tplTable,where)
    if err != nil{
        return err
    }
    // TODO:DEBUG
    fmt.Println(sqlStr,values)
    res,err := db.cli.ExecContext(ctx,sqlStr,values...)
    if err != nil{
        return err
    }
    affectedRows,err := res.RowsAffected()
    if err != nil{
        return err
    }
    if affectedRows == 0{
        return errors.New("no record delete")
    }
    return nil
}

// 单测如下:
func (u *UserDBTest)Test_Delete()  {
    where := map[string]interface{}{
        "username in": []string{"test_add"},
    }
    err := u.db.Delete(context.Background(),where)
    u.Nil(err)
}

这里在传入where条件时,key使用的username in,这里使用空格加了一个操作符in,这是gendry库所支持的写法,当我们的SQL存在一些操作符时,就可以通过这样方法进行书写,形式如下:

where := map[string]interface{}{
    "field 操作符": "value",
}

官文文档给出的支持操作如下:

=
>
<
=
<=
>=
!=
<>
in
not in
like
not like
between
not between

既然说到了这里,顺便把gendry支持的关键字也说一下吧,官方文档给出的支持如下:

_or
_orderby
_groupby
_having
_limit
_lockMode

参考示例:

where := map[string]interface{}{
    "age >": 100,
    "_or": []map[string]interface{}{
        {
            "x1":    11,
            "x2 >=": 45,
        },
        {
            "x3":    "234",
            "x4 <>": "tx2",
        },
    },
    "_orderby": "fieldName asc",
    "_groupby": "fieldName",
    "_having": map[string]interface{}{"foo":"bar",},
    "_limit": []uint{offset, row_count},
    "_lockMode": "share",
}

这里有几个需要注意的问题:

  • 如果_groupby没有被设置将忽略_having
  • _limit可以这样写:

    • "_limit": []uint{a,b} => LIMIT a,b
    • "_limit": []uint{a} => LIMIT 0,a
  • _lockMode暂时只支持shareexclusive

    • share代表的是SELECT ... LOCK IN SHARE MODE.不幸的是,当前版本不支持SELECT ... FOR SHARE.
    • exclusive代表的是SELECT ... FOR UPDATE.

更新数据

更新数据可以使用builder.BuildUpdate方法进行构建sql语句,不过要注意的是,他不支持_orderby_groupby_having.只有这个是我们所需要注意的,其他的正常使用就可以了。


func (db *UserDB) Update(ctx context.Context,where map[string]interface{},data map[string]interface{}) error {
    sqlStr,values,err := builder.BuildUpdate(tplTable,where,data)
    if err != nil{
        return err
    }
    // TODO:DEBUG
    fmt.Println(sqlStr,values)
    res,err := db.cli.ExecContext(ctx,sqlStr,values...)
    if err != nil{
        return err
    }
    affectedRows,err := res.RowsAffected()
    if err != nil{
        return err
    }
    if affectedRows == 0{
        return errors.New("no record update")
    }
    return nil
}
// 单元测试如下:
func (u *UserDBTest) Test_Update()  {
    where := map[string]interface{}{
        "username": "asong",
    }
    data := map[string]interface{}{
        "nickname": "shuai",
    }
    err := u.db.Update(context.Background(),where,data)
    u.Nil(err)
}

这里入参变成了两个,一个是用来指定where条件的,另一个就是来放我们要更新的数据的。

查询数据

查询使用的是builder.BuildSelect方法来构建sql语句,先来一个示例,看看怎么用?


func (db *UserDB) Query(ctx context.Context,cond map[string]interface{}) ([]*model.User,error) {
    sqlStr,values,err := builder.BuildSelect(tplTable,cond,db.getFiledList())
    if err != nil{
        return nil, err
    }
    rows,err := db.cli.QueryContext(ctx,sqlStr,values...)
    defer func() {
        if rows != nil{
            _ = rows.Close()
        }
    }()
    if err != nil{
        if err == sql.ErrNoRows{
            return nil,errors.New("not found")
        }
        return nil,err
    }
    user := make([]*model.User,0)
    err = scanner.Scan(rows,&user)
    if err != nil{
        return nil,err
    }
    return user,nil
}
// 单元测试
func (u *UserDBTest) Test_Query()  {
    cond := map[string]interface{}{
        "id in": []int{1,2},
    }
    s,err := u.db.Query(context.Background(),cond)
    u.Nil(err)
    for k,v := range s{
        u.T().Log(k,v)
    }
}

BuildSelect(table string, where map[string]interface{}, selectField []string)总共有三个入参,table就是数据表名,where里面就是我们的条件参数,selectFiled就是我们要查询的字段,如果传nil,对应的sql语句就是select * ...。看完上面的代码,系统的朋友应该会对scanner.Scan,这个就是gendry提供一个映射结果集的方法,下面我们来看一看这个库怎么用。

scanner

执行了数据库操作之后,要把返回的结果集和自定义的struct进行映射。Scanner提供一个简单的接口通过反射来进行结果集和自定义类型的绑定,上面的scanner.Scan方法就是来做这个,scanner进行反射时会使用结构体的tag。默认使用的tagName是ddb:"xxx",你也可以自定义。使用scanner.SetTagName("json")进行设置,scaner.SetTagName是全局设置,为了避免歧义,只允许设置一次,一般在初始化DB阶段进行此项设置.

有时候我们可能不太想定义一个结构体去存中间结果,那么gendry还提供了scanMap可以使用:

rows,_ := db.Query("select name,m_age from person")
result,err := scanner.ScanMap(rows)
for _,record := range result {
    fmt.Println(record["name"], record["m_age"])
}

在使用scanner是有以下几点需要注意:

  • 如果是使用Scan或者ScanMap的话,你必须在之后手动close rows
  • 传给Scan的必须是引用
  • ScanClose和ScanMapClose不需要手动close rows

手写SQL

对于一些比较复杂的查询,gendry方法就不能满足我们的需求了,这就可能需要我们自定义sql了,gendry提供了NamedQuery就是这么使用的,具体使用如下:


func (db *UserDB) CustomizeGet(ctx context.Context,sql string,data map[string]interface{}) (*model.User,error) {
    sqlStr,values,err := builder.NamedQuery(sql,data)
    if err != nil{
        return nil, err
    }
    // TODO:DEBUG
    fmt.Println(sql,values)
    rows,err := db.cli.QueryContext(ctx,sqlStr,values...)
    if err != nil{
        return nil,err
    }
    defer func() {
        if rows != nil{
            _ = rows.Close()
        }
    }()
    user := model.NewEmptyUser()
    err = scanner.Scan(rows,&user)
    if err != nil{
        return nil,err
    }
    return user,nil
}
// 单元测试
func (u *UserDBTest) Test_CustomizeGet()  {
    sql := "SELECT * FROM users WHERE username={{username}}"
    data := map[string]interface{}{
        "username": "test_add",
    }
    user,err := u.db.CustomizeGet(context.Background(),sql,data)
    u.Nil(err)
    u.T().Log(user)
}

这种就是纯手写sql了,一些复杂的地方可以这么使用。

聚合查询

gendry还为我们提供了聚合查询,例如:count,sum,max,min,avg。这里就拿count来举例吧,假设我们现在要统计密码相同的用户有多少,就可以这么写:


func (db *UserDB) AggregateCount(ctx context.Context,where map[string]interface{},filed string) (int64,error) {
    res,err := builder.AggregateQuery(ctx,db.cli,tplTable,where,builder.AggregateCount(filed))
    if err != nil{
        return 0, err
    }
    numberOfRecords := res.Int64()
    return numberOfRecords,nil
}
// 单元测试
func (u *UserDBTest) Test_AggregateCount()  {
    where := map[string]interface{}{
        "password": "123456",
    }
    count,err := u.db.AggregateCount(context.Background(),where,"*")
    u.Nil(err)
    u.T().Log(count)
}

到这里,所有的基本用法基本演示了一遍,更多的使用方法可以自行解锁。

cli工具

除了上面这些API以外,Gendry还提供了一个命令行来进行代码生成,可以显著减少你的开发量,gforge是基于gendry的cli工具,它根据表名生成golang结构,这可以减轻您的负担。甚至gforge都可以为您生成完整的DAO层。

安装

go get -u github.com/caibirdme/gforge

使用gforge -h来验证是否安装成功,同时会给出使用提示。

生成表结构

使用gforge生成的表结构是可以通过golint govet的。生成指令如下:

gforge table -uroot -proot1997 -h127.0.0.1 -dasong -tusers

// Users is a mapping object for users table in mysql
type Users struct {
    ID uint64 `json:"id"`
    Username string `json:"username"`
    Nickname string `json:"nickname"`
    Password string `json:"password"`
    Salt string `json:"salt"`
    Avatar string `json:"avatar"`
    Uptime int64 `json:"uptime"`
}

这样就省去了我们自定义表结构的时间,或者更方便的是直接把dao层生成出来。

生成dao文件

运行指令如下:

gforge dao -uroot -proot1997 -h127.0.0.1 -dasong -tusers | gofmt > dao.go

这里我把生成的dao层直接丢到了文件里了,这里就不贴具体代码了,没有意义,知道怎么使用就好了。

解密

想必大家一定都跟我一样特别好奇gendry是怎么实现的呢?下面就以builder.buildSelect为例子,我们来看一看他是怎么实现的。其他原理相似,有兴趣的童鞋可以看源码学习。我们先来看一下buildSelect这个方法的源码:

func BuildSelect(table string, where map[string]interface{}, selectField []string) (cond string, vals []interface{}, err error) {
    var orderBy string
    var limit *eleLimit
    var groupBy string
    var having map[string]interface{}
    var lockMode string
    if val, ok := where["_orderby"]; ok {
        s, ok := val.(string)
        if !ok {
            err = errOrderByValueType
            return
        }
        orderBy = strings.TrimSpace(s)
    }
    if val, ok := where["_groupby"]; ok {
        s, ok := val.(string)
        if !ok {
            err = errGroupByValueType
            return
        }
        groupBy = strings.TrimSpace(s)
        if "" != groupBy {
            if h, ok := where["_having"]; ok {
                having, err = resolveHaving(h)
                if nil != err {
                    return
                }
            }
        }
    }
    if val, ok := where["_limit"]; ok {
        arr, ok := val.([]uint)
        if !ok {
            err = errLimitValueType
            return
        }
        if len(arr) != 2 {
            if len(arr) == 1 {
                arr = []uint{0, arr[0]}
            } else {
                err = errLimitValueLength
                return
            }
        }
        begin, step := arr[0], arr[1]
        limit = &eleLimit{
            begin: begin,
            step:  step,
        }
    }
    if val, ok := where["_lockMode"]; ok {
        s, ok := val.(string)
        if !ok {
            err = errLockModeValueType
            return
        }
        lockMode = strings.TrimSpace(s)
        if _, ok := allowedLockMode[lockMode]; !ok {
            err = errNotAllowedLockMode
            return
        }
    }
    conditions, err := getWhereConditions(where, defaultIgnoreKeys)
    if nil != err {
        return
    }
    if having != nil {
        havingCondition, err1 := getWhereConditions(having, defaultIgnoreKeys)
        if nil != err1 {
            err = err1
            return
        }
        conditions = append(conditions, nilComparable(0))
        conditions = append(conditions, havingCondition...)
    }
    return buildSelect(table, selectField, groupBy, orderBy, lockMode, limit, conditions...)
}
  • 首先会对几个关键字进行处理。
  • 然后会调用getWhereConditions这个方法去构造sql,看一下内部实现(摘取部分):
for key, val := range where {
        if _, ok := ignoreKeys[key]; ok {
            continue
        }
        if key == "_or" {
            var (
                orWheres          []map[string]interface{}
                orWhereComparable []Comparable
                ok                bool
            )
            if orWheres, ok = val.([]map[string]interface{}); !ok {
                return nil, errOrValueType
            }
            for _, orWhere := range orWheres {
                if orWhere == nil {
                    continue
                }
                orNestWhere, err := getWhereConditions(orWhere, ignoreKeys)
                if nil != err {
                    return nil, err
                }
                orWhereComparable = append(orWhereComparable, NestWhere(orNestWhere))
            }
            comparables = append(comparables, OrWhere(orWhereComparable))
            continue
        }
        field, operator, err = splitKey(key)
        if nil != err {
            return nil, err
        }
        operator = strings.ToLower(operator)
        if !isStringInSlice(operator, opOrder) {
            return nil, ErrUnsupportedOperator
        }
        if _, ok := val.(NullType); ok {
            operator = opNull
        }
        wms.add(operator, field, val)
    }

这一段就是遍历slice,之前处理过的关键字部分会被忽略,_or关键字会递归处理得到所有条件数据。之后就没有特别要说明的地方了。我自己返回到buildSelect方法中,在处理了where条件之后,如果有having条件还会在进行一次过滤,最后所有的数据构建好了后,会调用buildSelect方法来构造最后的sql语句。

总结

看过源码以后,只想说:大佬就是大佬。源码其实很容易看懂,这就没有做详细的解析,主要是这样思想值得大家学习,建议大家都可以看一遍gendry的源码,涨知识~~。

好啦,这篇文章就到这里啦,素质三连(分享、点赞、在看)都是笔者持续创作更多优质内容的动力!

建了一个Golang交流群,欢迎大家的加入,第一时间观看优质文章,不容错过哦(公众号获取)

结尾给大家发一个小福利吧,最近我在看[微服务架构设计模式]这一本书,讲的很好,自己也收集了一本PDF,有需要的小伙可以到自行下载。获取方式:关注公众号:[Golang梦工厂],后台回复:[微服务],即可获取。

我翻译了一份GIN中文文档,会定期进行维护,有需要的小伙伴后台回复[gin]即可下载。

翻译了一份Machinery中文文档,会定期进行维护,有需要的小伙伴们后台回复[machinery]即可获取。

我是asong,一名普普通通的程序猿,让gi我一起慢慢变强吧。我自己建了一个golang交流群,有需要的小伙伴加我vx,我拉你入群。欢迎各位的关注,我们下期见~~~

推荐往期文章:

查看原文

赞 0 收藏 0 评论 0

asong 发布了文章 · 2020-12-20

真的理解go interface了吗?

前言

我想,对于各位使用面向对象编程的程序员来说,"接口"这个名词一定不陌生,比如java中的接口以及c++中的虚基类都是接口的实现。但是golang中的接口概念确与其他语言不同,有它自己的特点,下面我们就来一起解密。

定义

Go 语言中的接口是一组方法的签名,它是 Go 语言的重要组成部分。简单的说,interface是一组method签名的组合,我们通过interface来定义对象的一组行为。interface 是一种类型,定义如下:

type Person interface {
    Eat(food string) 
}

它的定义可以看出来用了 type 关键字,更准确的说 interface 是一种具有一组方法的类型,这些方法定义了 interface 的行为。golang接口定义不能包含变量,但是允许不带任何方法,这种类型的接口叫empty interface

如果一个类型实现了一个interface中所有方法,我们就可以说该类型实现了该interface,所以我们我们的所有类型都实现了empty interface,因为任何一种类型至少实现了0个方法。并且go中并不像java中那样需要显式关键字来实现interface,只需要实现interface包含的方法即可。

实现接口

这里先拿java语言来举例,在java中,我们要实现一个interface需要这样声明:

public class MyWriter implments io.Writer{}

这就意味着对于接口的实现都需要显示声明,在代码编写方面有依赖限制,同时需要处理包的依赖,而在Go语言中实现接口就是隐式的,举例说明:

type error interface {
    Error() string
}
type RPCError struct {
    Code    int64
    Message string
}

func (e *RPCError) Error() string {
    return fmt.Sprintf("%s, code=%d", e.Message, e.Code)
}

上面的代码,并没有error接口的影子,我们只需要实现Error() string方法就实现了error接口。在Go中,实现接口的所有方法就隐式地实现了接口。我们使用上述 RPCError 结构体时并不关心它实现了哪些接口,Go 语言只会在传递参数、返回参数以及变量赋值时才会对某个类型是否实现接口进行检查。

Go语言的这种写法很方便,不用引入包依赖。但是interface底层实现的时候会动态检测也会引入一些问题:

  • 性能下降。使用interface作为函数参数,runtime 的时候会动态的确定行为。使用具体类型则会在编译期就确定类型。
  • 不能清楚的看出struct实现了哪些接口,需要借助ide或其它工具。

两种接口

这里大多数刚入门的同学肯定会有疑问,怎么会有两种接口,因为Go语言中接口会有两种表现形式,使用runtime.iface表示第一种接口,也就是我们上面实现的这种,接口中定义方法;使用runtime.eface表示第二种不包含任何方法的接口,第二种在我们日常开发中经常使用到,所以在实现时使用了特殊的类型。从编译角度来看,golang并不支持泛型编程。但还是可以用interface{} 来替换参数,而实现泛型。

interface内部结构

Go 语言根据接口类型是否包含一组方法将接口类型分成了两类:

  • 使用 runtime.iface 结构体表示包含方法的接口
  • 使用 runtime.eface 结构体表示不包含任何方法的 interface{} 类型;

runtime.iface结构体在Go语言中的定义是这样的:

type eface struct { // 16 字节
    _type *_type
    data  unsafe.Pointer
}

这里只包含指向底层数据和类型的两个指针,从这个type我们也可以推断出Go语言的任意类型都可以转换成interface

另一个用于表示接口的结构体是 runtime.iface,这个结构体中有指向原始数据的指针 data,不过更重要的是 runtime.itab 类型的 tab 字段。

type iface struct { // 16 字节
    tab  *itab
    data unsafe.Pointer
}

下面我们一起看看interface中这两个类型:

  • runtime_type

runtime_type是 Go 语言类型的运行时表示。下面是运行时包中的结构体,其中包含了很多类型的元信息,例如:类型的大小、哈希、对齐以及种类等。

type _type struct {
    size       uintptr
    ptrdata    uintptr
    hash       uint32
    tflag      tflag
    align      uint8
    fieldAlign uint8
    kind       uint8
    equal      func(unsafe.Pointer, unsafe.Pointer) bool
    gcdata     *byte
    str        nameOff
    ptrToThis  typeOff
}

这里我只对几个比较重要的字段进行讲解:

  • size 字段存储了类型占用的内存空间,为内存空间的分配提供信息;
  • hash 字段能够帮助我们快速确定类型是否相等;
  • equal 字段用于判断当前类型的多个对象是否相等,该字段是为了减少 Go 语言二进制包大小从 typeAlg 结构体中迁移过来的);
  • runtime_itab

runtime.itab结构体是接口类型的核心组成部分,每一个 runtime.itab 都占 32 字节,我们可以将其看成接口类型和具体类型的组合,它们分别用 inter_type 两个字段表示:

type itab struct { // 32 字节
    inter *interfacetype
    _type *_type
    hash  uint32
    _     [4]byte
    fun   [1]uintptr
}

inter_type是用于表示类型的字段,hash是对_type.hash的拷贝,当我们想将 interface 类型转换成具体类型时,可以使用该字段快速判断目标类型和具体类型 runtime._type是否一致,fun是一个动态大小的数组,它是一个用于动态派发的虚函数表,存储了一组函数指针。虽然该变量被声明成大小固定的数组,但是在使用时会通过原始指针获取其中的数据,所以 fun 数组中保存的元素数量是不确定的;

内部结构就做一个简单介绍吧,有兴趣的同学可以自行深入学习。

空的interface(runtime.eface

前文已经介绍了什么是空的interface,下面我们来看一看空的interface如何使用。定义函数入参如下:

func doSomething(v interface{}){    
}

这个函数的入参是interface类型,要注意的是,interface类型不是任意类型,他与C语言中的void *不同,如果我们将类型转换成了 interface{} 类型,变量在运行期间的类型也会发生变化,获取变量类型时会得到 interface{},之所以函数可以接受任何类型是在 go 执行时传递到函数的任何类型都被自动转换成 interface{}

那么我们可以才来一个猜想,既然空的 interface 可以接受任何类型的参数,那么一个 interface{}类型的 slice 是不是就可以接受任何类型的 slice ?下面我们就来尝试一下:


import (
    "fmt"
)

func printStr(str []interface{}) {
    for _, val := range str {
        fmt.Println(val)
    }
}

func main(){
    names := []string{"stanley", "david", "oscar"}
    printStr(names)
}

运行上面代码,会出现如下错误:./main.go:15:10: cannot use names (type []string) as type []interface {} in argument to printStr

这里我也是很疑惑,为什么Go没有帮助我们自动把slice转换成interface类型的slice,之前做项目就想这么用,结果失败了。后来我终于找到了答案,有兴趣的可以看看原文,这里简单总结一下:interface会占用两个字长的存储空间,一个是自身的 methods 数据,一个是指向其存储值的指针,也就是 interface 变量存储的值,因而 slice []interface{} 其长度是固定的N*2,但是 []T 的长度是N*sizeof(T),两种 slice 实际存储值的大小是有区别的。

既然这种方法行不通,那可以怎样解决呢?我们可以直接使用元素类型是interface的切片。

var dataSlice []int = foo()
var interfaceSlice []interface{} = make([]interface{}, len(dataSlice))
for i, d := range dataSlice {
    interfaceSlice[i] = d
}

非空interface

Go语言实现接口时,既可以结构体类型的方法也可以是使用指针类型的方法。Go语言中并没有严格规定实现者的方法是值类型还是指针,那我们猜想一下,如果同时使用值类型和指针类型方法实现接口,会有什么问题吗?

先看这样一个例子:

package main

import (
    "fmt"
)

type Person interface {
    GetAge () int
    SetAge (int)
}


type Man struct {
    Name string
    Age int
}

func(s Man) GetAge()int {
return s.Age
}

func(s *Man) SetAge(age int) {
    s.Age = age
}


func f(p Person){
    p.SetAge(10)
    fmt.Println(p.GetAge())
}

func main() {
    p := Man{}
    f(&p) 
}

看上面的代码,大家对f(&p)这里的入参是否会有疑问呢?如果不取地址,直接传过去会怎么样?试了一下,编译错误如下:./main.go:34:3: cannot use p (type Man) as type Person in argument to f: Man does not implement Person (SetAge method has pointer receiver)。透过注释我们可以看到,因为SetAge方法的receiver是指针类型,那么传递给f的是P的一份拷贝,在进行p的拷贝到person的转换时,p的拷贝是不满足SetAge方法的receiver是个指针类型,这也正说明一个问题go中函数都是按值传递

上面的例子是因为发生了值传递才会导致出现这个问题。实际上不管接收者类型是值类型还是指针类型,都可以通过值类型或指针类型调用,这里面实际上通过语法糖起作用的。实现了接收者是值类型的方法,相当于自动实现了接收者是指针类型的方法;而实现了接收者是指针类型的方法,不会自动生成对应接收者是值类型的方法。

举个例子:

type Animal interface {
    Walk()
    Eat()
}


type Dog struct {
    Name string
}

func (d *Dog)Walk()  {
    fmt.Println("go")
}

func (d *Dog)Eat()  {
    fmt.Println("eat shit")
}

func main() {
    var d Animal = &Dog{"nene"}
    d.Eat()
    d.Walk()
}

上面定义了一个接口Animal,接口定义了两个函数:

Walk()
Eat()

接着定义了一个结构体Dog,他实现了两个方法,一个是值接受者,一个是指针接收者。我们通过接口类型的变量调用了定义的两个函数是没有问题的,如果我们改成这样呢:

func main() {
    var d Animal = Dog{"nene"}
    d.Eat()
    d.Walk()
}

这样直接就会报错,我们只改了一部分,第一次将&Dog{"nene"}赋值给了d;第二次则将Dog{"nene"}赋值给了d。第二次报错是因为,d没有实现Animal。这正解释了上面的结论,所以,当实现了一个接收者是值类型的方法,就可以自动生成一个接收者是对应指针类型的方法,因为两者都不会影响接收者。但是,当实现了一个接收者是指针类型的方法,如果此时自动生成一个接收者是值类型的方法,原本期望对接收者的改变(通过指针实现),现在无法实现,因为值类型会产生一个拷贝,不会真正影响调用者。

总结一句话就是:如果实现了接收者是值类型的方法,会隐含地也实现了接收者是指针类型的方法。

类型断言

一个interface被多种类型实现时,有时候我们需要区分interface的变量究竟存储哪种类型的值,go可以使用comma,ok的形式做区分 value, ok := em.(T)em 是 interface 类型的变量,T代表要断言的类型,value 是 interface 变量存储的值,ok 是 bool 类型表示是否为该断言的类型 T。总结出来语法如下:

<目标类型的值>,<布尔参数> := <表达式>.( 目标类型 ) // 安全类型断言
<目标类型的值> := <表达式>.( 目标类型 )  //非安全类型断言

看个简单的例子:

type Dog struct {
    Name string
}

func main() {
    var d interface{} = new(Dog)
    d1,ok := d.(Dog)
    if !ok{
        return
    }
    fmt.Println(d1)
}

这种就属于安全类型断言,更适合在线上代码使用,如果使用非安全类型断言会怎么样呢?

type Dog struct {
    Name string
}

func main() {
    var d interface{} = new(Dog)
    d1 := d.(Dog)
    fmt.Println(d1)
}

这样就会发生错误如下:

panic: interface conversion: interface {} is *main.Dog, not main.Dog

断言失败。这里直接发生了 panic,所以不建议线上代码使用。

看过fmt源码包的同学应该知道,fmt.println内部就是使用到了类型断言,有兴趣的同学可以自行学习。

问题

上面介绍了interface的基本使用方法及可能会遇到的一些问题,下面出三个题,看看你们真的掌握了吗?

问题一

下面代码,哪一行存在编译错误?(多选)

type Student struct {
}

func Set(x interface{}) {
}

func Get(x *interface{}) {
}

func main() {
    s := Student{}
    p := &s
    // A B C D
    Set(s)
    Get(s)
    Set(p)
    Get(p)
}

答案:B、D;解析:我们上文提到过,interface是所有go类型的父类,所以Get方法只能接口*interface{}类型的参数,其他任何类型都不可以。

问题二

这段代码的运行结果是什么?

func PrintInterface(val interface{}) {
    if val == nil {
        fmt.Println("this is empty interface")
        return
    }
    fmt.Println("this is non-empty interface")
}
func main() {
    var pointer *string = nil
    PrintInterface(pointer)
}

答案:this is non-empty interface。解析:这里的interface{}是空接口类型,他的结构如下:

type eface struct { // 16 字节
    _type *_type
    data  unsafe.Pointer
}

所以在调用函数PrintInterface时发生了隐式的类型转换,除了向方法传入参数之外,变量的赋值也会触发隐式类型转换。在类型转换时,*string类型会转换成interface类型,发生值拷贝,所以eface struct{}是不为nil,不过data指针指向的poniternil

问题三

这段代码的运行结果是什么?


type Animal interface {
    Walk()
}

type Dog struct{}

func (d *Dog) Walk() {
    fmt.Println("walk")
}

func NewAnimal() Animal {
    var d *Dog
    return d
}

func main() {
    if NewAnimal() == nil {
        fmt.Println("this is empty interface")
    } else {
        fmt.Println("this is non-empty interface")
    }
}

答案:this is non-empty interface. 解析:这里的interface是非空接口iface,他的结构如下:

type iface struct { // 16 字节
    tab  *itab
    data unsafe.Pointer
}

d是一个指向nil的空指针,但是最后return d 会触发匿名变量 Animal = p值拷贝动作,所以最后NewAnimal()返回给上层的是一个Animal interface{}类型,也就是一个iface struct{}类型。 p为nil,只是iface中的data 为nil而已。 但是iface struct{}本身并不为nil.

总结

interface在我们日常开发中使用还是比较多,所以学好它还是很必要,希望这篇文章能让你对Go语言的接口有一个新的认识,这一篇到这里结束啦,我们下期见~~~。

素质三连(分享、点赞、在看)都是笔者持续创作更多优质内容的动力!

建了一个Golang交流群,欢迎大家的加入,第一时间观看优质文章,不容错过哦(公众号获取)

结尾给大家发一个小福利吧,最近我在看[微服务架构设计模式]这一本书,讲的很好,自己也收集了一本PDF,有需要的小伙可以到自行下载。获取方式:关注公众号:[Golang梦工厂],后台回复:[微服务],即可获取。

我翻译了一份GIN中文文档,会定期进行维护,有需要的小伙伴后台回复[gin]即可下载。

翻译了一份Machinery中文文档,会定期进行维护,有需要的小伙伴们后台回复[machinery]即可获取。

我是asong,一名普普通通的程序猿,让gi我一起慢慢变强吧。我自己建了一个golang交流群,有需要的小伙伴加我vx,我拉你入群。欢迎各位的关注,我们下期见~~~

推荐往期文章:

查看原文

赞 0 收藏 0 评论 0

asong 发布了文章 · 2020-12-06

Leaf—Segment分布式ID生成系统(Golang实现版本)

Leaf-Segment

简介:今天直接开门见山,先来介绍一下我今天所带来的东西。没错,看标题想必大家已经想到了 —— Leaf-segment数据库获取ID方案。这个方案已经喜闻乐见了,美团早就进行了开源,不过他是由java来实现的,所以最近为了学习这一方面知识,我用go自己实现了一下,目前自己验证是没有发现什么bug,等待大家的检验,发现bug可及时反馈(提mr或加我vx都可)。

代码已收录到我的个人仓库——[go-算法系列(go-algorithm)](https://github.com/asong2020/...

欢迎Star,感谢各位~~~。

注:下文leaf-segment数据库方案设计直接参考美团(摘取部分)。详细请参阅:https://tech.meituan.com/2017...

快速使用

创建数据库

CREATE TABLE `leaf_alloc` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `biz_tag` varchar(128)  NOT NULL DEFAULT '',
  `max_id` bigint(20) NOT NULL DEFAULT '1',
  `step` int(11) NOT NULL,
  `description` varchar(256)  DEFAULT NULL,
  `update_time` bigint(20) NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`),
  UNIQUE KEY (`biz_tag`)
) ENGINE=InnoDB;

也可以直接使用我已经生成好的SQL文件(已在工程项目中)。各个字段的介绍我会在后文代码实现部分进行解析,这里就不一一解析了。

获取并运行项目

// 1 新建文件目录
$ mdkir asong.cloud
$ cd asong.cloud
// 2 获取项目
$ git clone git@github.com:asong2020/go-algorithm.git
// 3 进入项目目录
$ cd go-go-algorithm/leaf
// 4 运行
$ go run main.go //运行

测试

创建业务号段

URI: POST http://localhost:8080/api/leaf
Param(json):
{
    "biz_tag": "test_create_one",
    "max_id":  1, // 可以不传 默认为1
    "step": 2000, // 可以不传 默认为200
    "descprition": "test api one"
}
  • 示例:
curl --location --request POST 'http://localhost:8080/api/leaf' \
--header 'Content-Type: application/json' \
--data-raw '{
    "biz_tag": "test_create_one",
    "descprition": "test api one"
}'

初始化DB中的号段到内存中

URI: PUT http://localhost:8080/api/leaf/init/cache
Param(json):
{
    "biz_tag": "test_create"
}
  • 示例
curl --location --request PUT 'http://localhost:8080/api/leaf/init/cache' \
--header 'Content-Type: application/json' \
--data-raw '{
    "biz_tag": "test_create"
}'

获取ID

URI: GET http://localhost:8080/api/leaf
Param: 
?biz_tag=test_create
  • 示例
curl --location --request GET 'http://localhost:8080/api/leaf?biz_tag=test_create'

更新step

URI: PUT http://localhost:8080/api/leaf/step
Param(json):
{
    "step":   10000,
    "biz_tag": "test_create"
}
  • 示例
curl --location --request PUT 'http://localhost:8080/api/leaf/step' \
--header 'Content-Type: application/json' \
--data-raw '{
    "step": 10000,
    "biz_tag": "test_create"
}'

Leaf-Segment方案实现

背景

在复杂分布式系统中,往往需要对大量的数据和消息进行唯一标识。一个能够生成全局唯一ID的系统是非常必要的。比如某宝,业务分布广泛,这么多业务对数据分库分表后需要有一个唯一ID来标识一条数据或消息,数据库的自增ID显然不能满足需求;所以,我们可以总结一下业务系统对ID号的要求有哪些呢?

  1. 全局唯一性:不能出现重复的ID号,既然是唯一标识,这是最基本的要求。
  2. 趋势递增:在MySQL InnoDB引擎中使用的是聚集索引,由于多数RDBMS使用B-tree的数据结构来存储索引数据,在主键的选择上面我们应该尽量使用有序的主键保证写入性能。
  3. 单调递增:保证下一个ID一定大于上一个ID,例如事务版本号、IM增量消息、排序等特殊需求。
  4. 信息安全:如果ID是连续的,恶意用户的扒取工作就非常容易做了,直接按照顺序下载指定URL即可;如果是订单号就更危险了,竞对可以直接知道我们一天的单量。所以在一些应用场景下,会需要ID无规则、不规则。

上述123对应三类不同的场景,3和4需求还是互斥的,无法使用同一个方案满足。

本文只讲述场景3的方案,即leaf-segment。场景4可以用雪花算法实现,这个我之前实现过了,有兴趣的童鞋可以参考一下。传送门:https://github.com/asong2020/...

数据库生成

leaf-sement是在使用数据库生成方案上做的改进。这里先抛砖引玉一下,看一下数据库生成方案是怎样实现的。

以MySQL举例,利用给字段设置auto_increment_incrementauto_increment_offset来保证ID自增,每次业务使用下列SQL读写MySQL得到ID号。

begin;
REPLACE INTO Tickets64 (stub) VALUES ('a');
SELECT LAST_INSERT_ID();
commit;

这种方案的优缺点如下:

优点:

  • 非常简单,利用现有数据库系统的功能实现,成本小,有DBA专业维护。
  • ID号单调自增,可以实现一些对ID有特殊要求的业务。

缺点:

  • 强依赖DB,当DB异常时整个系统不可用,属于致命问题。配置主从复制可以尽可能的增加可用性,但是数据一致性在特殊情况下难以保证。主从切换时的不一致可能会导致重复发号。
  • ID发号性能瓶颈限制在单台MySQL的读写性能。

对于MySQL性能问题,可用如下方案解决:在分布式系统中我们可以多部署几台机器,每台机器设置不同的初始值,且步长和机器数相等。比如有两台机器。设置步长step为2,TicketServer1的初始值为1(1,3,5,7,9,11…)、TicketServer2的初始值为2(2,4,6,8,10…)。这是Flickr团队在2010年撰文介绍的一种主键生成策略(Ticket Servers: Distributed Unique Primary Keys on the Cheap )。如下所示,为了实现上述方案分别设置两台机器对应的参数,TicketServer1从1开始发号,TicketServer2从2开始发号,两台机器每次发号之后都递增2。

TicketServer1:
auto-increment-increment = 2
auto-increment-offset = 1

TicketServer2:
auto-increment-increment = 2
auto-increment-offset = 2

假设我们要部署N台机器,步长需设置为N,每台的初始值依次为0,1,2…N-1那么整个架构就变成了如下图所示:

这种架构貌似能够满足性能的需求,但有以下几个缺点:

  • 系统水平扩展比较困难,比如定义好了步长和机器台数之后,如果要添加机器该怎么做?假设现在只有一台机器发号是1,2,3,4,5(步长是1),这个时候需要扩容机器一台。可以这样做:把第二台机器的初始值设置得比第一台超过很多,比如14(假设在扩容时间之内第一台不可能发到14),同时设置步长为2,那么这台机器下发的号码都是14以后的偶数。然后摘掉第一台,把ID值保留为奇数,比如7,然后修改第一台的步长为2。让它符合我们定义的号段标准,对于这个例子来说就是让第一台以后只能产生奇数。扩容方案看起来复杂吗?貌似还好,现在想象一下如果我们线上有100台机器,这个时候要扩容该怎么做?简直是噩梦。所以系统水平扩展方案复杂难以实现。
  • ID没有了单调递增的特性,只能趋势递增,这个缺点对于一般业务需求不是很重要,可以容忍。
  • 数据库压力还是很大,每次获取ID都得读写一次数据库,只能靠堆机器来提高性能

Leaf-Segment数据库方案

Leaf-Segment数据库方案是在上面的数据库生成方案上做的改进。

做了如下改变: - 原方案每次获取ID都得读写一次数据库,造成数据库压力大。改为利用proxy server批量获取,每次获取一个segment(step决定大小)号段的值。用完之后再去数据库获取新的号段,可以大大的减轻数据库的压力。 - 各个业务不同的发号需求用biz_tag字段来区分,每个biz-tag的ID获取相互隔离,互不影响。如果以后有性能需求需要对数据库扩容,不需要上述描述的复杂的扩容操作,只需要对biz_tag分库分表就行。

数据库表设计如下:

CREATE TABLE `leaf_alloc` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `biz_tag` varchar(128)  NOT NULL DEFAULT '',
  `max_id` bigint(20) NOT NULL DEFAULT '1',
  `step` int(11) NOT NULL,
  `description` varchar(256)  DEFAULT NULL,
  `update_time` bigint(20) NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`),
  UNIQUE KEY (`biz_tag`)
) ENGINE=InnoDB;

这里我依旧使用了一个自增主键,不过没什么用,可以忽略。biz_tag用来区分业务(所以我把它设置成了唯一索引),max_id表示该biz_tag目前所被分配的ID号段的最大值,step表示每次分配的号段长度。原来获取ID每次都需要写数据库,现在只需要把step设置得足够大,比如1000。那么只有当1000个号被消耗完了之后才会去重新读写一次数据库。读写数据库的频率从1减小到了1/step,大致架构如下图所示:

test_tag在第一台Leaf机器上是1~1000的号段,当这个号段用完时,会去加载另一个长度为step=1000的号段,假设另外两台号段都没有更新,这个时候第一台机器新加载的号段就应该是3001~4000。同时数据库对应的biz_tag这条数据的max_id会从3000被更新成4000,更新号段的SQL语句如下:

Begin
UPDATE table SET max_id=max_id+step WHERE biz_tag=xxx
SELECT tag, max_id, step FROM table WHERE biz_tag=xxx
Commit

这种模式有以下优缺点:

优点:

  • Leaf服务可以很方便的线性扩展,性能完全能够支撑大多数业务场景。
  • ID号码是趋势递增的8byte的64位数字,满足上述数据库存储的主键要求。
  • 容灾性高:Leaf服务内部有号段缓存,即使DB宕机,短时间内Leaf仍能正常对外提供服务。
  • 可以自定义max_id的大小,非常方便业务从原有的ID方式上迁移过来。

缺点:

  • ID号码不够随机,能够泄露发号数量的信息,不太安全。
  • TP999数据波动大,当号段使用完之后还是会hang在更新数据库的I/O上,tg999数据会出现偶尔的尖刺。
  • DB宕机会造成整个系统不可用。

双buffer优化

对于第二个缺点,Leaf-segment做了一些优化,简单的说就是:

Leaf 取号段的时机是在号段消耗完的时候进行的,也就意味着号段临界点的ID下发时间取决于下一次从DB取回号段的时间,并且在这期间进来的请求也会因为DB号段没有取回来,导致线程阻塞。如果请求DB的网络和DB的性能稳定,这种情况对系统的影响是不大的,但是假如取DB的时候网络发生抖动,或者DB发生慢查询就会导致整个系统的响应时间变慢。

为此,我们希望DB取号段的过程能够做到无阻塞,不需要在DB取号段的时候阻塞请求线程,即当号段消费到某个点时就异步的把下一个号段加载到内存中。而不需要等到号段用尽的时候才去更新号段。这样做就可以很大程度上的降低系统的TP999指标。详细实现如下图所示:

采用双buffer的方式,Leaf服务内部有两个号段缓存区segment。当前号段已下发10%时,如果下一个号段未更新,则另启一个更新线程去更新下一个号段。当前号段全部下发完后,如果下个号段准备好了则切换到下个号段为当前segment接着下发,循环往复。

  • 每个biz-tag都有消费速度监控,通常推荐segment长度设置为服务高峰期发号QPS的600倍(10分钟),这样即使DB宕机,Leaf仍能持续发号10-20分钟不受影响。
  • 每次请求来临时都会判断下个号段的状态,从而更新此号段,所以偶尔的网络抖动不会影响下个号段的更新。

代码实现

终于到本文的重点了,下面就给大家讲解一下我是怎么实现的。

代码架构

这里先贴一下我的代码架构,具体如下:

leaf
├── common -- common包,放的是一些client初始的代码
├── conf   -- conf包,配置文件
├── config -- config包,读取配置文件代码部分
├── dao    -- dao包,DB操作部分
├── handler -- hanler包,路由注册即API代码实现部分
├── images -- 本文的图片文件
├── model -- model包,db模型或其他模型
├── service -- service包,逻辑实现部分
├── wire    -- wire包,依赖绑定
├── leaf_svr.go -- main运行先置条件
└── main.go -- main函数

实现分析

在我们实现之前,肯定要分析一波,我们要做什么,怎么做?我老大经常跟我说的一句话:"先把需求分析明白了,再动手,返工反而是浪费时间"。

  1. 首先我们要把一段号段存从DB中拿到存到内存中,并且可能同时存在很多业务,这里我就想到用map来存,使用biz_tag来作为key,因为它具有唯一性,并且能很快的定位。所以我们可以定义一个这样的结构体作为全局ID分发器:
// 全局分配器
// key: biz_tag value: SegmentBuffer
type LeafSeq struct {
    cache sync.Map
}

这里考虑到并发操作,所以使用sync.map,因为他是并发安全的。

  1. 确定了ID怎么存,接下来我们就要思考,我们存什么样的结构比较合适。这里我决定直接实现"双buffer优化"的方案。这里我准备自己定义struct,然后用切片来模拟双buffer。所以可以设计如下结构:
// 号段
type LeafSegment struct {
    Cursor uint64 // 当前发放位置
    Max    uint64 // 最大值
    Min    uint64 // 开始值即最小值
    InitOk bool   // 是否初始化成功
}

字段说明:首先要有一个字段来记录当前数据发放到什么位置了,Cursor就是来做这个的。其次我们还要把范围固定住,也是这个号段的开始和结束,也就是minmax字段的作用。最后我们还要考虑一个问题,既然我们使用的双buffer,也就是说我们并不能确定下一段buffer是否可用,所以加了一个initOK字段来进行表明。

  1. 上面也设计好了号段怎么存,接下来我们设计怎么线程安全的把这些id有序的发放出去,所以可以设计如下结构:
type LeafAlloc struct {
    Key        string                 // 也就是`biz_tag`用来区分业务
    Step       int32                  // 记录步长
    CurrentPos int32                  // 当前使用的 segment buffer光标; 总共两个buffer缓存区,循环使用
    Buffer     []*LeafSegment         // 双buffer 一个作为预缓存作用
    UpdateTime time.Time              // 记录更新时间 方便长时间不用进行清理,防止占用内存
    mutex      sync.Mutex             // 互斥锁
    IsPreload  bool                   // 是否正在预加载
    Waiting    map[string][]chan byte // 挂起等待
}

字段介绍:key也就是我们的biz_tag,可以用它快速从map中定义数据。Step记录当前号段的步长,因为步长是可以动态改变的,所以这里需要记录一下。currentPos这个完全是记录当前使用buffer,因为是双buffer,所以需要定位。buffer这个不用介绍大家也应该知道,就是缓存池。Update_time这个字段大多人可能想不到为什么会有这个,我们的号段现在都存到内存当中了,那么当我们的业务变多了以后,那么内存就会越占越多,所以我们需要记录他的更新时间,这样我们可以使用一个定时器定期去清除长时间不使用的号段,节省内存。IsPreload这个字段是给预加载使用,当前缓冲区的号段使用了90%后,我们就会去预加载下一段缓存池,为了防止多次重复加载,所以使用该字段做标识。waiting这里我使用的是map+chan的结合,这里的作用就是当我们当前缓存池的号段消耗的比较快或者预加载失败了,就会导致现在没有缓存池可用,所以我们可以去等待一会,因为现在有可能正在做预加载,这样可以保持系统的高可用,如果超时仍未等到预加载成功则返回失败,下一次调用即可。

基本思想就是这样啦,下面我们就分块看一下代码。

先从DB层走起

我这个人写代码,爱从DB层开始,也就是把我需要的CRUD操作都提前写好并测试,这里就不贴每段代码的实现了,要不代码量有点大,并且也没有必要,直接介绍一下有哪些方法就可以了。

func (l *LeafDB) Create(ctx context.Context, leaf *model.Leaf) error {}
func (l *LeafDB) Get(ctx context.Context, bizTag string, tx *sql.Tx) (*model.Leaf, error) {}
func (l *LeafDB) UpdateMaxID(ctx context.Context, bizTag string, tx *sql.Tx) error {}
func (l *LeafDB) UpdateMaxIdByCustomStep(ctx context.Context, step int32, bizTag string, tx *sql.Tx) error {}
func (l *LeafDB) GetAll(ctx context.Context) ([]*model.Leaf, error) {}
func (l *LeafDB) UpdateStep(ctx context.Context, step int32, bizTag string) error {
  1. 创建leaf方法。
  2. 获取某个业务的号段
  3. 号段用完时是需要更新DB到下一个号段
  4. 根据自定义step更新DB到下一个号段
  5. 获取DB中所有的业务号段
  6. 更新DB中step

实现获取新号段部分的代码

先贴出我的代码:

func (l *LeafDao) NextSegment(ctx context.Context, bizTag string) (*model.Leaf, error) {
    // 开启事务
    tx, err := l.sql.Begin()
    defer func() {
        if err != nil {
            l.rollback(tx)
        }
    }()
    if err = l.checkError(err); err != nil {
        return nil, err
    }
    err = l.db.UpdateMaxID(ctx, bizTag, tx)
    if err = l.checkError(err); err != nil {
        return nil, err
    }
    leaf, err := l.db.Get(ctx, bizTag, tx)
    if err = l.checkError(err); err != nil {
        return nil, err
    }
    // 提交事务
    err = tx.Commit()
    if err = l.checkError(err); err != nil {
        return nil, err
    }
    return leaf, nil
}

func (l *LeafDao) checkError(err error) error {
    if err == nil {
        return nil
    }
    if message, ok := err.(*mysql.MySQLError); ok {
        fmt.Printf("it's sql error; str:%v", message.Message)
    }
    return errors.New("db error")
}

func (l *LeafDao) rollback(tx *sql.Tx) {
    err := tx.Rollback()
    if err != sql.ErrTxDone && err != nil {
        fmt.Println("rollback error")
    }
}

实现其实很简单,也就是先更新一下数据库中的号段,然后再取出来就可以了,这里为了保证数据的一致性和防止多次更新DB导致号段丢失,所以使用了事务,没有什么特别的点,看一下代码就能懂了。

初始化及获取ID

这里我把DB中的号段初始化到内存这一步和获取ID合到一起来说吧,因为在获取ID时会有兜底策略进行初始化。先看初始化部分代码:

// 第一次使用要初始化也就是把DB中的数据存到内存中,非必须操作,直接使用的话有兜底策略
func (l *LeafService) InitCache(ctx context.Context, bizTag string) (*model.LeafAlloc, error) {
    leaf, err := l.dao.NextSegment(ctx, bizTag)
    if err != nil {
        fmt.Printf("initCache failed; err:%v\n", err)
        return nil, err
    }
    alloc := model.NewLeafAlloc(leaf)
    alloc.Buffer = append(alloc.Buffer, model.NewLeafSegment(leaf))

    _ = l.leafSeq.Add(alloc)
    return alloc, nil
}

这里步骤主要分两步:

  1. 从DB中获取当前业务的号段
  2. 保存到内存中,也就是存到map

之后是我们来看一下我们是如何获取id的,这里步骤比较多了,也是最核心的地方了。

先看主流程:

func (l *LeafService) GetID(ctx context.Context, bizTag string) (uint64, error) {
    // 先去内存中看一下是否已经初始了,未初始化则开启兜底策略初始化一下。
    l.mutex.Lock()
    var err error
    seqs := l.leafSeq.Get(bizTag)
    if seqs == nil {
        // 不存在初始化一下
        seqs, err = l.InitCache(ctx, bizTag)
        if err != nil {
            return 0, err
        }
    }
    l.mutex.Unlock()

    var id uint64
    id, err = l.NextID(seqs)
    if err != nil {
        return 0, err
    }
    l.leafSeq.Update(bizTag, seqs)

    return id, nil
}

主要分为三步:

  1. 先去内存中查看该业务是否已经初始化了,未初始化则开启兜底策略进行初始化。
  2. 获取id
  3. 更新内存中的数据。

这里最终要的就是第二步,获取id,这里我先把代码贴出来,然后细细讲解一下:

func (l *LeafService) NextID(current *model.LeafAlloc) (uint64, error) {
    current.Lock()
    defer current.Unlock()
    var id uint64
    currentBuffer := current.Buffer[current.CurrentPos]
    // 判断当前buffer是否是可用的
    if current.HasSeq() {
        id = atomic.AddUint64(&current.Buffer[current.CurrentPos].Cursor, 1)
        current.UpdateTime = time.Now()
    }

    // 当前号段已下发10%时,如果下一个号段未更新加载,则另启一个更新线程去更新下一个号段
    if currentBuffer.Max-id < uint64(0.9*float32(current.Step)) && len(current.Buffer) <= 1 && !current.IsPreload {
        current.IsPreload = true
        cancel, _ := context.WithTimeout(context.Background(), 3*time.Second)
        go l.PreloadBuffer(cancel, current.Key, current)
    }

    // 第一个buffer的segment使用完成 切换到下一个buffer 并移除现在的buffer
    if id == currentBuffer.Max {
        // 判断第二个buffer是否准备好了(因为上面开启协程去更新下一个号段会出现失败),准备好了切换  currentPos 永远是0 不管怎么切换
        if len(current.Buffer) > 1 && current.Buffer[current.CurrentPos+1].InitOk {
            current.Buffer = append(current.Buffer[:0], current.Buffer[1:]...)
        }
        // 如果没准备好,直接返回就好了,因为现在已经分配id了, 后面会进行补偿
    }
    // 有id直接返回就可以了
    if current.HasID(id) {
        return id, nil
    }

    // 当前buffer已经没有id可用了,此时补偿线程一定正在运行,我们等待一会
    waitChan := make(chan byte, 1)
    current.Waiting[current.Key] = append(current.Waiting[current.Key], waitChan)
    // 释放锁 等待让其他客户端进行走前面的步骤
    current.Unlock()

    timer := time.NewTimer(500 * time.Millisecond) // 等待500ms最多
    select {
    case <-waitChan:
    case <-timer.C:
    }

    current.Lock()
    // 第二个缓冲区仍未初始化好
    if len(current.Buffer) <= 1 {
        return 0, errors.New("get id failed")
    }
    // 切换buffer
    current.Buffer = append(current.Buffer[:0], current.Buffer[1:]...)
    if current.HasSeq() {
        id = atomic.AddUint64(&current.Buffer[current.CurrentPos].Cursor, 1)
        current.UpdateTime = time.Now()
    }
    return id, nil

}

这里我觉得用文字描述不清楚,所以我画了个图,不知道你们能不能看懂,可以对照着代码来看,这样是最清晰的,有问题欢迎留言讨论:

预加载的流程也被我画上去了,预加载的步骤主要有三个:

  1. 获取某业务下一个阶段的号段
  2. 存储到缓存buffer中,留着备用.
  3. 唤醒当前正在挂起等待的客户端

代码实现如下:

func (l *LeafService) PreloadBuffer(ctx context.Context, bizTag string, current *model.LeafAlloc) error {
    for i := 0; i < MAXRETRY; i++ {
        leaf, err := l.dao.NextSegment(ctx, bizTag)
        if err != nil {
            fmt.Printf("preloadBuffer failed; bizTag:%s;err:%v", bizTag, err)
            continue
        }
        segment := model.NewLeafSegment(leaf)
        current.Buffer = append(current.Buffer, segment) // 追加
        l.leafSeq.Update(bizTag, current)
        current.Wakeup()
        break
    }
    current.IsPreload = false
    return nil
}
func (l *LeafAlloc) Wakeup() {
    l.mutex.Lock()
    defer l.mutex.Unlock()
    for _, waitChan := range l.Waiting[l.Key] {
        close(waitChan)
    }
    l.Waiting[l.Key] = l.Waiting[l.Key][:0]
}

缓存清理

到这里,所有核心代码都已经实现了,还差最后一步,就是清缓存,就像上面说到的,超长时间不用的号段我们就要清除它,不能让他堆积造成内存浪费。这里我实现的是清理超过15min未使用的号段。实现也很简单,就是使用timer做一个定时器,每隔15min就去遍历存储号段的`map,把超过15min未更新的号段清除掉(虽然会造成号段浪费,但也要这要做)。

// 清理超过15min没用过的内存
func (l *LeafSeq) clear() {
    for {
        now := time.Now()
        // 15分钟后
        mm, _ := time.ParseDuration("15m")
        next := now.Add(mm)
        next = time.Date(next.Year(), next.Month(), next.Day(), next.Hour(), next.Minute(), 0, 0, next.Location())
        t := time.NewTimer(next.Sub(now))
        <-t.C
        fmt.Println("start clear goroutine")
        l.cache.Range(func(key, value interface{}) bool {
            alloc := value.(*LeafAlloc)
            if next.Sub(alloc.UpdateTime) > ExpiredTime {
                fmt.Printf("clear biz_tag: %s cache", key)
                l.cache.Delete(key)
            }
            return true
        })
    }
}

总结

好啦,到这里就是接近尾声了,上面就是我实现的整个过程,目前自己测试没有什么问题,后期还会在缝缝补补,大家也可以帮我找找问题,欢迎提出你们宝贵的建议~~~。

代码已收录到我的个人仓库——[go-算法系列(go-algorithm)](https://github.com/asong2020/...

欢迎Star,感谢各位~~~。

好啦,这一篇文章到这就结束了,我们下期见~~。希望对你们有用,又不对的地方欢迎指出,可添加我的golang交流群,我们一起学习交流。

结尾给大家发一个小福利吧,最近我在看[微服务架构设计模式]这一本书,讲的很好,自己也收集了一本PDF,有需要的小伙可以到自行下载。获取方式:关注公众号:[Golang梦工厂],后台回复:[微服务],即可获取。

我翻译了一份GIN中文文档,会定期进行维护,有需要的小伙伴后台回复[gin]即可下载。

翻译了一份Machinery中文文档,会定期进行维护,有需要的小伙伴们后台回复[machinery]即可获取。

我是asong,一名普普通通的程序猿,让gi我一起慢慢变强吧。我自己建了一个golang交流群,有需要的小伙伴加我vx,我拉你入群。欢迎各位的关注,我们下期见~~~

推荐往期文章:

查看原文

赞 0 收藏 0 评论 0

asong 发布了文章 · 2020-12-04

【推荐】mysql优化神器

前言

今天逛github时,发现了这款对 SQL 进行优化和改写的自动化工具sora。感觉挺不错的,就下载学习了一下。这个工具支持的功能比较多,可以作为我们日常开发中的一款辅助工具,现在我就把它推荐给你们~~~

github传送门:https://github.com/XiaoMi/soar

背景

在我们日常开发中,优化SQL总是我们日常开发任务之一。例行 SQL 优化,不仅可以提升程序性能,还能够降低线上故障的概率。

目前常用的 SQL 优化方式包括但不限于:业务层优化、SQL逻辑优化、索引优化等。其中索引优化通常通过调整索引或新增索引从而达到 SQL 优化的目的。索引优化往往可以在短时间内产生非常巨大的效果。如果能够将索引优化转化成工具化、标准化的流程,减少人工介入的工作量,无疑会大大提高我们的工作效率。

SOAR(SQL Optimizer And Rewriter) 是一个对 SQL 进行优化和改写的自动化工具。 由小米人工智能与云平台的数据库团队开发与维护。

与业内其他优秀产品对比如下:

SOARsqlcheckpt-query-advisorSQL AdvisorInceptionsqlautoreview
启发式建议✔️✔️✔️✔️✔️
索引建议✔️✔️✔️
查询重写✔️
执行计划展示✔️
Profiling✔️
Trace✔️
SQL在线执行✔️
数据备份✔️

从上图可以看出,支持的功能丰富,其功能特点如下:

  • 跨平台支持(支持 Linux, Mac 环境,Windows 环境理论上也支持,不过未全面测试)
  • 目前只支持 MySQL 语法族协议的 SQL 优化
  • 支持基于启发式算法的语句优化
  • 支持复杂查询的多列索引优化(UPDATE, INSERT, DELETE, SELECT)
  • 支持 EXPLAIN 信息丰富解读
  • 支持 SQL 指纹、压缩和美化
  • 支持同一张表多条 ALTER 请求合并
  • 支持自定义规则的 SQL 改写

就介绍这么多吧,既然是SQL优化工具,光说是没有用的,我们还是先用起来看看效果吧。

安装

这里有两种安装方式,如下:

    1. 下载二进制安装包
$ wget https://github.com/XiaoMi/soar/releases/download/0.11.0/soar.linux-amd64 -O soar
chmod a+x soar

这里建议直接下载最新版,要不会有bug

下载好的二进制文件添加到环境变量中即可(不会的谷歌一下吧,这里就不讲了)。

测试一下:

$ echo 'select * from user' | soar.darwin-amd64(根据你自己的二进制文件名来输入)
# Query: AC4262B5AF150CB5

★ ★ ★ ☆ ☆ 75分

​```sql

SELECT
  *
FROM
  USER
​```

## 最外层 SELECT 未指定 WHERE 条件

* **Item:**  CLA.001

* **Severity:**  L4

* **Content:**  SELECT 语句没有 WHERE 子句,可能检查比预期更多的行(全表扫描)。对于 SELECT COUNT(\*) 类型的请求如果不要求精度,建议使用 SHOW TABLE STATUS 或 EXPLAIN 替代。

## 不建议使用 SELECT * 类型查询

* **Item:**  COL.001

* **Severity:**  L1

* **Content:**  当表结构变更时,使用 \* 通配符选择所有列将导致查询的含义和行为会发生更改,可能导致查询返回更多的数据。
    1. 源码安装

依赖环境:

1. Go 1.10+
2. git

高级依赖(仅面向开发人员)

  • mysql 客户端版本需要与容器中MySQL版本相同,避免出现由于认证原因导致无法连接问题
  • docker MySQL Server测试容器管理
  • govendor Go包管理
  • retool 依赖外部代码质量静态检查工具二进制文件管理

生成二进制文件:

go get -d github.com/XiaoMi/soar
cd ${GOPATH}/src/github.com/XiaoMi/soar && make

生成的二进制文件与上面一样,直接放入环境变量即可,这里我没有尝试,靠你们自己踩坑了呦~~~

简单使用

0. 前置准备

准备一个table,如下:

CREATE TABLE `users` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `username` varchar(64) NOT NULL DEFAULT '',
  `nickname` varchar(255) DEFAULT '',
  `password` varchar(256) NOT NULL DEFAULT '',
  `salt` varchar(48) NOT NULL DEFAULT '',
  `avatar` varchar(128) DEFAULT NULL,
  `uptime` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4

1. 直接输入sql语句(不运行)

$ echo "select * from users" | soar.darwin-amd64
$ # Query: 30AFCB1E1344BEBD

★ ★ ★ ☆ ☆ 75分

​```sql

SELECT
  *
FROM
  users
​```

## 最外层 SELECT 未指定 WHERE 条件

* **Item:**  CLA.001

* **Severity:**  L4

* **Content:**  SELECT 语句没有 WHERE 子句,可能检查比预期更多的行(全表扫描)。对于 SELECT COUNT(\*) 类型的请求如果不要求精度,建议使用 SHOW TABLE STATUS 或 EXPLAIN 替代。

## 不建议使用 SELECT * 类型查询

* **Item:**  COL.001

* **Severity:**  L1

* **Content:**  当表结构变更时,使用 \* 通配符选择所有列将导致查询的含义和行为会发生更改,可能导致查询返回更多的数据。

现在是完全根据SQL语句进行分析的,因为没有连接到mysql。可以看到,给出的报告也很详细,但是只是空壳子,仅凭SQL语句给出的分析并不是准确的,所以我们开始接下来的应用。

2. 连接mysql生成EXPLAIN分析报告

我们可以在配置文件中配置好mysql相关的配置,操作如下:

vi soar.yaml
# yaml format config file
online-dsn:
    addr:     127.0.0.1:3306
    schema:   asong
    user:     root
    password: root1997
    disable:  false

test-dsn:
    addr:     127.0.0.1:3306
    schema:   asong
    user:     root
    password: root1997
    disable:  false

配置好了,我们来实践一下子吧:

$ echo "SELECT id,username,nickname,password,salt,avatar,uptime FROM users WHERE username = 'asong1111'" | soar.darwin-amd64 -test-dsn="root:root1997@127.0.0.1:3306/asong" -allow-online-as-test -log-output=soar.log
$ # Query: D12A420193AD1674

★ ★ ★ ★ ★ 100分

​```sql

SELECT
  id, username, nickname, PASSWORD, salt, avatar, uptime
FROM
  users
WHERE
  username  = 'asong1111'
​```

##  Explain信息

| id | select\_type | table | partitions | type | possible_keys | key | key\_len | ref | rows | filtered | scalability | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1  | SIMPLE | *users* | NULL | const | username | username | 258 | const | 1 | ☠️ **100.00%** | ☠️ **O(n)** | NULL |



### Explain信息解读

#### SelectType信息解读

* **SIMPLE**: 简单SELECT(不使用UNION或子查询等).

#### Type信息解读

* **const**: const用于使用常数值比较PRIMARY KEY时, 当查询的表仅有一行时, 使用system. 例:SELECT * FROM tbl WHERE col = 1.

这回结果中多了EXPLAIN信息分析报告。这对于刚开始入门的小伙伴们是友好的,因为我们对Explain解析的字段并不熟悉,有了它我们可以完美的分析SQL中的问题,是不是很棒。

3. 语法检查

soar工具不仅仅可以进行sql语句分析,还可以进行对sql语法进行检查,找出其中的问题,来看个例子:

$ echo "selec * from users" | soar.darwin-amd64 -only-syntax-check
At SQL 1 : line 1 column 5 near "selec * from users" (total length 18)

这里select关键字少了一个t,运行该指令帮助我们一下就定位了问题,当我们的sql语句很长时,就可以使用该指令来辅助我们检查SQL语句是否正确。

4. SQL美化

我们日常开发时,经常会看其他人写的代码,因为水平不一样,所以有些SQL语句会写的很乱,所以这个工具就派上用场了,我们可以把我们的SQL语句变得漂亮一些,更容易我们理解哦。

$ echo "SELECT id,username,nickname,password,salt,avatar,uptime FROM users WHERE username = 'asong1111'" | soar.darwin-amd64 -report-type=pretty

SELECT
  id, username, nickname, PASSWORD, salt, avatar, uptime
FROM
  users
WHERE
  username  = 'asong1111';

这样看起来是不是更直观了呢~~。

结尾

因为我也才是刚使用这个工具,更多的玩法我还没有发现,以后补充。更多玩法可以自己研究一下,github传送门:https://github.com/XiaoMi/soar。官方文档其实很粗糙,更多方法解锁还要靠自己研究,毕竟源码已经给我们了,对于学习go也有一定帮助,当作一个小项目慢慢优化岂不是更好呢~~。

好啦,这一篇文章到这就结束了,我们下期见~~。希望对你们有用,又不对的地方欢迎指出,可添加我的golang交流群,我们一起学习交流。

结尾给大家发一个小福利吧,最近我在看[微服务架构设计模式]这一本书,讲的很好,自己也收集了一本PDF,有需要的小伙可以到自行下载。获取方式:关注公众号:[Golang梦工厂],后台回复:[微服务],即可获取。

我翻译了一份GIN中文文档,会定期进行维护,有需要的小伙伴后台回复[gin]即可下载。

翻译了一份Machinery中文文档,会定期进行维护,有需要的小伙伴们后台回复[machinery]即可获取。

我是asong,一名普普通通的程序猿,让gi我一起慢慢变强吧。我自己建了一个golang交流群,有需要的小伙伴加我vx,我拉你入群。欢迎各位的关注,我们下期见~~~

推荐往期文章:

查看原文

赞 0 收藏 0 评论 0

asong 发布了文章 · 2020-11-29

十张动图带你搞懂排序算法(go版本实现)

排序算法

简介:排序算法在我们日常开发中、面试中都会使用到,所以就打算弄一个合集,把常用的排序算法用Go实现一下。如果你还不会这些那就说不过去了哦~~~。

代码已经收录到我的github,需要的自取:https://github.com/asong2020/...

算法分类

我们常见的排序算法可以分为两大类:

  • 比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此也称为非线性时间比较类排序。
  • 非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序。

在这里插入图片描述

算法复杂度概览

排序算法时间复杂度(平均)时间复杂度(最坏)时间复杂度(最优)空间复杂度稳定性
冒泡排序O(𝑛2)O(𝑛2)O(𝑛)O(1)稳定
快速排序O(nlogn)O(𝑛2)O(nlogn)O(nlogn)~O(n)不稳定
插入排序O(𝑛2)O(𝑛2)O(𝑛)O(1)稳定
希尔排序O(nlogn)~O(𝑛2)O(𝑛2)O(𝑛1.3)O(1)不稳定
选择排序O(𝑛2)O(𝑛2)O(𝑛2)O(1)稳定
堆排序O(nlogn)O(nlogn)O(nlogn)O(1)不稳定
归并排序O(nlogn)O(nlogn)O(nlogn)O(n)稳定
计数排序O(n+k)O(n+k)O(n+k)O(k)稳定
桶排序O(n+k)O(𝑛2)O(𝑛2)O(n+k)稳定
基数排序O(n*k)O(n*k)O(n*k)O(n+k)稳定

时间、空间复杂度

在这里也简单解释一下什么是时间、空间复杂度吧。具体计算方法就不在这篇文章讲解了。

1. 时间复杂度

时间复杂度是指执行这个算法所需要的计算工作量,其复杂度反映了程序执行时间「随输入规模增长而增长的量级」,在很大程度上能很好地反映出算法的优劣与否。一个算法花费的时间与算法中语句的「执行次数成正比」,执行次数越多,花费的时间就越多。一个算法中的执行次数称为语句频度或时间频度,记为T(n),其中n称为问题的规模,当n不断变化时,它所呈现出来的规律,我们称之为时间复杂度。比如:imgimg,虽然算法的时间频度不一样,但他们的时间复杂度却是一样的,「时间复杂度只关注最高数量级,且与之系数也没有关系」。通常一个算法由控制结构(顺序,分支,循环三种)和原操作(固有数据类型的操作)构成,而算法时间取决于两者的综合效率。

2. 空间复杂度

空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度,所谓的临时占用存储空间指的就是代码中「辅助变量所占用的空间」,它包括为参数表中「形参变量」分配的存储空间和为在函数体中定义的「局部变量」分配的存储空间两个部分。我们用 S(n)=O(f(n))来定义,其中n为问题的规模(或大小)。通常来说,只要算法不涉及到动态分配的空间,以及递归、栈所需的空间,空间复杂度通常为0(1)。一个一维数组a[n],空间复杂度O(n),二维数组为O(n^2)。

3. 大O表示方法

大O符号是由德国数论学家保罗·巴赫曼(Paul Bachmann)在其1892年的著作《解析数论》(Analytische Zahlentheorie)首先引入的。算法的复杂度通常用大O符号表述,定义为T(n) = O(f(n))。称函数T(n)以f(n)为界或者称T(n)受限于f(n)。 如果一个问题的规模是n,解这一问题的某一算法所需要的时间为T(n)。T(n)称为这一算法的“时间复杂度”。当输入量n逐渐加大时,时间复杂度的极限情形称为算法的“渐近时间复杂度”。空间复杂度同理。举个例子,令f(n) = 2n^2 + 3n + 5,O(f(n)) = O(2 n^2 + 3n + 5) = O(n^2)

好啦,算法的知识就简单介绍一下概念吧,因为这并不是本文的重点,下面我们我们一起来看看这几种排序。

1. 冒泡排序

1.1 算法步骤

  • 比较相邻的元素。如果第一个比第二个大,就交换他们两个。
  • 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
  • 针对所有的元素重复以上的步骤,除了最后一个。
  • 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。

1.2 动画演示

1.3 代码示例(go实现)

package main

import (
    "fmt"
)

type uint64Slice []uint64

func main()  {

    numbers := []uint64{5,4,2,3,8}
    sortBubble(numbers)
    fmt.Println(numbers)
}

func sortBubble(numbers uint64Slice)  {
    length := len(numbers)
    if length == 0{
        return
    }
    flag := true

    for i:=0;i<length && flag;i++{
        flag = false
        for j:=length-1;j>i;j--{
            if numbers[j-1] > numbers[j] {
                numbers.swap(j-1,j)
                flag = true // 有数据才交换
            }
        }
    }
}

// 交换方法
func (numbers uint64Slice)swap(i,j int)  {
    numbers[i],numbers[j] = numbers[j],numbers[i]
}

1.4 复杂度分析

分析一下他的时间复杂度吧。当最好的情况下,也就是要排序的表本身就是有序的,那么我们比较次数,根据我们的代码可以推断出来就是n-1次的比较,没有数据交换,时间复杂度为O(n)。当最坏情况下,即待排序表是逆序的,那么我们可以列出一个公式如下: , 因此可以计算出冒泡排序的时间复杂度为O(n2)。因为我们的代码在运行时运行过程中临时占用存储空间大小的量度没有变化,所以空间复杂度仍为O(1)

2. 快速排序

2.1 算法步骤

  • 从数列中挑出一个元素,称为 “基准”(pivot);
  • 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
  • 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序;

2.2 动画演示

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RV7HzuNs-1606657384212)(https://p1-juejin.byteimg.com...]

2.3 代码示例

package main

import (
    "fmt"
)

type uint64Slice []uint64


func main()  {
    numbers := []uint64{5,4,20,3,8,2,8}
    quickSort(numbers,0,len(numbers)-1)
    fmt.Println(numbers)
}

func quickSort(numbers uint64Slice,start,end int)  {
    var middle int
    tempStart := start
    tempEnd := end

    if tempStart >= tempEnd{
        return
    }
    pivot := numbers[start]
    for start < end{
        for start < end && numbers[end] > pivot{
            end--
        }
        if start<end{
            numbers.swap(start,end)
            start++
        }
        for start < end && numbers[start] < pivot{
            start++
        }
        if start<end{
            numbers.swap(start,end)
            end--
        }
    }
    numbers[start] = pivot
    middle = start

    quickSort(numbers,tempStart,middle-1)
    quickSort(numbers,middle+1,tempEnd)

}


// 交换方法
func (numbers uint64Slice)swap(i,j int)  {
    numbers[i],numbers[j] = numbers[j],numbers[i]
}

2.4 复杂度分析

快速排序涉及到递归调用,所以该算法的时间复杂度还需要从递归算法的复杂度开始说起;
递归算法的时间复杂度公式:T[n] = aT[n/b] + f(n) ;对于递归算法的时间复杂度这里就不展开来说了。

  • 最优情况

    快速排序最优的情况就是每一次取到的元素都刚好平分整个数组(很显然我上面的不是);

    此时的时间复杂度公式则为:T[n] = 2T[n/2] + f(n);T[n/2]为平分后的子数组的时间复杂度,f[n] 为平分这个数组时所花的时间;

​ 下面来推算下,在最优的情况下快速排序时间复杂度的计算(用迭代法):

T[n] =  2T[n/2] + n              ----------------第一次递归
令:n = n/2        =  2 { 2 T[n/4] + (n/2) }  + n     ----------------第二次递归
                  =  2^2 T[ n/ (2^2) ] + 2n

令:n = n/(2^2)   =  2^2  {  2 T[n/ (2^3) ]  + n/(2^2)}  +  2n    ----------------第三次递归  
                 =  2^3 T[  n/ (2^3) ]  + 3n
......................................................................................                        
令:n = n/(  2^(m-1) )    =  2^m T[1]  + mn   ----------------第m次递归(m次后结束)

当最后平分的不能再平分时,也就是说把公式一直往下跌倒,到最后得到T[1]时,说明这个公式已经迭代完了(T[1]是常量了)。
得到:T[n/ (2^m) ]  =  T[1]    ===>>   n = 2^m   ====>> m = logn;
T[n] = 2^m T[1] + mn ;其中m = logn;
T[n] = 2^(logn) T[1] + nlogn  =  n T[1] + nlogn  =  n + nlogn  ;其中n为元素个数
又因为当n >=  2时:nlogn  >=  n  (也就是logn > 1),所以取后面的 nlogn;
综上所述:快速排序最优的情况下时间复杂度为:O( nlogn )
  • 最差情况

最差的情况就是每一次取到的元素就是数组中最小/最大的,这种情况其实就是冒泡排序了(每一次都排好一个元素的顺序)
这种情况时间复杂度就好计算了,就是冒泡排序的时间复杂度:T[n] = n \* (n-1) = n^2 + n;
综上所述:快速排序最差的情况下时间复杂度为:O( n^2 )

  • 空间复杂度

首先就地快速排序使用的空间是O(1)的,也就是个常数级;而真正消耗空间的就是递归调用了,因为每次递归就要保持一些数据;
最优的情况下空间复杂度为:O(logn) ;每一次都平分数组的情况。
最差的情况下空间复杂度为:O( n ) ;退化为冒泡排序的情况。

3. 插入排序

3.1 算法步骤

  • 将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
  • 从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)

3.2 动画演示

3.3 代码示例

package main

import (
    "fmt"
)

type uint64Slice []uint64

func main()  {
    numbers := []uint64{5,4,20,3,8,2,9}
    insertSort(numbers)
    fmt.Println(numbers)
}

func insertSort(numbers uint64Slice)  {
    for i:=1; i < len(numbers); i++{
        tmp := numbers[i]
        // 从待排序序列开始比较,找到比其小的数
        j:=i
        for j>0 && tmp<numbers[j-1] {
            numbers[j] = numbers[j-1]
            j--
        }
        // 存在比其小的数插入
        if j!=i{
            numbers[j] = tmp
        }
    }
}


// 交换方法
func (numbers uint64Slice)swap(i,j int)  {
    numbers[i],numbers[j] = numbers[j],numbers[i]
}

3.4 复杂度分析

我们来分析一下这个算法,从空间上来看,它只需要一个记录的辅助空间,因此关键是看它的时间复杂度。在最好的情况,我们要排序的表本身就是有序的,那我们的比较次数就是上面代码tmp<numbers[j-1]的比较,因此没有移动记录,时间复杂度为O(n)。当最坏情况,即待排序表是逆序的情况,此时需要比较 ,而记录的移动次数也达到最大值 次。如果排序记录是随机的,那么根据概率相同的原则,平均比较和移动次数约为𝑛2/4次。因此,我们得出直接插入排序法的时间复杂度为O(𝑛2)。从这里也可以看出,同样的O(𝑛2)时间复杂度,直接插入排序比冒泡排序性能要好一些。

4 希尔排序

4.1 算法步骤

  • 选择一个增量序列 t1,t2,……,tk,其中 ti > tj, tk = 1;
  • 按增量序列个数 k,对序列进行 k 趟排序;
  • 每趟排序,根据对应的增量 ti,将待排序列分割成若干长度为 m 的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。

4.2 动画演示

4.3 代码示例

package main

import (
    "fmt"
    "math"
)

type uint64Slice []uint64

func main()  {
    numbers := []uint64{8,9,1,7,2,3,5,4,6,0}
    shellSort(numbers)
    fmt.Println(numbers)
}

func shellSort(numbers uint64Slice)  {
    gap := 1
    for gap < len(numbers){
        gap = gap * 3 + 1
    }
    for gap > 0{
        for i:= gap; i < len(numbers); i++{
            tmp := numbers[i]
            j := i - gap
            for j>=0 && numbers[j] > tmp{
                numbers[j+gap] = numbers[j]
                j -= gap
            }
            numbers[j+gap] = tmp
        }
        gap = int(math.Floor(float64(gap / 3)))
    }
}

4.4 复杂度分析

通过上面的代码我们可以分析,希尔排序的关键并不是随便分组后各自排序,而是将相隔某个"增量"的记录组成一个子序列,实现跳跃式的移动,使得排序的效率提高。这里的"增量"选取就非常关键了。我们是用gap = gap * 3 + 1的方式选取增量,可究竟应该选取什么样的增量才是最好的呢?目前还是数学难题,迄今为止还没有人找到一种最好的增量序列。不过大量研究表明,当增量序列为时,可以获得不错的效率,其时间复杂度为O(n3/2),要好于直接排序的O(n2)。需要注意的是,增量最后一个增量值必须等于1才行。因为记录是跳跃式移动,希尔排序并不是一种稳定的排序算法。

5. 选择排序

5.1 算法步骤

  • 首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置
  • 再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
  • 重复第二步,直到所有元素均排序完毕。

5.2 动画演示

5.3 代码示例

package main

import (
    "fmt"
)

type uint64Slice []uint64

func main()  {
    numbers := []uint64{5,23,1,6,7,9,2}
    selectSort(numbers)
    fmt.Println(numbers)
}

func selectSort(numbers uint64Slice)  {
    for i := 0; i < len(numbers) - 1; i++{
        // 记录最小值位置
        min := i

        for j:= i+1; j<len(numbers);j++{
            if numbers[j] < numbers[min]{
                min = j
            }
        }
        if i != min{
            numbers.swap(i,min)
        }
    }
}

// 交换方法
func (numbers uint64Slice)swap(i,j int)  {
    numbers[i],numbers[j] = numbers[j],numbers[i]
}

5.4 复杂度分析

从简单选择排序的过程来看,他最大的特点就是交换移动数据次数相当少,这样也就节约了相应的时间。分析它的时间复杂度发现,无论最好最差的情况,其比较次数都是一样的多,第i趟排序需要进行n-i次关键字的比较,此时需要比较。而对于交换次数而言,当最好的时候,交换为0次,最差的时候,也就初始降序时,交换次数为n-1次,基于最终的排序时间是比较与交换的次数总和,因此,总的时间复杂度依然为O(n2)。虽然与冒泡排序同为O(n2),但选择排序的性能上还是要略优于冒泡排序的。

6. 堆排序

6.1 算法步骤

堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。

  • 将初始待排序关键字序列(R1,R2….Rn)构建成大顶堆,此堆为初始的无序区;
  • 将堆顶元素R[1]与最后一个元素R[n]交换,此时得到新的无序区(R1,R2,……Rn-1)和新的有序区(Rn),且满足R[1,2…n-1]<=R[n];
  • 由于交换后新的堆顶R[1]可能违反堆的性质,因此需要对当前无序区(R1,R2,……Rn-1)调整为新堆,然后再次将R[1]与无序区最后一个元素交换,得到新的无序区(R1,R2….Rn-2)和新的有序区(Rn-1,Rn)。不断重复此过程直到有序区的元素个数为n-1,则整个排序过程完成。

6.2 动画演示

6.3 代码示例

package main

import (
    "fmt"
)

type uint64Slice []uint64

func main()  {
    numbers := []uint64{5,2,7,3,6,1,4}
    sortHeap(numbers)
    fmt.Println(numbers)
}

func sortHeap(numbers uint64Slice)  {
    length := len(numbers)
    buildMaxHeap(numbers,length)
    for i := length-1;i>0;i--{
        numbers.swap(0,i)
        length -=1
        heapify(numbers,0,length)
    }
}

// 构造大顶堆
func buildMaxHeap(numbers uint64Slice,length int)  {
    for i := length / 2; i >= 0; i-- {
        heapify(numbers, i, length)
    }
}

func heapify(numbers uint64Slice, i, length int) {
    left := 2*i + 1
    right := 2*i + 2
    largest := i
    if left < length && numbers[left] > numbers[largest] {
        largest = left
    }
    if right < length && numbers[right] > numbers[largest] {
        largest = right
    }
    if largest != i {
        numbers.swap(i, largest)
        heapify(numbers, largest, length)
    }
}

// 交换方法
func (numbers uint64Slice)swap(i,j int)  {
    numbers[i],numbers[j] = numbers[j],numbers[i]
}

6.4 复杂度分析

堆排序的运行时间主要消耗在初始构建堆和在重建堆时的反复筛选上。在构建堆的过程中,因为我们是完全二叉树从最下层最右边的非终端节点开始构建,将它与其孩子进行比较,若有必要的交换,对于每个非终端节点来说,其实最多进行两次比较和呼唤操作,因此整个构建堆的时间复杂度为O(n)

在正式排序时,第i次取堆顶记录重建堆需要用O(logi)的时间(完全二叉树的某个节点到根结点的距离为|logi|+1),并且需要取n-1次堆顶记录,因此,重建堆的时间复杂度为O(nlogn)

所以总体来说,堆排序的时间复杂度为O(nlogn)。由于堆排序对原始记录的排序状态并不敏感,因此他无论是最好、最坏和平均时间复杂度均为O(nlogn)。这在性能上显然要远远好过于冒泡、简单选择、直接插入的O(n2)的时间复杂度了。

空间复杂度上,他只有一个用来交换的暂存单元,也非常的不错。不过由于记录的比较与交换是跳跃式进行的,因此堆排序也是一种不稳定的排序方法。注意:由于初始构建堆所需的比较次数较多,因此,他并不适合待排序序列个数较少的情况。

7. 归并排序

7.1 算法步骤

  • 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;
  • 设定两个指针,最初位置分别为两个已经排序序列的起始位置;
  • 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置;
  • 重复步骤 3 直到某一指针达到序列尾;
  • 将另一序列剩下的所有元素直接复制到合并序列尾。

7.2 动画演示

7.3 代码示例

package main

import (
    "fmt"
)

type uint64Slice []uint64

func main()  {
    numbers := []uint64{3,44,38,5,47,15,36,26,27,2,46,4,19,50,48}
    res := mergeSort(numbers)
    fmt.Println(res)
}

func mergeSort(numbers uint64Slice) uint64Slice {
    length := len(numbers)
    if length < 2{
        return numbers
    }
    middle := length/2
    left := numbers[0:middle]
    right := numbers[middle:]
    return merge(mergeSort(left),mergeSort(right))
}

func merge(left uint64Slice,right uint64Slice) uint64Slice {
    result := make(uint64Slice,0)
    for len(left) != 0 && len(right) != 0 {
        if left[0] <= right[0] {
            result = append(result, left[0])
            left = left[1:]
        } else {
            result = append(result, right[0])
            right = right[1:]
        }
    }

    for len(left) != 0 {
        result = append(result, left[0])
        left = left[1:]
    }

    for len(right) != 0 {
        result = append(result, right[0])
        right = right[1:]
    }

    return result
}

// 交换方法
func (numbers uint64Slice)swap(i,j int)  {
    numbers[i],numbers[j] = numbers[j],numbers[i]
}

7.4 复杂度分析

可以说合并排序是比较复杂的排序,特别是对于不了解分治法基本思想的同学来说可能难以理解。总时间=分解时间+解决问题时间+合并时间。分解时间就是把一个待排序序列分解成两序列,时间为一常数,时间复杂度o(1).解决问题时间是两个递归式,把一个规模为n的问题分成两个规模分别为n/2的子问题,时间为2T(n/2).合并时间复杂度为o(n)。总时间T(n)=2T(n/2)+o(n).这个递归式可以用递归树来解,其解是o(nlogn).此外在最坏、最佳、平均情况下归并排序时间复杂度均为o(nlogn).从合并过程中可以看出合并排序稳定。

用递归树的方法解递归式T(n)=2T(n/2)+o(n):假设解决最后的子问题用时为常数c,则对于n个待排序记录来说整个问题的规模为cn。

8. 计数排序

8.1 算法步骤

  • 花O(n)的时间扫描一下整个序列 A,获取最小值 min 和最大值 max
  • 开辟一块新的空间创建新的数组 B,长度为 ( max - min + 1)
  • 数组 B 中 index 的元素记录的值是 A 中某元素出现的次数
  • 最后输出目标整数序列,具体的逻辑是遍历数组 B,输出相应元素以及对应的个数

8.2 动画演示

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-i2Y68TqV-1606657384218)(https://p3-juejin.byteimg.com...]

8.3 代码示例

package main

import (
    "fmt"
)

type uint64Slice []uint64

func main()  {
    numbers := []uint64{2, 3, 8, 7, 1, 2, 2, 2, 7, 3, 9, 8, 2}

    countSort(numbers,getMaxValue(numbers))
    fmt.Println(numbers)
}

func countSort(numbers uint64Slice,maxValue uint64) {
    bucketLen := maxValue + 1
    bucket := make(uint64Slice,bucketLen) // 初始都是0的数组
    sortedIndex := 0

    for _,v:= range numbers{
        bucket[v] +=1
    }
    var j uint64
    for j=0;j<bucketLen;j++{
        for bucket[j]>0{
            numbers[sortedIndex] = j
            sortedIndex +=1
            bucket[j] -= 1
        }
    }
}


func getMaxValue(numbers uint64Slice) uint64{
   maxValue := numbers[0]
   for _,v:=range numbers {
       if maxValue < v {
           maxValue = v
       }
   }
       return maxValue
}

8.4 复杂度分析

这个算法不是基于比较的排序算法,因此它的下界可以优于Ω(nlgn),甚至这个算法都没有出现比较元素的操作。这个算法很明显是稳定的,也就是说具有相同值得元素在输出数组中的相对次序和他们在输入数组中的相对次序相同。算法中的循环时间代价都是线性的,还有一个常数k,因此时间复杂度是Θ(n+k)。当k=O(n)时,我们采用计数排序就很好,总的时间复杂度为Θ(n)。

计数排序是复杂度为O(n+k)的稳定的排序算法,k是待排序列最大值,适用在对最大值不是很大的整型元素序列进行排序的情况下(整型元素可以有负数,我们可以把待排序列整体加上一个整数,使得待排序列的最小元素为0,然后执行计数排序,完成之后再变回来。这个操作是线性的,所以计数这样做计数排序的复杂度仍然是O(n+k))。本质上是一种空间换时间的算法,如果k比较小,计数排序的效率优势是很明显的,当k变得很大的时候,这个算法可能就不如其他优秀的排序算法。

9. 桶排序

9.1 算法步骤

桶排序是计数排序的升级版。这个是利用了函数的映射关系,是否高效就在于这个映射函数的确定。所以为了使桶排序更加高效,我们要保证做到以下两点:

1. 在额外空间充足的情况下,尽量增大桶的数量
2. 使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中
  • 设置固定数量的空桶。
  • 把数据放到对应的桶中。
  • 对每个不为空的桶中数据进行排序。
  • 拼接不为空的桶中数据,得到结果

最后,对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要。

9.2 动画演示

9.3 代码示例

package main

import (
    "fmt"
)

func main()  {
    numbers := []uint64{5,3,4,7,4,3,4,7}
    sortBucket(numbers)
    fmt.Println(numbers)
}

func sortBucket(numbers []uint64) {
    num := len(numbers) // 桶数量
    max := getMaxValue(numbers)
    buckets := make([][]uint64,num)
    var index uint64
    for _,v := range numbers{
        // 分配桶 index = value * (n-1)/k
        index = v * uint64(num-1) / max

        buckets[index] = append(buckets[index],v)
    }

    // 桶内排序
    tmpPos := 0
    for k:=0; k < num; k++ {
        bucketLen := len(buckets[k])
        if bucketLen>0{
            sortUseInsert(buckets[k])
            copy(numbers[tmpPos:],buckets[k])
            tmpPos +=bucketLen
        }
    }
}

func sortUseInsert(bucket []uint64)  {
    length := len(bucket)
    if length == 1 {return}
    for i := 1; i < length; i++ {
        backup := bucket[i]
        j := i -1
        for  j >= 0 && backup < bucket[j] {
            bucket[j+1] = bucket[j]
            j --
        }
        bucket[j + 1] = backup
    }
}

//获取数组最大值
func getMaxValue(numbers []uint64) uint64{
    max := numbers[0]
    for i := 1; i < len(numbers); i++ {
        if numbers[i] > max{ max = numbers[i]}
    }
    return max
}

9.4 复杂度分析

1. 时间复杂度

因为时间复杂度度考虑的是最坏的情况,所以桶排序的时间复杂度可以这样去看(只看主要耗时部分,而且常熟部分K一般都省去)

  • N次循环,每一个数据装入桶
  • 然后M次循环,每一个桶中的数据进行排序(每一个桶中有N/M个数据),假设为使用比较先进的排序算法进行排序

一般较为先进的排序算法时间复杂度是O(N*logN),实际的桶排序执行过程中,桶中数据是以链表形式插入的,那么整个桶排序的时间复杂度为:

O(N)+O(M*(N/M)*log(N/M))=O(N*(log(N/M)+1))

所以,理论上来说(N个数都符合均匀分布),当M=N时,有一个最小值为O(N)

PS:这里有人提到最后还有M个桶的合并,其实首先M一般远小于N,其次再效率最高时是M=N,这是就算把这个算进去,也是O(N(1+log(N/M)+M/N)),极小值还是O(2N)=O(N)

求M的极小值,具体计算为:(其中N可以看作一个很大的常数)
F(M) = log(N/M)+M/N) = LogN-LogM+M/N
它的导函数
F'(M) = -1/M + 1/N
因为导函数大于0代表函数递增,小于0代表函数递减
所以F(M)在(0,N) 上递减
在(N,+∞)上递增
所以当M=N时取到极小值

2. 空间复杂度

空间复杂度一般指算法执行过程中需要的额外存储空间

桶排序中,需要创建M个桶的额外空间,以及N个元素的额外空间

所以桶排序的空间复杂度为 O(N+M)

3. 稳定性·

稳定性是指,比如a在b前面,a=b,排序后,a仍然应该在b前面,这样就算稳定的。

桶排序中,假如升序排列,a已经在桶中,b插进来是永远都会a右边的(因为一般是从右到左,如果不小于当前元素,则插入改元素的右侧)

所以桶排序是稳定的

PS:当然了,如果采用元素插入后再分别进行桶内排序,并且桶内排序算法采用快速排序,那么就不是稳定的

用排序主要适用于均匀分布的数字数组,在这种情况下能够达到最大效率

10. 基数排序

10.1 算法步骤

基数排序与桶排序、计数排序都用到了桶的概念,但对桶的使用方法上有明显差异:

  • 基数排序:根据键值的每位数字来分配桶;
  • 计数排序:每个桶只存储单一键值;
  • 桶排序:每个桶存储一定范围的数值;

基数排序按取数方向分为两种:从左取每个数列上的数,为最高位优先(Most Significant Digit first, MSD);从右取每个数列上的数,为最低位优先(Least Significant Digit first, LSD)
下列以LSD为例。

基数排序步骤:

  • 将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零
  • 从最低位开始,依次进行一次排序
  • 从最低位排序一直到最高位排序完成以后, 数列就变成一个有序序列

10.2 动画演示

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HDo6G2Fg-1606657384218)(https://p1-juejin.byteimg.com...]

10.3 代码示例

package main

import (
    "fmt"
)

func main()  {
    numbers := []uint64{3221, 1, 10, 9680, 577, 9420, 7, 5622, 4793, 2030, 3138, 82, 2599, 743, 4127}
    radixSort(numbers)
    fmt.Println(numbers)
}

func radixSort(numbers []uint64)  {
    key := maxDigits(numbers)
    tmp := make([]uint64,len(numbers),len(numbers))
    count := new([10]uint64)
    length := uint64(len(numbers))
    var radix uint64 =  1
    var i, j, k uint64
    for i = 0; i < key; i++ { //进行key次排序
        for j = 0; j < 10; j++ {
            count[j] = 0
        }
        for j = 0; j < length; j++ {
            k = (numbers[j] / radix) % 10
            count[k]++
        }
        for j = 1; j < 10; j++ { //将tmp中的为准依次分配给每个桶
            count[j] = count[j-1] + count[j]
        }
        for j = length-1; j > 0; j-- {
            k = (numbers[j] / radix) % 10
            tmp[count[k]-1] = numbers[j]
            count[k]--
        }
        for j = 0; j < length; j++ {
            numbers[j] = tmp[j]
        }
        radix = radix * 10
    }
}


//获取数组的最大值的位数
func maxDigits(arr []uint64) (ret uint64) {
    ret = 1
    var key uint64 = 10
    for i := 0; i < len(arr); i++ {
        for arr[i] >= key {
            key = key * 10
            ret++
        }
    }
    return
}

10.4 复杂度分析

如果使用桶排序或者计数排序(必需是稳定排序算法),时间复杂度可以做到 O(n)。如果要排序的数据有 k 位,那我们就需要 k 次桶排序或者计数排序,总的时间复杂度是 O(kn)。当 k 不大的时候,比如手机号码排序的例子,基数排序的时间复杂度就近似于 O(n)。

基数排序对要排序的数据要求如下:

  1. 需要分割出独立的"位"来比较,而且位之间可以进行比较。
  2. 每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n)。
  3. 如果排序的元素位数不一样,位数不够的可以在后面补位。

总结

这篇文章总结到这里就结束了,花费了好长时间耶(动画太难弄了)~~。这排序算法长时间不写都快忘光了,这一次又重新整理了一遍,收获很大。虽然这些算法是很简单的算法,但是却很重要,日常开发都会用到,所以大家一定要学好。希望这篇文章对你们有用。如果觉得不错,给个三连吧(点赞、看一看,分享),这就对笔者的最大鼓励,感谢啦~~~。

代码已经收录到我的github,需要的自取:https://github.com/asong2020/...

好啦,这一篇文章到这就结束了,我们下期见~~。希望对你们有用,又不对的地方欢迎指出,可添加我的golang交流群,我们一起学习交流。

结尾给大家发一个小福利吧,最近我在看[微服务架构设计模式]这一本书,讲的很好,自己也收集了一本PDF,有需要的小伙可以到自行下载。获取方式:关注公众号:[Golang梦工厂],后台回复:[微服务],即可获取。

最近被吐槽说我写的代码太丑陋了,所以最近也在看clean code这本书,有需要的小伙伴公众号自取哈。获取方式:关注公众号:[Golang梦工厂],后台回复:[code],即可获取

我翻译了一份GIN中文文档,会定期进行维护,有需要的小伙伴后台回复[gin]即可下载。

翻译了一份Machinery中文文档,会定期进行维护,有需要的小伙伴们后台回复[machinery]即可获取。

我是asong,一名普普通通的程序猿,让gi我一起慢慢变强吧。我自己建了一个golang交流群,有需要的小伙伴加我vx,我拉你入群。欢迎各位的关注,我们下期见~~~

推荐往期文章:

查看原文

赞 0 收藏 0 评论 0

asong 发布了文章 · 2020-11-21

Go语言相关书籍推荐(从入门到放弃)

前言

最近总有读者问我可不可以推荐一下go语言入门必读书籍。所以今天就推荐几本。因为笔者也没读过几本,所以就咨询了几位前辈,现在我就把这一份书单介绍给你们,从入门到进阶。

Go语言简介

Go语言是最近几年流行起来的语言,该语言由谷歌发明,现在得到广泛应用。Go语言的最大特点就是语法简单且并发支持度好,开发效率很高。平常我们在C/C++语言中需要几十行的代码量,在Go语言中可能就只需要几行代码就可以搞定。现在也越来越多的人开始转学Go

Go语言优势

  • 脚本化的语法;开发效率高,容易上手
  • 静态类型+编译型,程序运行速度有保障;静态类型+编译型语言相对于动态类型+解释型语言的效率高
  • 原生的支持并发编程;降低开发、维护成本/程序可以更好的执行
  • 对于云原生支持比较好,容器化,微服务化比较容易。

Go的缺点

  • 它不支持泛型,即使有很多关于它的讨论。可能也在议程当中,期待那一天的到来。
  • 使用这种编程语言分发的软件包非常有用,但Go在传统意义上并不是面向对象的。
  • 缺少一些库,尤其是UI工具包。

Go原生应用

  • Docker:一组用于部署Linux容器的工具
  • Openshift:由Red Hat提供的云计算平台即服务。
  • Kubernetes:无缝自动化部署流程的未来
  • Tidb: 开源分布式关系型数据库。
  • InfluxDB:是由InfluxData开发的开源时间序列数据库。
  • Etcd:分布式的键值对数据存储系统,提供共享配置、服务的注册和发现。

擅长领域

Go语言主要用途如下:

  1. 服务器编程,如处理日志、数据打包、虚拟机处理、文件系统等
  2. 分布式系统,数据库代理器等
  3. 网络编程,如Web应用、API应用、下载应用
  4. 内存数据库,如groupcache、couchbase的部分组建
  5. 云平台,目前国外很多云平台在采用Go开发,CloudFoundy的部分组建,前VMare的技术总监自己出来搞的apcera云平台。

入门书籍

  • Go语言核心编程

学习任何一门语言,首先要学习的就是语法,这一本书其实就完全可以带你入门,我读的第一本Go相关书籍就是它,对Go的基础语法、核心用都进行了详细讲解,尤其其中有几篇文章对Go语言陷阱进行讲解,真的很棒,强烈推荐。

  • Go语言程序设计

这本书来头不小,其作者是Kernigan和谷歌公司Go团队主管Alan Donovan。这本书应该说是Go语言入门必读的第一本书。全书总共分为13章,主要内容包括:Go的基础知识、基本结构、基本数据类型、复合数据类型等等。这里就不全列举了。不过这本书我没有读过,所以给他放在了第二位。

  • Go语言编程

这本书是国内某云的研发团队编写的。该公司是国内最早大规模使用Go的。这本强烈推荐给大家,这本书不仅介绍Go语言的关键语法,并且从工程实践的角度介绍Go语言的内容,从中一定会收获不少。

  • Go并发编程实战

这本书讲解了Go语言的最大特点:并发编程。这本书对Go语言并发进行深入讲解,在你熟悉了Go语言基本语法后,强烈推荐大家看一下这本书,让你对并发的理解更上一个层次。

进阶书籍

  • Go Web编程

这个是我读的第二本书,本书将教读者运用现代化设计理念构建Go Web应用的方法。阅读本书能让读者学会如何通过依赖注入设计模式来编写测试替身,如何在Web应用中使用并发特性,还有如何在Web服务中创建以及处理JSON数据和XML数据。除此之外,读者还将学会如何尽可能地减少应用对外部框架的依赖,并了解大量与应用测试以及应用部署有关的有价值的生产技术。

  • Go语言编程之旅

这本书的作者是我们的煎鱼大佬,这本书是市面上少有的面向项目实践的一本书。这本书涵盖命令行应用、HTTP应用、RPC应用、WebSocket应用等常见项目,从做、学、排三个方向讲解,让我对项目实践有了更透彻的理解,特别是最后一章,排查和分析问题的总结,让我受益匪浅。

  • Go语言高并发与微服务实战

本书以当前流行的微服务架构和Go语言的高并发特性为主线,介绍Go语言微服务的各个组件和并发实战。目前在市面上大部分微服务相关书籍中都是JAVA语言实现的,而本书则是基于Go语言来对微服务结构进行深入剖析,以大量实战总结和案例为主线怼微服务的相关技术做讲解。如果想系统学习微服务,这本书不容错过。

  • Go语言圣经

很多大佬都推荐这一本书,但是我还是把它放在了最后,因为他真的不适合新手学习,里面的练习题真的难。所以一定要有一定经验了再去看这一本书,这本书确实是本好书,但不太适合非 C 系编程语言的人作为入门 Go 的首选。但是强烈推荐大家看一下这本书,不过不是刚入门的时候。

总结

上面这8本书,只是推荐阅读哈,并不是入门一定要看书哈,看视频也是一个不错的选择,B站入门视频就很多,可以白嫖~~~。

Go语言圣经强烈推荐看,兄弟们~~~。

好啦,这一篇文章到这就结束了,我们下期见~~。希望对你们有用,又不对的地方欢迎指出,可添加我的golang交流群,我们一起学习交流。

结尾给大家发一个小福利吧,最近我在看[微服务架构设计模式]这一本书,讲的很好,自己也收集了一本PDF,有需要的小伙可以到自行下载。获取方式:关注公众号:[Golang梦工厂],后台回复:[微服务],即可获取。

我翻译了一份GIN中文文档,会定期进行维护,有需要的小伙伴后台回复[gin]即可下载。

翻译了一份Machinery中文文档,会定期进行维护,有需要的小伙伴们后台回复[machinery]即可获取。

我是asong,一名普普通通的程序猿,让gi我一起慢慢变强吧。我自己建了一个golang交流群,有需要的小伙伴加我vx,我拉你入群。欢迎各位的关注,我们下期见~~~

推荐往期文章:

查看原文

赞 2 收藏 1 评论 0

asong 发布了文章 · 2020-11-15

(Mysql)对数据库设计时设计标识字段引发的一些思考

前言

哈喽,everybody,我是asong。今天asong教你们一个mysql优化设计状态标识。学会了他,我们的DB结构看起来更清晰,也避免了DB结构过大的问题,具体怎么设计,下面你就看我怎么操作就好了~~~

背景

我们在很多应用场景中,通常是需要给数据加上一些标识,已表明这条数据的某个特性。比如标识用户的支付渠道,标识商家的结算方式、商品的类型等等。对于这样的具有有限固定的几个值的标识,我们通过枚举的方式来标识就可以了,但是对于一些同时具有多个属性且变化比较大的就显然不合适了,举个很简单的例子,我们在某宝上想买一个平板,这个平板的商品类型可标识为电子商品、二手商品、、手机、数码等等,对于这种场景,一个商品对应多种类型,不确定性很大,这种就不是简单的通过几个值标识就能解决的了。本文就是针对这个问题,给出了自己的一些思考。

问题与分析

我们就拿最近刚过去的双11举个例子,在双11要开始之前,某宝就会通过各种优惠的方式发放优惠卷、积分抵扣等等福利,这样我们在双11清空购物车时享受这些优惠。这种场景其实对我们程序员来说并不是简单的实现优惠减免这么简单,这种场景更多是标识优惠以计算用户实际所需支付金额,以及为后续业绩统计、制定促销计划、提高用户活跃度等提供数据依据。下面我们根据例子进行分析:

假设当前某宝平台可以使用的优惠方式如下:

序号优惠内容使用条件是否长期有效备注
1账户余额直接抵扣现金用户充值获得(平台奖励吸引的充值,如:充100送10元)
2平台积分100积分抵扣1元通过参与平台活动、购物行为积累获取
3满减卷5元满100减5元平台活动促销发放
4免邮费订单总金额符合条件即可平台单笔订单总金额满199元免邮费

当用户进行下单时,只要满足各优惠的使用条件时,就可以使用各种优惠。这时我们思考一个问题,数据库是怎么存储这些优惠的呢?

根据上面的举例,用户下单时可以同时使用上面4种优惠抵扣方式,也就说用户可能出现的组合有2^4 - 1=15种,如果我们的表结构设计成单独用一个普通标识字段来标识存储,实现起来是比较简单,但是其需要标识的组合种类实在有点多,不太利于编码与后续扩展,想一想,优惠政策会随着平台发展不断推出的,如果新加了一种优惠类型,其需要添加多少种组合标识啊,且呈指数式爆长,这种方式显然不太合理。那么有没有什么解决方案呢?

方案一:

采用另外引入一张关联表的方式,专门用一张关联表来存储订单使用的优惠组合信息,每使用一种优惠就添加一条关联记录,相比单独使用普通字段标识,这在一定程度上减少了设置标识的繁琐性,增加了灵活性(每多使用一种优惠就添加一条关联记录),但是,同时也带来了另一些问题,其中主要问题是:新增一张关联表后,数据维护起来麻烦。在互联网场景下,数据量通常是非常大的,像订单数据一般都需要进行数据库sharding,以应对数据量暴涨后数据库的读写性能瓶颈,增加系统的水平扩展能力。因此,另外增加一张数据量是订单数据本身数据量几倍的关联表也显然不太合适。

方案二:

这就是本文的重点了,也就是我们使用“特殊标识位”的方式来实现,具体思路如下:

  • 我们不再直接使用十进制数字来标识存储优惠信息,而是存储一个二进制数转化后的十进制数,这些1、2、3之类的优惠数字表示占二进制数的第几位(从右至左数);
  • 具体数据的存储、读取判断通过一个通用方法进行转换。

现在我们假设使用int32数据类型进行存储,共32位,除去符号位,可用于标识的位数有31位,即最多可以标识31种优惠情况。

优惠项占第几位二进制数十进制数
账户余额10000 00011
平台积分20000 00102
满减卷5元30000 01004
免邮费40000 10008

说明:若用户使用了账户余额,则使用二进制数 00000001 标识,若使用了平台积分,则使用二进制数 00000010 标识,存储到DB时,转换成对应十进制数分别对应1、2;若同时使用了账户余额、平台积分,则使用二进制数 00000011 标识,最终存储到DB的对应十进制数是3。其它优惠项,所占的二进制位依次类推。

代码样例

先看代码

package main

import (
    "fmt"
)

// golang没有enum 使用const代替
const (
    TYPE_BALANCE      = 1 // type = 1
    TYPE_INTEGRAL     = 2 // type = 2
    TYPE_COUPON       = 3 // type = 3
    TYPE_FREEPOSTAGE  = 4 // type = 4
)

// 是否使用有优惠卷
func IsUseDiscount(discountType , value uint32) bool {
    return (value & (1<< (discountType-1))) > 0
}


// 设置使用
func SetDiscountValue(discountType ,value uint32) uint32{
    return value | (1 << (discountType-1))
}

func main()  {
    // 测试1 不设置优惠类型
    var flag1 uint32 = 0
    fmt.Println(IsUseDiscount(TYPE_BALANCE,flag1))
    fmt.Println(IsUseDiscount(TYPE_INTEGRAL,flag1))
    fmt.Println(IsUseDiscount(TYPE_COUPON,flag1))
    fmt.Println(IsUseDiscount(TYPE_FREEPOSTAGE,flag1))


    // 测试2 只设置一个优惠类型
    var flag2 uint32 = 0
    flag2 = SetDiscountValue(TYPE_BALANCE,flag2)
    fmt.Println(IsUseDiscount(TYPE_BALANCE,flag2))
    fmt.Println(IsUseDiscount(TYPE_INTEGRAL,flag2))
    fmt.Println(IsUseDiscount(TYPE_COUPON,flag2))
    fmt.Println(IsUseDiscount(TYPE_FREEPOSTAGE,flag2))

    // 测试3 设置两个优惠类型
    var flag3 uint32 = 0
    flag3 = SetDiscountValue(TYPE_BALANCE,flag3)
    flag3 = SetDiscountValue(TYPE_INTEGRAL,flag3)
    fmt.Println(IsUseDiscount(TYPE_BALANCE,flag3))
    fmt.Println(IsUseDiscount(TYPE_INTEGRAL,flag3))
    fmt.Println(IsUseDiscount(TYPE_COUPON,flag3))
    fmt.Println(IsUseDiscount(TYPE_FREEPOSTAGE,flag3))

    // 测试4 设置三个优惠类型
    var flag4 uint32 = 0
    flag4 = SetDiscountValue(TYPE_BALANCE,flag4)
    flag4 = SetDiscountValue(TYPE_INTEGRAL,flag4)
    flag4 = SetDiscountValue(TYPE_COUPON,flag4)
    fmt.Println(IsUseDiscount(TYPE_BALANCE,flag4))
    fmt.Println(IsUseDiscount(TYPE_INTEGRAL,flag4))
    fmt.Println(IsUseDiscount(TYPE_COUPON,flag4))
    fmt.Println(IsUseDiscount(TYPE_FREEPOSTAGE,flag4))

    // 测试5 设置四个优惠类型
    var flag5 uint32 = 0
    flag5 = SetDiscountValue(TYPE_BALANCE,flag5)
    flag5 = SetDiscountValue(TYPE_INTEGRAL,flag5)
    flag5 = SetDiscountValue(TYPE_COUPON,flag5)
    flag5 = SetDiscountValue(TYPE_FREEPOSTAGE,flag5)
    fmt.Println(IsUseDiscount(TYPE_BALANCE,flag5))
    fmt.Println(IsUseDiscount(TYPE_INTEGRAL,flag5))
    fmt.Println(IsUseDiscount(TYPE_COUPON,flag5))
    fmt.Println(IsUseDiscount(TYPE_FREEPOSTAGE,flag5))
}

运行结果:

false
false
false
false
true
false
false
false
true
true
false
false
true
true
true
false
true
true
true
true

因为go没有枚举,所以我们使用const声明常量的方式来实现,定义四个常量,代表四种优惠种类,这个并不是最最终存储到DB的值,而是表示占二进制数的第几位(从右至左数,从1开始);当需要存储优惠种类到DB中,或者从DB中查询对应的优惠种类时,通过SetDiscountValueIsUseDiscount这两个方法对值进行设置(项目中可以封装一个文件中作为工具类)。

SetDiscountValue方法的实现:通过位运算来实现,(1 << (discountType-1))通过位移的方法来找到其在二进制中的位置,然后通过与value位或的方法设定所占二进制位数,最终返回设置占位后的十进制数。

IsUseDiscount方法的实现:(1<< (discountType-1))通过位移的方法来找到其在二进制中的位置,然后通过与value位与的方法来判断优惠项应占位是否有占位,返回判断结果。

上面就是一个使用特殊标识位的一个简单代码样例,这个程序还可以进行扩展与完善,等待你们的开发呦~~~。

总结

在这里简单总结一下使用特殊标识位的优缺点:

  • 优点

    • 方便扩展,易于维护;当业务场景迅速扩展时,这种方式可以方便的标识新增的业务场景,数据也易于维护。要知道,在互联网场景下,业务的变化是非常快的,新加字段并不是那么方便。
    • 方便标识存储,一个字段就可以标识多种业务场景。
  • 缺点

    • 数据的存储、查询需要转换,不够直观;相对普通的标识方式,没接触过的人需要一点时间理解这种使用特殊标识位的方式。
    • DB数据查询时,稍显繁琐。

你们学废了嘛?反正我学废了,哈哈哈哈哈~~~~~。

好啦,这一篇文章到这就结束了,我们下期见~~。希望对你们有用,又不对的地方欢迎指出,可添加我的golang交流群,我们一起学习交流。

结尾给大家发一个小福利吧,最近我在看[微服务架构设计模式]这一本书,讲的很好,自己也收集了一本PDF,有需要的小伙可以到自行下载。获取方式:关注公众号:[Golang梦工厂],后台回复:[微服务],即可获取。

我翻译了一份GIN中文文档,会定期进行维护,有需要的小伙伴后台回复[gin]即可下载。

翻译了一份Machinery中文文档,会定期进行维护,有需要的小伙伴们后台回复[machinery]即可获取。

我是asong,一名普普通通的程序猿,让gi我一起慢慢变强吧。我自己建了一个golang交流群,有需要的小伙伴加我vx,我拉你入群。欢迎各位的关注,我们下期见~~~

推荐往期文章:

查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 16 次点赞
  • 获得 0 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 0 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2020-06-18
个人主页被 1.3k 人浏览