SegmentFault NoSay最新的文章
2021-10-28T16:05:38+08:00
https://segmentfault.com/feeds/blogs
https://creativecommons.org/licenses/by-nc-nd/4.0/
【面试篇】Go语言常见踩坑(一)
https://segmentfault.com/a/1190000040878744
2021-10-28T16:05:38+08:00
2021-10-28T16:05:38+08:00
NoSay
https://segmentfault.com/u/nosay
1
<h2>引言</h2><p>本系列会列举一些在Go面试中常见的问题。</p><h2>切片循环问题</h2><p>For循环在我们日常编码中可能用的很多。在很多业务场景中我们都需要用for循环处理。但golang中的for循环在使用上需要注意一些问题,大家可否遇到。先看下边这一段代码:</p><pre><code>func testSlice() {
a := []int64{1,2,3}
for _, v := range a {
go func() {
fmt.Println(v)
}()
}
time.Sleep(time.Second)
}
output: 3 3 3</code></pre><p>那么为什么会输出的是这个结果呢?</p><p>在golang的for循环中,循环内部创建的函数变量都是共享同一块内存地址,for循环总是使用同一块内存去接收循环中的的value变量的值。不管循环多少次,value的内存地址都是相同的。我们可以测试一下:</p><pre><code>func testSliceWithAddress() {
a := []int64{1,2,3}
for _, v := range a {
go func() {
fmt.Println(&v)
}()
}
time.Sleep(time.Second)
}
output:
0xc0000b2008
0xc0000b2008
0xc0000b2008</code></pre><p>符合预期。如果大家比较感兴趣的话可以去将这段代码的汇编打印出来,就可以发现循环的v一直在操作同一块内存。</p><p>同样的,在slice循环这块我们还会遇见另一个有趣的地方,大家可以看看下边这段代码输出什么?</p><pre><code>func testRange3() {
a := []int64{1,2,3}
for _, v := range a {
a = append(a, v)
}
fmt.Println(a)
}</code></pre><p>这段代码的输出结果是:[1 2 3 1 2 3],为什么呢?因为golang在循环前会先拷贝一个a,然后对新拷贝的a进行操作,所以循环的次数不会随着append而增多。</p><h2>interface和nil比较</h2><p>比如返回了一个空指针,但并不是一个空interface</p><pre><code>func testInterface() {
doit := func(arg int) interface{} {
var result * struct{} = nil
if arg > 0 {
result = &struct{}{}
}
return result
}
if res := doit(-1); res != nil {
fmt.Println("result:", res)
}
}</code></pre><p>输出结果为:result: <nil>,为什么呢?因为在go里边变量有类型和值两个属性,在比较的时候也会比较类型和值都相同才会认为相等。代码中result的类型是指针,值是nil,所以会有这样的输出。</p><h2>可变参数是空接口类型</h2><p>当参数的可变参数是空接口类型时,传入空接口的切片时需要注意参数展开的问题。</p><pre><code>func testVolatile() {
var a = []interface{}{1, 2, 3}
fmt.Println(a)
fmt.Println(a...)
}</code></pre><p>输出结果为:</p><pre><code>[1 2 3]
1 2 3
</code></pre><h2>map遍历时顺序不固定</h2><p>不要相信map的顺序!</p><pre><code>func testMap() {
m := map[string]string{
"a": "a",
"b": "b",
"c": "c",
}
for k, v := range m {
println(k, v)
}
}</code></pre><p>具体原因大家可以看一下源码:map.go:mapiterinit,就会发现下边这个代码用来决定从哪开始遍历map。另一个原因是map 在某些特定情况下(例如扩容),会发生key的搬迁重组。而遍历的过程,就是按顺序遍历bucket,同时按顺序遍历bucket中的key。搬迁后,key的位置发生了重大的变化,所以遍历map的结果就不可能按原来的顺序了。</p><pre><code>func mapiterinit(t *maptype, h *hmap, it *hiter) {
......
// decide where to start
r := uintptr(fastrand())
......
}</code></pre><h2>关注我们</h2><p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~<br><img src="/img/bVcVGAJ" alt="image.png" title="image.png"></p>
【深入理解Go】从0到1实现一个validator
https://segmentfault.com/a/1190000040741978
2021-09-27T10:32:17+08:00
2021-09-27T10:32:17+08:00
NoSay
https://segmentfault.com/u/nosay
0
<p>validator是我们平时业务中用的非常广泛的框架组件,很多web框架、微服务框架都有集成。通常用来做一些请求参数的校验以避免写出重复的检验逻辑。接下来的文章中,我们就去看看如何去实现一个validator。</p><h3>初体验</h3><p>实践是第一生产力,我先提供一个场景,现在我们有一个接口,作用是填写用户信息,需要我们保存入库。我们该怎么做呢?</p><p>首先,我们先定义一个结构体,规定用户信息的几个参数:</p><pre><code class="golang">type ValidateStruct struct {
Name string `json:"name"`
Address string `json:"address"`
Email string `json:"email"`
}</code></pre><p>用户传进来数据,我们需要校验才能入库,例如Name是必填的,Email是合法的这些等等,那我们要怎么去实现它?可以是这样:</p><pre><code class="golang">
func validateEmail(email string) error {
//do something
return nil
}
func validateV1(req ValidateStruct) error{
if len(req.Name) > 0 {
if len(req.Address) > 0 {
if len(req.Email) > 0 {
if err := validateEmail(req.Email); err != nil {
return err
}
}else {
return errors.New("Email is required")
}
} else {
return errors.New("Address is required")
}
} else {
return errors.New("Name is required")
}
return nil
}
</code></pre><p>也可以是这样:</p><pre><code>func validateV2(req ValidateStruct) error{
if len(req.Name) < 0 {
return errors.New("Name is required")
}
if len(req.Address) < 0 {
return errors.New("Name is required")
}
if len(req.Email) < 0 || validateEmail(req.Email) != nil {
return errors.New("Name is required")
}
return nil
}</code></pre><p>可以用倒是可以用了,试想一下,如果现在我们要增加100个接口,每个接口有不同的请求参数,那这样的逻辑我们岂不是要写100遍?那是不可能的!我们再想想办法。</p><h3>进阶</h3><p>我们会发现参数名虽然不同,但是校验逻辑是可以相同的,例如参数大于0,小于0,不等于这种,共性可以找到,那我们是不是就可以抽出通用的逻辑来了呢?先来看我们的通用逻辑,这个方法可以帮我们实现int,string参数的校验,因为只是做演示使用,所以只是简单的进行实现,以此来表达这种方式的可行性。</p><pre><code>func validateEmail(input string) bool {
if pass, _ := regexp.MatchString(
`^([\w\.\_]{2,10})@(\w{1,}).([a-z]{2,4})$`, input,
); pass {
return true
}
return false
}
//通用的校验逻辑,采用反射实现
func validate(v interface{}) (bool, string) {
vt := reflect.TypeOf(v)
vv := reflect.ValueOf(v)
errmsg := "success"
validateResult := true
for i := 0; i < vt.NumField(); i++ {
if errmsg != "success" {
return validateResult, errmsg
}
fieldValue := vv.Field(i)
tagContend := vt.Field(i).Tag.Get("validate")
k := fieldValue.Kind()
switch k {
case reflect.Int64:
val := fieldValue.Int()
tagValStr := strings.Split(tagContend, "=")
if tagValStr[0] != "eq" {
errmsg = "validate int failed, tag is: " + tagValStr[0]
validateResult = false
}
tagVal, _ := strconv.ParseInt(tagValStr[1], 10, 64)
if val != tagVal {
errmsg = "validate int failed, tag is: "+ strconv.FormatInt(
tagVal, 10,
)
validateResult = false
}
case reflect.String:
valStr := fieldValue.String()
tagValStr := strings.Split(tagContend, ";")
for _, val := range tagValStr {
if val == "email" {
nestedResult := validateEmail(valStr)
if nestedResult == false {
errmsg = "validate mail failed, field val is: "+ val
validateResult = false
}
}
tagValChildStr := strings.Split(val, "=")
if tagValChildStr[0] == "gt" {
length, _ := strconv.ParseInt(tagValChildStr[1], 10, 64)
if len(valStr) < int(length) {
errmsg = "validate int failed, tag is: "+ strconv.FormatInt(
length, 10,
)
validateResult = false
}
}
}
case reflect.Struct:
// 如果有内嵌的 struct,那么深度优先遍历
// 就是一个递归过程
valInter := fieldValue.Interface()
nestedResult, msg := validate(valInter)
if nestedResult == false {
validateResult = false
errmsg = msg
}
}
}
return validateResult, errmsg
}</code></pre><p>接下来我们来跑一下:</p><pre><code>//定义我们需要的结构体
type ValidateStructV3 struct {
// 字符串的 gt=0 表示长度必须 > 0,gt = greater than
Name string `json:"name" validate:"gt=0"`
Address string `json:"address" validate:"gt=0"`
Email string `json:"email" validate:"email;gt=3"`
Age int64 `json:"age" validate:"eq=0"`
}
func ValidateV3(req ValidateStructV3) string {
ret, err := validate(req)
if !ret {
println(ret, err)
return err
}
return ""
}
//实现这个结构体
req := demos.ValidateStructV3{
Name: "nosay",
Address: "beijing",
Email: "nosay@qq.com",
Age: 3,
}
resp := demos.ValidateV3(req)
//输出:validate int failed, tag is: 0</code></pre><p>这样就不需要在每个请求进入业务逻辑之前都写重复的validate()函数了,我们同样可以集成在框架里。</p><h3>原理介绍</h3><p>正如我们上文validator的实现一样,他的原理就是下图这个结构,如果是可判断类型就通过tag去做相应的动作,如果是struct就递归,继续去遍历。<br><img src="/img/bVcU6xt" alt="image.png" title="image.png"></p><p>struct是我们的请求体(也就是父节点),子节点对应我们的每一个元素,它的类型是int64,string,struct或者其它的类型,我们通过类型去执行对应的行为(即int类型的eq=0,string类型的gt=0等)。</p><p>举个例子,我们按照下边这种方式去跑我们的validator:</p><pre><code>type ValidateStructV3 struct {
// 字符串的 gt=0 表示长度必须 > 0,gt = greater than
Name string `json:"name" validate:"gt=0"`
Address string `json:"address" validate:"gt=0"`
Email EmailV4
Age int64 `json:"age" validate:"eq=0"`
}
type EmailV4 struct {
// 字符串的 gt=0 表示长度必须 > 0,gt = greater than
Email string `json:"email" validate:"email;gt=3"`
}
req := demos.ValidateStructV3{
Name: "nosay",
Address: "beijing",
Email: demos.EmailV4{
Email: "nosayqq.com",
},
Age: 0,
}
resp := demos.ValidateV3(req)</code></pre><p>这时候它的执行流程大概长这个样子:<br><img src="/img/bVcU6xH" alt="image.png" title="image.png"></p><h3>扩展</h3><p>到这里其实基本原理我们都已经讲完了,但是真正的实现肯定没这么简单,这边笔者给你们推荐一个专门的validator库(<a href="https://link.segmentfault.com/?enc=I6IisGgHtcSmXqC24tUSkA%3D%3D.JI63oaKss1hUQDpcvWU%2BVqoFjtkFkHlZlxS4k7%2Bkrn1UmKVAes4qONKFwJ7KmDNf" rel="nofollow">https://github.com/go-playgro...</a>),有兴趣的读者可以阅读一下~</p><h3>关注我们</h3><p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~<br><img src="/img/bVcU604" alt="image.png" title="image.png"></p>
【深入理解Go】从0到1实现一个filter(middleware)
https://segmentfault.com/a/1190000040735370
2021-09-26T01:05:36+08:00
2021-09-26T01:05:36+08:00
NoSay
https://segmentfault.com/u/nosay
0
<p>filter(也称middleware)是我们平时业务中用的非常广泛的框架组件,很多web框架、微服务框架都有集成。通常在一些请求的前后,我们会把比较通用的逻辑都会放到filter组件来实现。如打请求日志、耗时、权限、接口限流等通用逻辑。那么接下来我会和你一起实现一个filter组件,同时让你了解到,它是如何从0到1搭建起来的,具体在演进过程中遇到了哪些问题,是如何解决的。</p><h2>从一个简单的server说起</h2><p>我们看这样一段代码。首先我们在服务端开启了一个http server,配置了/这个路由,hello函数处理这个路由的请求,并往body中写入hello字符串响应给客户端。我们通过访问127.0.0.1:8080就可以看到响应结果。具体的实现如下:</p><pre><code class="go">// 模拟业务代码
func hello(wr http.ResponseWriter, r *http.Request) {
wr.Write([]byte("hello"))
}
func main() {
http.HandleFunc("/", hello)
if err := http.ListenAndServe(":8080", nil); err != nil {
panic(err)
}
}</code></pre><h3>打印请求耗时v1.0</h3><p>接下来有一个需求,需要打印这个请求执行的时间,这个也是我们业务中比较常见的场景。我们可能会这样实现,在hello这个handler方法中加入时间计算逻辑,主函数不变:</p><pre><code class="go">// 模拟业务代码
func hello(wr http.ResponseWriter, r *http.Request) {
// 增加计算执行时间逻辑
start := time.Now()
wr.Write([]byte("hello"))
timeElapsed := time.Since(start)
// 打印请求耗时
fmt.Println(timeElapsed)
}
func main() {
http.HandleFunc("/", hello)
if err := http.ListenAndServe(":8080", nil); err != nil {
panic(err)
}
}</code></pre><p>但是这样实现仍然有一定问题。假设我们有一万个请求路径定义、所以有一万个handler和它对应,我们在这一万个handler中,如果都要加上请求执行时间的计算,那必然代价是相当大的。</p><p>为了提升代码复用率,我们使用filter组件来解决此类问题。大多数web框架或微服务框架都提供了这个组件,在有些框架中也叫做middleware。</p><h2>filter登场</h2><p>filter的基本思路,是把功能性(业务代码)与非功能性(非业务代码)分离,保证对业务代码无侵入,同时提高代码复用性。在讲解2.0的需求实现之前,我们先回顾一下1.0中比较重要的函数调用http.HandleFunc("/", hello)</p><p>这个函数会接收一个路由规则pattern,以及这个路由对应的处理函数handler。我们一般的业务逻辑都会写在handler里面,在这里就是hello函数。我们接下来看一下http.HandleFunc()函数的详细定义:</p><pre><code class="go">func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) </code></pre><p>这里要注意一下,标准库中又把func(ResponseWriter, *Request)这个func重新定义成一个类型别名HandlerFunc:</p><pre><code class="go">type HandlerFunc func(ResponseWriter, *Request)</code></pre><p>所以我们一开始用的http.HandleFunc()函数定义,可以直接简化成这样:</p><pre><code class="go">func HandleFunc(pattern string, handler HandlerFunc) </code></pre><p>我们只要把「HandlerFunc类型」与「HandleFunc函数」区分开就可以一目了然了。因为hello这个用户函数也符合HandlerFunc这个类型的定义,所以自然可以直接传给http.HandlerFunc函数。而HandlerFunc类型其实是Handler接口的一个实现,Handler接口的实现如下,它只有ServeHTTP这一个方法:</p><pre><code class="go">type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}</code></pre><p>HandlerFunc就是标准库中提供的默认的Handler接口实现,所以它要实现ServeHTTP方法。它在ServeHTTP中只做了一件事,那就是调用用户传入的handler,执行具体的业务逻辑,在我们这里就是执行hello(),打印字符串,整个请求响应流程结束</p><pre><code class="go">func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}</code></pre><h3>打印请求耗时v2.0</h3><p>所以我们能想到的比较容易的办法,就是把传入的用户业务函数hello在外面包一层,而非在hello里面去加打印时间的代码。我们可以单独定义一个timeFilter函数,他接收一个参数f,也是http.HandlerFunc类型,然后在我们传入的f前后加上的time.Now、time.Since代码。</p><p>这里注意,timeFilter最终返回值也是一个http.HandlerFunc函数类型,因为毕竟最终还是要传给http.HandleFunc函数的,所以filter必须也要返回这个类型,这样就可以实现最终业务代码与非业务代码分离的同时,实现打印请求时间。详细实现如下:</p><pre><code class="go">// 打印请求时间filter,和具体的业务逻辑hello解耦
func timeFilter(f http.HandlerFunc) http.HandlerFunc {
return func(wr http.ResponseWriter, r *http.Request) {
start := time.Now()
// 这里就是上面我们看过HandlerFun类型中ServeHTTP的默认实现,会直接调用f()执行业务逻辑,这里就是我们的hello,最终会打印出字符串
f.ServeHTTP(wr, r)
timeElapsed := time.Since(start)
// 打印请求耗时
fmt.Println(timeElapsed)
}
}
func hello(wr http.ResponseWriter, r *http.Request) {
wr.Write([]byte("hello\n"))
}
func main() {
// 在hello的外面包上一层timeFilter
http.HandleFunc("/", timeFilter(hello))
if err := http.ListenAndServe(":8080", nil); err != nil {
panic(err)
}
}</code></pre><p>然而这样还是有两个问题:</p><ul><li>如果有十万个路由,那我要在这十万个路由上,每个都去加上相同的包裹代码吗?</li><li>如果有十万个filter,那我们要包裹十万层吗,代码可读性会非常差</li></ul><p>目前的实现很可能造成以下后果:</p><pre><code class="go">http.HandleFunc("/123", filter3(filter2(filter1(hello))))
http.HandleFunc("/456", filter3(filter2(filter1(hello))))
http.HandleFunc("/789", filter3(filter2(filter1(hello))))
http.HandleFunc("/135", filter3(filter2(filter1(hello))))
...</code></pre><p>那么如何更优雅的去管理filter与路由之间的关系,能够让filter3(filter2(filter1(hello)))只写一次就能作用到所有路由上呢?</p><h3>打印请求耗时v3.0</h3><p>我们可以想到,我们先把filter的定义抽出来单独定义为Filter类型,然后可以定义一个结构体Frame,里面的filters字段用来专门管理所有的filter。这里可以从main函数看起。我们添加了timeFilter、路由、最终开启服务,大体上和1.0版本的流程是一样的:</p><pre><code class="go">// Filter类型定义
type Filter func(f http.HandlerFunc) http.HandlerFunc
type Frame struct {
// 存储所有注册的过滤器
filters []Filter
}
// AddFilter 注册filter
func (r *Frame) AddFilter(filter Filter) {
r.filters = append(r.filters, filter)
}
// AddRoute 注册路由,并把handler按filter添加顺序包起来。这里采用了递归实现比较好理解,后面会讲迭代实现
func (r *Frame) AddRoute(pattern string, f http.HandlerFunc) {
r.process(pattern, f, len(r.filters) - 1)
}
func (r *Frame) process(pattern string, f http.HandlerFunc, index int) {
if index == -1 {
http.HandleFunc(pattern, f)
return
}
fWrap := r.filters[index](f)
index--
r.process(pattern, fWrap, index)
}
// Start 框架启动
func (r *Frame) Start() {
if err := http.ListenAndServe(":8080", nil); err != nil {
panic(err)
}
}
func main() {
r := &Frame{}
r.AddFilter(timeFilter)
r.AddFilter(logFilter)
r.AddRoute("/", hello)
r.Start()
}</code></pre><p>r.AddRoute之前都很好理解,初始化主结构,并把我们定义好的filter放到主结构中的切片统一管理。接下来AddRoute这里是核心逻辑,接下来我们详细讲解一下</p><h4>AddRoute</h4><p>r.AddRoute("/", hello) 其实和v1.0里的 http.HandleFunc("/", hello) 其实一摸一样,只不过内部增加了filter的逻辑。在r.AddRoute内部会调用process函数,我将参数全部替换成具体的值:</p><pre><code class="go">r.process("/", hello, 1)</code></pre><p>那么在process内部,首先index不等于-1,往下执行到</p><pre><code class="go">fWrap := r.filters[index](f)</code></pre><p>他的含义就是,取出第index个filter,当前是r.filters[1],r.filters[1]就是我们的logFilter,logFilter接收一个f(这里就是hello),logFilter里的f.ServerHTTP可以直接看成执行f(),即hello,相当于直接用hello里的逻辑替换掉了logFilter里的f.ServerHTTP这一行,在下图里用箭头表示。最后将logFilter的返回值赋值给fWrap,将包裹后的fWrap继续往下递归,index--:<br><img src="/img/remote/1460000040735372" alt="" title=""><br>同理,接下来的递归参数为:</p><pre><code class="go">r.process("/", hello, 0)</code></pre><p>这里就轮到r.filters[0]了,即timeFilter,过程同上:<br><img src="/img/remote/1460000040735373" alt="" title=""><br>最后一轮递归,index = -1,即所有filter都处理完了,我们就可以最终和v1.0一样,调用http.HandleFunc(pattern, f)将最终我们层层包裹后的f,最终注册上去,整个流程结束:<br><img src="/img/remote/1460000040735374" alt="" title=""></p><p>AddRoute的递归版本相对容易理解,我也同样用迭代实现了一个版本。每次循环会在本层filter将f包裹后重新赋值给f,这样就可以将之前包裹后的f沿用到下一轮迭代,基于上一轮的f继续包裹剩余的filter。在gin框架中就用了迭代这种方式来实现:</p><pre><code class="go">// AddRouteIter AddRoute的迭代实现
func (r *Frame) AddRouteIter(pattern string, f http.HandlerFunc) {
filtersLen := len(r.filters)
for i := filtersLen; i >= 0; i-- {
f = r.filters[i](f)
}
http.HandleFunc(pattern, f)
}</code></pre><p>这种filter的实现也叫做洋葱模式,最里层是我们的业务逻辑helllo,然后外面是logFilter、在外面是timeFilter,很像这个洋葱,相信到这里你已经可以体会到了:<br><img src="/img/remote/1460000040735375" alt="" title=""></p><h2>小结</h2><p>我们从最开始1.0版本业务逻辑和非业务逻辑耦合严重,到2.0版本引入filter但实现仍不优雅,到3.0版本解决2.0版本的遗留问题,最终实现了一个简易的filter管理框架</p><h2>关注我们</h2><p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~<br><img src="/img/bVcUVAA" alt="image.png" title="image.png"></p>
【深入理解Go】协程设计与调度原理(下)
https://segmentfault.com/a/1190000040713660
2021-09-21T14:32:55+08:00
2021-09-21T14:32:55+08:00
NoSay
https://segmentfault.com/u/nosay
0
<h2>回顾</h2><p>在上一篇文章中,我们讲述了基本的调度流程。但是我们没有解决如果协程内部如果存在阻塞的情况下该如何处理。比如某个G中存在对channel的收发等操作会发生阻塞,那么这个协程就不能一直占用M的资源,如果一直占用可能就会导致所有M都被阻塞住了。所以我们需要把当前G暂时挂起,待阻塞返回之后重新调度这个G来运行。</p><p>所以,我们需要一种调度机制,及时释放阻塞G占用的资源,重新触发一次调度器的调度逻辑,把当前G先挂起,让其他未执行过的G来执行,从而实现资源利用率的最大化。</p><h2>runtime可以拦截的阻塞</h2><p>什么是runtime可以拦截的?一般是我们在代码中的阻塞,大概有这几种:</p><ul><li>channel生产/消费阻塞</li><li>select</li><li>lock</li><li>time.sleep</li><li>网络读写</li></ul><p>在runtime可以拦截的情况下,会先让G进某种数据结构,待ready后重新调度G来继续执行,阻塞期间不会继续持有线程M。接下来我们以第一种情况channel为例,看看这个流程具体是如何执行的。</p><h3>以channel阻塞为例</h3><p>如刚才channel的那个例子所述,由于阻塞了,所以这个G需要被动的让出所持有的P和M。我们以channel这个例子过一遍这个流程。假设有这么一行代码:</p><pre><code class="go">ch <- 1</code></pre><p>这是一个无缓冲的通道,此时往通道里写入了一个值1。假设此时消费端还没有去消费,这个时候这个通道写入的操作就会阻塞。channel的数据结构叫做hchan:<br><img src="/img/remote/1460000040713662" alt="" title=""></p><pre><code class="go">type hchan struct {
// 通道里元素的数量
qcount uint
// 循环队列的长度
dataqsiz uint
// 指针,指向存储缓冲通道数据的循环队列
buf unsafe.Pointer
// 通道中元素的大小
elemsize uint16
// 通道是否关闭的标志
closed uint32
// 通道中元素的类型
elemtype *_type
// 已接收元素在循环队列的索引
sendx uint
// 已发送元素在循环队列的索引
recvx uint
// 等待接收的协程队列
recvq waitq
// 等待发送的协程队列
sendq waitq
// 互斥锁,保护hchan的并发读写,下文会讲
lock mutex
}</code></pre><p>这里我们重点关注recvq和sendq这两个字段。他们是一个链表,存储阻塞在这个channel的发送端和接收端的G。以上ch <- 1其实底层实现是一个chansend函数,实现如下:</p><pre><code class="go">func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
...
// 尝试从recvq,也就是接收方队列中出队一个元素,如果非空,则说明找到了一个正在等待的receiver,可以发送数据过去了。发送完数据直接return
if sg := c.recvq.dequeue(); sg != nil {
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true
}
// 代码走到这里,说明没有接收方,需要阻塞住等待接收方(如果是无缓冲channel的话)
if !block {
unlock(&c.lock)
return false
}
// 把当前channel和G,打包生成一个sudog结构,后面会讲为什么这样做
gp := getg()
mysg := acquireSudog()
mysg.g = gp
mysg.c = c
...
// 将sudog放到sendq中
c.sendq.enqueue(mysg)
// 调用gopark,这里内部实现会讲M与G解绑,并触发一次调度循环
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
return true
}</code></pre><p>我们总结一下这个chansend的流程(以无缓冲通道为例):</p><ul><li>尝试从recvq中获取消费者</li><li>若recvq不空,发送数据;若为空,则需要阻塞</li><li>获取一个sudog结构,给g字段赋值为当前G</li><li>把sudog挂到sendq上等待唤醒</li><li>调用gopark将M与G解绑,重新触发一次调度,M去执行其他的G</li></ul><h3>为什么是sudog而非G</h3><p>那么这里为什么要用sudog而非原始的G结构呢。答案在于,一个G可以在多个等待链表上。recvq和sendq都是一个waitq结构。是一个双向链表。假如第一个G已经挂到了链表上,那么他必然要存储下一个G的地址,才能成功的完成双向链表的逻辑,如:</p><pre><code class="go">type g struct {
next *g
prev *g
}</code></pre><p>而g又可能挂在多个等待链表上(如select操作,一个G可能会阻塞在多个channel上),所以g里的next和prev必然会有多个值的情况。即next和prev的地址在多个等待链表上的值可能是不一样的。G和等待链表的关系是多对多的关系,所以这个prev和next必然不能在G上直接维护,所以我们就会将G和channel一起打包成sudog结构。它和我们MySQL中多对多的中间表设计有异曲同工之妙,相当于维护了一个g_id和channel_id:</p><pre><code class="go">type sudog struct {
// 原始G结构。相当于g_id
g *g
// 等待链表上的指针
next *sudog
prev *sudog
// 所属的channel,相当于channel_id
c *hchan
}</code></pre><p>最终的效果如下:<br><img src="/img/remote/1460000040713663" alt="" title=""></p><h3>gopark</h3><p>我们知道,在将sudog打包好放到sendq之后,会调用go_park执行阻塞逻辑。go_park内部又会调用park_m方法,切换到g0栈,解除M与当前G的绑定,重新触发一次调度,让M去绑定其他G执行:</p><pre><code class="go">// park continuation on g0.
func park_m(gp *g) {
_g_ := getg()
// 将G的状态设置为waiting
casgstatus(gp, _Grunning, _Gwaiting)
// 解除M与G的绑定
dropg()
// 重新执行一次调度循环
schedule()
}</code></pre><h3>什么时候唤醒</h3><p>那么问题来了,当前G已经阻塞在sendq上了,那么谁来唤醒这个G让他继续执行呢?显然是channel的接收端,在源码中和chansend相对的操作即chanrecv:</p><pre><code class="go">func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
...
// 尝试从sendq中拿一个等待协程出来
if sg := c.sendq.dequeue(); sg != nil {
// 如果拿到了,那么接收数据,刚才我们的channel就属于这种情况
recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true, true
}
if !block {
unlock(&c.lock)
return false, false
}
// 同上,打包成一个sudog结构,挂到recvq等待链表上
gp := getg()
mysg := acquireSudog()
mysg.g = gp
mysg.c = c
c.recvq.enqueue(mysg)
// 同理,拿不到sendq,调用gopark阻塞
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)
...
return true, success
}</code></pre><h3>goready</h3><p>我们看到,chanrecv和chansend逻辑大体一致,这里就不详细展开。由于刚才我们的sendq上有数据,那么这里一定会进入recv()方法接收数据。在这里会调用goready()方法:</p><pre><code class="go">// Mark gp ready to run.
func ready(gp *g, traceskip int, next bool) {
...
status := readgstatus(gp)
// 标记G为grunnable状态
_g_ := getg()
casgstatus(gp, _Gwaiting, _Grunnable)
// 放入runq中等待调度循环消费
runqput(_g_.m.p.ptr(), gp, next)
// 唤醒一个闲置的P来执行G
wakep()
releasem(mp)
}</code></pre><p>goready和gopark是一对操作,gopark是阻塞,goready是唤醒。它会将sudog中绑定的G拿出来,传入ready()方法,把它从gwaiting置为grunnable的状态。并再次执行runqput。将G放到P的本地队列/全局队列上等待调度循环来消费。这样整个流程就能跑起来了。</p><p>总结一下,</p><ul><li>sender调用gopark挂起,一定是由receiver(或close)通过goready唤醒</li><li>receiver调用gopark挂起,一定是由sender(或close)通过goready唤醒</li></ul><h2>runtime不能拦截的阻塞</h2><p>什么是runtime不能拦截的?即CGO代码和系统调用。CGO这里先不讲,由于系统调用这里也有可能发生阻塞,且不属于runtime层面的阻塞,runtime也不能让G进某一个相关数据结构,runtime无法捕获到。</p><p>那么这个时候就需要一个后台监控的特殊线程sysmon来监控这种情况。它会定期循环不断的执行。它会申请一个单独的M,且不需要绑定P就可以执行,优先级最高。</p><p>sysmon的核心是sysmon()方法。监控会在循环中调用retake()方法抢占处于长时间阻塞中的P,该函数会遍历运行时的所有P。retake()的实现如下:</p><pre><code class="go">func retake(now int64) uint32 {
n := 0
for i := 0; i < len(allp); i++ {
_p_ := allp[i]
pd := &_p_.sysmontick
s := _p_.status
//当处理器处于_Prunning或者_Psyscall状态时,如果上一次触发调度的时间已经过去了10ms,我们会调用preemptone()抢占当前P
if s == _Prunning || s == _Psyscall {
t := int64(_p_.schedtick)
if pd.schedwhen+forcePreemptNS <= now {
preemptone(_p_)
}
}
// 当处理器处系统调用阻塞状态时,当处理器的运行队列不为空或者不存在空闲P时,或者当系统调用时间超过了10ms,会调用handoffp将P从M上剥离
if s == _Psyscall {
if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now {
continue
}
if atomic.Cas(&_p_.status, s, _Pidle) {
n++
_p_.syscalltick++
handoffp(_p_)
}
}
}
return uint32(n)
}</code></pre><p>sysmon通过在后台监控循环中抢占P,来避免同一个G占用M太长时间造成长时间阻塞及饥饿问题。</p><h2>关注我们</h2><p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~<br><img src="/img/bVcUVAA" alt="image.png" title="image.png"></p>
【深入理解Go】协程设计与调度原理(上)
https://segmentfault.com/a/1190000040710568
2021-09-19T19:26:34+08:00
2021-09-19T19:26:34+08:00
NoSay
https://segmentfault.com/u/nosay
1
<blockquote>协程是更轻量的用户态线程,是Go语言的核心。那么如何去调度这些协程何时去执行、如何更合理的分配操作系统资源,需要一个设计良好的调度器来支持。<br>什么才是一个好的调度器?能在适当的时机将合适的协程分配到合适的位置,保证公平和效率。</blockquote><h2>从go func说起</h2><pre><code class="go">func main() {
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
time.Sleep(1 * time.Second)
}</code></pre><p>这段代码中,我们开启了10个协程,每个协程打印去打印i这个变量。由于这10个协程的调度时机并不固定,所以等到协程被调度执行的时候才会去取循环中变量i的值。</p><p>我们写的这段代码,每个我们开启的协程都是一个计算任务,这些任务会被提交给go的runtime。如果计算任务非常多,有成千上万个,那么这些任务是不可能同时被立刻执行的,所以这个计算任务一定会被先暂存起来,一般的做法是放到内存的队列中等待被执行。</p><p>而消费端则是一个go runtime维护的一个<strong>调度循环</strong>。调度循环简单来说,就是不断从队列中消费计算任务并执行。这里本质上就是一个生产者-消费者模型,实现了用户任务与调度器的解耦。<br><img src="/img/remote/1460000040710652" alt="" title=""></p><p>这里图中的G就代表我们的一个<strong>goroutine计算任务</strong>,M就代表<strong>操作系统线程</strong></p><h2>调度策略</h2><p>接下来我们详细讲解一下调度策略。</p><h2>生产端</h2><h3>生产端1.0</h3><p>接上面的例子,我们生产了10个计算任务,我们一定是要在内存中先把它存起来等待调度器去消费的。那么很显然,最合适的数据结构就是队列,先来先服务。但是这样做是有问题的。现在我们都是多核多线程模型,消费者肯定不止有一个,所以如果多个消费者去消费同一个队列,会出现线程安全的问题,必须加锁。所有计算任务G都必须在M上来执行。<br><img src="/img/remote/1460000040710570" alt="G-M" title="G-M"></p><h3>生产端2.0</h3><p>在Go中,为了解决加锁的问题,将全局队列拆成了多个本地队列,而这个本地队列由一个叫做<strong>P的结构来管理</strong>。<br><img src="/img/remote/1460000040710571" alt="G-M-P" title="G-M-P"></p><p>这样一来,每个M只需要去先找到一个P结构,和P结构绑定,然后执行P本地队列里的G即可,完美的解决了加锁的问题。</p><p>但是每个P的本地队列长度不可能无限长(目前为256),想象一下有成千上万个go routine的场景,这样很可能导致本地队列无法容纳这么多的Goroutine,所以Go保留了全局队列,用以处理上述情况。</p><p>那么为什么本地队列是数组,而全局队列是链表呢?由于全局队列是本地队列的兜底策略,所以全局队列大小必须是无限的,所以必须是一个链表。</p><p>全局队列被分配在全局的调度器结构上,只有一份:</p><pre><code class="go">type schedt struct {
...
// Global runnable queue.
runq gQueue // 全局队列
runqsize int32 // 全局队列大小
...
}</code></pre><p>那么本地队列为什么做成数组而不是链表呢?因为操作系统内存管理会将连续的存储空间提前读入缓存(局部性原理),所以数组往往会被都读入到缓存中,对缓存友好,效率较高;而链表由于在内存中分布是分散的,往往不会都读入到缓存中,效率较低。所以本地队列综合考虑性能与扩展性,还是选择了数组作为最终实现。</p><p>而Go又为了实现局部性原理,在P中又加了一个runnext的结构,这个结构大小为1,在runnext中的G永远会被最先调度执行。接下来会讲为什么需要这个runnext结构。完整的生产端数据结构如下:<br><img src="/img/remote/1460000040710572" alt="" title=""></p><p>P结构的定义:</p><pre><code class="go">type p struct {
...
// Queue of runnable goroutines. Accessed without lock.
runqhead uint32 // 本地队列队头
runqtail uint32 // 本地队列队尾
runq [256]guintptr // 本地队列,大小256
runnext guintptr // runnext,大小为1
...
}</code></pre><h3>完整的生产流程</h3><ul><li>我们执行go func的时候,主线程m0会调用newproc()生成一个G结构体,这里会先选定当前m0上的P结构</li><li>每个协程G都会被尝试先放到P中的runnext,若runnext为空则放到runnext中,生产结束</li><li>若runnext满,则将原来runnext中的G踢到本地队列中,将当前G放到runnext中。生产结束</li><li>若本地队列也满了,则将本地队列中的G拿出一半,加上当前协程G,这个拼成的结构在源码中叫batch,会将batch一起放到全局队列中,生产结束。这样一来本地队列的空间就不会满了,接下来的生产流程不会被本地队列满而阻塞</li></ul><p>所以我们看到,最终runnext中的G一定是最后生产出来的G,也会被优先被调度去执行。这里是考虑到局部性原理,最近创建出来的协程一定会被最先执行,优先级是最高的。</p><p><img src="/img/remote/1460000040710573" alt="" title=""></p><p>runqput的逻辑:</p><pre><code class="go">func runqput(_p_ *p, gp *g, next bool) {
// 先尝试放到runnext中
if randomizeScheduler && next && fastrand()%2 == 0 {
next = false
}
if next {
retryNext:
// 拿到老的runnext值。
oldnext := _p_.runnext
// 交换当前runnext的老的G和当前G的地址,相当于将当前G放入了runnext
if !_p_.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
goto retryNext
}
// 老的runnext为空,生产结束
if oldnext == 0 {
return
}
// 老的runnext不空,则将被替换掉的runnext赋值给gp,然后下面会set到本地队列的尾部
gp = oldnext.ptr()
}
retry:
// 尝试放到本地队列
h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with consumers
t := _p_.runqtail
// 本地队列没有满,那么set进去
if t-h < uint32(len(_p_.runq)) {
_p_.runq[t%uint32(len(_p_.runq))].set(gp)
atomic.StoreRel(&_p_.runqtail, t+1) // store-release, makes the item available for consumption
return
}
// 如果本地队列不满刚才会直接return;若已满会走到这里,会将本地队列的一半G放到全局队列中
if runqputslow(_p_, gp, h, t) {
return
}
// the queue is not full, now the put above must succeed
goto retry
}</code></pre><h2>消费端</h2><p>消费端就是一个调度循环,不断的从本地队列和全局队列消费G、给G绑定一个M、执行G,然后再次消费G、给G绑定一个M、执行G...那么执行这个调度循环的人是谁呢?答案是g0,每个M上,都有一个g0,控制自己线程上面的调度循环:</p><pre><code class="go">type m struct {
g0 *g // goroutine with scheduling stack
...
}</code></pre><p>g0是一个特殊的协程。为了给接下来M执行计算任务G做准备,g0需要先帮忙获取一个线程M,根据随机算法给M绑定一个P,让P上的计算任务G得到执行,然后正式进入调度循环。整体的调度循环分为四个步骤:</p><ul><li>schedule:g0来执行,处理具体的调度策略,如从P的runnext/本地或者全局队列中获取一个G,然后会调用execute()</li><li>execute:把G和M绑定,初始化一些字段,调用gogo()</li><li>gogo:和操作系统架构相关,会将待执行的G调度到线程M上来执行,完成栈的切换</li><li>goexit:执行一些清理逻辑,并调用schedule()重新开始一轮调度循环</li></ul><p><img src="/img/remote/1460000040710574" alt="" title=""></p><p>即每次调度循环,都会完成g0 -> G -> g0的上下文切换。</p><h3>schedule</h3><p>schedule是调度循环的核心。由于P中的G分布在runnext、本地队列和全局队列中,则需要挨个判断是否有可执行的G,大体逻辑如下:</p><ul><li>先到P上的runnext看一下是否有G,若有则直接返回</li><li>runnext为空,则去本地队列中查找,找到了则直接返回</li><li>本地队列为空,则去阻塞的去全局队列、网路轮询器、以及其他P中查找,一直阻塞直到获取到一个可用的G为止</li></ul><p>源码实现如下:</p><pre><code class="go">func schedule() {
_g_ := getg()
var gp *g
var inheritTime bool
...
if gp == nil {
// 每执行61次调度循环会看一下全局队列。为了保证公平,避免全局队列一直无法得到执行的情况,当全局运行队列中有待执行的G时,通过schedtick保证有一定几率会从全局的运行队列中查找对应的Goroutine;
if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp = globrunqget(_g_.m.p.ptr(), 1)
unlock(&sched.lock)
}
}
if gp == nil {
// 先尝试从P的runnext和本地队列查找G
gp, inheritTime = runqget(_g_.m.p.ptr())
}
if gp == nil {
// 仍找不到,去全局队列中查找。还找不到,要去网络轮询器中查找是否有G等待运行;仍找不到,则尝试从其他P中窃取G来执行。
gp, inheritTime = findrunnable() // blocks until work is available
// 这个函数是阻塞的,执行到这里一定会获取到一个可执行的G
}
...
// 调用execute,继续调度循环
execute(gp, inheritTime)
}</code></pre><p>其中schedtick这里,每执行61次的调度循环,就需要去全局队列尝试获取一次。为什么要这样做呢?假设有十万个G源源不断的加入到P的本地队列中,那么全局队列中的G可能永远得不到执行被饿死,所以必须要在从本地队列获取之前有一个判断逻辑,定期从全局队列获取G以保证公平。</p><p>与此同时,调度器会将全局队列中的调度器会先去计算,如果要所有P平分全局队列中的G,每个P要分得多少个,这里假设会分得n个。然后把这n个G,转移到当前G所在P的本地队列中去。但是最多不能超过P本地队列长度的一半(即128):</p><pre><code class="go">func globrunqget(_p_ *p, max int32) *g {
...
// gomaxprocs = p的数量
// sched.runqsize是全局队列长度
// 这里n = 全局队列的G平分到每个P本地队列上的数量 + 1
n := sched.runqsize/gomaxprocs + 1
if n > sched.runqsize {
n = sched.runqsize
}
if max > 0 &amp;&amp; n > max {
n = max
}
// 平分后的数量n不能超过本地队列长度的一半,也就是128
if n > int32(len(_p_.runq))/2 {
n = int32(len(_p_.runq)) / 2
}
// 执行将G从全局队列中取n个分到当前P本地队列的操作
sched.runqsize -= n
gp := sched.runq.pop()
n--
for ; n > 0; n-- {
gp1 := sched.runq.pop()
runqput(_p_, gp1, false)
}
return gp
}</code></pre><p>这样做的目的是,如果下次调度循环到来的时候,就不必去加锁到全局队列中在获取一次G了,性能得到了很好的保障。</p><p>这里去其他P中查找可用G的逻辑也叫work stealing,即工作窃取。这里也是会使用随机算法,随机选择一个P,偷取该P中一半的G放入当前P的本地队列,然后取本地队列尾部的一个G拿来执行。</p><h2>GMP模型</h2><p>到这里相信大家已经了解了GMP的概念,我们最终来总结一下:</p><ul><li>G:goroutine,代表一个计算任务,由代码和上下文(如当前代码执行的位置、栈信息、状态等)组成</li><li>M:machine,系统线程,想要在CPU上执行代码必须有线程,通过系统调用clone创建</li><li>P:processor,虚拟处理器。M必须获得P才能执行P队列中的G代码,否则会陷入休眠</li></ul><h2>阻塞处理</h2><p>以上只是假设G正常执行的情况。如果G存在阻塞等待(如channel、系统调用)等,那么需要将此时此刻的M与P上的G进行解绑,从而最大化提升CPU利用率。以及从系统调用中陷入、恢复需要触发调度器调度的时机,这部分逻辑会在下一篇文章中做出讲解。</p><h2>关注我们</h2><p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~<br><img src="/img/bVcUVAA" alt="image.png" title="image.png"></p>
【业务学习】分库分表回顾
https://segmentfault.com/a/1190000040698089
2021-09-16T21:04:13+08:00
2021-09-16T21:04:13+08:00
NoSay
https://segmentfault.com/u/nosay
3
<h2>背景</h2><p>前段时间因为业务需要,需要对核心库分库分表,迁移了大概有40亿数据,在此记录以便之后再来看看这种方案的优劣。</p><h2>写在前面</h2><h3>为什么要拆库拆表?</h3><p>随着公司业务快速发展,数据库中的数据量猛增,访问性能也变慢了,优化迫在眉睫。分析一下问题出现在哪儿呢? 关系型数据库本身比较容易成为系统瓶颈,单机存储容量、连接数、处理能力都有限。当单表的数据量达到1000W或100G以后,由于查询维度较多,即使添加从库、优化索引,做很多操作时性能仍下降严重。<br>针对生产环境中出现的这种情况,我们通常有软硬两种方式去处理,“硬”指的是在硬件方面上进行提高,即我们通常挂在嘴边的加存储、加CPU等,这种方案的成本很高(ps:有钱人忽略),并且如果瓶颈不在硬件就很难受了。”软“指的是我们在设计层去做分割,即将打表打散,将压力大的库拆分(星星之火也可以燎原的)。</p><h3>常见的几种拆表方式</h3><p>分库分表包括分库和分表两个部分,在生产中通常包括:垂直分库、水平分库、垂直分表、水平分表四种方式。<br>我们先来了解下垂直和水平的概念:</p><ul><li>“垂直”通常指的是将一个表按照字段分成多表,每个表存储其中一部分字段。</li><li>”水平“ 通常指的是不会改变表结构,将数据按照一定的规则划分到多处。</li></ul><p>我们知道这个概念之后再来解释常见的四种分库分表方式:</p><ul><li>垂直分表:将一个宽表的字段按访问频次、是否是大字段的原则或者其它特定的规则拆分为多个表,这样既能使业务清晰,还能提升部分性能。拆分后,尽量从业务角度避免联查,否则性能方面将得不偿失。</li><li>垂直分库:将多个表按业务耦合松紧归类,分别存放在不同的库,这些库可以分布在不同服务器,从而使访问压力被多服务器负载,大大提升性能,同时能提高整体架构的业务清晰度,不同的业务库可根据自身情况定制优化方案。但是它需要解决跨库带来的所有复杂问题。</li><li>水平分库:将一个表的数据(按数据行)分到多个不同的库,每个库只有这个表的部分数据,这些库可以分布在不同服务器,从而使访问压力被多服务器负载,大大提升性能。它不仅需要解决跨库带来的所有复杂问题,还要解决数据路由的问题(数据路由问题后边介绍)。</li><li>水平分表:将一个表的数据(按数据行)分到多个同一个数据库的多张表中,每个表只有这个表的部分数据,这样做能小幅提升性能,它仅仅作为水平分库的一个补充优化。</li></ul><p>一般来说,在系统设计阶段就应该根据业务耦合松紧来确定垂直分库,垂直分表方案,在数据量及访问压力不是特别大的情况,首先考虑缓存、读写分离、索引技术等方案。若数据量极大,且持续增长,再考虑水平分库水平分表方案。这里我们还要考虑一个问题,对于已有的业务我们如何从原来的单库单表平滑无损的去迁移到新的分片库分片表呢?(读者可以思考下这个问题,这也是本篇的重点)。</p><h3>分库分表后带来的问题</h3><ul><li>主键 id 唯一性。</li><li>分布式事务问题:在执行分库分表之后,由于数据存储到了不同的库上,数据库事务管理出现了困难。</li><li>跨库跨表的 join 问题:在执行了分库分表之后,难以避免会将原本逻辑关联性很强的数据划分到不同的表、不同的库上,这时,表的关联操作将受到限制,我们无法join位于不同分库的表。</li></ul><h2>前期准备</h2><h3>收敛所有直连DB的情况</h3><p>为什么会有这一项呢?因为在我们的业务代码中,很难避免的因为种种的问题导致有些业务场景中是直连的DB的,如果是自己团队的还好,如果不是就会造成不可预知的后果。这部分工作如果是工程相对规范且DB监控做的比较好的情况下还比较好排查,否则将很难去梳理全面,所以监控和规范不是没有用的,如果你说没有,拆库拆表试试吧。<br>收敛除了维护比较好维护之外,业务方对于自己的数据掌控度也比较大,所有的数据写入与读取都有明确的记录(想象一下自己维护的数据不知道被谁偷偷改了的烦恼)。</p><h3>分布式ID生成器</h3><p>我们采用分库分表,最佳的实现方式是在不同的分片表中使用全局唯一id。到这有的同学会问了,“为什么呢?我即使分库分表后,每条数据拆分到每个表中,由于MySQL数据库主键自增的缘故,它们的ID在各个表是独立的,查询的时候 select * from 表名,也能够查询出来对应的信息,欸,这也不需要唯一性ID啊“。但我们换个角度考虑,如:电商场景订单量巨大,订单数据存入数据库,肯定需要对数据库进行了分库分表,欸,你有没有发现每个人的订单号肯定都是不同的,这就体现了全局唯一性ID,当然同学又会说,我再开一个字段去单独存储这个订单id不行么?这就是要刚我啊,少侠手下留情。详见:<a href="https://segmentfault.com/a/1190000040633600">ID生成器详解</a></p><h3>梳理ID类型变更对依赖方的影响</h3><p>既然我们采用了全局唯一id,我们就不得不考虑对依赖方的影响,大概有以下几点:</p><ul><li>下游依赖有没有对库表的id进行强制转换类型,例如强制转化为int32。</li><li>前端是否有直接读取整形的ID,因为Javascript的数字存储使用了IEEE 754中规定的双精度浮点数数据类型,而这一数据类型能够安全存储 -(2^53-1) 到 2^53-1 之间的数值(包含边界值)。JSON 是 Javascript 的一个子集,所以它也遵守这个规则。而int64 类型的数值范围是 -(2^63-1) 到 2^63-1。使用int64 类型json 对于超出范围的数字,会出现解析错误的情况。</li></ul><h3>梳理对于binlog的依赖</h3><p>因为我们需要进行分库分表操作,所以对于原有的依赖老库binlog的地方也要进行相应改造。</p><h3>梳理分片键是否都可以获取到</h3><p>这个可以根据自己的业务看是否需要处理</p><h3>SOP</h3><p>一定要制定详细的SOP且要严格执行</p><h2>方案设计(单库单表到分片库表的切换)</h2><h3>方案一</h3><h4>阶段一:创建新库并同步老库数据到新库</h4><ol><li>根据binlog同步数据</li></ol><h4>阶段二:校验数据一致性</h4><ol start="2"><li>校验binlog同步数据的一致性</li></ol><h4>阶段三:业务切流量</h4><ol start="3"><li>业务代码打开开关,切换为读写新库并放量</li></ol><h3>方案二</h3><h4>阶段一:创建新库并同步数据到新库</h4><h4>阶段二:停服&&校验数据一致性</h4><h4>阶段三:业务切流量</h4><h3>方案三</h3><h4>阶段一: 双写阶段</h4><h5>双写分为几种场景,insert&&update&&delete</h5><ol><li><p>情况一:正常情况(没有失败,更新的记录存在新老库之中)</p><ol><li>Insert 业务方双写新库老库: 新库老库正常插入数据,因为是分库分表,所以采用全局唯一id来代替原来的自增id,历史数据保留原有信息</li><li>Update 对于新老库进行更新(只针对新库有记录)</li><li>Delete 现有业务暂无硬删除,忽略</li></ol></li><li><p>情况二: 异常情况(老库失败,新库失败)</p><ol><li><p>老库失败(老库Insert失败,Update失败)</p><ol><li>因为双写是串行的,所以即使失败了也不需要考虑</li></ol></li><li><p>新库失败(新库Insert库失败,新库Update失败)</p><ol><li>新库Insert失败,记录写库信息,发送失败补偿消息,进行数据修复</li><li>新库Update失败,记录写库信息,发送失败补偿消息,进行数据修复</li></ol></li><li><p>情况三:失败消息重试</p><ol><li>对于新库写库失败的数据,进行重试,对比新老库中的数据,相同则跳过,不相同用老库数据覆盖更新新库数据(加锁),如果此时仍然失败,重新推送到消息队列</li><li>对于新库还未存在的数据进行更新时,根据更新信息从老库读取数据,然后插入到新库,此过程对新老库记录加锁保证数据的一致性</li></ol></li></ol></li></ol><h4>阶段二:迁移数据 (脚本)</h4><p>同步老库数据到新库,采用insert ignore防止新老数据冲突</p><h4>阶段三:数据一致性保证</h4><p>分批次对比新老库数据(脚本)</p><ol><li>相同, 跳过</li><li>不同,老库覆盖新库(仅老库加锁即可)</li></ol><h4>阶段四: 切读</h4><ol><li>灰度放量新库读</li><li>全量切读</li></ol><h4>阶段五: 迁移下游依赖</h4><p>主要是binlog依赖</p><h4>阶段六: 停写老库</h4><h4>阶段七: 回收资源&&清理开关</h4><h3>方案四</h3><h4>阶段一:创建新库并同步数据到新库(开启老库到新库同步数据)</h4><h4>阶段二:校验数据一致性</h4><h4>阶段三:停服Rename 毫秒级别</h4><h4>阶段四:业务切流量至新库 先切读 再切写</h4><h4>阶段五:开启新库到老库增量数据同步,保证新老数据增量是一致的</h4><h3>方案优缺点简单比较</h3><table><thead><tr><th> </th><th>方案一</th><th>方案二</th><th>方案三</th><th>方案四</th></tr></thead><tbody><tr><td>优点</td><td>操作相对简单</td><td>操作相对简单</td><td>1. 整个过程不停服平滑迁移且无数据损失 2. 任何阶段零风险回滚。3. 下游有充裕的时间做迁移。4. 方便读新库灰度。</td><td>1. 操作相对简单2. 下游有充裕的时间做迁移。3. 业务侵入较少。</td></tr><tr><td>缺点</td><td>1. 切流量至新库之后若不符合预期,期间产生的数据均为问题数据且问题数据无法快速恢复。2. 依赖下游迁移进度。3. 切换过程中写入失败的数据会丢失。4. 上线后验证时间短。5. 回滚后再次上线代价较大,以上几个问题有重复出现的风险。</td><td>同1</td><td>1. 业务侵入较大。2. 双写影响接口性能</td><td>1. 在切换过程中服务不可用2. 验证时间短3. 老库到新库写切换不干净会导致数据时序问题(老库写和新库写操作同一条数据时)4. 回滚后再次上线后,以上几个问题有重复出现的风险。</td></tr></tbody></table><h2>总结</h2><p>在合适的业务场景采用不同的方案才是最好的。</p><h2>关注我们</h2><p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~<br><img src="/img/bVcUVAA" alt="image.png" title="image.png"></p>
【业务学习】简述ID生成器
https://segmentfault.com/a/1190000040633600
2021-09-05T19:42:41+08:00
2021-09-05T19:42:41+08:00
NoSay
https://segmentfault.com/u/nosay
2
<h3>引言</h3><p>大家好,好久不见,时隔一年终于又拾起了写博客这件事。<br>在我们日常工作中,我们常需要用全局唯一ID作为数据库主键,或者用于生成订单id,用于生成商品ID等等。本篇主要介绍我们常见的ID生成器的方式:利用数据库生成和雪花算法。</p><h3>利用数据库生成ID</h3><h4>自增ID达成目的</h4><p>利用MYSQL自增主键的特性来构造ID生成器。首先生成一张ID生成器表,每次我们需要生成ID的时候在这个表里插入一行记录,获取到这行记录生成的主键id,就可以拿到一个全局唯一id,基本满足需求。</p><table><thead><tr><th>| id</th><th>ctime</th><th> </th></tr></thead><tbody><tr><td>1</td><td>2021-09-05 12:00:00</td></tr><tr><td>2</td><td>2021-09-05 12:01:00</td></tr></tbody></table><h4>尝试进阶</h4><p>那么有的盆友们会说了,这个方法肯定会影响性能啊,每次请求都需要去连接DB获取id,伤不起啊。没关系,我们优化一下。我们采用分段请求,即每次请求一段ID(例如20个ID)缓存到本地,这样就只需要等本地ID使用完了再去请求了,增加了性能。到这又有朋友说了,每次批量插入数据性能还是不行,而且在多业务方同时使用的时候性能更差,这怎么办呢?别着急,往下看。<br>我们可以将表设计成类似下表这个结构。</p><table><thead><tr><th>biz_tag(业务线)</th><th>max_id</th><th>step(每次申请ID数)</th><th>update_time</th></tr></thead><tbody><tr><td>app</td><td>1000</td><td>1000</td><td>2021-09-05 12:00:00</td></tr><tr><td>pc</td><td>2000</td><td>1000</td><td>2021-09-05 12:00:00</td></tr></tbody></table><p>从上表我们可以看出,每次我们请求ID的时候的大概流程是这样的:获取到当前的max_id -> 拿业务线标识申请(max_id,max_id + step]之间的id -> id使用完 -> 重新发起流程。 这样DB的压力就会小很多,但是呢,在生产环境中也会存在一些问题:例如:</p><pre><code>- 系统性能依赖DB的更新,如果更新DB出现尖刺,服务性能将收到影响
- 强依赖DB的可用性,DB一旦出现宕机将整个不可用
</code></pre><p>对于以上两个问题,我们有以下解决思路:</p><ol><li>预处理,当本地队列使用百分之七十的时候就去申请后新的ID(比例根据业务需求设置)</li><li>在本地缓存一个队列作为“备胎”,当服务不可用且正常队列用完时可以使用“备胎”进行工作,并且不断的去申请新的队列。</li></ol><p>如果我们服务的会出现突增流量的情况,我们也可以动态的调整每次申请的id数,设定适配业务的算法去调整这个step,具体的算法此处不再赘述。</p><p>当然,业界使用数据库去生成DB的方式有很多,在如何保障可用性上做了很多的优化,因为这种方式最终需要强依赖DB</p><h3>雪花算法</h3><p>它给每台机器分配一个唯一标识,然后通过时间戳+标识+自增实现全局唯一ID。这种方式好处在于ID生成算法完全是一个无状态机,无网络调用,高效可靠。这种方法也是我们业务中所常见的方式,下面我们来看看我们采用的的52位和64位的ID怎么生成。</p><h4>64bit</h4><p>42b timestamp + 8b counter + 8b countspace + 6b serverid</p><ul><li>timestamp:毫秒级时间戳</li><li>counter: 毫秒内的自增counter,取值[0, 255]</li><li>countspace:标识counterspace,同一个业务方下可能有不同的counterspace,此6bit不同</li><li>serverid:服务器id,全球唯一</li></ul><h4>52bit</h4><p>32bit timestamp + 16bit counter + 4bit server_id</p><ul><li>timestamp:秒级时间戳</li><li>counter: 秒级自增counter</li><li>serverid:服务器id,全球唯一</li></ul><p>通过以上两种规则即可实现分布式ID的生成,当然,业界还有很多种不同的规则,但是都是一个道理,大家可以按照自己的需求去处理。</p><h3>关注我们</h3><p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~<br><img src="/img/bVcUEHC" alt="image.png" title="image.png"></p>
Go多协程并发环境下的异常处理
https://segmentfault.com/a/1190000023691221
2020-08-19T19:56:47+08:00
2020-08-19T19:56:47+08:00
NoSay
https://segmentfault.com/u/nosay
9
<h2>引言</h2><p>在Go语言中,我们通常会用到panic和recover来抛出错误和捕获错误,这一对操作在单协程环境下我们正常用就好了,并不会踩到什么坑。但是在多协程并发环境下,我们常常会碰到以下两个问题。假设我们现在有2个协程,我们叫它们协程A和B好了:</p><ul><li>如果协程A发生了panic,协程B是否会因为协程A的panic而挂掉?</li><li>如果协程A发生了panic,协程B是否能用recover捕获到协程A的panic?</li></ul><p>答案分别是:会、不能。<br>那么下面我们来一一验证,并给出在具体的业务场景下的最佳实践。</p><h2>问题一</h2><ul><li>如果协程A发生了panic,协程B是否会因为协程A的panic而挂掉?</li></ul><p>为了验证这个问题,我们写一段程序:</p><pre><code class="go">package main
import (
"fmt"
"time"
)
func main() {
// 协程A
go func() {
for {
fmt.Println("goroutine1_print")
}
}()
// 协程B
go func() {
time.Sleep(1 * time.Second)
panic("goroutine2_panic")
}()
time.Sleep(2 * time.Second)
}</code></pre><p>首先主协程开启两个子协程A和B,A协程不停的循环打印goroutine1_print字符串;B协程在睡眠1s后,就会抛出panic(睡眠这一步为了确保在A跑起来开始打印了之后,B才会panic),主协程睡眠2s,等待A、B子协程全部执行完毕,主协程退出。最终打印结果如下:</p><pre><code class="go">...
goroutine1_print
goroutine1_print
goroutine1_print
goroutine1_print
goroutine1_print
goroutine1_print
goroutine1_print
goroutine1_print
goroutine1_print
goroutine1_print
goroutine1_print
goroutine1_print
panic: goroutine2_panicgoroutine1_print
goroutine1_print
goroutine goroutine1_print
19goroutine1_print
goroutine1_print
goroutine1_print
goroutine1_print
[runninggoroutine1_print
]:
goroutine1_print
goroutine1_print
goroutine1_print
main.main.func2()
/Users/jiangbaiyan/go/src/awesomeProject/main.go:18 +0x46
created by main.main
/Users/jiangbaiyan/go/src/awesomeProject/main.go:16 +0x4d
</code></pre><p>我们可以看到,在协程B发生panic之前,协程A一直在打印字符串;然后协程A和panic交替打印字符串,最后主协程与协程A、B全部退出。所以我们可以看到,一个协程panic之后,是会导致所有的协程全部挂掉的,程序会整体退出,到这里我们就验证了第一个问题的答案。<br>至于panic和协程A交替打印的原因,可能是因为panic也需要打印字符串。因为打印也是需要时间的,当我们执行panic这一行代码的时候,到panic真正触发所有协程挂掉,是需要一定的时间的(尽管这个时间很短暂),所以再这一小段时间内,我们会看到交替打印的现象。</p><h2>问题二</h2><ul><li>如果协程A发生了panic,其他协程是否能用recover捕获到协程A的panic?</li></ul><p>还是类似上面那段代码,我们还可以再精简一下:</p><pre><code class="go">package main
import (
"fmt"
"time"
)
func main() {
defer func() {
if e := recover(); e != nil {
fmt.Println("recover_panic")
}
}()
go func() {
panic("goroutine2_panic")
}()
time.Sleep(2 * time.Second)
}</code></pre><p>我们这次只开启一个协程,并在主协程中加入了recover,希望它能够捕获到子协程中的panic,但是结果未能如愿:</p><pre><code class="go">panic: goroutine2_panic
goroutine 6 [running]:
main.main.func2()
/Users/jiangbaiyan/go/src/awesomeProject/main.go:17 +0x39
created by main.main
/Users/jiangbaiyan/go/src/awesomeProject/main.go:16 +0x57
Process finished with exit code 2
</code></pre><p>我们看到,recover并没有生效。所以,哪个协程发生了panic,我们就需要在哪个协程recover,我们改成这样:</p><pre><code class="golang">package main
import (
"fmt"
"time"
)
func main() {
go func() {
defer func() {
if e := recover(); e != nil {
fmt.Println("recover_panic")
}
}()
panic("goroutine2_panic")
}()
time.Sleep(2 * time.Second)
}</code></pre><p>结果成功打印recover_panic字符串:</p><pre><code class="go">recover_panic
Process finished with exit code 0</code></pre><p>所以我们的答案也得到了验证:协程A发生panic,协程B无法recover到协程A的panic,只有协程自己内部的recover才能捕获自己抛出的panic。</p><h2>最佳实践</h2><p>我们先假设有这样一个场景,我们要开发一个客户端,这个客户端需要调用2个服务,这2个服务没有任何先后顺序的依赖,所以我们可以开启2个goroutine,通过并发调用这两个服务来获得性能提升。那么这个时候我们刚才所谈到的问题一就成了问题。<br>通常来讲,我们不希望其中一个服务调用失败,另一个服务调用也跟着失败,而是要继续执行完其他几个服务调用逻辑,这个时候我们该怎么办呢?<br>聪明的你一定会想到,我在每个协程内部编写一个recover语句,让他接住每个协程自己可能会发生的panic,就能够解决一个协程panic而导致所有协程挂掉的问题了。我们编写如下代码,这就是在业务开发中,结合问题二解决问题一的最佳实践:</p><pre><code class="go">// 并发调用服务,每个handler都会传入一个调用逻辑函数
func GoroutineNotPanic(handlers ...func() error) (err error) {
var wg sync.WaitGroup
// 假设我们要调用handlers这么多个服务
for _, f := range handlers {
wg.Add(1)
// 每个函数启动一个协程
go func(handler func() error) {
defer func() {
// 每个协程内部使用recover捕获可能在调用逻辑中发生的panic
if e := recover(); e != nil {
// 某个服务调用协程报错,可以在这里打印一些错误日志
}
wg.Done()
}()
// 取第一个报错的handler调用逻辑,并最终向外返回
e := handler()
if err == nil && e != nil {
err = e
}
}(f)
}
wg.Wait()
return
}</code></pre><p>以上方法调用示例:</p><pre><code class="go">// 调用示例
func main() {
// 调用逻辑1
aRpc := func() error {
panic("rpc logic A panic")
return nil
}
// 调用逻辑2
bRpc := func() error {
fmt.Println("rpc logic B")
return nil
}
err := GoroutineNotPanic(aRpc, bRpc)
if err != nil {
fmt.Println(err)
}
}</code></pre><p>这样我们就实现了一个通用的并发处理逻辑,每次调用我们只需要把业务逻辑的函数传入即可,不用每次自己单独编写一套并发控制逻辑;同时调用逻辑2就不会因为调用逻辑1的panic而挂掉了,容错率更高。在业务开发中我们可以参考这种实现方式~</p><h2>关注我们</h2><p>欢迎各位订阅我们的公众号,关注博主下次不迷路~</p><p><img src="https://segmentfault.com/img/bVbESbH" alt=" title="Nosay"" title=" title="Nosay""></p>
【分布式系统遨游】分布式高可靠之负载均衡
https://segmentfault.com/a/1190000022572256
2020-05-07T19:58:14+08:00
2020-05-07T19:58:14+08:00
NoSay
https://segmentfault.com/u/nosay
1
<h2>引言</h2><p>负载均衡是分布式可靠性中非常关键的一个问题或技术,在一定程度上反映了分布式系统对业务处理的能力。比如,早期的电商抢购活动,当流量过大时,你可能就会发现有些地区可以购买,而有些地区因为服务崩溃而不能抢购。这,其实就是系统的负载均衡出现了问题。那么,究竟什么是负载均衡呢?接下来我们一起来看一看。</p><h2>什么是负载均衡</h2><p>先举个生活中常见的例子,我们在去超市购物结账的时候,会发现有几个收银窗口,平常的时候假设只有一个窗口和一个收银员。如果在平常人流量不大的情况上每个顾客需要等待时间很短,因为人流量小。但一旦到周六末的时候,人流量增大,每个人等待的时间就相应的变长了。长久以往,顾客们肯定不愿意去了,怎么办呢?我们新开几个窗口,多招几个收银员就好了。当然,我们也需要去督促顾客们去“均匀”排队,这样每个收银员都有一样的顾客,才能最大程度的最大减少排队时间,提升用户体验。这种“不患寡,而患不均”的思想就是负载均衡的基本原理。</p><h2>负载均衡的分类</h2><p>通常情况下,负载均衡分为两种:</p><ul><li>请求负载均衡,即将用户的请求均衡地分发到不同的服务器进行处理;</li><li>数据负载均衡,即将用户更新的数据分发到不同的存储服务器;</li></ul><p>数据分布算法很重要的一个衡量标准,就是均匀分布。可见,哈希和一致性哈希等,其实就是数据负载均衡的常用方法。因为这些方法在上篇文章中已经讲述,所今天, 我就与你着重说说服务请求的负载均衡技术吧。</p><h2>请求负载均衡</h2><p>在分布式系统中,服务请求的负载均衡是指,当处理大量用户请求时,请求应尽量均衡地分配到多台服务器进行处理,每台服务器处理其中一部分而不是所有的用户请求,以完成高并发的请求处理,避免因单机处理能力的上限,导致系统崩溃而无法提供服务的问题。那么,它又是怎样去实现的呢?</p><h3>请求负载均衡的方法</h3><p>一般来说,在计算机领域中的不同层有着不同的负载均衡的方法,例如网络层通常有基于DNS,IP报文等的负载均衡方法;在中间件层常见的均衡策略主要包括轮询策略、随机策略、哈希和一致性哈希策略等等,而中间件层也就是我们所说的分布式系统层是我们今天着重去分析的。接下来,我们就具体看看吧。</p><h4>轮询策略</h4><p>轮询策略是非常简单的且很常用的一种负载均衡策略,目的就是让服务器轮流处理用户请求,尽可能的让各个服务器处理的请求数相同。用一个很形象的例子来表达就是:学校中打扫卫生总是会让某个班级轮流去打扫一片区域,这就是轮询。</p><p>那有的人就有疑问了,在学校打扫卫生如果打扫的不干净或者别的原因会处罚多次打扫,对于这种惩罚性的措施是不是要特殊处理呢?其实这种也是轮询,但是因为需要处理,所以我们把它称之为加权轮询。说到这我们就知道了轮询策略其实是分为两种的:顺序轮询和加权轮询。</p><p>首先我们来看下顺序轮询,假设我们有6个请求,编号分别为1-6,有三个服务器去处理请求,编号为1-3,如果我们采用顺序轮询策略的花,服务器会按照123顺序轮流进行请求,大致步骤如下图所示。</p><table><thead><tr><th>步骤</th><th>请求编号</th><th>选择的服务器编号</th></tr></thead><tbody><tr><td>1</td><td>1</td><td>1</td></tr><tr><td>2</td><td>2</td><td>3</td></tr><tr><td>3</td><td>3</td><td>3</td></tr><tr><td>4</td><td>4</td><td>1</td></tr><tr><td>5</td><td>5</td><td>2</td></tr><tr><td>6</td><td>6</td><td>3</td></tr></tbody></table><p>最终的处理结果是,服务器1处理请求1和请求4,服务器2处理请求2和请求 5,服务器3处理请求3和请求6。</p><p>那什么是加权轮询呢?首先,加权是我们需要增加一个优先级,还是上边的例子,这次我们增加一个优先级,服务器1-3分配了优先级{4,1,1},这6个请求过来的时候还当成6个步骤,如图所示。</p><table><thead><tr><th>步骤</th><th>请求编号</th><th>选择的服务器编号</th><th>各服务器优先级</th></tr></thead><tbody><tr><td>1</td><td>1</td><td>1</td><td>{3,1,1}</td></tr><tr><td>2</td><td>2</td><td>1</td><td>{2,1,1}</td></tr><tr><td>3</td><td>3</td><td>1</td><td>{1,1,1}</td></tr><tr><td>4</td><td>4</td><td>1</td><td>{0,1,1}</td></tr><tr><td>5</td><td>5</td><td>2</td><td>{0,0,1}</td></tr><tr><td>6</td><td>6</td><td>3</td><td>{4,1,1}</td></tr></tbody></table><p>大致步骤就是在优先级确定的情况下,每来一个请求会把服务器优先级减1,当变为0后就轮询下一个服务器,最后都减为0之后重新开始。</p><p>最终的处理结果是,服务器1处理请求1~4,服务器2处理请求5,服务器3会处理请求6。当然这种加权轮询只是很基础的一个策略,我们在实际应用中可以根据自己的需求去作出相应的改变,例如Nginx 默认的负载均衡策略就是一种改进的加权轮询策略。</p><p>总结一下,轮询策略的优点就是,实现简单,且对于请求所需开销差不多时,负载均衡效果比较明显, 同时加权轮询策略还考虑了服务器节点的异构性,即可以让性能更好的服务器具有更高的优先级,从而可以处理更多的请求,使得分布更加均衡。而缺点是每次请求到达的目的节点不确定,不适用于有状态请求的场景。并且,轮询策略主要强调请求数的均衡性,所以不适用于处理请求所需开销不同的场景。所以,轮询策略适用于用户请求所需资源比较接近的场景。</p><h4>随机策略</h4><p>随机策略也比较容易理解,指的就是当用户请求到来时,会随机发到某个服务节点进行处理,可以采用随机函数实现。这里,随机函数的作用就是,让请求尽可能分散到不同节点,防止所有请求放到同一节点或少量几个节点上。</p><p><img src="/img/bVbGSf2" alt="fzjh1.png" title="fzjh1.png"></p><p>这种方式的优点是,实现简单,但缺点也很明显,与轮询策略一样,每次请求到达的目的节点不确定,不适用于有状态的场景,而且没有考虑到处理请求所需开销。除此之外,随机策略也没有考虑服务器节点的异构性,即性能差距较大的服务器可能处理的请求差不多。<br>因此,随机策略适用于集群中服务器节点处理能力相差不大,用户请求所需资源比较接近的场景。</p><p>那么对于轮询策略和随机策略都不能定位到同一个服务器的问题我们该怎么处理呢?首先我们来看一个为什么需要落到同一个服务器的例子,比如我们这台服务器是缓存服务器,那么就会对缓存同步带来巨大的挑战,尤其是系统繁忙时,主从同步比较慢,可能会造成同一客户端两次访问得到不同的结果。解决方案就是利用哈希算法定位到对应的服务器,接下来我们就看一看哈希算法。</p><h4>哈希和一致性哈希策略</h4><p>听到这个名字你可能会很熟悉,因为我们在之前的《分布式存储》一文中已经讲解过两个算法。当时说数据分布算法的均匀性,一方面指数据的存储均匀,另一方面也指数据请求的均匀。这里的数据请求就是用户请求的一部分,所以说哈希、一致性哈希、带有限负载的一致性哈希和带虚拟节点的一致性哈希算法,同样适用于请求负载均衡。因为之前的文章已经对这几种算法作出介绍,此处不再赘述。</p><p>那么,哈希与一致性策略的优点是什么呢?哈希与一致性策略的优点是,哈希函数设置合理的话,负载会比较均衡。而且,相同 key 的请求会落在同一个服务节点上,可以用于有状态请求的场景。除此之外,带虚拟节点 的一致性哈希策略还可以解决服务器节点异构的问题。同样,它的缺点是什么呢?我们之前在介绍哈希和一致性哈希算法的时候有说过,当节点故障时候的处理存在数据迁移(哈希)和数据倾斜(一致性哈希)的问题,所以这两种策略也没考虑请求开销不同造成的不均衡问题。</p><p>除了以上这些策略,还有一些负载均衡策略比较常用。比如,根据服务节点中的资源信息 (CPU,内存等)进行判断,服务节点资源越多,就越有可能处理下一个请求;再比如, 根据请求的特定需求,如请求需要使用 GPU 资源,那就需要由具有 GPU 资源的节点进行 处理等。</p><h2>下期预告</h2><p>【分布式系统遨游】分布式缓存</p><h2>关注我们</h2><p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p><p><img src="https://segmentfault.com/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【分布式系统遨游】分布式数据复制
https://segmentfault.com/a/1190000022561993
2020-05-06T23:02:00+08:00
2020-05-06T23:02:00+08:00
NoSay
https://segmentfault.com/u/nosay
1
<p>今天我们来讨论分布式数据复制。</p>
<h2>为什么需要数据复制</h2>
<p>我们在上一篇文章中谈到了分布式数据的分片存储技术,其核心在于将原数据划分为多个数据子集,然后将每个子集分散到不同的集群节点上存储,以实现负载均衡。那么,这里其实是有一个问题的。由于每个节点上面的数据完全没有交集,假设其中一个节点挂了,那么这个节点上的数据就丢失了。所以,我们需要一种技术方案,在数据分片的基础上,实现分布式集群的高可用性与可靠性,这就是我们今天要讨论的分布式数据复制技术。</p>
<h2>数据复制是什么</h2>
<p>数据复制就是一种数据备份技术。比如现在有集群节点1,那么我们会启用节点2,将节点1上的数据拷贝到节点2上,这样节点1与节点2上的数据完全相同。当节点1挂了的时候,可以立即启用节点2,继续对外提供数据存取服务,实现了分布式存储系统的自动容错。</p>
<h2>数据复制的几种方案</h2>
<p>要想节点2能够完全代替节点1进行工作,那么节点2必须要与节点1上的数据一致。但是这个一致性也要分强弱。我们接下来回顾一下CAP理论。<br>P就是指在集群中网络出现故障的时候,集群内的网络就被隔离开了(分区),但是必须能够对外正常提供服务。所以,由于分布式系统中的网络故障不可避免,P是必须满足的,否则就退化成了单机系统。另一方面,这条原则就让我们必须将数据分散到多个物理节点上,才能在某一条网络链路出现故障的时候,让副本节点顶上来,对外继续正常提供服务,这也就是我们为什么要做数据冗余备份与数据复制。<br><img src="https://baiyan-1300428464.cos.ap-beijing.myqcloud.com/article/2020/5/6/1588776067701.png" alt="" title=""><br>但是CAP理论又告诉我们,只能满足CP(偏一致性)或者AP(偏可用性)。那么,为什么CAP不能同时满足呢?假设我们就要满足CP,各节点间实现强一致性。那么,假设我对其中一个节点进行写操作,那么这个写操作就会一直阻塞等待,直到将更新后的数据同步到另一个节点上才会返回写操作成功。返回之后,才可以进行后续的读请求。这样保证了无论读哪个节点,数据都是一致的。但是,由于我们在复制完成之前一直会阻塞,会导致写操作等待时间较长,这样就损失了一部分可用性。所以,CAP是不可能同时满足的。像支付这种场景可能会追求CP(一致性),而电商这种场景更追求AP(可用性)。<br>基于以上分析,就可以引出我们三种不同的数据复制方案:</p>
<ul>
<li>同步复制技术,注重一致性;</li>
<li>异步复制技术,注重可用性;</li>
<li>半同步复制技术,介于前两者之间。</li>
</ul>
<p>接下来我们以数据库主从同步为例,逐一讨论。</p>
<h3>同步复制技术</h3>
<p>同步复制技术保证的是各节点之间的数据强一致性,所以,像我们刚才说的那样,在数据库读写分离时,用户更新数据的请求会打到主数据库节点上。主数据库必须要同步到备数据库之后才可给用户返回,即如果主数据库还没有同步到备数据库,用户的更新操作会一直阻塞。这种方式保证了数据的强一致性,但牺牲了系统的可用性,即CP。<br><img src="https://baiyan-1300428464.cos.ap-beijing.myqcloud.com/article/2020/5/6/1588775968226.png" alt="" title=""><br>这种方案适用于对数据一致性有严格要求的场合,比如金融、交易之类的场景。</p>
<h3>异步复制技术</h3>
<p>异步复制技术主要保证可用性,各节点之间容忍暂时的数据不一致,保证最终一致性即可。所以当用户请求更新数据时,主数据库处理完请求后可直接给用户响应,而不必等待备数据库完成同步,即备数据库会异步进行数据的同步,用户的更新操作不会因为备数据库未完成数据同步而导致阻塞。这种方式保证了系统的可用性,但牺牲了数据的一致性,即AP。<br><img src="https://baiyan-1300428464.cos.ap-beijing.myqcloud.com/article/2020/5/6/1588776210892.png" alt="" title=""><br>MySQL 集群默认的数据复制模式,采用的就是异步复制技术。我们主要关注两个关键的组件:binlog与relay log。<br>既然是异步,那么我们直接在主节点上执行完就可以返回了,让备数据库获取我们的更新内容,自己去做同步就好了,不用那么着急。所以,我们需要找个地方记下来做了什么操作,这里binlog就派上用场了。MySQL会往binlog中写入主数据库执行的所有更新操作,以便备数据库获取更新信息。然后备数据库专门启动一个IO线程读取binlog的内容,然后写入自己的relay log中。备数据库会有一个后台线程定期检查relay log的内容,一旦发现有更新,立即重放relay log,最终与主节点的数据达成一致:<br><img src="https://baiyan-1300428464.cos.ap-beijing.myqcloud.com/article/2020/5/6/1588776525846.png" alt="" title=""><br>这里我再额外提一下relay log,网上很多文章都没有说为什么要使用relay log。由于relay log是在从节点那一侧,那么从节点就可以在relay log上记录当前同步的进度并做各种标记。就相当于把公共资源复制了一份据为己有,我就可以基于这份复制的东西为所欲为了。而如果没有relay log,直接去读取并重放binlog的话,就没法实现了。此外,读取binlog并直接重放bionlog这个过程,相比直接拷贝binlog的内容到relay log这种方案,多了一个重放这个步骤,这就要多占用很多主从节点之间网络连接的时间资源,导致性能下降。所以,这就是为什么要使用relay log。<br>异步复制技术大多应用在对用户请求响应时延要求很高的场景。比如电商、秒杀这类场景。这时后台的数据库或缓存如果采用同步复制技术,用户可能就会因服务响应速度慢而崩溃,最终导致用户流失。因此这种场景最好采用异步复制技术,优先保证可用性。</p>
<h3>半同步复制技术</h3>
<p>半同步复制技术顾名思义,介于前两种方案之间。满足了一部分可用性及一致性。半同步复制技术主要应用在一主多从场景中。主节点不用等待所有备节点同步完毕就便可以响应写操作成功。也就是说,主节点可以等待一部分备节点同步完成后,就可以响应用户写操作执行成功。<br>而这里基于要等待多少个节点响应成功才算写操作成功,有细分为两种方案:</p>
<ul>
<li>主节点收到一个备节点同步成功,就返回写操作成功;</li>
<li>主节点收到超过一半节点回复数据同步成功后,再给用户响应写操作成功。</li>
</ul>
<p>这两种方案相对而言,第一种更偏向可用性,第二种更偏向一致性。在MySQL一主多备的场景下,一般采用的是第一种半同步复制技术。而Zookeeper则采用了第二种半同步复制技术。<br>实际上,多数的分布式存储系统与中间件,可以通过配置来选择不同的数据复制技术。我们根据我们自己的业务场景,选择合适的数据复制方案即可。</p>
<h2>下期预告</h2>
<p>【分布式系统遨游】分布式高可靠之负载均衡</p>
<h2>关注我们</h2>
<p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p>
<p><img src="https://segmentfault.com/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【分布式系统遨游】分布式数据存储
https://segmentfault.com/a/1190000022549422
2020-05-05T19:44:57+08:00
2020-05-05T19:44:57+08:00
NoSay
https://segmentfault.com/u/nosay
3
<p>今天我们来讨论分布式数据存储。</p>
<h2>引言</h2>
<p>由于现在我们有了很多个节点,我们就可以把之前一台机器上面的数据,打散到各个分布式集群中的节点上面去,以充分利用集群资源。其流程大概如图所示:<br><img src="/img/remote/1460000022549439" alt="" title=""><br>比如,12306网站可以将京广线的订单数据存在机器A上,京沪线的订单数据存在机器B上,这样如果用户想购买京广线的火车票,只需要从机器A查询即可,其时间复杂度相比全量查询会大大降低,解决了之前单机架构的瓶颈问题,这里每台机器上的数据是没有交集的,这就是数据分片:<br><img src="/img/remote/1460000022549429" alt="" title=""><br>那么,经过如此改进之后的存储系统,其复杂度究竟有哪些变化呢?</p>
<ul>
<li>存储数据的时候,需要选择合适的某一个集群节点来存储</li>
<li>查询数据的时候,需要找到之前我们存储数据的那个节点</li>
</ul>
<p>所以,其复杂度相比单机架构,多了找机器这个步骤。需要有一种算法,将我们的数据请求,导向某个特定的数据节点上,才能正确的找到我们需要的数据。在上面的例子,我们利用了一个按照业务的范围去分片的方法,将不同铁路线的数据分散到对应的机器。同时,还有哈希、一致性哈希等其他数据分片方法。那么,面对这么多种分片方案,我们究竟该如何设计和选择呢?</p>
<h2>数据分片设计原则</h2>
<p>在分布式数据存储中,主要的设计原则有三种:数据均匀、数据稳定、节点差异。我们来一个一个分析:</p>
<ul>
<li>数据均匀:即数据要均匀的分布在各个节点上,如果有100G的数据,有4个节点,我们希望每个节点上都有25G左右的数据</li>
<li>数据稳定:当集群中某个节点挂掉了需要下线的时候,每个节点内部的数据不会发生大规模迁移,所有数据都要重新分布</li>
<li>节点差异:也被称为“节点异构性”。这里的差异是指每个节点的配置可能是有差距的。有些节点配置高,性能好,那我们就要充分利用他们,给他们分配更多的数据量</li>
</ul>
<p>这里我们注意了,数据均匀和节点差异看起来好像是冲突的,但是不是这样的。数据均匀是在节点之间配置差距不大的前提下,让数据均匀的分布。如果节点之间配置差距很大,那么还是应该以能者多劳为主。<br>对于业内主要的数据分片方案,有哈希和一致性哈希两种。</p>
<h2>哈希</h2>
<p>哈希算法在业内有非常之多的实现,其中最广为人知的一种就是取余操作。假设我们有4台机器,我们对按照如下的算法来计算:数据(可以是id) % 机器的数量(4) = 该数据所属的节点编号。在所有的数据计算完毕之后,其分布如下:<br><img src="/img/remote/1460000022549425" alt="" title=""></p>
<h4>优点</h4>
<p>我们来分析一下这种方案的优缺点。我们可以看到,当哈希函数设置得当,那么可以很好地保证所有数据都在每个节点上均匀分布,且实现较为简单。这就是它的优点。</p>
<h4>缺点</h4>
<p>但是假如我们其中一个节点出问题了,需要下线这个节点。下线完成之后,目前我们的机器节点数量为3。如果再次使用相同的哈希函数,对4取余,那么会造成计算错误。所以,我们需要重新计算所有的数据的分布情况,即对3取余,才能得到正确的数据分布。普通的哈希方法适用于集群中节点数量较为稳定的场景。一旦集群中的节点发生变更,会导致所有数据重新计算哈希值。<br>为了解决上面的问题,一致性哈希应运而生。</p>
<h2>一致性哈希</h2>
<p>一致性哈希,是指把节点和数据都放到一个首尾相连的环上。节点应该放在环的哪个位置上,可以根据节点的IP地址进行哈希,然后放到环对应的位置上;而数据要放在哪个节点上,则是先对数据进行哈希运算,然后找到距离运算结果最近的环上的机器节点:<br><img src="/img/remote/1460000022549430" alt="" title=""><br>图中蓝色是机器节点经过哈希运算后所在的位置,黄色是数据的哈希运算结果。针对黄色的数据节点,100、200、300、400这4条数据应该放到顺时针的第一顺位的节点上,即放到数值为400的蓝色节点上。其他的以此类推即可。</p>
<h4>优点</h4>
<p>一致性哈希解决了哈希算法在集群中的节点发生变更的情况下,会导致所有数据重新计算哈希值的问题。我们分析一下,为什么解决了这个问题。假设我们图中400的蓝色节点下线了,我们只需要将100、200、300、400的这几条数据,存储到下一个600的蓝色节点即可,这样只影响到了100、200、300、400这几条数据与600这个节点,并不会影响集群中的其他节点与数据。</p>
<h4>缺点</h4>
<p>那么这种一致性哈希方案也并不完美。我们看到,600这个蓝色节点会随着400的蓝色几点下线而变得更加劳累,会有更多的数据(100、200、300、400)被重新定位到这个600蓝色节点上,导致了数据分布不均匀的问题,长时间高负载也可能导致600这个节点也更容易发生故障,进而导致后面的900节点也发生故障,产生雪崩效应。为了解决数据分布不均匀的问题,“带有限负载的一致性哈希”方法来了。</p>
<h3>有负载的一致性哈希</h3>
<p>看到这个名字大家也明白了,说白了就是每个节点都有一个存储容量上限。如果超出了这个上限,就放到顺时针方向上的下一个节点即可:<br><img src="/img/remote/1460000022549426" alt="" title=""><br>假设400这个蓝色节点在添加100、200、300这几项数据之后,已经达到了存储上限,那么继续添加400这项数据,该条数据则不会像之前那样,分给400这个节点,而是会在顺时针方向上,寻找到600这个蓝色节点并存储,从而解决了某一个节点负载过高的问题,实现了一定程度上的均匀分布。</p>
<h3>有虚拟节点的一致性哈希</h3>
<p>以上两种方案均是基于节点之间的性能差不多的情况下,为了达到数据均匀分布的目的,产生的两种方案。而如果这些节点机器之间的性能差异很大,那么上面这两种方案就行不通了,这就是我们之前提到的“节点异构性”的问题。我们需要实现能者多劳。有虚拟节点的一致性哈希解决了这个问题。<br>其核心思想就是,根据每个节点的性能,为他们生成不同数量的虚拟节点。性能越强,虚拟节点的数量越多:<br><img src="/img/remote/1460000022549428" alt="" title=""><br>我们假设节点1(浅绿色)性能最差,节点2(绿色)性能一般,节点3性能最强(蓝色),那么我们就为节点1生成1个虚拟节点,为节点2生成2个虚拟节点,为节点3生成3个虚拟节点。这样,性能最强的节点3就会接受到400、500、600这几条数据,节点2和1的数据量依次减少。这个算法的本质思想就是,让性能强的节点与上一个节点的环上间隔变长,就能让更多的数据打到这个节点上。<br>当然,这种方案的实现比前两种更为复杂,在新增或者删除一个节点的时候,维护成本也更高。所以,我们应当按照不同的业务场景,结合集群中各节点的性能,选出一种最佳的数据分片方案。</p>
<h2>下期预告</h2>
<p>【分布式系统遨游】分布式复制</p>
<h2>关注我们</h2>
<p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p>
<p><img src="https://segmentfault.com/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【分布式系统遨游】分布式计算
https://segmentfault.com/a/1190000022518475
2020-04-30T12:39:49+08:00
2020-04-30T12:39:49+08:00
NoSay
https://segmentfault.com/u/nosay
1
<h2>引言</h2>
<p>随着计算技术的发展,有些应用需要非常巨大的计算能力才能完成,如果采用集中式计算,需要耗费相当长的时间来完成。怎么解决这个问题呢?当然是把这些问题分成多份,在不同的机器上去解决,众人拾柴火焰高嘛。而分布式计算就是将该应用分解成许多小的部分,分配给多台计算机进行处理。这样可以节约整体计算时间,大大提高计算效率。在分布式中,针对这种情况我们大概有两种计算模式:MapReduce和Stream,接下来就让我们来看看它们是何方神圣。注意:本文讲述的两种计算模式是以特定数据类型(分别对应静态数据和动态数据)作为计算维度。而在分布式领域中还有另外两种分布式计算模式,即 Actor 和流水线。它们是以计算过程或处理过程的维度的,不做本文讲述的重点。</p>
<h2>Map Reduce</h2>
<p>相信大家都有听说过Hadoop这个框架,这个框架主要用来解决海量数据的计算问题。那么它是如何做到海量数据的计算呢?你可能会想,既然是海量数据,有这么大的规模,那就让多个进程去处理,最后去汇总一下结果,这样就可以加大马力,提升速度了。<br>没错,就是这种想法,在分布式领域中我们称这种叫作MR(Map Reduce)模式。我们上边分成多个进程处理的想法可以归结成一个词--分而治之,是的,MR就是一个典型的分而治之(简称分治法)的代表。</p>
<h3>分治法是什么</h3>
<p>分治法就是将一个复杂的、难以直接解决的大问题,分割成一些规模较小的、可以比较简单的或直接求解的子问题,这些子问题之间相互独立且与原问题形式相同,递归地求解这些子问题,然后将子问题的解合并得到原问题的解。比如我们统计全国人口数量。</p>
<h3>分治法的使用场景</h3>
<ol>
<li>问题规模比较大或复杂,且问题可以分解为几个规模较小的、简单的同类型问题进行求解;</li>
<li>子问题之间相互独立,不包含公共子问题;</li>
<li>子问题的解可以合并得到原问题的解。</li>
</ol>
<h3>分治法解决问题的步骤</h3>
<ol>
<li>分解原问题。将原问题分解为若干个规模较小,相互独立,且与原问题形式相同的子问题。</li>
<li>求解子问题。若子问题规模较小且容易被解决则直接求解,否则递归地求解各个子问题。</li>
<li>合并解,就是将各个子问题的解合并为原问题的解。</li>
</ol>
<h3>MR的抽象模型</h3>
<p>了解了分治法之后,我们再来看看本段的主角MR,如下图所示,MapReduce 分为 Map 和 Reduce 两个核心阶段,其中 Map 对应“分”, 即把复杂的任务分解为若干个“简单的任务”执行;Reduce 对应着“合”,即对 Map 阶段的结果进行汇总。</p>
<p><img src="/img/bVbGEcS" alt="mr1.png" title="mr1.png"></p>
<p>在第一阶段,也就是 Map 阶段,将大数据计算任务拆分为多个子任务,拆分后的子任务通常具有如下特征: 相对于原始任务来说,划分后的子任务与原任务是同质的,比如原任务是统计全国人口数,拆分为统计省的人口数子任务时,都是统计人口数;并且,子任务的数据规模和计算规模会小很多。多个子任务之间没有依赖,可以独立运行、并行计算,比如按照省统计人口数,统计河北省的人口数和统计湖南省的人口数之间没有依赖关系,可以独立、并行的统计。<br>第二阶段,也就是 Reduce 阶段,第一阶段拆分的子任务计算完成后,汇总所有子任务的 计算结果,以得到最终结果。也就是,汇总各个省统计的人口数,得到全国的总人口数。</p>
<p>上边了解了这么多,那么在 MapReduce 里,各个组件是如何分工完成一个复杂任务的呢?</p>
<h3>MR的工作原理</h3>
<p>为了解答这个问题,我先带你了解一下 MapReduce 的组件结构。</p>
<p><img src="/img/bVbGEcT" alt="mr2.png" title="mr2.png"></p>
<p>如上图所示,MapReduce 主要包括以下三种组件:</p>
<ul>
<li>Master,也就是 MRAppMaster,该模块像一个大总管一样,独掌大权,负责分配任 务,协调任务的运行,并为 Mapper 分配 map() 函数操作、为 Reducer 分配 reduce() 函数操作。</li>
<li>Mapper worker,负责 Map 函数功能,即负责执行子任务。</li>
<li>Reducer worker,负责 Reduce 函数功能,即负责汇总各个子任务的结果。</li>
</ul>
<p>基于这三种组件,MapReduce 的工作流程如下所示:</p>
<p><img src="/img/bVbGEcU" alt="mr3.png" title="mr3.png"></p>
<p>程序从 User Program 开始进入 MapReduce 操作流程。其中图中的“step1,step2, ...,step6”表示操作步骤。</p>
<ul>
<li>step1:User Program 将任务下发到 MRAppMaster 中。然后MRAppMaster 执行任 务拆分步骤,把 User Program 下发的任务划分成 M 个子任务(M 是用户自定义的数 值)。假设,MapReduce 函数将任务划分成了 5 个,其中 Map 作业有 3 个,Reduce 作 业有 2 个;集群内的 MRAppMaster 以及 Worker 节点都有任务的副本。</li>
<li>step2:MRAppMaster 分别为 Mapper 和 Reducer 分配相应的 Map 和 Reduce 作业。 Map 作业的数量就是划分后的子任务数量,也就是 3 个;Reduce 作业是 2 个。</li>
<li>step3:被分配了 Map 作业的 Worker,开始读取子任务的输入数据,并从输入数据中抽 取出 <key, value> 键值对,每一个键值对都作为参数传递给 map() 函数。</li>
<li>step4:map() 函数的输出结果存储在环形缓冲区 kvBuffer 中,这些 Map 结果会被定期 写入本地磁盘中,被存储在 R 个不同的磁盘区。这里的 R 表示 Reduce 作业的数量,也是 由用户定义的。在这个案例中,R=2。此外,每个 Map 结果的存储位置都会上报给 MRAppMaster。</li>
<li>step5:MRAppMaster 通知 Reducer 它负责的作业在哪一个分区,Reducer 远程读取相 应的 Map 结果,即中间键值对。当 Reducer 把它负责的所有中间键值对都读过来后,首 先根据键值对的 key 值对中间键值对进行排序,将相同 key 值的键值对聚集在一起,从而 有利于 Reducer 对 Map 结果进行统计。</li>
<li>step6:Reducer 遍历排序后的中间键值对,将具有相同 key 值的键值对合并,并将统计 结果作为输出文件存入负责的分区中。</li>
</ul>
<p>从上述流程可以看出,整个 MapReduce 的工作流程主要可以概括为 5 个阶段,即: Input(输入)、Splitting(拆分)、Mapping(映射)、Reducing(化简)以及 Final Result(输出)。<br>所有 MapReduce 操作执行完毕后,MRAppMaster 将 R 个分区的输出文件结果返回给 User Program,用户可以根据实际需要进行操作。比如,通常并不需要合并这 R 个输出文 件,而是将其作为输入交给另一个 MapReduce 程序处理。</p>
<h3>举个例子</h3>
<p>我们来描述一个具体的例子来帮助大家理解,假设我们现在要统计苏锡常地区第二季度手机订单数量 Top3 的品牌。我们来看看具体的统计步骤吧。</p>
<ol>
<li>任务拆分(Splitting 阶段)。根据地理位置,分别统计苏州、无锡、常州第二季度手机 订单 Top3 品牌,从而将大规模任务划分为 3 个子任务。</li>
<li>通过循环调用 map() 函数,统计每个品牌手机的订单数量。其中,key 为手机品牌, value 为手机购买数量(单位:万台)。如下图 Mapping 阶段所示(为简化描述,图中 直接列出了统计结果)。</li>
<li>与前面讲到的计算流程不同的是,Mapping 阶段和 Reducing 阶段中间多了一步 Shuffling 操作。Shuffling 阶段主要是读取 Mapping 阶段的结果,并将不同的结果划 分到不同的区。在大多数参考文档中,Mapping 和 Reducing 阶段的任务分别定义为映 射以及归约。但是,在映射之后,要对映射后的结果进行排序整合,然后才能执行归约 操作,因此往往将这一排序整合的操作单独放出来,称之为 Shuffling 阶段。</li>
<li>Reducing 阶段,归并同一个品牌的购买次数。</li>
<li>得到苏锡常地区第二季度 Top3 品牌手机的购买记录。</li>
</ol>
<p><img src="/img/bVbGEcV" alt="mr4.png" title="mr4.png"></p>
<p>由上述流程可以看出,Map/Reduce 作业和 map()/reduce() 函数是有区别的:</p>
<ul>
<li>Map 阶段由一定数量的 Map 作业组成,这些 Map 作业是并发任务,可以同时运行, 且操作重复。Map 阶段的功能主要由 map() 函数实现。每个 Map 作业处理一个子任务 (比如一个城市的手机消费统计),需要调用多次 map() 函数来处理(因为城市内不同 的居民倾向于不同的手机)。</li>
<li>Reduce 阶段执行的是汇总任务结果,遍历 Map 阶段的结果从而返回一个综合结果。与 Reduce 阶段相关的是 reduce() 函数,它的输入是一个键(key)和与之对应的一组数 据(values),其功能是将具有相同 key 值的数据进行合并。Reduce 作业处理一个分 区的中间键值对,期间要对每个不同的 key 值调用一次 reduce() 函数。在完成 Map 作 业后,每个分区中会存在多个临时文件;而执行完 Reduce 操作后,一个分区最终只有 一个输出文件。</li>
</ul>
<p>根据上文我们知道MR模式的核心思想是分治法,在这种模式下任务完成后整个进程就结束了,而且它并不适合去处理实时任务。实时性任务主要是针对流数据的处理,对处理时延要求很高,通常需要有常驻服务进程,等待数据的随时到来随时处理,以保证低时延。处理流数据任务的计算模式,在分布式领域中叫作 Stream。</p>
<h2>流式计算</h2>
<h3>流式计算是什么</h3>
<p>近年来,由于网络监控、传感监测、AR/VR 等实时性应用的兴起,一类需要处理流数据的 业务发展了起来。比如各种直播平台中,我们需要处理直播产生的音视频数据流等。这种如流水般持续涌现,且需要实时处理的数据,我们称之为流数据。它有什么特征呢?1、数据如流水般持续、快速地到达;2、海量数据规模,数据量可达到 TB 级甚至 PB 级;3、对实时性要求高,随着时间流逝,数据的价值会大幅降低; 4、数据顺序无法保证,也就是说系统无法控制将要处理的数据元素的顺序。那么,在分布式领域中,对于这种流数据的计算模式就是流计算,也叫做Stream。因为流数据大量、快速、时变的特点,所以它通常被用于处理数据密集型应用。</p>
<h3>流式计算的工作原理</h3>
<p>因为流式计算强调的是实时性,数据一旦产生就会被立即处理,所以在当一条数据处理完成后会序列化存储到缓存中,然后立刻通过网络传输到下一个节点,由下一个节点继续处理,而不是像MapReduce 那样,等到缓存写满才开始处理、传输。为了保证数据的实时性,在流计算中,不会存储任何数据,就像水流一样滚滚向前。那么,它的处理流程是怎么样的呢?使用流计算进行数据处理一般会有三个步骤,参见下图:</p>
<p><img src="/img/bVbGEcX" alt="str1.png" title="str1.png"></p>
<ul>
<li>step1:提交流式计算作业。怎么理解呢?一个模式运行的前提你需要有一定的制度,不然流计算系统它不知道怎么去处理数据不就搞笑了么。所以,这一步去做的是给计算系统灌输一个“制度”,其中包括处理节点的个数,数据转发的规则等。另外,流式计算作业是常驻计算服务。</li>
<li>step2: 加载流式数据进行计算。流式计算在启动后就一直处于待触发态,等到一旦有数据过来就立即执行计算逻辑去处理。从上图中我们可以看出,在流计算系统中,有多个流处理节点,流处理节点会对数据进行预定义的处理操作,并在处理完后按照某种规则转发给后续节点继续处理。此外,流计算系统中还存在管理节点,主要负责管理处理节点以及数据的流动规则。</li>
<li>step3:实时计算结果。流式计算作业在得到小批量数据的计算结果后,可以立刻将结 果数据写入在线 / 批量系统,无需等待整体数据的计算结果,以进一步做到实时计算结果的实时展现。</li>
</ul>
<p>小结一下,流计算是处理持续到达的数据,它并不会去存储数据,适用于对数据处理有较高实时性要求的场景,比如网络监控,传感检测,AR/VR和视频流等实时应用。</p>
<p>以上我们分别学习了MapReduce(批处理)和Stream(流式计算)模式,我们也对它们有了一些了解,虽然这两种计算模式对数据的处理方式不同,但都是以特定数据类型(分别对应静态数据和动态数据)作为计算维度。</p>
<h2>下期预告</h2>
<p>【分布式系统遨游】分布式数据存储</p>
<h2>关注我们</h2>
<p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p>
<p><img src="https://segmentfault.com/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【分布式系统遨游】分布式通信
https://segmentfault.com/a/1190000022506422
2020-04-29T13:51:18+08:00
2020-04-29T13:51:18+08:00
NoSay
https://segmentfault.com/u/nosay
2
<p>今天我们来讨论分布式通信技术。</p>
<h2>为什么需要分布式通信</h2>
<p>我们之前在讲分布式资源调度的时候,把分布式系统中的各个节点与操作系统的进程做了类比。我们知道,操作系统的进程之间由于需要数据的交换,是需要进程通信机制的。那么同理,分布式系统之间同样需要通信。在业务层面,每个分布式系统一般都承载着一个微服务,所以,微服务之间也一定是需要通信的。比如,我们各条业务线均需要查询用户中心微服务的数据等等。我们常用的通信方式有三种:RPC、发布-订阅、消息队列。</p>
<h2>RPC</h2>
<p>在传统的B/S模式中,服务端会对外暴露接口,然后客户端通过调用这个接口来完成二者之间的通信。那么在分布式系统中,我们同样也可以采用这种模式。但是,B/S 架构是基于 HTTP 协议实现的,每次调用接口时,都需要先进行 HTTP 请求。这样既繁琐又浪费时间,不适用于有低时延要求的大规模分布式系统,所以远程调用的实现大多采用更底层的网络通信协议。我们先用一张图俯瞰一下RPC的架构:<br><img src="/img/remote/1460000022506426" alt="" title=""><br>在这里,订单系统进程并不需要知道底层是如何传输的,在用户眼里,远程过程调用和调用一次本地服务没什么不同。这,就是 RPC 的核心。即图中的第3步和第8步,对我们调用方是透明的。与我们经常使用的接口调用不同,图中的网络通信基本是基于TCP协议自己封装的一些协议。这样做可以约定通信双方的数据格式,从而让客户端封包和服务端解包更加快速,更加适用于分布式系统。这里的通信协议封装可参考Redis的RESP协议与FastCGI协议。</p>
<h3>RPC的典型实现 - Dubbo</h3>
<p>假设我们要自己去实现一个RPC通信框架,我们应该如何实现呢?假如我们用4个调用方与4个服务提供方,我们该如何管理他们呢?<br><img src="/img/remote/1460000022506427" alt="" title=""><br>首先,我们最容易想到的,就是服务提供方为服务调用方,提供相关的SDK,服务调用方直接引入SDK即可发起RPC调用请求,而SDK内部具体是利用什么协议,调用方并不关心。这是一种方案。但是,随着服务提供方和服务调用方越来越多,服务调用关系会愈加复杂。假设服务提供方有 n个, 服务调用方有 m 个,则调用关系可达 n*m,这会导致系统的通信量很大,SDK就显得力不从心了。此时,你可能会想到,在计算机领域,所有的问题都可以通过增加一个中间层来解决。那么,我们为什么不使用一个服务注册中心来进行统一管理呢,这样调用方只需要到服务注册中心去查找相应的地址即可,并不关心有多少个服务提供方,从而实现了服务调用方与服务提供方的解耦:<br><img src="/img/remote/1460000022506430" alt="" title=""><br>Dubbo 在引入服务注册中心的基础上,又加入了监控中心组件(用来监控服务的调用情况,以方便进行服务治理),实现了一个 RPC 框架。如下图所示,Dubbo 的架构主要包括 4 部分:</p>
<ul>
<li>服务提供方。服务提供方会向服务注册中心注册自己提供的服务。</li>
<li>服务注册中心。服务注册与发现中心,负责存储和管理服务提供方注册的服务信息和服务调用方订阅的服务类型等。</li>
<li>服务调用方。根据服务注册中心返回的服务所在的地址列表,通过远程调用访问远程服务。</li>
<li>监控中心。统计服务的调用次数和调用时间等信息的监控中心,以方便进行服务管理或服务失败分析等。</li>
</ul>
<p>下面是Dubbo官网给出的一个调用方的Demo。首先是对需要调用的服务在服务注册中心的地址进行配置:</p>
<pre><code class="xml"><?xml version="1.0" encoding="UTF-8"?>
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:dubbo="http://dubbo.apache.org/schema/dubbo"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.3.xsd
http://dubbo.apache.org/schema/dubbo http://dubbo.apache.org/schema/dubbo/dubbo.xsd">
<!-- consumer's application name, used for tracing dependency relationship (not a matching criterion),
don't set it same as provider -->
<dubbo:application name="demo-consumer"/>
<!-- use multicast registry center to discover service -->
<dubbo:registry address="multicast://224.5.6.7:1234"/>
<!-- generate proxy for the remote service, then demoService can be used in the same way as the
local regular interface -->
<dubbo:reference id="demoService" check="false" interface="org.apache.dubbo.demo.DemoService"/>
</beans></code></pre>
<p>然后,在业务代码中调用刚刚配置好的服务提供方地址即可。我们不再需要SDK了:</p>
<pre><code class="java">import org.springframework.context.support.ClassPathXmlApplicationContext;
import org.apache.dubbo.demo.DemoService;
public class Consumer {
public static void main(String[] args) throws Exception {
ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext(new String[] {"META-INF/spring/dubbo-demo-consumer.xml"});
context.start();
// Obtaining a remote service proxy
DemoService demoService = (DemoService)context.getBean("demoService");
// Executing remote methods
String hello = demoService.sayHello("world");
// Display the call result
System.out.println(hello);
}
}</code></pre>
<h2>发布-订阅</h2>
<p>发布-订阅的思想在生活中随处可见。比如我们吃鸡的时候,一般是4个人组队开黑,我们在分布式系统中可以比作4个节点。举一个相当经典的场景,比如我跳了机场,资源很多,有5.56的子弹、7.62的子弹等。于是我就和队友说,我多5.56和7.62子弹,谁需要的话和我说一下。但是有些队友去打野了,就会比较穷,他们就会和我说,我要5.56子弹或者我要7.62子弹。然后,我就会找到这个穷队友,然后把相应的5.56和7.62子弹分给他们,这样就完成了一次发布-订阅的流程。其中,”我就和队友说,我多5.56和7.62子弹,谁需要的话和我说一下“,这个就是将”我多子弹“这个消息事件发布出去,然后很穷的队友说”我需要xxx子弹“,就相当于订阅我发布的这个消息事件,然后我就会把子弹给到他们,这个子弹就相当于我们的消息,这样就完成了一次发布-订阅模型的通信:<br><img src="/img/remote/1460000022506425" alt="" title=""><br>其中,生产者可以发送消息到中心,而消息中心通常以主题(Topic)进行划分,每条消息都会有相应的主题,它代表该条消息的类型。订阅该主题的所有消费者均可获得该消息进行消费。这里我们的5.56子弹与7.62子弹,就相当于两个topic,我们可以订阅其中一个topic,来获得我们需要的子弹类型。</p>
<h3>发布-订阅的典型实现 - Kafka</h3>
<p>Kafka是一个典型的发布订阅消息系统,其系统架构也是包括生产者、消费者和消息中心三部分:<br><img src="/img/remote/1460000022506429" alt="" title=""><br>在Kafka中,为了解决消息存储的负载均衡和系统可靠性问题,所以引入了主题(topic)和分区(partition)的概念。topic的概念我们刚才讲过了,它是一个逻辑概念,指的是消息类型或数据类型。那么分区是基于topic而言的。一个topic的内容可以被划分成多个分区,而这些分区又分布在不同的集群节点上,每个分区的数据内容依赖数据同步机制,来确保每个分区内部存储数据的一致性:<br><img src="/img/remote/1460000022506428" alt="" title=""><br>每个broker就代表了一个集群中的物理节点。通过分区机制,我们避免了“将数据都放在一个篮子里”,将数据分散在不同的broker机器上,提高了系统的数据可靠性,且实现了负载均衡。<br>在图中,还有一点不一样的地方就是,有两个消费者组成了一个消费组。那么为什么要引入消费组呢?我们知道,在消息过多的情况下,单个消费者消费能力有限时,会导致消费效率过低,从而导致 Broker 存储溢出,从而不得不丢弃一部分消息。Kafka为了解决这个问题,所以引入了消费组,提高了消费的速度。<br>在Kafka中,除了基本的三要素之外,还使用了Zookeeper。ZooKeeper是一个提供了分布式服务协同能力的第三方组件,用来协调和管理整个集群中的Broker和Consumer,实现了Broker 和Consumer的解耦,并为系统提供可靠性保证。Consumer 和 Broker 启动时均会向 ZooKeeper 进行注册,由 ZooKeeper 进行统一管理和协调。<br>ZooKeeper 中会存储一些元数据信息,比如对于 Broker,会存储主题对应哪些分区(Partition),每个分区的存储位置等;对于 Consumer,会存储消费组(Consumer Group)中包含哪些 Consumer,每个 Consumer 会负责消费哪些分区等。</p>
<h2>消息队列</h2>
<p>消息队列与发布-订阅模型比较相似,但是也有一些不同之处。接着我们之前吃鸡的例子来说,消息队列并不关心谁需要什么子弹,只把自己多的资源放到某个位置,让队友来拿就好了。如果队友有需要,自取即可。消息队列并不直接把资源分配到某个具体消费者,只负责发布到消息队列中,然后消费者各取所需。最典型的一个场景就是异步通信。<br>举个例子,用户注册需要写数据库、发送邮件,按照最简单的同步通信方式,那么从用户提交注册到收到响应,需要等系统完成这两个步骤,才会给用户返回注册成功。如果发送邮件耗时非常之长,那么用户就得一直等下去:<br><img src="/img/remote/1460000022506432" alt="" title=""><br>如下图所示,如果引入消息队列,作为注册消息写入数据库和发送邮件、短信这三个组件间的中间通信者,那么这三个组件就可以实现异步通信、异步执行:<br><img src="/img/remote/1460000022506431" alt="" title=""><br>即用户只需要在写入数据库之后,写入发送邮件的消息队列即可返回注册成功,而并不需要等待真正的去发送邮件之后才会返回。所以,我们解除了注册与发送邮件两种操作之间的耦合,大大提高了注册的响应速度。那你可能会问,如果发送邮件失败了怎么办?我们一般会在业务层写一些重试逻辑,确保邮件发送成功之后,才算成功消费。而队列一般也会有持久化机制,确保消息不会丢失。<br>除了将同步转化为异步,消息队列在高并发系统中也承担着流量削峰的作用。对于流量控制,还有漏桶和令牌桶算法,感兴趣的读者可以进一步去了解。</p>
<h2>下期预告</h2>
<p>【分布式系统遨游】分布式计算</p>
<h2>关注我们</h2>
<p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p>
<p><img src="https://segmentfault.com/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【分布式系统遨游】分布式资源调度
https://segmentfault.com/a/1190000022447729
2020-04-23T19:04:24+08:00
2020-04-23T19:04:24+08:00
NoSay
https://segmentfault.com/u/nosay
2
<p>今天我们来讨论分布式资源调度。</p>
<h2>为什么需要资源调度</h2>
<h3>从计算机演化说起</h3>
<p>我们知道,计算机的出现很大程度上是为了分担人类的工作的。所以,整个计算机体系架构的演化的过程,都离不开对任务与资源这两个因素的考虑。如何利用最少的资源,运行最多的任务,且耗时最短,这是一直以来伴随我们以及科学家的难题。对于单机系统来说,从最早的单道程序设计技术、到多道程序设计技术、到现在的多核并行架构,解决方案正在逐步进化,也就是我们最直观的感受:计算机处理任务变快了。</p>
<h3>从操作系统进程调度到分布式资源调度</h3>
<p>我们可以类比一下操作系统的概念。相比于分布式资源调度,操作系统其实就是一种微观上的资源调度。我们把任务与任务相关的一系列上下文(包括程序代码与数据),统统抽象为进程。进程就是任务。在单核CPU架构下,由于只有一个CPU核,所以我们只能同时对一个任务进行处理。<br>但是,我们的任务数量不只会有1个,而是会远远超出CPU的数量,即“僧多粥少”的局面,所以,操作系统的进程调度算法出现了,比如时间片轮转调度算法,即一段时间内,CPU快速在多个任务之间快速切换、交替执行,故对每个任务内部来说,好像自己在独占CPU一样,这就是所谓的并发。除此之外,还有高优先级、高响应比、多级反馈队列调度等等任务调度算法,都在力图在单核CPU基础上解决这个问题。但是,我们一定不会满足,为了追求更高程度的并发,即在同一时刻允许多个任务同时运行。所以,多核CPU就这样诞生了,即实现了所谓的并行。那么同理,分布式系统也同样需要这种资源与任务的调度机制,来协调资源与任务之间的关系。<br>分布式系统存在的意义之一,就是解决单体架构执行任务时的性能瓶颈,所以,我们找来了一堆机器,来分担原来一台机器上的计算任务。但是,资源是多了,但是我们要如何利用呢?这里面就涉及到资源如何公平公正的分给每一个计算任务,让整个集群合理的利用硬件资源,短时、高效、公平的完成一系列的计算任务,而不至于某个任务被饿死或者撑死。所以,需要一个宏观上的“操作系统”,来合理的将无穷多个计算任务,分配到m个集群节点的计算资源上去执行。这,就是为什么需要分布式资源调度机制。</p>
<h2>单体调度</h2>
<p>对于操作系统进程调度来说,资源只有一份,那就是当前操作系统所在的计算机硬件资源;而任务有很多,资源:任务 = 1:N的关系,操作系统在进行任务调度之前,只需要收集它所在的计算机的硬件资源即可。而对于一个分布式系统中的集群,计算资源分布在多个节点上,任务还是有很多,他们之间是M:N的关系。所以,分布式系统的工作稍微复杂些,它需要收集所有节点上的资源信息而非仅仅一个节点,然后对所有收集来的资源信息做一个统筹规划。</p>
<h3>什么是单体调度</h3>
<p>如果让我们自己设计一个调度系统,我们自然会想到之前讲过的“分布式经典架构”中的集中式架构,由一个节点全权负责资源分配与任务调度。这,其实就是单体调度。单体调度模块称为“Scheduler”或“单体调度器”。所有的资源请求和任务调度都通过这个中心节点来进行。集中式调度器的常见模型,如下图所示。:<br><img src="/img/remote/1460000022447734" alt="" title=""><br>我们看到,master节点会收集每个节点的节点状态并交给master中的cluster state模块。这个节点状态就是指集群的计算资源的分布情况,而这个cluster state模块一般是一种内存数据库。然后,橙色方框Scheduling logic会到cluster state中查询集群资源的分布情况,然后根据分布情况执行自己的调度逻辑,进而将任务分配到各个节点上去执行。<br>我们可以看到,单体调度器拥有全局资源视图和全局任务,可以很容易地实现对任务的约束并实施全局性的调度策略。目前很多集群管理系统采用了单体调度设计,比如Google Borg、Kubernetes等。Kubernetes的架构经过我们之前的学习,相信你已经很熟悉了,下面我们来介绍Borg的调度架构,由于Kubernetes吸收了许多Borg的先进理念,说不定你会在Kubernetes的架构中看到许多Borg的影子。下面我们来以Borg为例,介绍一下它的单体调度实现。</p>
<h3>单体调度的典型实现 — Borg</h3>
<p>Borg是谷歌内部的大规模集群管理系统。有了之前的理论基础,我们直接上Borg的架构图:<br><img src="/img/remote/1460000022447732" alt="" title=""><br>我们看到,Borg主要由BorgMaster与Borglet构成。BorgMaster是整个集群的大脑,Borglet代表集群中的节点在这里,我们主要关注BorgMaster中的调度器Scheduler组件,它负责任务的调度,当用户提交一个作业给 BorgMaster 后,BorgMaster 会把该作业保存起来,并将这个作业的所有任务加入等待队列中。调度器异步地扫描等待队列,将任务分配到满足作业约束、且有足够资源的计算节点上。那么,Borg调度器是如何快速找到满足任务资源需求的那个机器呢?这个算法主要分为两个阶段:</p>
<ul>
<li>可行性检查,把不满足资源需求的机器过滤(Borglet)</li>
<li>评分,从可行的机器中选择一个合适的机器(Borglet)</li>
</ul>
<h4>可行性检查</h4>
<p>首先看可行性检查阶段,这个很好理解。假如当前任务需要8G内存的资源,而某个机器的内存总数低于8G,那么这台机器则会被无情的过滤掉。</p>
<h4>评分</h4>
<p>接下来就会进入到评分阶段,既然现在的所有机器已经符合要求了,是不是我们随便找一台机器把任务分了就完事了呢?其实不是。我们可以想想,大概有如下两种方案:</p>
<ul>
<li>将任务尽可能平均的分配到各台机器上。</li>
<li>优先把任务都塞到某一台机器上,直到这一台塞不下了,再塞第二台机器,以此类推;</li>
</ul>
<p>第一种方案被称为“最差匹配算法“,第二种方案被称为”最佳匹配算法“。我们分析一下这两种方案的优缺点:</p>
<h5>最差匹配算法</h5>
<ul>
<li>优点:由于各台机器上负载较均衡,所以不会造成机器长时间高负载运行,比较适合小型任务较多的场景</li>
<li>缺点:它会导致每个机器都有少量的无法使用的剩余资源,会存在较多碎片</li>
</ul>
<h5>最佳匹配算法</h5>
<ul>
<li>优点:由于任务会优先塞满第一台机器,那么其余大部分机器都是没有任务的,所以对需要资源多的大型任务的执行非常友好,适合大型任务较多的场景</li>
<li>缺点:不利于有突发负载的应用,而且对申请少量 CPU 的批处理作业也不友好,因为这些作业申请少量 CPU 本来就是为了更快速地被调度执行,并可以使用碎片资源。还有一个问题,这种策略有点类似“把所有鸡蛋放到一个篮子里面”,当这台服务器故障后,运行在这台服务器上的作业都会故障,对业务造成较大的影响</li>
</ul>
<h3>单体调度的优缺点</h3>
<h4>优点</h4>
<ul>
<li>单体调度器有所有节点的资源信息,而且任务也由单体调度器来分发,所以它拥有全局的视野,对整体资源的把控较为准确</li>
<li>单体调度系统的状态同步比较容易且稳定,这是因为资源使用和任务执行的状态被统一管理,降低了状态同步和并发控制的难度。</li>
</ul>
<h4>缺点</h4>
<ul>
<li>单体调度器承担任务过重,很容易达到单点瓶颈</li>
<li>调度算法只能全部内置在核心调度器当中,因此调度框架的灵活性和策略的可扩展性不高。</li>
<li>单体调度存在单点故障的可能性。</li>
</ul>
<h2>两层调度</h2>
<p>在单体调度架构中,中央服务器的性能会限制调度的效率,这个很好理解,但为什么会限制支持的任务类型呢?<br>简单地说,这是因为不同的服务具有不同的特征,对调度框架和计算的要求都不一样。比如说,你的业务最开始时只有批处理任务,后来发展到同时还包括流式计算任务。这两种计算任务的资源与调度需求各不相同,所以我们的调度器需要适配每一种任务,为每一个类型的任务设计不同的资源分配与调度策略,所以单体调度框架会随着任务类型增加而变得越来越复杂,最终出现扩展瓶颈。<br>为了解决以上单体调度的问题,一种方法就是另起一层,分担中央服务器的任务,将任务调度与适配放到我们刚才说的具体的二层调度器中,一层调度器不再去适配每一种任务的资源与调度需求。也就是说,一层调度器只负责资源管理和分配,二层调度器负责任务与资源的匹配。这就是我们所的两层调度架构。<br>总结一下,在两层调度中,中央调度器从整体上收集节点资源信息,并进行资源的管理与分配,将资源分配到第二层调度器;再由第二层调度器负责将资源与具体的任务配对。所以,第二层调度可以有多个调度器,以支持不同的任务类型:<br><img src="/img/remote/1460000022447735" alt="" title=""><br>看到这里大家可能还是不明白一层调度器这个资源分配,到底是分配了什么。我们用一个例子来详细讲解一下:</p>
<h3>两层调度的典型实现 — Mesos</h3>
<p>Mesos也是一个大型分布式集群资源管理框架。既然是资源管理,所以Mesos只负责集群底层资源的管理和分配,并不涉及任务调度与管理等功能。所以,Mesos如果要实现类似Borg那样的资源与任务管理,还需要上层框架的配合。<br>Mesos本身实现的调度器为第一层调度,负责资源管理,然后将第二层任务调度,交给框架完成。所以,Mesos是一个典型的两层调度架构:<br><img src="/img/remote/1460000022447733" alt="" title=""><br>这里我们所说的二层调度的框架,是运行在Mesos上,是负责任务管理与调度的组件,比如 Hadoop、Spark等,每个框架有他们自己的任务调度器,用于调度并完成不同的任务,比如批处理任务、实时分析任务等。框架主要由调度器(Scheduler)和执行器(Executor)组成,调度器可以从 Master 节点获取集群节点的信息 ,执行器在Slave节点上执行任务。<br>在Mesos中,分配资源的过程叫做Resource Offer机制。Mesos Master主动将节点空闲资源,以类似发放Offer的方式发给每个框架,如果框架需要则使用,不需要则还回。也就是说,通过 Resource Offer 机制,第一层调度器将资源主动分配给第二层调度器,然后第二层调度进行具体的任务匹配,从而实现了任务调度与资源管理的分离。<br>总结一下,Mesos Master通过资源分配算法决定给各个Framework提供多少资源,而Framework则决定接受哪些资源,以及哪些任务使用这些资源运行。这样一来,一个两层调度架构就实现了。<br>但是两层调度的一个问题是,由于第二层调度只能获得部分资源视图,并没有单体调度掌控全局的能力。因此无法实现全局最优调度。为了解决这个问题,共享状态调度机制出现了。</p>
<h2>共享状态调度</h2>
<p>为了解决单体调度的扩展瓶颈问题,以及两层调度只能获得部分资源视图的问题,我们想,那么让两层调度器也能够看到所有节点的状态不就可以了,即我们要一个地方来存储所有节点的状态就OK了。这,就是共享状态调度。<br>共享状态调度结合了单体调度掌控全局的特点,以及两层调度职责分离的优势。通过将单体调度器分解为多个调度器,且每个调度器都有全局的资源状态信息,从而实现最优的任务调度,提供了更好的可扩展性。其架构如下:<br><img src="/img/remote/1460000022447736" alt="" title=""></p>
<h3>共享状态调度的典型实现 — Omega</h3>
<p>Omega是Google的第二代集群管理系统,Omega 在设计时参考了 Borg 的设计,吸收了Borg 的优点,并改进了其不足之处。<br>Omega中以Cell为单位来管理集群,它是一个集群中的节点集合。比如集群中有10个节点,那么我们可以把其中的2个节点称为一个Cell。在Omega中,由于需要共享每一个Cell的资源状态,那么需要一个共享存储空间,来共享每个Cell的状态。其架构图如下:<br><img src="/img/remote/1460000022447737" alt="" title=""><br>我们看到,为了提高性能、并且能更方便的查到共享的集群状态数据,每个Cell都会从中心的State Storage同步一份数据到每个Cell内部。这样一来,Omega 就有效地解决了两层调度中 Framework 只拥有局部资源,无法实现全局最优的问题。</p>
<h2>总结</h2>
<p>那么这几种资源调度方案字哪种场景下使用更好呢?</p>
<ul>
<li>在小规模的集群应用场景,我们应该尽量采取单体调度模型。这是因为这种模型设计和使用都比较简单,调度器容易对整个系统的状态有全面的把握。</li>
<li>在规模较大的集群中,可以考虑使用双层调度器设计。 双层调度器调度策略的编写较为复杂,而且框架调度器并没有全局的资源视野,调度准确率较低。随着新一代共享状态调度器的发展,未来可能会慢慢退出舞台。</li>
<li>共享状态调度器因为集合了单体调度与两层调度的优点,以及适应多种任务调度需求、灵活性高、可扩展性强的特点,正渐渐变成主流。</li>
</ul>
<p>最后我们把讲过的三种调度方式做一个比较:<br><img src="/img/remote/1460000022447738" alt="" title=""></p>
<h2>下期预告</h2>
<p>【分布式系统遨游】分布式计算</p>
<h2>关注我们</h2>
<p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p>
<p><img src="https://segmentfault.com/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【分布式系统遨游】分布式事务
https://segmentfault.com/a/1190000022434727
2020-04-22T20:00:03+08:00
2020-04-22T20:00:03+08:00
NoSay
https://segmentfault.com/u/nosay
1
<h2>引言</h2>
<p>对于我们网上购物的每一笔订单来说,平台都会有两个核心步骤:一是订单业务采取下订单操作,二是库存业务采取减库存操作。</p>
<p>通常,这种不同业务会运行在不同的机器上边的,假设这两个动作是在同一台机器上发生的,我们进行操作的时候是不是需要保证订单操作和库存操作动作一致才能保证这个交易的准确性(通常我们用mysql事务来保证),如果这个问题放到了分布式结构中,我们是不是同样需要保证操作的正确性,那么这个问题,就是分布式事务。</p>
<h2>什么是分布式事务</h2>
<p>首先,事务大家都不陌生,事务就是包含一系列操作的、一个有边界的工作序列,有明确的开始和结束标志,且要 么被完全执行,要么完全失败,这种叫做本地事务或者单机事务。</p>
<p>那什么是分布式事务呢?顾名思义,在分布式系统中运行的事务叫分布式事务,它其实是由多个本地事务组合而成。因为分布式事务是由事务组成而成的产物,那么分布式事务固然能够基本满足ACID,只不过随着分布式系统规模不断扩大,复杂度急剧上升,达成强一致性所需时间周期较长,限定了复杂业务的处理。为了适应复杂业务,出现了 BASE 理论,该理论的一个关键点就是采用最终一致性代替强一致性。</p>
<h2>如何实现分布式事务</h2>
<p>实际上,分布式事务主要是解决在分布式环境下,组合事务的一致性问题。实现分布式事务 有以下 3 种基本方法:</p>
<ul>
<li>基于 XA 协议的二阶段提交协议方法;</li>
<li>三阶段提交协议方法;</li>
<li>基于消息的最终一致性方法。</li>
</ul>
<p>其中,基于 XA 协议的二阶段提交协议方法和三阶段提交协议方法,采用了强一致性,遵从 ACID,基于消息的最终一致性方法,采用了最终一致性,遵从 BASE 理论。</p>
<p>接下来,我们就来看看这三种方法到底是何方神圣。</p>
<h2>二阶段提交协议方法</h2>
<p>二阶段提交协议(下文统称为2PC)有两个阶段:Prepare(投票)和Commit(提交)。在无failure情况下的2PC协议流程的画风是这样的,首先看阶段1:<br><img src="/img/bVbGirW" alt="2pc阶段1.png" title="2pc阶段1.png"><br> 从图中可以看出,有几个步骤:</p>
<ol>
<li>询问 协调者向所有参与者发送事务请求,询问是否可执行事务操作,然后等待各个参与者的响应。</li>
<li>执行 各个参与者接收到协调者事务请求后,<strong>执行事务</strong>操作(例如更新一个关系型数据库表中的记录),并将 Undo 和 Redo 信息记录事务日志中。</li>
<li>响应 如果参与者成功执行了事务并写入 Undo 和 Redo 信息,则向协调者返回 YES 响应,否则返回 NO 响应。当然,参与者也可能宕机,从而不会返回响应。</li>
<li>阶段1中参与者因为宕机或者网络问题等造成协调者无法接收到所有参与者YES的回应或者某一个节点返回了NO回应,协调者会发送回退命令,道理和我们在使用mysql中rollback一致。</li>
</ol>
<p>这里需要注意的是,2PC阶段1是执行了事务并写入了Undo和Redo信息,但是没有提交,只是给协调者返回了YES的信号。当第一阶段中协调者收到的ack都为YES的时候进入第二阶段-提交。首先,我们看下流程图:<br><img src="/img/bVbGisg" alt="2pc2.png" title="2pc2.png"><br>他又有什么步骤呢?请看下文:</p>
<ol>
<li>首先协调者向所有的参与者发送commit请求。</li>
<li>参与者执行commit动作,注意这里只是执行提交,提交完成后释放资源。</li>
<li>将执行结果返回给协调者。</li>
<li>协调者接收到所有参与者的响应后作出提交事务动作。</li>
</ol>
<p>当然,上边两个阶段我们都是在各个参与者都成功的情况下,也有可能会有失败的情况,我们看下边几个case:</p>
<ol>
<li>当我们一阶段执行的时候,每一个参与者都在等待协凋者的响应,试想一下,如果其中一个参与者宕机了,那么协凋者就会一直等待下去,从而导致所有的参与者都在等待协调者的下一步指令。当然,2PC存在超时事务中断的处理(协调者的功能),但在这个超时时间段内,依然避免不了所有参与者的阻塞。</li>
<li>在2PC中,协调者拥有绝对的权利(当然,这是在提交命令发出之前),如果协调者挂了,那么就会使参与者一直阻塞并一直占用资源,当然,我们可以用主备来保证服务可用性,但是新的协调者它是无法获取上一个协调者的状态信息,所以就无法处理上一个的事务,同样会引起遗留的事务的阻塞。</li>
<li>在第二阶段中,在发送出提交指令之后的一切事情都不受协调者控制了,因为事务已经提交了,协调者能做的也就是等待超时后像事务返回一个“我不确定该事务是否执行成功”的命令,然而它确于事无补。当然,还会有在提交的时候因为协调者宕机或者网络问题导致部分参与者没有接收到协调者指令,这就导致各参与者不一致的问题。</li>
</ol>
<p>很无奈,2PC存在这么多的问题,不过不用怕,已经有人帮我们解决了,针对于这么多的缺点,诞生了3PC(三阶段提交协议)。下边让我们看看3PC是什么妖魔鬼怪。</p>
<h2>三阶段提交协议方法</h2>
<p>三阶段提交协议(Three-phase commit protocol,3PC),顾名思义,它有三个阶段:CanCommit,PreCommit和DoCommit。接下来让我们一起来看一看它们的真面貌。<br>cancommit阶段流程图如下:<br><img src="/img/bVbGisi" alt="3pc1.png" title="3pc1.png"><br>第一眼看到这个图,有没有发现和2PC的第一阶段很像,是的,确实是。但我们接着看它的执行流程,看看到底哪里不一样。</p>
<ol>
<li>协调者向所有的参与者发送CanCommit请求,请求中包含事务的内容,询问是否可以执行事务提交操作,并开始等待参与者响应。</li>
<li>参与者收到事务内容之后进行分析,判断自身是否可以执行,如果可以就返回YSE,进入预备状态,否则返回NO。当收到一个NO或者以上的回应时会产生事务中断。</li>
</ol>
<p>注意:这个阶段是协调者发送事务内容给参与者,参与者本身并没有执行事务的,而在2PC的第一阶段是协调者发送事务请求之后各个参与者执行了请求之后返回状态。</p>
<p>当所有的参与者经过自己一波猛如虎的分析操作之后,返回自己都OK的情况下进入第二阶段--PreCommit。<br><img src="/img/bVbGism" alt="3pc2.png" title="3pc2.png"></p>
<ol>
<li>协调者发送PreCommit请求给参与者。这时候就是进入到一个奇妙的时刻(这个阶段就是2PC的第一阶段)。</li>
<li>参与者收到请求之后,执行事务操作,并将 Undo 和 Redo 信息记录事务日志中。</li>
<li>如果执行成功进行反馈,等待下一步操作。</li>
</ol>
<p>虽然这个阶段的动作类似于2PC的阶段1,但是还是有点不一样的地方的,我们继续看?我们看到途中增加了一个新名词Abort,这个是干什么的呢?往下看:</p>
<ol>
<li>当第一阶段中协调者收到NO或者超时等情况下,协调者向参与者发送中断事务请求,其实这个2PC也有,但是为什么会这次会在图上表现出来了呢?因为我们要提出一个新的东西,请往下看。</li>
<li>参与者收到 Abort 请求后,会触发事务中断。此外,如果参与者在等待协调者指令超时,会自己触发事务中断,在 2PC 中,参与者会一直阻塞的等待协调者指令,所以 3PC 中解决了因为这种情况带来的阻塞。</li>
</ol>
<p>协调者根据第二阶段的响应决定最终操作,如果协调者收到了所有参与者在 PreCommit 阶段的 Ack 响应,那么会进入执行事务提交阶段,否则执行事务中断。来梳理下流程图:<br><img src="/img/bVbGiso" alt="3pc3.png" title="3pc3.png"></p>
<ol>
<li>协调者收到所有参与者在 PreCommit 阶段返回的 Ack 响应后,向所有参与者发送 doCommit 请求,并进入提交状态。</li>
<li>参与者收到 Commit 请求后,执行事务提交,提交完成后释放事务执行期占用的所有资源。</li>
<li>参与者完成事务提交之后,向协调者返回 Ack 响应。</li>
<li>协调者收到所有参与者的 Ack 响应后,完成事务。</li>
</ol>
<p>这个流程其实和2PC的第二阶段一样,没什么好说的了,需要提及一点的是,如果参与者在等待DoCommir超时的时候,参与者自己会提交,减少阻塞。这样即使协调者挂了也能保证顺利向下走。</p>
<p>对比一下2PC和3PC,有什么区别呢?3PC比2PC多了一个CanCommit的步骤,在提交前先问参与者能不能提交,而不是直接发送执行命令,好像更人性化了一点呢。我们之前说3PC是为了解决2PC的问题,那么它究竟是解决了哪些问题呢?我们来捋一捋。</p>
<ol>
<li>参与者返回 CanCommit 请求的响应后,等待第二阶段指令,若等待超时,则自动 abort,降低了阻塞;参与者返回 PreCommit 请求的响应后,等待第三阶段指令,若等待超时,则自动 commit 事务,也降低了阻塞;总结下就是自动提交解决了阻塞。</li>
<li>参与者返回 CanCommit 请求的响应后,等待第二阶段指令,若协调者宕机,等待超时后自动 abort;参与者返回 PreCommit 请求的响应后,等待第三阶段指令,若协调者宕机,等待超时后自动 commit 事务;总结下就是自动提交解决单点阻塞。</li>
</ol>
<p>但是2PC还有一个问题没有解决你有没有发现,那就是数据不一致的问题,比如第三阶段协调者发出了 abort 请求,然后有些参与者没有收到 abort,那么就会自动 commit,数据就不一致了。当然如果你说在CanCommit请求响应后参与者没有接到PreCommit的请求,这个其实不会导致数据不一致么因为如果协调者接受不到成功结果他就会进行事务中断的。</p>
<p>其实2PC和3PC还有一个共同的缺点,那就是他们在整个流程中都会锁住资源,导致系统性能降低。那么针对以上两个缺点,该怎么办呢?这时候基于分布式消息的最终一致性方案解决了这个问题,我们看一看这位老大哥是怎么做到的。</p>
<h2>基于分布式消息的最终一致性方案</h2>
<p>基于分布式消息的最终一致性方案的事务处理,引入了一个消息中间件(Message Queue,MQ),用于在多个应用之间进行消息传递。基于消息中间件协商多个节点分布式 事务执行操作的示意图,如下所示。<br><img src="/img/bVbGisu" alt="分布式一致.png" title="分布式一致.png"></p>
<p>单纯的看定义不太容易理解,我们来举个例子:以阿里巴巴的 RocketMQ 中间件为例,分析下其设计和实现思路。</p>
<p>RocketMQ 第一阶段发送 Prepared 消息时,会拿到消息集群返回的消息的地址,第二阶段执行本地事物,第三阶段通过第一阶段拿到的消息地址去访问消息,并修改状态。聪明的你可能会问了,如果确认消息发送失败了怎么办?<br>RocketMQ 会定期扫描消息集群中的事物消息,这时候发现了 Prepared 消息,它会向消息发送者确认,Bob 的钱到底是减了还是没减呢?如果减了是回滚还是继续发送确认消息呢?RocketMQ 会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。如下图:<br><img src="/img/bVHpZ2" alt="分布式一致性2.png" title="分布式一致性2.png"><br>那么问题又来了,怎么确保消息接收者收到了消息呢?毕竟消费者消费也可能会出现问题。首先,主流的 MQ 产品都具有持久化消息的功能。如果消费者宕机或者消费失败,都可以执行重试机制的(有些 MQ 可以自定义重试次数)。当然,如果我们消费成功了然后在执行接收者事务的操作时候失败了,我们应该有详细日志记录以及邮件报警,通知相关人员去处理。</p>
<p>那么从上文例子我们可以看出,分布式事务在操作过程中可能会由于同步延迟等问题导致不一致,但最终状态下,数据都是一致的。</p>
<h2>下期预告</h2>
<p>【分布式系统遨游】分布式资源调度</p>
<h2>关注我们</h2>
<p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p>
<p><img src="https://segmentfault.com/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【分布式系统遨游】分布式经典架构
https://segmentfault.com/a/1190000022425407
2020-04-19T23:39:04+08:00
2020-04-19T23:39:04+08:00
NoSay
https://segmentfault.com/u/nosay
1
<h2>引言</h2>
<p>我们知道,分布式系统就是将具有独立计算能力的系统单元,部署在不同的机器上。那么,如何有效的管理这些机器之间的协同工作,就是一个很大的难题。目前,大体有两种典型的分布式系统架构:集中式与非集中式。</p>
<h2>集中式架构</h2>
<p>我们可以将分布式系统中的每一个节点比作操作系统中进程或者线程的概念。操作系统诞生的原因之一就是为了解决如何高效、合理的运行用户的多个任务请求。所以,操作系统对我们的任务请求抽象成一个个进程,然后将我们的CPU、内存等资源公平、合理的分配给每一个进程;并且为了最大化硬件资源的使用,操作系统提供了多种进程调度算法。我们看到,操作系统类似所有进程的“老大”,我们在分布式系统中也同样需要这样一个角色,用来协调和管理集群中的每一个节点,我们它称为 “Master” ,而把它管理的小弟们称为 “Slave”或者"worker" 。我们把它称为集中式架构。注意,这里的集中式并不代表我们传统的将所有服务或者数据部署到一台机器上的那种单机架构。集中式架构如图所示:<br><img src="/img/remote/1460000022425410" alt="" title=""><br>这种架构的应用场景有很多。其中一种表现形式是 MySQL 、 Redis 的读写分离策略,主负责写,从负责读, master 定期将数据同步给 slave ;而另外一种表现形式则是,系统内所有的业务也先由 master 处理,多个 slave 与 master 连接,并将自己的信息汇报给 master ,由 master 统一进行资源和任务调度并存储集群节点的状态,然后 master 根据这些信息,将任务下达给 slave ;slave 执行任务,并将结果反馈给 master 。比如 nginx 与 php-fpm 的 master-worker 机制,以及集群中的 Borg 、Kubernetes 的集群管理机制都是这样。上一篇文章中的分布式互斥中的协调者也是一种实现方式。也是下面我们以 Kubernetes 为例,看一下集中式架构的一种代表实现。</p>
<h3>集中式架构的代表 — Kubernetes</h3>
<p>Kubernetes 是用于自动部署、扩展和管理容器化应用程序的开源系统。在集群的节点上运行的容器化应用,可以进行自动化容器操作,包括自动化部署、调度和在节点间弹性伸缩等,大大降低了服务运维的成本。我们可以用几个典型的场景来阐述一下 Kubernetes 的作用:</p>
<ul>
<li>随机关掉一台机器,看你的服务能否正常(调度)</li>
<li>减少的应用实例能否自动迁移并恢复到其他节点</li>
<li>服务能否随着流量进行自动伸缩</li>
</ul>
<p>Kubernetes为了解决以上容器化应用的运维的难题而生,是典型的集中式结构。一个 Kubernetes 集群,主要由 Master 节点和 Worker 节点组成。</p>
<h4>master节点</h4>
<p>Master 节点由 API Server、Scheduler、Cluster State Store 和 Control Manger Server 四部分组成。Master 负责对集群进行调度管理,它是整个集群的大脑,类似我们刚才说的操作系统,负责整个容器集群的调度与生命周期管理。</p>
<ul>
<li>API Server:提供和外部交互的接口</li>
<li>Scheduler:根据容器需要的资源、以及当前Worker节点所在机器上的资源信息,自动为容器选择合适的节点机器。</li>
<li>Cluster State Store:集群状态存储,默认采用 etcd 。etcd 是一个分布式 key-value 存储,主要用来做共享配置和服务发现。</li>
<li>Control Manager:用于执行大部分的集群层次的功能,比如执行生命周期功能(命名空间创建和生命周期、事件垃圾收集、已终止垃圾收集、级联删除垃圾收集等)和 API业务逻辑。</li>
</ul>
<h4>worker节点</h4>
<p>类似我们刚才提到的操作系统进程,worker 节点内部运行了多个包含业务应用的容器,由master统一调度。这相当于我们操作系统中运行的多个任务程序,由操作系统统一调度。worker运行在从节点服务器,主要包括 kubelet 和 kube-proxy 这两大核心组件:</p>
<ul><li>kubelet:用于通过命令行与 API Server 进行交互,根据接收到的请求对 Worker 节点进行相应的操作。也就是说,通过与 API Server 进行通信,接收 Master 节点根据调度策略发出的请求或命令,在 Worker 节点上管控容器(Pod),并管理容器的运行状态(比如,重新启动出现故障的 Pod)等。</li></ul>
<blockquote>Pod 是 Kubernetes 的最小工作单元,每个 Pod 包含一个或多个容器。通过 Pod 机制,Kubernetes 实现了多个容器的协作,能够有效避免将太多功能集中到单一容器镜像,还可以向外扩展跨越多个 Pods 实现初步部署,且相关部署可随时进行规模伸缩。</blockquote>
<ul><li>kube-proxy:具有相同服务的一组 Pod 可抽象为一个Service。它负责为 Service 下的多个 Pod 创建代理服务,实现了请求到 Pod 的路由和转发。并且在有多个副本的情况下,kube-proxy会实现负载均衡。</li></ul>
<p><img src="/img/remote/1460000022425411" alt="" title=""><br>Service 用于将具备类似功能的多个 Pod 整合为一组,可轻松进行配置以实现其可发现性、可观察性、横向扩展以及负载均衡。 kube-proxy 就实现了这个机制。Kubernetes的整体架构图如下:<br><img src="/img/remote/1460000022425413" alt="" title=""></p>
<h3>集中式架构的优劣</h3>
<p>我们通过以上的案例可以看到,我们只需要 master 与各 slave 节点之间通信即可,各 slave 节点之间并不需要通信,这大大降低了网络通信的成本,且部署难度非常之简单。但是,集中式架构中心服务器性能要求很高,而且存在单点瓶颈和单点故障问题。一旦 master 挂掉,整个集群就会对外不可用。双 master 就能够解决这个问题。一旦主 master 挂掉,集群就会触发重新选主的机制,备 master 升为主 master。那么,有没有其他解决问题的方案呢?那就是非集中式架构。</p>
<h2>非集中式架构</h2>
<p>在非集中式结构中,服务的执行和数据的存储,被分散到不同的服务器集群节点,集群节点之间通过消息传递进行通信和协调。上一篇文章中,分布式互斥问题的解决方案之一:民主式协商算法,也是非集中式架构的一种体现:<br><img src="/img/remote/1460000022425412" alt="" title=""><br>非集中式架构的最大特点就是“众生平等”,没有老大这一说。节点与节点之间的地位相同,没有 master 和 slave 之分。下面我们以Redis Cluster 为例,看一下非集中式架构的典型实现:</p>
<h3>非集中式架构的代表 — Redis Cluster</h3>
<p>Redis 是一个开源的高性能分布式 key-value 数据库,它的数据可分片存储在不同的 Redis 节点上,来对外提供高性能与高可用性,而提供这项能力的就是 Redis 集群。Redis 提供了三种集群方案:主从、基于哨兵的主从、以及 Redis Cluster。主从机制就是我们刚讲过的集中式架构,而哨兵则是增加了对主从节点的监控,而最后面的Redis Cluster 则是典型的非集中式架构。<br>由于Redis Cluster中的节点对外提供的是数据存储服务,所以在设计时,需要考虑数据的分片存储以及可靠性这两个问题。</p>
<h4>数据存储</h4>
<p>为了解决数据分片存储的问题,Redis Cluster 中不存在 master 节点,是典型的去中心化结构,每个节点均可与其他节点通信。所有节点均可负责存储数据、记录集群的状态(包括键值到正确节点的映射),客户端可以访问或连接到任一节点上,而非集中式架构中仅仅能够访问 master 节点。集群节点同样能自动发现其他节点,检测故障的节点,并在需要的时候在从节点中推选出主节点。Redis 集群的架构图如下所示:<br><img src="/img/remote/1460000022425414" alt="" title=""><br>Redis Cluster 引入了哈希槽的概念将数据分散到多个节点中。Redis 集群内置了 16384 个哈希槽,每个节点负责一部分哈希槽。当客户端要存储一个数据或对象时,对该对象的 key 通过 CRC16 校验后对 16384 取模,也就是 HASH_SLOT = CRC16(key) mod 16384 来决定哈希槽,从而确定存储在哪个节点上。<br>举个例子,当前集群有 3 个节点,那么节点1包含 0 到 5460(图中不应该是5760) 号哈希槽,节点 B 包含 5416 到 10922 号哈希槽,节点 C 包含 10923 到 16383 号哈希槽:<br><img src="/img/remote/1460000022425416" alt="" title=""><br>那么问题来了,如果某个请求随机打到某个集群节点上,而请求需要的数据并不在这个节点上,应该怎么办呢?与节点取余和一致性哈希分区不同,哈希槽是服务端分区。客户端可以将数据提交到任意一个redis cluster节点上,如果存储该数据的槽不在这个节点上,则返回给客户端目标节点信息,告知客户端应该转而向目标节点提交数据即可:<br><img src="/img/remote/1460000022425415" alt="" title=""><br>所以我们可以看到,每一个节点上的数据都不会与其他节点上的数据重复,数据只有一份。节点与节点之间的数据是没有交集的,这样一来,Redis 将对数据的读写操作分摊到了多个节点上,提高了读写并发能力。</p>
<h4>可靠性</h4>
<p>但是这样仍然有一个问题,从全局上看,数据只会存一份,那么必然会存在数据的可靠性的问题。所以我们在每个节点内部,仍会采用 master - slave 1主1备的机制,如果某个节点挂掉了,仍然可以替补上阵。也就是说,每台服务器上都运行两个 Redis服务,分别为主备,主故障后,备升主。所以,我们可以看到,集中式架构与非集中式架构往往是可以搭配使用的,在合适的场景下,使用合适的架构即可。</p>
<h3>非集中式架构的优劣</h3>
<p>在非集中式架构中,每个节点之间都要互相知晓对方的信息与状态。而对于外部请求来说,我们可以将请求打到任意一个节点上。这样一来,相比于集中式架构,非集中式架构在解决了单点瓶颈和单点故障问题的同时,还提升了系统的并发度,比较适合大规模集群的管理。但是相比集中式来说,维护成本与部署复杂度较高。</p>
<h2>下期预告</h2>
<p>【分布式系统遨游】分布式事务</p>
<h2>关注我们</h2>
<p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p>
<p><img src="https://segmentfault.com/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【分布式系统遨游】分布式互斥与分布式锁
https://segmentfault.com/a/1190000022423451
2020-04-19T22:12:36+08:00
2020-04-19T22:12:36+08:00
NoSay
https://segmentfault.com/u/nosay
1
<h2>写在前面</h2>
<p>在工作学习中,我们常常听说分布式,集群,容器等等名词,但是当学妹们问你什么是分布式的时候,你是否有一种书到用时方恨少的感觉呢?为了在学妹面前“扬眉吐气”一把,今天开始,我们就去会一会分布式,Let's go!</p>
<h2>为什么有分布式结构</h2>
<p>在我们了解为什么会有分布式结构之前,我们想学习一下什么是分布式结构。分布式就是一个业务分拆多个子业务,部署在不同的服务器上。举个例子,假设需要开发一个在线商城。按照微服务的思想,我们需要按照功能模块拆分成多个独立的服务,如:用户服务、产品服务、订单服务、后台管理服务、数据分析服务等等。这一个个服务都是一个个独立的项目,可以独立运行。如果服务之间有依赖关系,那么通过RPC方式调用。了解了什么是分布式结构,是不是就很自然的总结出一系列的原因,比如解耦和,链路更清晰,职责更明确等等。</p>
<p>那么,分布式之间是如何协调与同步呢?针对这个问题,我们通过以下几个问题去解释。</p>
<h2>分布式互斥</h2>
<p>首先,我们想象一下,对于多个服务去访问同一资源时,就会出现分布式互斥现象,怎么理解呢?想象一下,你正在一家餐厅使用自助咖啡机泡制咖啡,突然有个人过来挪走了你的杯子,开始泡制他自己的咖啡。你耐着性子等他操作完,继续泡制自己的咖啡。结果你开始没多久,他又回来中断了你泡制咖啡的过程。此时,咖啡机就是一个临界资源,两个人表示两个服务,这种现象就叫做分布式互斥。<br>那么,我们如何去解决这种服务之前互斥访问临界资源的现象呢?</p>
<h3>集中式算法</h3>
<p>首先,我们最先想到的肯定是增加一个“管理员”,约束大家不要插入别人使用咖啡机的时间,那么做好一个管理员,我们就需要一套完整的管理体系,例如我们打卡上下班一样,放到计算机世界来说,这里就需要一个算法来协调,这就是分布式互斥算法,每个程序在需要访问临界资源时,先给协调者发送一个请求。如果当前没有程序使用这个资源,协调者直接授权请求程序访问;否则,按照先来后到的顺序为请求程序“排一个号”。如果有程序使用完资源,则通知协调者,协调者从“排号”的队列里取出排在最前面的请求,并给它发送授权消息。拿到授权消息的程序,可以直接去访问临界资源。<br>这样的算法我们给他起个名字就叫做”集中式算法”,集中式算法大概的流程是:存在一个协调者,在程序进行临界资源的访问的时候,首先要向协调者发送请求授权信息,如果存在有程序正在使用,则将其放入等待队列,等到正在执行的程序释放资源,协调者就去等待队列中取出一个程序发放授权信息,程序执行完毕后释放。那么这个算法的优缺点就很明显了,优点在于简单,直观,容易实现且只需要和协调者进行通信。缺点就在于协调者会成为系统的性能瓶颈,协调者处理的消息数量会随着需要访问临界资源的程序数量线性增加,并且容易引发单点故障问题。当然,对于故障问题我们可以采用主备备份的方式来解决,所以,集中式算法可以适用于比较广泛的应用场景。<img src="/img/bVbGftq" alt="集中式算法.png" title="集中式算法.png"></p>
<h3>民主协商算法</h3>
<p>那么既然引入协调者会带来一些问题,这时你可能就会想,不用协调者是否可以实现对临界资源的互斥访问呢?想象一下,当你需要使用自助咖啡机的时候,是不是可以先去征求其他人的意见,在确认其他人都没在使用也暂时不会使用咖啡机时,你就可以放心大胆地去泡制自己的咖啡了呢?<br>同理,我们可以把这套算法用于分布式系统。当一个程序要访问临界资源时,先向系统中的其他程序发送一条请求消息,在接收到所有程序返回的同意消息后,才可以访问临界资源。其中,请求消息需要包含所请求的资源、请求者的 ID,以及发起请求的时间。这就是“民主协商算法“,仔细品一下就会发现一个程序在对临界资源进行访问的时候就要进行2*(N-2)条消息,在大型系统中使用分布式算法,消息数量会随着需要访问临界资源的程序数量呈指数级增加,容易导致高昂的“沟通成本,并且当一个节点出现宕机或者其它异常情况的时候会让其它获取资源的程序陷入一直等待的处境,虽然我们可以加上一个监控异常的方式,但是对于每个程序进行故障检测,无疑增加了更大的复杂性。所以,民主协商适用于节点数目少且变动不频繁的系统。<br><img src="/img/bVbGfts" alt="协商算法.png" title="协商算法.png"></p>
<h3>令牌环算法</h3>
<p>我们能不能避免这种多通信呢?当然可以,集中式算法就可以啊,但是我们不想新增一个协调者,这可怎么办呢?下边我们来讲述一下”令牌环算法“,令牌环方法的实现类似于华为的轮值CEO模式(任老板不久刚换下来么),所有程序构成一个环结构,令牌按照顺时针(或逆时针)方向在程序之间传递,收到令牌的程序有权访问临界资源,访问完成后将令牌传送到下一个程序;若该程序不需要访问临界资源,则直接把令牌传送给下一个程序。无人机通信就是一个很直白的例子。<br>他解决了什么问题呢?集中式和分布式都存在单点故障的问题,在令牌环中,若某一个程序出现故障,则直接将令牌传递给故障程序的下一个程序,从而很好地解决单点故障问题,提高系统的健壮性,带来更好的可用性。当然,他也存在一定的弊端,就是一个程序无论有没有需求都会进行排队,这就降低了系统的实时性。适用于系统规模较小,并且系统中每个程序使用临界资源的频率高且使用时间比较短的场景。</p>
<p>可以看到,上面提到的集中式、分布式和令牌环 3 个互斥算法,都不适用于规模过大、节点数量过多的系统。那么,什么样的互斥算法适用于大规模系统呢?现在有一个很流行的互斥算法,就是两层结构的分布式令牌环算法,对于节点很多的系统,我们分成不同的管理环,然后根据管理环的内容再划分不同的节点环。从而达到分治的目的,以此来提高性能。<br><img src="/img/bVbGftx" alt="令牌环算法.png" title="令牌环算法.png"></p>
<h2>分布式锁</h2>
<p>在讲述了分布式互斥算法之后,我们主要了解了如何协调进程获取权限以及根据权限有序访问共享资源,但是这里边我们忽略了一个词--权限,这个权限是怎么产生的呢?工作原理是什么?接下来,我们就一起来看下,我们是如何控制权限的。<br>在之前的文章我们有提到过在访问公用资源的时候需要加锁来保证数据一致性,那么对于分布式下的权限我们是否也可以用锁来实现的呢?当然可以,接下来我们来看看分布式锁。<br>首先,我们知道,锁是实现多线程同时访问同一共享资源,保证同一时刻只有一个线程可访问共享资源所做的一种标记。对于单机来说,锁是可以独立维护的,但是对于分布式来说,这种方式显然不太适用,因为单个机器的线程锁无法管控到多个机器对统一资源的访问。那么问题就来了,我们是不是也需要有一个地方去维护这个锁呢?分布式中锁被存在公共存储(比如 Redis、Memcache、数据库等三方存储中),以实现多个进程并发访问同一个临界资源,同一时刻只有一个进程可访问共享资源,确保数据的一致性。我们来看一看以下三种实验方式。</p>
<h3>基于数据库实现分布式锁</h3>
<p>这里的数据库指的是关系型数据库。创建一张锁表,然后通过操作该表中的数据来实现。当我们要锁住某个资源时,就在该表中增加一条记录,想要释放锁的时候就删除这条记录。数据库对共享资源做了唯一性约束,如果有多个请求被同时提交到数据库的话,数据库会保证只有一个操作可以成功,操作成功的那个线程就获得了访问共享资源的锁,可以进行操作。这种可以达到分布式锁的目的,而且实现非常简单,但是因为所有的数据都是在落在硬盘上,肯定会导致IO开销增大,性能变差,所以这种方式只适用于并发量低并且对性能要求低的场景。而且他会存在单点故障和死锁的问题,如果一个程序在获取锁之后挂掉,就会导致其他进程一直获取不到锁。<br><img src="/img/bVbGftJ" alt="数据库分布式锁.png" title="数据库分布式锁.png"></p>
<h3>基于缓存实现分布式锁</h3>
<p>既然数据库性能限制了业务的并发,那么我们就试试缓存,把数据放在计算机内存中,不用写入磁盘,减少IO读写,最常见的就是用Redis的setnx来实现,通过维护进程访问共享资源的先后顺序来协调锁资源,而且大家都知道,redis的key可以设置过期时间,这就可以解决死锁的问题。我们总结一下它的优点,性能更好,很多缓存可以跨集群部署,避免单点故障,可以设置控制锁的超时时间。缺点呢?缺点就在于当一个死锁产生时,它需要等待锁超时释放,这就导致了一种场景,12点的时候用户购买商品购买失败,但是到12.30又可以购买了(假如过期时间在30分钟后),或者说一个进程本来执行时间就长,当过期时间小于它的执行时间时,不正确的释放了锁。</p>
<h3>基于Zookeeper实现分布式锁</h3>
<p>上边的两种方式好像都不太符合我们的预期,有没有更好的办法呢?接下来我们看一看Zookeeper对于分布式锁的实现。ZooKeeper 基于临时顺序节点实现分布式锁,来解决多个进程同时访问同一临界资源时,数据的一致性问题,通常用于配置管理,名字服务,提供分布式同步以及集群管理。它维护了一个临时节点的存储结构,当客户端与 ZooKeeper 断开连接后,该进程创建的临时节点就会被删除。这块可能不太容易理解,我们举一个例子来帮助大家理解:假设用户 A、B、C 同时在 11 月 11 日的零点整提交了购买吹风机的请求,ZooKeeper 会采用如下方法来实现分布式锁:</p>
<ol>
<li>在与该方法对应的持久节点 shared_lock 的目录下,为每个进程创建一个临时顺序节点。如下图所示,吹风机就是一个拥有 shared_lock 的目录,当有人买吹风机时,会为他创建一个临时顺序节点。</li>
<li>每个进程获取 shared_lock 目录下的所有临时节点列表,注册子节点变更的Watcher,并监听节点。</li>
<li>每个节点确定自己的编号是否是 shared_lock 下所有子节点中最小的,若最小,则获得锁。例如,用户 A 的订单最先到服务器,因此创建了编号为 1 的临时顺序节点LockNode1。该节点的编号是持久节点目录下最小的,因此获取到分布式锁,可以访问临界资源,从而可以购买吹风机。</li>
<li>若本进程对应的临时节点编号不是最小的,则分为两种情况:<br>a. 本进程为读请求,如果比自己序号小的节点中有写请求,则等待。<br>b. 本进程为写请求,如果比自己序号小的节点中有读请求,则等待。</li>
</ol>
<p>可以看到,使用 ZooKeeper 可以完美解决设计分布式锁时遇到的各种问题,比如单点故障、不可重入、死锁(临时节点的特性)等问题。虽然 ZooKeeper 实现的分布式锁,几乎能涵盖所有分布式锁的特性,且易于实现,但需要频繁地添加和删除节点,所以性能不如基于缓存实现的分布式锁。<br><img src="/img/bVbGftP" alt="Zookeeper.png" title="Zookeeper.png"></p>
<h2>下期预告</h2>
<p>【分布式系统遨游】分布式经典架构</p>
<h2>关注我们</h2>
<p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p>
<p><img src="https://segmentfault.com/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【Go语言踩坑系列(十)】Channel(下)
https://segmentfault.com/a/1190000022347278
2020-04-12T17:45:19+08:00
2020-04-12T17:45:19+08:00
NoSay
https://segmentfault.com/u/nosay
0
<h2>声明</h2>
<p>本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。</p>
<h2>引入</h2>
<p>有了上一篇文章的基础,这一节我们来看通道的底层实现,我们先看一个例子,相信你已经很熟悉了:</p>
<pre><code class="go">func main() {
ch := make(chan int)
go func() {
ch <- 1
}()
time.Sleep(5 * time.Second)
fmt.Println(len(ch)) // 0
<- ch
}</code></pre>
<p>我们在主协程中创建一个通道,并且开了一个子协程往通道里写入一个数据,然后再在主协程读取这个数据,且在读取之前打印通道的有效长度。由于我们在上一节说过,这是一个非缓冲通道,如果我们要向里面写入数据,必须得有接收方同时来接收才可以,否则就会发生死锁。</p>
<h3>代码执行流程</h3>
<p>我们分析下这个程序的执行流程,由于我们在主协程中强行睡眠了5s,那么子协程中的 ch <- 1 写入操作一定大概率会先于主协程的 <- ch 读取操作执行。又因为主协程中一直没有对通道进行读取操作,所以子协程中的 ch <- 1操作一直会阻塞在这里得不到执行,直到主协程从睡眠中醒来,对通道进行读取的时候,子协程才会执行写入通道的操作。</p>
<h3>为什么len返回0</h3>
<p>经过上面的解释,我们也就能够理解为什么打印的 len(ch) 为0了。因为打印的时候,子协程还在阻塞而并没有往通道中写入,自然通道里没有任何有效的数据。事实上,对非缓冲通道求 len ,其值永远都为0。因为我们都是等待接收方准备好之后,才会真正往通道中“推送”数据(这里并不是推送,而是内存拷贝,下文会讲),而不是事先直接把数据推送到通道中就完事了,这样就无法实现非缓冲通道的阻塞效果了。</p>
<h2>通道的底层实现</h2>
<p>我们上文说过,非缓冲通道并不需要一个存储空间去暂存推送数据,而是等到接收方准备好了、真正写入数据那一刻才会去推送数据,这个时候我们直接使用内存拷贝就好了。反之,由于缓冲通道在容量足够的时候,写入操作并不会阻塞,强依赖于读取操作,而是写入完毕直接返回,这样就决定了缓冲通道需要一块存储空间来真正存储推送的数据。</p>
<h3>数据结构</h3>
<p>channel还有一个先进先出的特性。先写入的数据会先被读出来,那对于缓冲通道,自然我们会想到使用队列来存储数据了。我们知道, channel是引用类型,其零值为nil。那么进过前面的学习,我们知道,所有引用类型的底层实现都可能会是一个结构体,然后结构体中的某个字段真正指向某块存储空间,而我们在函数参数传递的时候传的都是这个结构体本身,而并不拷贝结构体某个字段指向的底层结构,这就会造成在调用函数内部也能够修改函数外部的值的假象(好像是引用传递,其实是传递了结构体这个值,比如切片与字典的底层实现)<br>我们扯得有点远了,我们在使用make函数创建一个channel、甚至切片与map的时候,实际上就是创建了一个结构体,channel的结构体叫做hchan,切片叫sliceHeader,字典叫hmap。我们看下hchan的结构:</p>
<pre><code class="go">type hchan struct {
// 通道里元素的数量
qcount uint
// 循环队列的长度
dataqsiz uint
// 指针,指向存储缓冲通道数据的循环队列
buf unsafe.Pointer
// 通道中元素的大小
elemsize uint16
// 通道是否关闭的标志
closed uint32
// 通道中元素的类型
elemtype *_type
// 已接收元素在循环队列的索引
sendx uint
// 已发送元素在循环队列的索引
recvx uint
// 等待接收的协程队列
recvq waitq
// 等待发送的协程队列
sendq waitq
// 互斥锁,保护hchan的并发读写,下文会讲
lock mutex
}</code></pre>
<p>果然,我们发现了buf这个字段,它是一个指向底层循环队列的指针,也验证了我们之前对“为什么channel是引用类型”的猜想。在我们进行函数参数值传递的时候,我们也仅仅是传的这个结构体值罢了,也相当于传了这个buf指针的值,而底层的buf内存空间则是两个指针共享的。最后,waitq类型则是用来存放Channel缓冲空间不足而阻塞的Goroutine列表,它是一个双向链表:</p>
<pre><code class="go">type waitq struct {
first *sudog
last *sudog
}</code></pre>
<p>我们根据这个结构体画出数据结构图:<br><img src="/img/remote/1460000022347283" alt="" title=""></p>
<h3>并发安全</h3>
<p>在多核CPU环境下的Go的多协程环境,必然会出现多个协程同时并行的对同一个channel进行读取操作的情况,这样就带来了资源并发访问的问题。对于非缓冲通道,发送方与接收方必须是一对一、点对点的操作。如果有多个协程同时对某一个非缓冲channel进行操作,那么就会发生有部分读/写操作不能匹配到相应的写/读操作,必然会导致死锁。所以接下来我们只针对缓冲通道进行讨论。<br>为了叙述简便,我们把写入操作简称为send操作,读取操作简称为recv操作。send操作包括了“复制元素值”和“放置副本到通道内部”这两个步骤。recv操作包含了“复制通道内的元素值”“放置副本到接收方”“删除原值”三个步骤。<br>我们拿recv操作来举例,对于多个协程的接收操作来说,这个存数据的循环队列就是共享资源,如果多个协程同时去复制通道内的元素值,然后拷贝副本到接收方,那么我们就会出现“多次读取相同的元素”的情况。所以,Go语言会在“复制”操作之前,使用lock字段给循环队列加锁,表示我要去进行接收了,然后直到最后删除原值这个操作结束之后才会释放锁。这样就保证了recv中的三个子操作是一个整体,是一个原子操作,协程之间的recv操作都是串行执行的。在某一个协程进行recv操作的时候,其他协程只能阻塞在那里,等待上一个协程释放锁,这样就保证了循环队列数据的并发安全性。<br>所以,基于以上锁的机制,在同一时刻,Go语言的运行时系统只会执行对同一个通道的任意个发送操作中的某一个,直到这个元素值被完全复制进该通道之后,其他针对该通道的send操作才可能被执行,recv操作也同理。接下来我们总结一下send和recv的流程:</p>
<h4>send</h4>
<ul>
<li>给循环队列加锁</li>
<li>复制元素值</li>
<li>把数据从协程1中copy到循环队列中</li>
<li>释放锁</li>
</ul>
<h4>recv</h4>
<ul>
<li>给循环队列加锁</li>
<li>复制通道内的元素值</li>
<li>把数据从循环队列中copy到协程2中</li>
<li>删除原值</li>
<li>释放锁</li>
</ul>
<h3>send流程图解</h3>
<p>我们以send操作为例,我们用图片展示一下整个过程。<br>首先我们初始化一个空的通道数据结构:<br><img src="/img/remote/1460000022347284" alt="" title=""><br>接下来我们往通道中写入一个数据,加锁:<br><img src="/img/remote/1460000022347281" alt="" title=""><br>然后我们复制元素值,并最终拷贝到循环队列中:<br><img src="/img/remote/1460000022347282" alt="" title=""><br>以上都是针对缓冲通道来说的,而非缓冲通道因为根本不需要这个循环队列,我们直接使用内存copy,从协程1拷贝到协程2即可。</p>
<h3>协程等待队列</h3>
<p>最后,我们讲一下hchan结构体的sendq与recvq字段,他们的作用是什么呢?我们知道,在通道满了之后,如果我们继续往通道中写入数据,那么当前协程会被阻塞。这两个字段就是使用两个双向链表,来存储所有等待的协程的。一旦通道不再为空或者不再为满,那么Go协程的调度器就会去这个双向链表中唤醒一个链表中的协程,允许这个协程往通道中写入/接收数据也同理。具体如何唤醒的流程属于Go的GMP协程调度模型,这里就不再展开讲了,有兴趣的读者可以阅读下面的参考资料。</p>
<h2>参考资料</h2>
<p><a href="https://link.segmentfault.com/?enc=FWOU0P65g%2F3sf8CCTsMLWw%3D%3D.uqPojRDqLBqnellrxQnYpEOtSm42k%2FWDgnIFiuHSR%2FcDB8O4FKT6jEjobcxJl12Diw0KGYyN%2BKAonHvDaNWwng%3D%3D" rel="nofollow">图解Go的channel底层原理</a></p>
<h2>尾声与致谢</h2>
<p>到这里,我们的【Go语言踩坑系列】就全部写作完成了。只写别人没有写过的;别人如果写过的,我们就要写的更好。这是我们NoSay一直秉承的写作理念。我们会继续努力,致力于发布更高水平、更高质量、更有比较优势的文章。很高兴大家能够和我们一起成长,感兴趣的读者可以继续关注我们后续的产出,谢谢!</p>
<h2>关注我们</h2>
<p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p>
<p><img src="https://segmentfault.com/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【Go语言踩坑系列(九)】Channel(上)
https://segmentfault.com/a/1190000022347249
2020-04-12T17:42:22+08:00
2020-04-12T17:42:22+08:00
NoSay
https://segmentfault.com/u/nosay
1
<h2>声明</h2>
<p>本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。</p>
<p>我们知道,Go实现了两种并发形式,第一种是多线程共享内存,其实就是Java,C++等语言的多线程并发,通过锁来进行访问。另一种则是Go特有的CSP(communicating sequential processes)并发模型。</p>
<h2>什么是CSP?</h2>
<p>CSP 是 Communicating Sequential Process 的简称,中文可以叫做通信顺序进程,是一种并发编程模型,由 Tony Hoare 于 1977 年提出。它是在串行时代提出的一个概念,慢慢的演化成了现在的一种并发模型。简单来说,CSP 模型由并发执行的实体(线程或者进程)所组成,实体之间通过发送消息进行通信,这里发送消息时使用的就是通道,或者叫 channel。那么,CSP 模型的关键是关注 channel,而不关注发送消息的实体。而Go 语言实现了 CSP 部分理论,具体的模式如下图所示。<br><img src="/img/bVbFVHy" alt="channel1.png" title="channel1.png"><br>Channel 在 gouroutine 间架起了一条管道,在管道里传输数据,实现 gouroutine 间的通信;由于它是线程安全的,所以用起来非常方便;channel 还提供 “先进先出” 的特性;它还能影响 goroutine 的阻塞和唤醒。</p>
<p>说到这,可能就有的同学有些疑问,为什么要用channel,Goroutine不就可以看作一个线程,然后线程间通信用共享内存来通信不行么?请往下看。</p>
<h2>为什么要用channel</h2>
<p>相信大家都听过这么一句话,Do not communicate by sharing memory; instead, share memory by communicating(不要通过共享内存来通信,而要通过通信来实现内存共享),这两句话难道不是一个意思么?从本质上来看,计算机上线程和协程同步信息其实都是通过共享内存来进行的,因为无论是哪种通信模型,线程或者协程最终都会从内存中获取数据,所以更为准确的说法是为什么我们使用发送消息的方式来同步信息,而不是多个线程或者协程直接共享内存?<br>我们从使用场景分析一下,首先,前半句应该是指我们多应用于多线程通信的方式,一般线程同步在线程间交换的信息仅仅是控制信息,比如某个A线程释放了锁,B线程能获取到锁并开始运行,这个不涉及数据的交换。数据的交换主要还是通过共享内存(共享变量或者队列)来实现,为了保证数据的安全和正确性,共享内存就必需要加锁等线程同步机制。而线程同步使用起来特别麻烦,容易造成死锁,且过多的锁会造成线程的阻塞以及这个过程中上下文切换带来的额外开销。我们通常会因为在代码中加锁而感到烦恼。<br>下半句呢?我理解后半句是说的channel来共享内存,在Go的这种方式中,要传递某个数据给另一个goroutine(协程),可以把这个数据封装成一个对象,然后把这个对象的指针传入某个channel中,另外一个goroutine从这个channel中读出这个指针,并处理其指向的内存对象。channel本身保证来同一时间只有一个goroutine能访问channel的数据,就不用开发者去处理锁了。<br>我们根据他们的差异来总结一下:</p>
<ul>
<li>首先,使用发送消息来同步信息相比于直接使用共享内存和互斥锁是一种更高级的抽象,使用更高级的抽象能够为我们在程序设计上提供更好的封装,让程序的逻辑更加清晰;</li>
<li>其次,消息发送在解耦方面与共享内存相比也有一定优势,我们可以将线程的职责分成生产者和消费者,并通过消息传递的方式将它们解耦,不需要再依赖共享内存;</li>
<li>最后,Go 语言选择消息发送的方式,通过保证同一时间只有一个活跃的线程能够访问数据,能够从设计上天然地避免线程竞争和数据冲突的问题;</li>
</ul>
<p>另外,是不是我们都得使用channel来代替共享内存mutex,当然是不可能的,我们在这来说明一个原因:如果我们向 Channel 中发送了一个指针而不是值的话,发送方在发送该条消息之后其实也保留了修改指针对应值的权利,如果这时发送方和接收方都尝试修改指针对应的值,仍然会造成数据冲突的问题。当然这种大多数情况下是一种设计上的问题,然而针对这种情况使用更为底层的互斥锁才是一种正确的方式。</p>
<p>当然,我们会问channel怎么保证同一时间只有一个活跃的线程能够访问数据的呢?其实channel本身也是通过锁来实现,这就对照我们上边所说的抽象的思想的结论了。具体是怎么实现的呢?我们将会在下一篇文章讲述。</p>
<h2>channel的不同种类以及常见的错误</h2>
<p>channel分为两种,有缓冲channel和无缓冲channel。我们通过下边的代码例子来区分不同的channel种类。</p>
<pre><code class="go">func main() {
pipline := make(chan string) //构造无缓冲通道
pipline <- "hello world" //发送数据
fmt.Println(<-pipline) //读数据
} </code></pre>
<p>运行会抛出错误,如下:</p>
<pre><code>fatal error: all goroutines are asleep - deadlock!</code></pre>
<p>思考一下,我们创建的是一个无缓冲通道,而<strong>对于无缓冲通道,在接收者未准备好之前,发送操作是阻塞的</strong>。那么,我们该怎么去解决这种问题呢?看下边代码。</p>
<pre><code class="go">func hello(pipline chan string) {
<-pipline
}
func main() {
pipline := make(chan string)
go hello(pipline) //如果我们换成直接在同一个协程里读数据会永远阻塞
pipline <- "hello world"
}
</code></pre>
<p>那么我们如果把这个例子改成有缓冲通道还会阻塞吗?我们看下边的例子:</p>
<pre><code class="go">func main() {
pipline := make(chan string, 1)
pipline <- "hello world"
fmt.Println(<-pipline)
} </code></pre>
<p>运行正常,此时是不是就能看出缓冲和没有缓冲的区别呢?是的,区别在于在发送操作是否发生在有接受者时。那么,对于有缓冲通道会发生什么特殊情况呢?</p>
<pre><code class="go">func main() {
ch1 := make(chan string, 1)
ch1 <- "hello world"
ch1 <- "hello China"
fmt.Println(<-ch1)
}</code></pre>
<p>看这个例子,没错,他也会阻塞,<strong>每个缓冲通道,都有容量,当通道里的数据量等于通道的容量后,此时再往通道里发送数据,就失造成阻塞,必须等到有人从通道中消费数据后,程序才会往下进行</strong>。<br>比如这段代码,通道容量为 1,但是往通道中写入两条数据,对于一个协程来说就会造成死锁。</p>
<p>那么问题来了,当程序一直在等待从通道里读取数据,而此时并没有人会往通道中写入数据。此时程序就会陷入死循环,造成死锁,我们如何去解决呢?看下边的例子:</p>
<pre><code class="go">func main() {
pipline := make(chan string)
go func() {
pipline <- "hello world"
pipline <- "hello China"
}()
for data := range pipline{
fmt.Println(data)
}
}</code></pre>
<p>运行结果当然是all goroutines are asleep - deadlock!,通道没有被关闭,程序就一直在等待读取值,怎么解决呢?</p>
<pre><code class="go">func main() {
pipline := make(chan string)
go func() {
pipline <- "hello world"
pipline <- "hello China"
close(pipline) // 重点
}()
for data := range pipline{
fmt.Println(data)
}
}</code></pre>
<p>注意看我标为重点的地方,关闭通道,很明确的方法,既然问题是因为通道没有被关闭造成的阻塞,那么我在发送完数据后关掉就ok了啊~</p>
<h2>下期预告</h2>
<p>【Go语言踩坑系列(十)】Channel(下)</p>
<h2>关注我们</h2>
<p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p>
<p><img src="https://segmentfault.com/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【Go语言踩坑系列(八)】Goroutine(下)
https://segmentfault.com/a/1190000022275834
2020-04-06T11:43:41+08:00
2020-04-06T11:43:41+08:00
NoSay
https://segmentfault.com/u/nosay
0
<h2>声明</h2>
<p>本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。</p>
<h2>引入</h2>
<p>还记得我们在上一篇文章中提到的例子吗:</p>
<pre><code class="go">func main() {
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
}</code></pre>
<p>现在我们分析一下这段代码,循环十次,每次使用go语句创建一个协程,并在每个协程中打印i值,注意这个i值是这条打印语句真正得到执行的时候,从外部for语句代码块中取的的当前的i值。那么为什么在上一篇文章中,我们说每次打印的i值是不确定的呢?答案就在于Go协程的调度机制的不确定性。下面我们从Go协程演化的角度,来逐步揭开协程调度机制的面纱。</p>
<h2>起源</h2>
<h3>单进程</h3>
<p>我们在上一篇文章中已经了解到,在单进程的计算机时代,计算机只能一个任务一个任务处理,而且如果有I/O阻塞,CPU就会一直等待这个进程直到阻塞返回,后面的任务完全得不到机会执行。这里根本不需要调度器。</p>
<h3>多进程/多线程</h3>
<p>为了解决这个问题,我们有了多进程/多线程,一旦某个进程或线程阻塞了,CPU可以在多个进程或线程之间使用时间片轮转调度算法来回切换执行的进程/线程,让CPU不再去等待阻塞返回,这样极大的提高了CPU的利用率。这个时候,就需要调度器来做这个工作了,什么时候、哪个进程任务允许CPU去执行。刚才时间片轮转调度算法就是一个例子。这样我们就实现了在一个CPU上面"同时"运行多个任务。这个同时只是我们看起来是同时,CPU在同一时间只能运行一个任务,只是多个任务之间切换的速度较快,我们看起来好像是同时在运行的,这个就叫做并发。而并行则是完完全全在同一时刻,能够执行多个任务。在多核CPU的时代,我们就可以做到并行。<br>但是,多进程/多线程仍然是操作系统内核级别的东西,内核仍然需要全权负责他们整个生命周期。其每次创建、销毁、切换的开销都是非常大的,而且内核的调度算法可能并不符合我们的需求,灵活性较差,那么怎么解决内核线程的问题呢?</p>
<h3>用户态需要做更多的事情</h3>
<p>而用户态线程则解决了这个问题,它与内核态线程有一个对应关系,可以是1:1 、N:1或者 M:N。用户态线程所有的创建、切换等操作都在用户态完成,开销更小也更灵活。内核不再需要做那么多的切换或者调度工作。Goroutine(协程)就是一种用户态线程的实现。</p>
<h2>Go协程的演化</h2>
<p>我们想了一下,设计一个协程无非需要考虑这三个因素:资源、任务、调度器。<br>资源就是操作系统的内核态线程,而任务就是我们用go语句启动的一堆Goroutine,而调度器就是如何将资源分配给这些任务,在有限的操作系统资源中,最大化利用CPU与多线程的能力,且让每一个任务公平且快速的得到执行。那么,Go语言中这三要素是如何演化的呢?</p>
<h3>先自己实现一个</h3>
<p>我们先想一个最简单的方案,先说如何存放任务。说到公平,那么我们首先想到的数据结构就是队列,先来的任务先执行就好,那么我们用队列去存这一大堆的任务。那么资源呢就直接让内核中多个线程去消费这个队列,拿到一个任务执行就好。我们把任务简单叫做G(Goroutine):<br><img src="/img/remote/1460000022275837" alt="" title=""><br>我们来分析一下这里面的问题。首先,多个内核态线程共享一个任务队列,会存在并发问题。如果多个线程同一时刻拿到同一个任务G,那么会导致两个内核态线程全都在处理同一个任务G,会导致重复的任务处理。这显然需要加锁,才能解决这个问题。而且,这个时候仍然是操作系统内核直接调度整个任务队列,我们在用户态并没有帮助内核做太多调度的事情。</p>
<h3>G-M模型</h3>
<p>所以,我们让多个任务队列对应多个内核线程,这样就可以不用加锁了,提高了内核线程的处理效率:<br><img src="/img/remote/1460000022275838" alt="" title=""><br>但是这个版本仍然是有问题的。我们仅仅是在用户态实现了一个任务队列而已。而内核态仍然需要负责从任务队列里拿出任务、判断任务当前的状态是否可以运行、然后才真正运行这个任务,内核线程的负担过重。<br>计算机科学中有一个经典的理论:计算机上的所有问题都可以通过增加一个抽象层来解决。所以,我们给他加一个帮手,把任务直接喂到线程的嘴里,内核线程只管运行就好了,至于怎么调度的,什么时候会运行哪个任务,内核态线程不用再关心了。这样,内核的任务逐渐减少,一个真正的完整用户态线程的调度机制浮出水面,我们把这个帮手叫做M。M是Machine的缩写,每一个M就代表一个内核态线程,就是之前我们说的可用的线程资源(Machine):<br><img src="/img/remote/1460000022275839" alt="" title=""><br>事实上,在Go1.1版本之前,Go语言就是采用的G-M模型来进行协程调度。但是这种调度模型仍然有一个问题。试想一下,如果我们M与这个队列一对一绑定死,那么如果M中的所有G都运行完了,我们就需要从另一个M结构中拿出一些未执行的任务G,然后放到自己的结构中,继续执行。这样做其实是非常麻烦且不灵活的。</p>
<h3>G-M-P模型</h3>
<p>如果有一个结构,能让我们动态的去绑定M与任务队列就好了,M只关心和他绑定的这个结构,能让我执行任务即可,并不关心这个任务我要如何存储,更不用关心要不要从另一个M的队列里拿一些任务放到自己这边。所以,一个M与任务队列的中介出现了,那就是P:<br><img src="/img/remote/1460000022275840" alt="" title=""><br>P是Go1.1版本新加入的一个数据结构。这个中间层让我们可以更加灵活的、随时切换任务队列运行所需要的线程资源M,真正实现了M与任务的动态1:N的绑定方式。<br>回到我们最开始的问题,打印字符串是一个耗时的I/O操作,需要使用系统调用,将字符写到标准输出中。那么假设执行这个任务的G执行系统调用的时间较长,一直未能等到系统调用完成返回,那么当前的M就会一直阻塞在这个任务G上,不能执行其他的任务。为了解决这个问题,P解除和原有M的绑定,带着剩余的任务G小弟们去寻找另一个下家M,不然G要一直等待阻塞结束,那就要饿死了。<br>所以,通过P,我们可以灵活的将任务队列迁移到任意一个可用的线程资源M上,让剩下的任务能够继续得到执行,不再让线程资源傻傻的等待。注意每个任务G需要保存当前执行的上下文,以便阻塞的任务完成的时候,能够让M继续任务执行后续的逻辑。所以,有了P这个中间层,一个M就可以动态绑定多个任务队列了,而不再将任务队列写死放到M的数据结构内部,解除了M与任务G的直接耦合。<br>正因为Go协程有这种调度机制,所以我们开篇那个例子,循环并不会等待打印操作执行完再创建下一个协程,而是直接进行下一个循环,立刻创建新协程,一共创建了10个协程。而这10个协程的调度时机又是不确定的,所以打印的所以我们也没有办法确认最终的打印顺序。<br><img src="/img/remote/1460000022275843" alt="" title=""><br>相比前文的G-M调度模型。如果上文的M管辖的队列已经没有任务了,M还需要自己去找其他队列,并把任务加到自己的数据结构中。而有了P之后,那M直接从其他有G的P那里偷取一半G过来,放到自己的P本地队列即可。看到区别了吗,通过加入P这个中间层,真正实现了任务与M的动态绑定,与G-M模型相比更加灵活。这个机制叫做work stealing。<br><img src="/img/remote/1460000022275841" alt="" title=""><br>假设我们又想添加一个任务G,但是所有P的队列都满了,怎么办呢?在这个模型中还有一个全局共享的任务队列,因为其仍有我们第一版实现中需要加锁的缺点,所以任务实在放不下的时候才会使用全局队列。所以全局队列在调度器中的地位也是非常低的。只有本地队列无法找到任务来运行的时候,才会到全局队列中拿到任务来运行。</p>
<h2>总结</h2>
<p>我们用一张图来总结一下整体的数据结构:<br><img src="/img/remote/1460000022275842" alt="" title=""></p>
<p>接下来我们总结一下我们从中学到的几个思想:</p>
<ul>
<li>复用思想:当M绑定的P无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。这个机制被叫做work stealing。</li>
<li>非阻塞思想:当本线程因为G进行系统调用阻塞时,M释放绑定的P,P会转移给其他空闲的线程执行,最大化压榨CPU,提高了CPU的利用率。这个机制被叫做hand off。</li>
<li>中间层思想:当一个实体承载的任务过多,可以加一个中间层以减轻负担,同时能够解除双方的耦合,更加灵活。</li>
<li>架构的边界划分:M的加入让内核只需要执行任务即可,P让M中不再与任务G耦合,让M更专注线程资源本身的管理,而非任务队列的管理。</li>
</ul>
<h2>参考资料</h2>
<p><a href="https://segmentfault.com/a/1190000021951119">Golang调度器GMP原理与调度全分析</a><br><a href="https://link.segmentfault.com/?enc=E7gb6Uuw2BwxWO9P3WxcrA%3D%3D.7sLQyRg5UXXfbu04pc0ywtCbipJk7cIQgUB9r0OZxih8DvUVPydtmNoMTbgPCdSF" rel="nofollow">Go并发编程-Goroutine如何调度的?</a></p>
<h2>关注我们</h2>
<p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p>
<p><img src="https://segmentfault.com/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【Go语言踩坑系列(七)】Goroutine(上)
https://segmentfault.com/a/1190000022265009
2020-04-04T19:57:04+08:00
2020-04-04T19:57:04+08:00
NoSay
https://segmentfault.com/u/nosay
0
<h2>声明</h2>
<p>本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。</p>
<h2>进程,线程到协程的发展</h2>
<p>计算机发展至今有几个至关重要的时期:</p>
<h3>1. 单任务时代</h3>
<p>这个时代主要标志为批处理。我们都知道早期的计算机就是穿孔打卡来运行的,需要人工去做输入输出的处理工作,计算机只进行了计算,而且每次都只能执行一个流程,然后循环往复,这个大流程下计算机很大程度上是没有执行的,所以。为了解决这个问题,出现了批处理,它把之前一次执行的任务聚合起来,统一传输给计算机处理,计算机做统一的输出,减少了人与机器的交互过程,计算机资源自然而然的利用起来了。整个过程好比之前我们喝水是每次渴了的时候就拿水杯从池塘去打杯水喝,而现在是你每次渴了的时候就拿两个桶去挑一担一样,挑回来的水每次都够喝一段时间,节省了你很多次去打水的过程,这样我们只需要保证桶里一直有水就可以了。具体的流程图如下图所示,注意差别在单个和多个上。<br><img src="/img/bVbFAiu" alt="卡片时代.png" title="卡片时代.png"></p>
<h3>2. 多进程时代</h3>
<p>单任务时代的批处理虽然提高了计算机整体资源利用的问题,但是这个时代一台计算器只能干一件事情,进行读取或写入的时候就不能进行计算,进行计算的时候就不能进行读写的IO,也就是说是串行的。因为IO和CPU计算速度存在着巨大的差异,这样就造成了CPU所花的计算时间非常少,而大部分的时间都在等待IO的结束。</p>
<p>所以为了更合理的利用CPU资源,前辈们就把计算机内存划分为多个块,不同的任务拥有自己的内存空间,任务之间互不干涉,这里单独的任务也就单独分为了一个进程,CPU可以在多个进程之间切换这执行,当一个进程需要进行磁盘IO的时候CPU就切换到另外一个进程去执行指令(这块有个问题,磁盘IO难道就不占用CPU么?有兴趣的可以去了解一下DMA(Direct Memory Access),这样就让CPU的资源更合理的运用起来,这个时候随着内存的加大,可划分的块也越来越多,这样能“同时”运行的进程也越来越多,CPU在不同的进程之间切换执行,任务多的时候它会一直处于工作状态。这样极大的提升了计算机的工作效率。<br><img src="/img/bVbFAiw" alt="多进程.png" title="多进程.png"></p>
<p>这里每个进程都有自己的独立内存空间,由于进程比较重,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,在切换时他需要保存自己的上下文,栈寄存器等信息(这样才能切换回来),但相对比较稳定安全。</p>
<h3>3. 多线程时代</h3>
<p>CPU基于进程的调度极大的提高了CPU的利用率,但是存在一个问题,如果一个进程内存在阻塞的动作,这个进程就会总得不到CPU,而且当一个进程工作的时候别的进程是无法获取CPU的,想象一下,我们在写代码的时候不能同时听野狼Disco了还会激情满满么。 当然,如果计算机说可以给每个进程都分配一个CPU,那也可以,但是这是后话了。</p>
<p>所以我们的CPU选择了基于粒度更小的线程来调度执行任务。一个进程可以创建很多个线程来执行任务,没有了进程的界限区分,CPU会在线程之间来回切换的工作, CPU的时间片分的更细,以至他在多个线程之间切换执行,我们并没有明显的感知,这样的话我们多个进程也变得可以“同时”工作了,同时CPU等待IO的几率又更加小了。</p>
<p>此时线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。因为它是进程的一个实体,它可与同属一个进程的其他的线程共享进程所拥有的全部资源,当然除了在运行中必不可少的资源(如程序计数器,一组寄存器和栈),这就导致线程上下文切换很快,资源开销比较少,但因为它和其他线程拥有共享资源,所以相比进程不够稳定容易丢失数据。</p>
<h3>4. 多核CPU</h3>
<p>之前的时代都是一个CPU来执行,之前我们一直怕不能充分利用CPU,但是随着计算机的高速发展,我们发现CPU不够用了。因此就出现了现在的双核,四核,八核等CPU,出现了多核CPU之后,计算机拥有了同一时间处理不同线程的能力,也就是真正意义的并行了。</p>
<h3>5. 协程的出现</h3>
<p>协程不能说是我们这个时代出现的产物,但是它是这个时代兴起的产物,它和进程和线程不太一样,它是用户态线程,也就是说,它的调用是用户自己来控制的。那么出现协程的背景是什么呢?现在通常会面临下边这种情况,一个网络服务器有很多客户,那么整个服务器就充斥着大量并行的 IO 请求。而为了处理这种情况,也出现了很多种解决的办法,其中最著名的就是epoll(Linux)或 IOCP(Windows)机制,这两个机制颇为类似,都是在需要 IO 时登记一个 IO 请求,然后统一在某个线程查询谁的 IO 先完成了,谁先完成了就让谁处理。<br>从系统调用次数的角度,epoll 或 IOCP 都是产生了更多次数的系统调用。从内存拷贝来说也没有减少。所以真正最有意义的事情是:减少了线程的数量。<br>那么为什么要减少线程的数量?这就需要从时间,空间成本上来考虑了,首先说空间成本,默认情况下 Linux 线程在数 MB 左右,其中最大的成本是堆栈(虽然,线程的堆栈大小是可以设置的,但是出于线程执行安全性的考虑,线程的堆栈不能太小)。我们可以想象如一个线程1MB,一千个有多大?时间成本,线程之间的切换也不可忽略。调度成本,执行体之间的同步与互斥成本,也是一个不可忽略的成本。虽然单位成本看起来还好,但是盖不住次数实在太多。<br>而协程的出现恰恰可以解决这个痛点,切换上下文快,没有锁同步的开销以及占用的资源很少。然而协程只是解决的单核CPU下的并发,如果以计算为主且是多核的话,它可能没有线程性能要好,这也是它的一大弊端。当然,协程的工作模式导致了擅长在IO密集型场景中,具体的协程原理我们在下一篇文章中会做详细阐述。</p>
<p>协程是一种用户态的轻量级线程,协程的调度完全由用户控制。我们可以认为协程是线程内的某一段,拥有自己的寄存器上下文和栈,此时的栈空间在不同的设计上会比线程更优,例如Go,它的协程的栈空间是按需扩展的,开始只有4k。 协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈所以基本没有内核切换的开销,它可以不加锁的访问全局变量,所以上下文的切换非常块。</p>
<h2>Goroutine经典案例浅谈</h2>
<p>我们具体来看一道在面试中经常遇到的编程题。</p>
<pre><code class="go">func main() {
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
}</code></pre>
<p>问,这段代码会输出什么?此刻读者请思考一下。</p>
<p>这道题的典型回答是:不会有任何内容被打印出来。但是,如果你尝试去跑这段代码你就会知道它输出的可能是10个10,不会有任何内容被打印出来又或是“打印出乱序的0到9等等。那么这是为什么呢?<br>首先,我们需要知道一个与主 goroutine 有关的重要特性,即:一旦主 goroutine 中的代码(也就是main函数中的那些代码)执行完毕,当前的 Go 程序就会结束运行。<br>其次,为什么会出现这么多种结果呢?原因就是GO所拥有的调度方法以及GPM模型的神秘之处。<br>我们在此先说明一下简单的流程,在执行一条go语句的时候,Go 语言的运行时系统(runtime),会先试图从某个存放空闲的 G(也就是 goroutine) 的队列中获取一个 G(它只有在找不到空闲 G 的情况下才会去创建一个新的 G),在拿到了一个空闲的 G 之后,Go 语言运行时系统会用这个 G 去包装当前的那个go函数(或者说该函数中的那些代码),然后再把这个 G 追加到某个存放可运行的 G 的队列中。这类队列中的 G 总是会按照先入先出的顺序,很快地由运行时系统内部的调度器安排运行(具体实现下篇会讲解)。经过这么多的运算,go函数的执行时间总是会明显滞后于它所属的go语句的执行时间。然而只要go语句本身执行完毕,Go 程序完全不会等待go函数的执行,它会立刻去执行后边的语句,等到所有的语句执行完毕,Go程序就会结束运行,它也不会去等待go函数执行完毕。<br>所以我们再来看上述的代码的问题,在for语句中我们会依次的去执行go语句,但是go函数什么时候执行是我们控制不了的,除非我们使用了某种 Go 语言提供的方式进行了人为干预。<br>当然,在此我们只是简单的说明了这个现象,如果你想要对GO的GPM模型和调度方法有更多的了解,相信你会在我们下篇的文章里有所收获。</p>
<h2>扩展</h2>
<h3>什么是用户态和内核态?</h3>
<p>内核态:当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态(或简称为内核态)。<br>用户态就是提供应用程序运行的空间,为了使应用程序访问到内核管理的资源例如CPU,内存,I/O。也就是说是内核态的一层封装的空间。</p>
<h3>为什么会有用户态和内核态?</h3>
<p>由于开发和维护内核的复杂性,只有最基本的和性能关键的代码才放在内核中。其他东西,如GUI、管理和控制代码,通常被编程为用户空间应用程序常见,这种是Linux中很常见的做法。</p>
<h3>用户态线程和内核态线程有什么关系?</h3>
<p>所谓用户态线程就是把内核态的线程在用户态实现了一遍而已,目的是更轻量化(更少的内存占用、更少的隔离、更快的调度)和更高的可控性(可以自己控制调度器)。用户态所有东西内核态都「看得见」,只是对于内核而言「用户态线程」只是一堆内存数据而已。</p>
<h2>下期预告</h2>
<p>【Go语言踩坑系列(七)】Goroutine(下)</p>
<h2>关注我们</h2>
<p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p>
<p><img src="https://segmentfault.com/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【Go语言踩坑系列(六)】面向对象
https://segmentfault.com/a/1190000022187655
2020-03-29T11:21:07+08:00
2020-03-29T11:21:07+08:00
NoSay
https://segmentfault.com/u/nosay
1
<h2>声明</h2>
<p>本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。</p>
<h2>问题思考</h2>
<h3>为什么有结构体?</h3>
<p>首先,我们需要明确面向对象的思想是包含各种独立而又互相调用,这就需要一个承载的数据结构,那么这个结构是什么呢?很显然,在GO语言中就是结构体。<br>其次,结构体作为一种数据结构,无论是在C还是C++还是Go都发挥了极其重要的作用。另外,在Go语言中其实并没有明确的面向对象的说法,实在要扯上的话,我们可以将struct比作其它语言中的class。至于为什么不用class,可能是作者想要划清和其他语言不同的界限,毕竟Go在面向对象实现这方面是极其轻量的。我们简单看一下结构体的声明:</p>
<pre><code class="go">type Poem struct {
Title string //声明属性,开头大小写表示属性的访问权限
Author string
intro string
}
func (poem *Poem) publish() { //和其它语言不一样,golang声明方法和普通方法一致,只是在func后增加了poem Poem这样的声明
fmt.Println("poem publish")
}</code></pre>
<h3>结构体比较</h3>
<p>如果结构体的全部成员都是可以比较的,那么结构体也是可以比较的,那样的话两个结构体将可以使用==或!=运算符进行比较。相等比较运算符==将比较两个结构体的每个成员,因此下面两个比较的表达式是等价的:</p>
<pre><code class="go">func main() {
type Point struct{ X, Y int }
p := Point{1, 2}
q := Point{2, 1}
fmt.Println(p.X == q.X && p.Y == q.Y) // "false"
fmt.Println(p == q) // "false"
}</code></pre>
<p>可比较的结构体类型和其他可比较的类型一样,可以用于map的key类型。</p>
<pre><code class="go">func main() {
type address struct {
name string
age int
}
hits := make(map[address]int)
hits[address{"nosay", 8}]++
fmt.Println(hits[address{"nosay", 8}]) // 1
}</code></pre>
<h3>结构体在使用时的一个技巧</h3>
<p>在结构体传递过程中,如果考虑效率的话,较大的结构体通常会用指针的方式传入和返回。而且如果要在函数内部修改结构体成员的话,用指针传入是必须的;因为在Go语言中,所有的函数参数都是值拷贝传入的(结构体较大的话会重新分配空间,浪费资源),函数参数将不再是函数调用时的原始变量。</p>
<h3>接口是什么?</h3>
<p>Go 语言中的接口就是一组方法的签名,它是 Go 语言的重要组成部分。使用接口能够让我们更好地组织并写出易于测试的代码。但其实接口的本质就是引入一个新的中间层,调用方可以通过接口与具体实现分离,解除上下游的耦合,上层的模块不再需要依赖下层的具体模块,只需要依赖一个约定好的接口。我们日常使用的sql又何尝不是一个接口呢?例如下图:<br><img src="/img/bVbFgeW" alt="调用关系.png" title="调用关系.png"></p>
<p>GO语言接口是隐式的,一种鸭子模型很明确的体现,那么鸭子模型是什么?“当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。”在接口上体现就是当你实现了接口的所有方法的时候就会认为你实现了接口,而不用像其他语言一样去显示声明我实现了这个接口。例如下边这个例子Dog就实现了Pet接口:</p>
<pre><code class="go">type Pet interface {
SetName(name string)
}
type Dog struct {
Class string
}
func (dog *Dog) SetName(name string) {
dog.Class = name
}</code></pre>
<p>在Go语言中,只需要实现所有接口中定义的方法,我们就默认这个类型实现了接口。</p>
<h3>接口的数据结构</h3>
<p>Go 语言根据接口类型『是否包含一组方法』对类型做了不同的处理,也就是分为空接口和有方法的接口。我们使用 iface 结构体表示包含方法的接口;使用 eface 结构体表示不包含任何方法的 interface{} 类型。接下来我们来看看这两种数据结构。</p>
<pre><code class="go">eface:
type eface struct { // 16 bytes
_type *_type
data unsafe.Pointer
}</code></pre>
<p>由于 interface{} 类型不包含任何方法,所以它的结构也相对来说比较简单,只包含指向底层数据和类型的两个指针。从上述结构我们也能推断出 — Go 语言中的任意类型都可以转换成 interface{} 类型。</p>
<p>另一个用于表示接口的结构体就是 iface,iface 内部维护两个指针,tab 指向一个 itab 实体, 它表示接口的类型以及赋给这个接口的实体类型。data 则指向接口具体的值,一般而言是一个指向堆内存的指针。</p>
<pre><code class="go">iface:
type iface struct { // 16 bytes
tab *itab
data unsafe.Pointer
}</code></pre>
<p>接下来我们分别来看看type和tab里边又是什么内容:</p>
<pre><code class="go">type:
type _type struct {
size uintptr //字段存储了类型占用的内存空间,为内存空间的分配提供信息
ptrdata uintptr
hash uint32 //字段能够帮助我们快速确定类型是否相等
tflag tflag //类型的 flag,和反射相关
align uint8 // 内存对齐相关
fieldAlign uint8
kind uint8 //类型的编号,有bool, slice, struct 等等等等
equal func(unsafe.Pointer, unsafe.Pointer) bool //字段用于判断当前类型的多个对象是否相等,该字段是为了减少 Go 语言二进制包大小从 typeAlg 结构体中迁移过来的
gcdata *byte //gc相关
str nameOff
ptrToThis typeOff
}
tab:
type itab struct {
inter *interfacetype //接口的类型
_type *_type //实体的类型
link *itab
hash uint32 // type.hash的拷贝,用于比较目标类型和接口类型
bad bool // type does not implement interface
inhash bool // has this itab been added to hash?
unused [2]byte
fun [1]uintptr // 放置和接口方法对应的具体数据类型的方法地址,这里存储的是第一个方法的函数指针,如果有更多的方法,在它之后的内存空间里继续存储
}
type interfacetype struct {
typ _type //实体类型
pkgpath name //包名
mhdr []imethod //函数列表
}</code></pre>
<p>因为eface和iface结构上有一定的共性,我们这里就只看一下iface数据结构的图解,eface只是稍微变化一下就可以:<br><img src="/img/bVbFgbE" alt="iface.png" title="iface.png"><br>在这里有个问题,一个iface结构体维护一个接口类型和实体类型的对应关系,我们在代码中常常会去多次实现接口,那怎么存呢?答案就是只要在代码中存在引用关系, go 就会在运行时为这一对具体的 <Interface, Type> 生成 itab 信息。</p>
<h3>值方法和指针方法的区别</h3>
<p>我们都知道,方法的接收者类型必须是某个自定义的数据类型,而且不能是接口类型或接口的指针类型。所谓的值方法,就是接收者类型是非指针的自定义数据类型的方法。那么,值方法和指针方法体现在哪里呢?我们看下边的代码:</p>
<pre><code class="go">func (cat *Cat) SetName(name string) {
cat.name = name
}</code></pre>
<p>方法SetName的接收者类型是*Cat。Cat左边再加个*代表的就是Cat类型的指针类型,这时,Cat可以被叫做*Cat的基本类型。你可以认为这种指针类型的值表示的是指向某个基本类型值的指针。那么,这个SetName就是指针方法。那么什么是值方法呢?通俗的讲,把Cat前边的*去掉就是值方法。指针方法和值方法究竟有什么区别呢?请看下文。</p>
<ol><li>值方法的接收者是该方法所属的那个类型值的一个副本。我们在该方法内对该副本的修改一般都不会体现在原值上,除非这个类型本身是某个引用类型(比如切片或字典)的别名类型。</li></ol>
<p>而指针方法的接收者,是该方法所属的那个基本类型值的指针值的一个副本。我们在这样的方法内对该副本指向的值进行修改,却一定会体现在原值上。这块可能有点绕,但如果之前函数传切片那块理解的话这块也可以想明白,总之就是一个拷贝的是整个数据结构,一个拷贝的是指向数据结构的地址。</p>
<ol><li>一个自定义数据类型的方法集合中仅会包含它的所有值方法,而该类型的指针类型的方法集合却囊括了前者的所有方法,包括所有值方法和所有指针方法。</li></ol>
<p>严格来讲,我们在这样的基本类型的值上只能调用到它的值方法。但是,Go 语言会适时地为我们进行自动地转译,使得我们在这样的值上也能调用到它的指针方法。</p>
<p>例如下边这种也是可以调用的:</p>
<pre><code class="go">type Pet interface {
Name() string
}
type Dog struct {
Class string
}
func (dog Dog) Name() string{
return dog.Class
}
func (dog *Dog) SetName(name string) {
dog.Class = name
}
func main() {
a := Dog{"grape"}
a.SetName("nosay") //a会先取地址然后去调用指针方法
//Dog{"grape"}.SetName("nosay") //因为是值类型,调用失败,cannot call pointer method on Dog literal,cannot take the address of Dog literal
(&Dog{"grape"}).SetName("nosay") //可以
}</code></pre>
<p>在后边你会了解到,一个类型的方法集合中有哪些方法与它能实现哪些接口类型是息息相关的。如果一个基本类型和它的指针类型的方法集合是不同的,那么它们具体实现的接口类型的数量就也会有差异,除非这两个数量都是零。</p>
<p>比如,一个指针类型实现了某某接口类型,但它的基本类型却不一定能够作为该接口的实现类型。例如:</p>
<pre><code class="go">type Pet interface {
SetName(name string)
Name()string
Category()string
}
type Dog struct {
name string
}
func (dog *Dog) SetName(name string) {
dog.name = name
}
func(dog Dog) Name()string{
return dog.name
}
func (dog Dog)Category()string{
return "dog"
}
func main() {
dog:=Dog{"little pig"}
_,ok:=interface{}(dog).(Pet)
fmt.Printf("Dog implements interface Pet: %v\n", ok) //false
_, ok = interface{}(&dog).(Pet)
fmt.Printf("*Dog implements interface Pet: %v\n", ok)
fmt.Println() //true
}</code></pre>
<p>对于编译器在什么情况下调用这些方法会调用失败有以下几种情况:</p>
<table>
<thead><tr>
<th> </th>
<th>值方法</th>
<th>指针方法</th>
</tr></thead>
<tbody>
<tr>
<td>结构体初始化变量</td>
<td>通过</td>
<td>不通过</td>
</tr>
<tr>
<td>结构体指针初始化变量</td>
<td>通过</td>
<td>通过</td>
</tr>
</tbody>
</table>
<p>说完基础知识的疑惑,接下来我们具体举例看看GO如何实现面向对象的三把斧(继承,封装,多态的);</p>
<h2>面向对象的三把斧</h2>
<h3>继承</h3>
<p>首先,我们需要明确一个概念,Go语言中是没有继承的概念的,具体原因在官网上是明确作出声明的(参见<a href="https://link.segmentfault.com/?enc=uAc7PAh5DgOkFu3PCfB11Q%3D%3D.gpYp41WTPWZYyuSI5E3OMpfMJHMyQ%2BlFNdHwPgy7AjasiJTJcV5ojPCI%2FlPoDWbX" rel="nofollow">为什么没有继承?</a>,简单的说,面向对象编程中的继承,其实是通过牺牲一定的代码简洁性来换取可扩展性,而且这种可扩展性是通过侵入的方式来实现的,而Go因为类型和接口之间没有明确的关系,所以不需要管理或讨论类型层次结构。<br>那么,我们通过下边一个例子来看一下Go是怎么通过嵌入组合来实现继承的:</p>
<pre><code class="go">type Animal struct {
name string
subject string
}
// 动物的公共方法
func (a *Animal) Eat(food string) {
fmt.Println("动物")
}
type Cat struct {
// 继承动物的属性和方法
Animal
// 猫自己的属性
age int
}
// 猫类独有的方法
func (c Cat) Sleep() {
fmt.Println("睡觉")
}
func main() {
// 创建一个动物类
animal := Animal{name:"动物", subject:"动物科"}
animal.Eat("肉")
// 创建一个猫类
cat := Cat{Animal: Animal{name:"猫", subject:"猫科"},age:1}
cat.Eat("鱼") //调用的Animal的Eat方法,“继承”的体现
cat.Sleep()
}</code></pre>
<h3>封装</h3>
<p>Go语言在包的级别进行封装。 以小写字母开头的名称只在该程序包中可见。 你可以隐藏私有包中的任何内容,只暴露特定的类型,接口和工厂函数。</p>
<p>例如,在这里要隐藏上面的Foo类型,只暴露接口,你可以将其重命名为小写的foo,并提供一个NewFoo()函数,返回公共Fooer接口:</p>
<pre><code class="go">type foo struct {
}
func (f foo) Foo1() {
fmt.Println("Foo1() here")
}
func (f foo) Foo2() {
fmt.Println("Foo2() here")
}
func (f foo) Foo3() {
fmt.Println("Foo3() here")
}
func NewFoo() Fooer {
return &Foo{}
}</code></pre>
<p>然后来自另一个包的代码可以使用NewFoo()并访问由内部foo类型实现的Footer接口,当然要记得引入包名:</p>
<pre><code>f := NewFoo()
f.Foo1()
f.Foo2()
f.Foo3()</code></pre>
<h3>多态</h3>
<p>多态性是面向对象编程的本质:只要对象坚持实现同样的接口,Go语言就能处理不同类型的那些对象。 Go接口以非常直接和直观的方式提供这种能力。<br>这里有一个精心准备的例子,实现Ihello接口的多个实现被创建并存储在一个slice中,然后轮询调用Hello方法。 你会注意到不同实例化对象的风格。</p>
<pre><code class="go">type IHello interface {
Hello(name string)
}
func Hello(hello IHello) {
hello.Hello("hello")
}
type People struct {
Name string
}
func (people *People) Hello(say string) {
fmt.Printf("the people is %v, say %v\n", people.Name, say)
}
type Man struct {
People
}
func (man *Man) Hello(say string) {
fmt.Printf("the people is %v, say %v\n", man.Name, say)
}
type Women struct {
People
}
func (women *Women) Hello(say string) {
fmt.Printf("the people is %v, say %v\n", women.Name, say)
}
func Echo(hello []IHello) {
for _,val := range hello {
val.Hello("hello world")
}
}
func main() {
hello1 := &People{"people"}
hello2 := &Man{People{Name: "xiaoming"}}
hello3 := &Women{People{Name: "xiaohong"}}
sli := []IHello{hello1, hello2, hello3}
//the people is people, say hello world
//the people is xiaoming, say hello world
//the people is xiaohong, say hello world
Echo(sli)
}</code></pre>
<h2>下期预告</h2>
<p>【Go语言踩坑系列(七)】Goroutine</p>
<h2>关注我们</h2>
<p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p>
<p><img src="https://segmentfault.com/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【Go语言踩坑系列(五)】错误与异常处理
https://segmentfault.com/a/1190000022184343
2020-03-28T22:18:26+08:00
2020-03-28T22:18:26+08:00
NoSay
https://segmentfault.com/u/nosay
0
<h2>声明</h2>
<p>本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。</p>
<h2>为什么需要错误和异常处理</h2>
<p>任何一行代码都可能存在不可预知的问题,而这些问题就是bug的根源。为了妥善处理这类问题,我们需要编写一些代码,这类代码被称为运维代码。通常情况下,我们需要发现问题、判断问题的种类、然后根据问题的种类,分别进行响应与处理。这些处理可能是写入日志、也可能是直接让代码停止运行,这些都视你的业务逻辑而定。这样一来,在我们编写了足够健壮的运维代码之后,我们便能快速的定位并解决问题。</p>
<h2>错误处理方案的演化</h2>
<h3>单返回值</h3>
<p>我们先看一个最原始的错误处理解决方案:Unix读取文件的API:</p>
<pre><code class="c">int open(const char *pathname, int flags);</code></pre>
<p>如果成功打开这个文件,open()会返回一个int类型的文件描述符fd;如果打开失败,便会返回-1。为了正确处理该函数的错误,我们会编写以下代码:</p>
<pre><code class="c">if ((fd = open("/etc/hosts", O_RDONLY)) < 0) {
printf("%s", "open failed")
exit(1);
}</code></pre>
<p>这样做会有什么问题呢?由于错误码和正确的业务逻辑混在一个返回值里,假如你忘了去判断fd的值,代码就会继续往下执行,就会把错误的-1当成正确的fd,代码就会发生不可预知的错误。除此之外,这种错误处理方式的语义也并不清晰。<br>那么,我们该如何解决这个问题呢?</p>
<h3>多返回值</h3>
<p>我们想了一下,一个返回值不行,那么搞两个返回值,把错误处理逻辑与正常逻辑区分开,代码逻辑就会变得更加清晰了。Go语言就是这样做的,我们通常能够看到如下代码:</p>
<pre><code class="go">func main() {
res, err := json.Marshal(payload)
if err != nil {
return "", errors.New("序列化请求参数失败")
}
}</code></pre>
<p>通过将正确执行Marshal的返回结果与错误的返回结果分离,使代码语义更加清晰,而且这样会提醒程序员更加关注错误的返回值,而不会忘记进行错误处理。但是,这样仍然存在一个问题,会出现大量的类似代码:</p>
<pre><code>if err != nil {
// 错误处理逻辑
}</code></pre>
<p>在Go语言的代码中,会出现大量对err的if判断逻辑,重复率过高,而且错误处理逻辑仍然和正常的代码逻辑混淆在一起,我们如果想进一步将错误与正常逻辑分离,该如何做呢?</p>
<h3>try-catch</h3>
<p>Java、PHP等语言提供了try-catch-finally的解决方案。</p>
<pre><code class="php">try {
// 正常代码逻辑
} catch(\Exception $e) {
// 错误处理逻辑
} finally {
// 释放资源逻辑
}</code></pre>
<p>try-catch彻底完成了对错误与正常代码逻辑的分离。我们用try代码块中包裹可能出现问题的代码,在catch中对这些问题代码统一进行错误处理。</p>
<h3>资源的释放</h3>
<p>finally代码块比较特殊,它被常常用来做一些资源及句柄的释放工作。如果没有finally,我们的代码可能会像这样:</p>
<pre><code class="go">func main() {
mutex := sync.Mutex{}
// 加锁
mutex.Lock()
res, err := json.Marshal("abc")
if err != nil {
// 释放锁资源
mutex.Unlock()
// ....其余错误处理逻辑
}
file, err := os.Open("abvc")
if err != nil {
// 释放锁资源
mutex.Unlock()
// ....其余错误处理逻辑
}
mutex.Unlock()
}</code></pre>
<p>为了确保锁资源在代码结束之前一定要被释放,我们每次在错误处理逻辑中,都需要写一次mutex.Unlock代码,导致大量的代码冗余。finally代码块内的语句会在代码返回或者退出之前执行,而且是百分百会执行。这样,我们就可以把释放锁资源这一行代码放到finally块即可,且只用写一次,这样就解决了之前代码冗余率高的问题。在Go语言中,defer()也同样解决了这个问题。我们用Go中的defer语句改写一下上述代码:</p>
<pre><code class="go">func main() {
mutex := sync.Mutex{}
defer mutex.Unlock()
mutex.Lock()
res, err := json.Marshal("abc")
if err != nil {
// 错误处理
}
file, err := os.Open("abvc")
if err != nil {
// 错误处理
}
}</code></pre>
<p>这就是错误处理的演化过程了。我个人比较喜欢Java和PHP中的try-catch-finally语法。听说Go2.0要对代码冗余度高的问题进行优化,我们拭目以待吧。</p>
<h2>Go错误处理的实现</h2>
<p>接下来我们深入讲解Go语言中的错误处理实现。我们看一下之前讲过的例子中,json.Marshal方法的签名:</p>
<pre><code class="go">func Marshal(v interface{}) ([]byte, error) </code></pre>
<p>我们重点关注最后一个error类型的参数,它是一个Go语言内置的接口类型。那么,我们为什么要用接口类型来抽象所有的错误类型呢?先别急,我们先自己想想。</p>
<h3>简单版的实现</h3>
<p>在我们对字符串进行marshal操作的过程中,可能会产生好多种类型的错误。为了在marshal函数内部区分不同的错误类型,我们简单粗暴一点,可能会进行如下的处理:</p>
<pre><code class="go">func (e *encodeState) marshal(v interface{}, opts encOpts) (errorMsg string) {
// 操作1可能的错误
if errType1 := doOp1(), errType1 != nil {
err1 := errType1.getErrorMessage() // 获取errorType1的错误信息
return err1
}
// 操作2可能的错误
if errType2 := doOp2(), errType2 != nil {
err2 := errType2.getErrMsg() // 方法名和errorType1不同
return err2
}
return ""
}</code></pre>
<p>我们分析一下上面这段代码,操作doOp1可能会发生errorType1类型的错误,我们要返回给调用者errorType1类型中错误的字符串信息;doOp2也同理。这样做确实可以,但是还是有一些麻烦,我们看看还有没有其他方案来优化一下。</p>
<h3>抽象一下试试</h3>
<p>我们先简单介绍一下,Go语言用一个接口类型抽象了所有错误类型:</p>
<pre><code class="go">type error interface {
Error() string
}</code></pre>
<p>这个接口定义了一个Error()方法,用于返回错误信息,我们先记下来,等会要用。同上个例子,我们给之前自定义的两种错误类型加点料,实现这个error接口:</p>
<pre><code class="go">type errType1 struct {}
// 实现接口方法
func (*errType1) Error() {
fmt.Println("我是错误类型1的信息")
}
type errType2 struct {}
// 实现接口方法
func (*errType2) Error() {
fmt.Println("我是错误类型1的信息")
}</code></pre>
<p>然后在marshal()函数上稍作改动,使用这两种实现接口的错误类型:</p>
<pre><code class="go">func (e *encodeState) marshal(v interface{}, opts encOpts) (errorMsg string) {
// 操作1可能的错误
if errType1 := doOp1(), errType1 != nil {
return errType1.Error()
}
// 操作2可能的错误
if errType2 := doOp2(), errType2 != nil {
return errType2.Error()
}
return ""
}</code></pre>
<p>大家看到优势在哪里了吗?在我们调用每个错误类型的返回信息方法的时候,如果用我们一开始的方式,我们需要进入每一个错误类型的实现类中去翻看他的API,看看函数名是什么;而在第二种实现方案中,由于两种错误的实现类型均实现了Error()方法,这样,在marshal函数中如果想进行错误信息的获取,我们统一调用Error()函数,即可返回对应错误实现类的错误信息。<br>这其实就是一种依赖的倒置。调用方marshal()函数不再关注错误类型的具体实现类,里面有哪些方法,而转为依赖抽象的接口。下面给大家看一下与marshal函数相关的几种Go语言内部定义的错误类型,他们均实现了error接口中的Error()方法:<br>第一种错误类型:</p>
<pre><code class="go">type MarshalerError struct {
Type reflect.Type
Err error
sourceFunc string
}
func (e *MarshalerError) Error() string {
srcFunc := e.sourceFunc
if srcFunc == "" {
srcFunc = "MarshalJSON"
}
return "json: error calling " + srcFunc +
" for type " + e.Type.String() +
": " + e.Err.Error()
}</code></pre>
<p>第二种错误类型:</p>
<pre><code class="go">type UnsupportedValueError struct {
Value reflect.Value
Str string
}
func (e *UnsupportedValueError) Error() string {
return "json: unsupported value: " + e.Str
}</code></pre>
<p>而其他语言对错误类型的抽象化处理,有些是用继承来实现的。如PHP中的根Exception与众多继承它的子异常类xxxException。在PHP中,我们用Exception即可接收所有的异常类型,并可以调用通用的$exception->getMessage()、$exception->getFile()等方法。</p>
<h2>谈谈panic和recover</h2>
<p>Go语言的panic和其他语言的error有点像。如果调用了panic,代码会立刻停止运行,一层一层向上冒泡并积累堆栈信息,直到调用栈顶结束,并打印出所有堆栈信息。<br>panic没什么好说的,而recover我们需要好好聊一聊。recover专门用来恢复panic。也就是说,如果你在panic之前声明了recover语句,那么你就可以在panic之后使用recover接收到panic的信息。但是问题又来了,我们panic不是直接就退出程序了吗,就算声明了recover也执行不了呀。这个时候,我们就需要配合defer来使用了。defer能够让程序在panic之后,仍然执行一段收尾的代码逻辑。这样一来,我们就可以通过recover获得panic的信息,并对信息作出识别与处理了。仍然举上述的marshal的源码的例子,这次是真的源码了,不是我编的:</p>
<pre><code class="go">func (e *encodeState) marshal(v interface{}, opts encOpts) (err error) {
defer func() { // defer收尾
if r := recover(); r != nil { // recover恢复案发现场
if je, ok := r.(jsonError); ok { // 拿到panic的值,并转为错误来返回
err = je.error
} else {
panic(r)
}
}
}()
e.reflectValue(reflect.ValueOf(v), opts)
return nil
}</code></pre>
<p>我们看到,源码中将defer与recover配合使用,直接改变了panic的运行逻辑。原本是panic之后会直接退出程序,这样一来,现在程序并不会直接退出,而是被转为了jsonError类型,并返回。通过使用recover捕获运行时的panic,可以让代码继续运行下去而不至于直接停止。</p>
<h2>下期预告</h2>
<p>【Go语言踩坑系列(六)】面向对象</p>
<h2>关注我们</h2>
<p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p>
<p><img src="/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【Go语言踩坑系列(四)】字典
https://segmentfault.com/a/1190000022096141
2020-03-21T17:54:45+08:00
2020-03-21T17:54:45+08:00
NoSay
https://segmentfault.com/u/nosay
1
<h2>声明</h2>
<p>本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。</p>
<h2>要点</h2>
<p>本文关注Go语言map相关的语言特性。</p>
<h2>map初始化与内存分配</h2>
<p>首先,必须给map分配内存空间之后,才可以往map中添加元素:</p>
<pre><code class="go">func main() {
var m map[int]int // 使用var语法声明一个map,不会分配内存
m[1] = 1 // 报错:assignment to entry in nil map
}</code></pre>
<p>如果你使用的是make来创建一个map,Go在声明的同时,会自动为map分配内存空间,不会报错:</p>
<pre><code class="go">func main() {
m := make(map[int]int) // make语法创建map
m[1] = 1 // ok
}</code></pre>
<h2>map中get操作的返回值</h2>
<p>我们直接看一个例子:</p>
<pre><code class="go">func main() {
m := make(map[int]int)
fmt.Println(m[1]) // 0
m[1] = 0
fmt.Println(m[1]) // 0
}</code></pre>
<p>大家看到问题了吧,如果某个key-value对在map中并不存在,不像其他语言,我们访问这个key是并不会报错的,而是返回value的零值。如果是int,那就返回0。但是,如果我们真正的往map里添加一个key-value对,其值为0,那么我们如何区分是根本没有这个key-value对,还是有这个key-value对,但是值为0呢?其实,访问map中的元素这个表达式有两个返回值:</p>
<pre><code class="go">func main() {
m := make(map[int]int)
v, ok := m[1]
fmt.Println(v, ok) // 0, false
m[1] = 0
v, ok = m[1]
fmt.Println(v, ok) // 0, true
}</code></pre>
<p>第一个返回值和之前的例子相同,而第二个返回值就可以被用来判断,是否map中存在这个key-value对。如果存在,返回true;反之返回false,我们通常可以与if联合进行使用:</p>
<pre><code class="go">func main() {
m := make(map[int]int)
if _, ok := m[1]; !ok {
fmt.Println("key不存在")
}
}</code></pre>
<h2>map遍历的无序性</h2>
<p>在Go语言中,多次遍历相同的map,得到的结果是不一样的:</p>
<pre><code class="go">func main() {
m := make(map[int]int)
m[0] = 1
m[1] = 2
m[3] = 5
for k, v := range m {
fmt.Println(k, v)
}
// 第一次遍历结果:
0 1
1 2
3 5
// 第二次遍历结果:
3 5
0 1
1 2
}</code></pre>
<h2>为什么map是引用类型</h2>
<p>为什么我们常常把map视为引用类型?我们先看一个简单的例子:</p>
<pre><code class="go">func main() {
m := make(map[int]int)
m[1] = 1 // 赋一个初始值
test(m) // 函数调用
fmt.Println(m[1]) // 2
}
func test(m map[int]int) {
m[1] = 2 // 修改值
}</code></pre>
<p>我们看到,当map作为函数参数传递的时候,在外部函数对map的修改,会影响到原来map的值,为什么会这样呢?<br>大家都知道,Go语言只有值传递,那么为什么我们还会有把指针传过去的错觉呢?这还要从字典get与set操作的底层实现说起。Go语言的map在底层是用hashtable来实现的。在我们用var语法声明一个map的时候,实际上就创建了一个hmap结构体:</p>
<pre><code class="go">type hmap struct {
count int // 元素个数,调用 len(map) 时,直接返回此值
buckets unsafe.Pointer // 指向一个bucket数组
...
}</code></pre>
<p>我们主要关注count和buckets这两个字段。count就是指map元素的个数;而buckets是真正存储map的key-value对的地方。这也就可以解释为什么我们一开始那个坑的报错问题。我们用var m map[int]int声明的map,只是分配了一个hmap结构体而已,而buckets这个字段并没有分配内存空间。<br>所以,最后解答我们为什么是引用类型的问题。其实我们传给test函数的值,只是一个hmap结构体;而这个结构体里面又包含了一个bucket数组的指针,也就相当于,表面上我们传了个结构体值过去,而内部却是传了一个指针,这个指针所存储的地址,也就是指针指向的bucket数组结构并没有改变。我们如果对存储key-value对的bucket进行修改,如m[1] = 2这种操作,实际上修改的就是改变了外部函数的bucket值。我们画一个图表示下:<br><img src="/img/remote/1460000022096145" alt="" title=""><br>每一个bucket数组中存储的元素结构为bmap,这里真正存储着key与value的值:</p>
<pre><code class="go">type bmap struct {
tophash [8]uint8 // tophash,在hash计算过程中会用到
keys [8]keyType // 存储key
values [8]keyType // 存储value
pad uintptr // 填充,用于内存对齐
overflow uintptr // 溢出bucket,hash值相同时会用到
}</code></pre>
<h2>为什么key有类型约束</h2>
<p>我们常常能够听到“Go 语言字典的键类型不可以是函数类型、字典类型和切片类型,但是value可以为任意类型"。那么,为什么Go语言官方需要对key做限制呢?为了弄清楚这个问题,我们还需要继续深入底层实现。<br>之前我们已经讲过hmap与bmap的基本结构了,我们继续来看Go语言map的get与set操作,基于以上存储结构,究竟是如何实现的。首先,我们基于之前的那张图,继续画一个空map的内存布局:<br><img src="/img/remote/1460000022096147" alt="" title=""><br>如图所示,每一个bucket可以存放8个key-value对。</p>
<h3>set操作</h3>
<p>假设我们现在要往里插入一个元素m[1] = 2,我们应该把这个元素放在bucket数组中的哪一个bucket上呢?确定了哪一个bucket之后,我们需要放到该bucket内部的哪个位置上呢?<br>我们首先对key进行哈希运算,假设hash(1)的结果,转成二进制值为:</p>
<pre><code>10010111|000011110110110010001111001010100010010110010101010│00000</code></pre>
<p>哈希值的低5位用来定位究竟是在哪一个bucket;高8位用来定位这个key-value对在bucket内部的哪个位置。<br>低5位为0,那么在第0号bucket;高8位为151,这个就要和bmap中的tophash有所关联了。我们在往第0号bucket中插入key-value对的时候,发现key1的位置上为空,那么直接往tophash[0]的位置上写入刚才计算的高8位hash值151,然后把key-value对插入即可。<br>那么,我们为什么需要写入这个tophash值呢?是因为在进行get查找操作的时候,能更加方便快速的定位到bucket内部的元素,后面我们会详细讲。我们画一个插入完m[0] = 2的示意图:<br><img src="/img/remote/1460000022096148" alt="" title=""><br>到此为止都很简单,我们继续往里面插入一个元素,m[3] = 3,假设hash(3)算出来的哈希值和刚才的一摸一样,那么这个元素应该放到哪里呢?<br>由于仍在0号bucket,所以往后找一个空闲的bucket即可,即key2的位置,我们在tophash[1]的位置记录下这个hash值,然后将key-value的值插入到指定的位置:<br><img src="/img/remote/1460000022096146" alt="" title=""><br>现在我们往这个字典里插入了两个元素。假设现在我要访问刚刚插入的m[3]这个值,是什么样的流程呢?</p>
<h3>get操作</h3>
<p>查找操作的关键是定位到key的存储位置。首先,我们需要同样先计算hash值h(3),得到和上文一摸一样的结果:</p>
<pre><code>10010111|000011110110110010001111001010100010010110010101010│00000</code></pre>
<p>同样的,根据低5位定位到0号bucket,然后读取高8位的值,为151。注意下面就开始不一样了。<br>我们知道,所有哈希值都是存在tophash这个数组中的,我们遍历tophash这个数组,我们看到tophash[0] = 151,那么我们拿出tophash下标0对应1号位置上的key,等于1,与我们要找的key值比较之后,发现并不等于我们要找的key值3,需要继续遍历;然后继续遍历,找下一个2号位置上key,为3,和我们要找的key值3相等,最终拿出这个位置上的key-value对,就是我们最终要取得的值,get操作结束。<br>我想大家已经明白了,把对应位置上的key值与我们要找的key值做比较的过程,就需要用到key值比较的这个操作,所以,Go语言要求key值必须可以比较。这就解答了我们一开始的问题了。</p>
<h2>哈希冲突的解决</h2>
<p>解答了我们所有的问题之后,我们继续想一下,如果bucket内部满了,无法继续插入了,我们应该怎么办?这就是很经典的解决哈希表冲突的问题。<br>这个时候,bmap结构体内部的overflow字段就派上用场了。如果插入之后当前bucket无法容纳这个元素,Go就会新分配一个bucket,用当前bucket的overflow字段指向这个新的bucket,然后往新的bucket里插入当前key-value对即可。插入流程与前文一致:<br><img src="/img/remote/1460000022096144" alt="" title=""><br>如果overflow bucket数量过多,在get操作时,对这个overflow链表进行遍历的时间复杂度会大大升高,为了避免溢出bucket数量过多,Go语言会在超过某一个阈值的时候,触发扩容操作。Go语言bucket的扩容操作也是渐进式的,读者可以把这个扩容操作和redis的渐进式rehash扩容操作一起比较学习。<br>我们可以看到,Go语言结合了链地址法和开放定址法这两种方案。链地址法的操作维度是bucket,而在每个bucket内部采用的则是开放定址法。有兴趣的朋友可以看一下PHP数组底层的实现,比Go语言的实现更为简单。个人认为Go语言在某些方面(解决哈希冲突)的效率较PHP更高,而PHP中的底层结构更为简洁。限于篇幅,这里就不一一进行比较了。</p>
<h2>下期预告</h2>
<p>【Go语言踩坑系列(五)】错误与异常处理</p>
<h2>关注我们</h2>
<p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p>
<p><img src="/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【Go语言踩坑系列(三)】数组与切片
https://segmentfault.com/a/1190000022095435
2020-03-21T16:44:25+08:00
2020-03-21T16:44:25+08:00
NoSay
https://segmentfault.com/u/nosay
2
<h2>声明</h2>
<p>本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。</p>
<h2>要点</h2>
<p>本文关注Go语言数组和切片相关的语言特性。</p>
<h2>数组和切片以及字符串的关系</h2>
<h3>相同点</h3>
<p>Go语言中数组、字符串和切片三者是密切相关的数据结构。这三种数据类型,在底层原始数据有着相同的内存结构,在上层,因为语法的限制而有着不同的行为表现。</p>
<h3>差别</h3>
<ol>
<li>Go语言的数组是一种值类型,虽然数组的元素可以被修改,但是数组本身的赋值和函数传参都是以整体复制的方式处理的。</li>
<li>Go语言字符串底层数据也是对应的字节数组,但是字符串的只读属性禁止了在程序中对底层字节数组的元素的修改。字符串赋值只是复制了数据地址和对应的长度,而不会导致底层数据的复制。</li>
<li>Go语言切片的行为更为灵活,切片的结构和字符串结构类似,但是解除了只读限制。切片的底层数据虽然也是对应数据类型的数组,但是每个切片还有独立的长度和容量信息,切片赋值和函数传参数时也是将切片头信息部分按传值方式处理。因为切片头含有底层数据的指针,所以它的赋值也不会导致底层数据的复制。</li>
</ol>
<p>给大家贴个数组切片关系图:<br><img src="http://picture-1258077658.cos.ap-beijing.myqcloud.com/array.png?sign=q-sign-algorithm%3Dsha1%26q-ak%3DAKIDz33LOdcgajPYgHrx2fMwna8wSusUkXpJ%26q-sign-time%3D1584779750%3B1584783410%26q-key-time%3D1584779750%3B1584783410%26q-header-list%3Dhost%26q-url-param-list%3D%26q-signature%3D93830d8ce0e6b1159e5a298d1a660af8ebf5fc6e&x-cos-security-token=4637cbe0b0372a7c506c62af98c711e114081c9d10001" alt="" title=""></p>
<h2>空切片和nil切片不是一个切片</h2>
<pre><code class="go">var (
a []int // nil切片, 和 nil 相等, 一般用来表示一个不存在的切片
b = []int{} // 空切片, 和 nil 不相等, 一般用来表示一个空的集合
)</code></pre>
<p>其中:空切片和 nil 切片的区别在于,空切片指向的地址不是nil,指向的是一个内存地址,但是它没有分配任何内存空间,即底层元素包含0个元素。</p>
<h2>空切片会分配底层数组么?</h2>
<p>在Go语言中我们通常会以[]int{}的方式去定义一个切片,这种切片我们称为空切片,我们知道,它是一个空的集合,那么他会分配相应的底层数组的内存空间么?我们来看一下下面这个例子:</p>
<pre><code class="go">func main() {
var c = make( []int , 0 )
pc := (*reflect.SliceHeader)(unsafe.Pointer(&c))
fmt.Println(pc.Data) //824634224416
}</code></pre>
<p>我们会发现,它是会指向一个地址的,那么它指向的地址会是底层数组的地址么?我们看一下源码是怎么样的?</p>
<pre><code class="go">func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
if size == 0 {
return unsafe.Pointer(&zerobase)
}
...
}
}</code></pre>
<p>根据上边源码发现,在切片大小为0时,直接返回了一个内存地址,这点我们需要注意上边的那个结论:空切片指向的地址不是nil,指向的是一个内存地址,但是它没有分配任何内存空间,即底层元素包含0个元素。</p>
<h2>append对于数组和切片</h2>
<p>首先,数组是不能使用append方法的,也就是说数组在声明的时候声明了长度之后是没办法扩容的,而slice可以,这也是为什么大家都普遍使用slice的原因。我们看下代码:</p>
<pre><code class="go">func main() {
var a = []int{1,3,4,5}
a = append(a, 5)
fmt.Println(a) //[1 3 4 5 5]
var b = [4]int{1,3,4,5}
b = append(b, 5) //cannot use 'b' (type [4]int) as type []Type
}</code></pre>
<p>既然扩容是存在的,那么他也有一定的规则:在添加元素时候,若原本slice的容量不够了,则会自动扩大一倍cap,在扩大cap时候是将原来元素复制一份(而不是引用),即这种情况下原有的不会变。</p>
<pre><code class="go">func main() {
var a = []int{}
//数组和切片len,cap,数组len和cap是定义的长度,切片是len是实际值,cap是数组容量
fmt.Println(cap(a)) //0
a = append(a, 5)
fmt.Println(cap(a)) //1
a = append(a, 5)
fmt.Println(cap(a)) //2
a = append(a, 5)
fmt.Println(cap(a)) //4
a = append(a, 5)
fmt.Println(cap(a)) //4
a = append(a, 5)
fmt.Println(cap(a)) //8
}</code></pre>
<h2>append方法为什么不在内部修改以及实现方式</h2>
<p>在无需扩容时,append函数返回的是指向原底层数组的新切片,而在需要扩容时,append函数返回的是指向新底层数组的新切片。即append函数总会返回新的切片,而且如果新切片的容量比原切片的容量更大那么就意味着底层数组也是新的了。试想一下,如果这里直接修改原切片,会发生什么呢?比如,A和B都是公用的底层数组Array,那么在append(A,1)的时候就可能会对B造成影响,但是如果返回指向新数组的切片,这种影响是不是就没有了呢?例子:</p>
<pre><code class="go">type stringStruct struct {
array unsafe.Pointer // 指向一个 [len]byte 的数组
length int // 长度
}
func main() {
var a = []int{}
p := (*stringStruct)(unsafe.Pointer(&a))
fmt.Println(&p,p,cap(a)) //0xc000092018 &{0x1193a78 0} 0
a = append(a, 5)
p1 := (*stringStruct)(unsafe.Pointer(&a))
fmt.Println(&p1,p1,cap(a)) //0xc000092028 &{0xc000098008 1} 1
a = append(a, 5)
p2 := (*stringStruct)(unsafe.Pointer(&a))
fmt.Println(&p2,p2,cap(a))0xc000092030 &{0xc000098030 2} 2
a = append(a, 5)
p3 := (*stringStruct)(unsafe.Pointer(&a))
fmt.Println(&p3,p3,cap(a))//0xc000092040 &{0xc00009a040 3} 4
a = append(a, 5)
p4 := (*stringStruct)(unsafe.Pointer(&a))
fmt.Println(&p4,p4,cap(a))//0xc000092040 &{0xc00009a040 4} 4 无需扩容,地址不变
a = append(a, 5)
p5 := (*stringStruct)(unsafe.Pointer(&a))
fmt.Println(&p5,p5,cap(a))//0xc000092048 &{0xc000090080 5} 8
}</code></pre>
<p>另外,切片赋值会导致底层数据的变化,从而影响其它的切片值,例如:</p>
<pre><code class="go">func main() {
var c = [4]int{1,2,3,4}
var Aslice = c[0:2]
Aslice = append(Aslice,5)
fmt.Println(c) //[1 2 5 4] 改变了底层数组
fmt.Println(Aslice) //[1 2 5]
Bslice := append(Aslice,5,5,5) //扩容超过底层数组的容量
fmt.Println(c) //[1 2 5 4]
fmt.Println(Bslice) //[1 2 5 5 5 5] //指向了新的数组
} </code></pre>
<h2>切片是引用类型</h2>
<pre><code class="go">func main() {
//a是一个数组,注意数组是一个固定长度的,初始化时候必须要指定长度,不指定长度的话就是切片了
a := [3]int{1, 2, 3}
//b是数组,是a的一份拷贝
b := a
//c是切片,是引用类型,底层数组是a
c := a[:]
for i := 0; i < len(a); i++ {
a[i] = a[i] + 1
}
//改变a的值后,b是a的拷贝,b不变,c是引用,c的值改变
fmt.Println(a) //[2,3,4]
fmt.Println(b) //[1 2 3]
fmt.Println(c) //[2,3,4]
}</code></pre>
<p>因为切片赋值和函数传参数时也是将切片头信息部分按传值方式处理,所以会出现引用失效的问题:</p>
<pre><code class="go">func main() {
var a = []int{4, 2, 5, 7, 2, 1, 88, 1}
delete(a)
fmt.Println(a) //1:[4 2 5 7 2 1 88 1] 2:[1 2 5 7 2 1 88 1]
}
func delete(a []int){
a = a[:len(a)-1]//1:[4 2 5 7 2 1 88]
a[0] = 1 //2:[1 2 5 7 2 1 88 1]
fmt.Println(a)
}</code></pre>
<p><img src="http://picture-1258077658.cos.ap-beijing.myqcloud.com/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6.png?sign=q-sign-algorithm%3Dsha1%26q-ak%3DAKIDz33LOdcgajPYgHrx2fMwna8wSusUkXpJ%26q-sign-time%3D1584779750%3B1584783410%26q-key-time%3D1584779750%3B1584783410%26q-header-list%3Dhost%26q-url-param-list%3D%26q-signature%3D2b587111db4c741f3468a58995b474193b5f9fdc&x-cos-security-token=4637cbe0b0372a7c506c62af98c711e114081c9d10001" alt="切片和数组" title="切片和数组"><br>在函数传参中是值传递,所以会copy一份原始的切片,但是指向底层数组的指针不变,如果我们在函数中对这个copy过的切片操作(非赋值),例如重新进行切片操作,这样不会影响原切片,但是如果我们在此进行例如a[0]=1此类的操作,会修改原数组。</p>
<h2>判等</h2>
<p>对于数组来说,依次比较各个元素的值。根据元素类型的不同,再依据是基本类型、复合类型、引用类型或接口类型,按照特定类型的规则进行比较。所有元素全都相等,数组才是相等的。例如:</p>
<pre><code class="go">func main() {
var a = [4]int{1,3,4,5}
var b = [4]int{1,3,4,5}
var c = [4]int{2,3,4,5}
var d = [5]int{}
var e = [6]int{}
var f = [4]string{"2","3","4","5"}
fmt.Println(a == b) //true
fmt.Println(a == c) //false
fmt.Println(d == e) //mismatched types [5]int and [6]int
fmt.Println(f == c) //mismatched types [4]int and [4]string
}</code></pre>
<p>对于slice来说来说,在Go语言当中,切片类型是不可比较的。并且所有含有切片的类型都是不可比较的,因为不可比较性会传递,如果一个结构体由于含有切片字段不可比较,那么将它作为元素的数组不可比较,将它作为字段类型的结构体不可比较。那么为什么不可比较呢?我们看一下官方给的回答:</p>
<pre><code>Why don't maps allow slices as keys?
Map lookup requires an equality operator, which slices do not implement. They don't implement equality because equality is not well defined on such types; there are multiple considerations involving shallow vs. deep comparison, pointer vs. value comparison, how to deal with recursive types, and so on. We may revisit this issue—and implementing equality for slices will not invalidate any existing programs—but without a clear idea of what equality of slices should mean, it was simpler to leave it out for now. </code></pre>
<p>大致意思就是说切片在比较时需要考虑的因素太多太多,例如是比较切片的内容还是底层数组的内容等等,所以Go语言不允许slice比较,当然slice可以和nil比较。</p>
<h2>切片,数组可进行赋值操作</h2>
<p>基本规则:对于每个赋值一定要类型一致,和其他一样,不同的类型不可以进行赋值操作。当然,interface{}例外。</p>
<pre><code class="go">func main() {
//切片
var a = make([]string,10)
//a[0] = 1 //赋值其他类型均报错
a[0] = "grape"
//数组
var a = [3]int{}
a[0] =1
a[1] = "strin" //赋值其他类型均报错
} </code></pre>
<h2>切片,数组和字符串的循环</h2>
<p>切片数组字符串循环代码示例:</p>
<pre><code class="go">func main() {
var a = [3]int{1,2,3}
for i,v := range(a) {
fmt.Println(i,v) // 0 1 1 2 2 3
}
var b = []int{3,4,5}
for ide,v := range(b) {
fmt.Println(i,v) //0 3 1 4 2 5
}
var c = "hello world"
hello := c[:5]
world := c[7:]
fmt.Println(hello, world) //hello world
for i,v := range(c) {
fmt.Println(i,string(v)) // 'h', 'e', 'l', 'l', 'o', ',', ' ', 'w', 'o', 'r', 'l', 'd',
fmt.Println(i,v) //0 104 1 101 2 108 3 108 4 111 5 32 6 19990 9 30028 //range会转化底层byte为rune
}
}</code></pre>
<h2>切片类型强转</h2>
<p>对于一个float类型的切片转换为int型,我们要怎么转?是直接转换么?还是新开辟一个切片去做每一项强转?我们看下边的示例:</p>
<pre><code class="go">func main() {
var a = []float64{4, 2, 5, 7, 2, 1, 88, 1}
//var c = ([]int)(a) //报错
var b = make([]int, 8)
for i,v := range a {
b[i] = int(v)
}
fmt.Println(b)
}</code></pre>
<h2>下期预告</h2>
<p>【Go语言踩坑系列(四)】字典</p>
<h2>关注我们</h2>
<p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p>
<p><img src="/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【Go语言踩坑系列(二)】字符串
https://segmentfault.com/a/1190000022020977
2020-03-15T11:20:47+08:00
2020-03-15T11:20:47+08:00
NoSay
https://segmentfault.com/u/nosay
0
<h2>声明</h2>
<p>本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。</p>
<h2>要点</h2>
<p>本文关注Go语言字符串相关的语言特性、以及相关的[]byte、[]rune数据类型。</p>
<h2>从字符编码说起</h2>
<h3>ASCII</h3>
<p>计算机是为人类服务的,我们自然有表示我们人类所有语言与符号的需求。由于计算机底层实现全部为二进制,为了用计算机表示并存储人类文明所有的符号,我们需要构造一个“符号” => “唯一编码”的<strong>映射表</strong>,且这个编码能够用二进制来表示。这样就实现了用计算机来表示人类的文字与符号。最早的映射表叫做ASCII码表,如:a => 97。这个相信大家都很熟悉了,它是由美国人发明的,自然首先需要满足容纳所有英文字符的需求,所以并没有考虑其他国家的语言与符号要如何用计算机来表示。<br>但是随着计算机的发展,其他国家也陆续有了使用计算机的需求。由于ASCII码只用1个字节存储,所以最多只能表示256种符号,无法表示其他国家的文字(如中文等)。为了解决ASCII表示范围有限的问题,以容纳其他国家的文字与符号,Unicode出现了。</p>
<h3>Unicode</h3>
<p>Unicode究竟有多强大?我们举一个例子来直观的感受一下:中文的“世”字,若用Unicode映射规则来表示,为“U+4E16”。U+代表Unicode,我们先不用管。“4E16”就是“世”字在所有人类的字符集中的唯一编码了,可以把这个编码看成数据库中的id,唯一确定“世”这个符号。Unicode能够存储目前世界上所有的文字与符号。<br>我始终在强调"映射规则"。ASCII、Unicode只是定义了一个“符号” => “唯一编码”的<strong>映射规则</strong>而已,并不关心具体计算机底层是<strong>如何用二进制存储</strong>的。</p>
<h2>Unicode的存储实现</h2>
<h3>我们先自己实现一个</h3>
<p>接下来我们关注究竟如何用二进制,来表示并存储“世”字这个Unicode编码“4E16”:先抛开业界已有的方案,我们先自己设计一个。按照惯性思维,我们可以直接想到,直接在底层将“4E16"转为二进制进行存储:即01001110 00010110,共2个字节。我们可以看到,这里Unicode规则和计算机二进制编码一一对应,不加任何优化与修改,这就是最早的UTF-16编码方案。<br>但是UTF-16编码存在一定的问题:无论是ASCII中定义的英文字符,还是复杂的中文字符,它都采用2个字节来存储。如果严格按照2个字节存储,编码号比较小的(如英文字母)的许多高位都为0(如字母t:00000000 01110100)。<br>这样一来,由于很多英文编码的高位都是0,但仍需要固定的2个字节来存储,所以UTF-16编码就造成了大量的空间浪费。我们怎么优化呢?我们想到,没有必要所有符号都统一都用2个字节来表示。编码号较小的,如英文字符,仅用1个字节表示就可以了;而编码号较大的中文字符,则用3个字节来表示。这种规则就是我们所熟知的UTF-8编码方式。Unicode的实现方式称为Unicode转换格式(Unicode Transformation Format,简称为UTF)</p>
<h3>UTF-8</h3>
<p>UTF-8编码方式如下:</p>
<blockquote><ol>
<li>单字节的字符,字节的第一位设为0,对于英文,UTF-8码只占用一个字节,和ASCII码完全相同;</li>
<li>n个字节的字符(n>1),第一个字节的前n位设为1,第n+1位设为0,后面字节的前两位都设为10,这n个字节的其余空位填充该字符unicode码,高位用0补足。</li>
</ol></blockquote>
<p>对于我们之前的例子,“世”需要用3个字节来存储,在UTF-8中以“E4B896”来存储。而对于英文字符“t”则以“74”来存储。所以,我们可以看到,虽然中文所需的存储空间比UTF-16多了1个字节,但是英文字符却减少了一个字节。综合考虑,由于我们使用英文字符的频率远远高于中文字符,所以这种改动是利大于弊的。相较前文的UTF-16编码方式,UTF-8的灵活度更大,也更节省存储空间。</p>
<h3>编程范式</h3>
<p>综上,UTF-16、UTF-8、还有其他五花八门的编码存储方式,都是Unicode的底层存储实现。用编程范式的语言来描述:Unicode是接口,定义了有哪些映射规则;而UTF-8、UTF-16则是Unicode这个接口的实现,它们在计算机底层实现了这些映射规则。</p>
<h2>Go语言的字符串</h2>
<h3>字符串的长度是什么</h3>
<p>为什么我们上文要讲编码呢?请看下面一个例子:</p>
<pre><code class="go">func main() {
s := "hello世界"
fmt.Println(len(s)) // 11
}</code></pre>
<p>这里的结果并不符合我们预期的结果8。Go语言中的字符串实现,基于UTF-8编码。按照前文的描述,“世界”的编码共需要6个字节,加上hello,共需要11个字节,这样就能够解释len(s)的返回值了。<br>所以,从这里我们也能够回答标题中的问题,字符串的长度究竟代表什么?“长度”并没有一个标准的定义。通过这个例子来看,求字符串的长度函数len()的返回值,是这个字符串所占用的<strong>字节数</strong>,并不是字符的总个数。我们暂且把长度定义为字符串的字节数。</p>
<h3>为什么需要byte和rune</h3>
<p>我们知道,Go语言中有两种特殊的别名类型,是byte和rune,分别代表uint8和int32类型,即1个字节和4个字节。我们在开发中,常常会用到string类型和[]byte、[]rune类型的转换。它可能长下面这个样子:</p>
<pre><code class="go">func main() {
s := "hello 世界"
runeSlice := []rune(s) // len = 8
byteSlice := []byte(s) // len = 12
// 打印每个rune切片元素
for i:= 0; i < len(runeSlice); i++ {
fmt.Println(runeSlice[i])
// 输出104 101 108 108 111 32 19990 30028
}
fmt.Println()
// 打印每个byte切片元素
for i:= 0; i < len(byteSlice); i++ {
fmt.Println(byteSlice[i])
// 输出104 101 108 108 111 32 228 184 150 231 149 140
}
}</code></pre>
<p>我们可以看到,因为Go中的字符串采用UTF-8编码,且由于rune类型是4个字节,所以切片[]rune中,一个rune切片中的单个元素(4个字节),就能够完整的容纳一个UTF-8编码的中文字符(3个字节);而在[]byte中,由于每个byte切片元素只有1个字节,所以需要3个byte切片元素来表示一个中文字符。这样,用[]byte表示的字符串就要比[]rune表示的字符串,切片长度多4(6 - 2),打印结果符合预期。<br>所以,我个人认为设计rune类型的目的,就是为了更方便的表示类似中文的非英文字符,处理起来更加方便;而byte类型则对英文字符的处理更加友好。这里总结一下:</p>
<blockquote><ol>
<li>一个值在从string类型向[]byte类型转换时代表着以 UTF-8 编码的字符串,会被拆分成零散、独立的字节。可能一个完整的字符(如中文),会由多个byte切片中的元素组成。</li>
<li>一个值在从string类型向[]rune类型转换时代表着以 UTF-8 编码字符串,会被拆分成一个个完整的字符。</li>
</ol></blockquote>
<h3>字符串的底层实现</h3>
<p>那么,既然[]byte和[]rune都能够表示一个字符串,那么Go语言底层是如何存储字符串的呢?</p>
<pre><code class="go">// string is the set of all strings of 8-bit bytes, conventionally but not
// necessarily representing UTF-8-encoded text. A string may be empty, but
// not nil. Values of string type are immutable.
type string string</code></pre>
<p>我们看英文注释,关键在于:string是一个8bit字节的集合,且是不可变的。所以,Go语言字符串的底层实现为[]byte:</p>
<pre><code class="go">type stringStruct struct {
str unsafe.Pointer // 指针,指向底层存储数据的[]byte
len int // 长度
}</code></pre>
<p>我们看到,Go语言底层并没有像C语言一样,类似直接定义一个char[]来表示字符串,直接定义一个[]byte切片,而是采用了一个指针,这个指针相当于C语言的void *,可以指向任何地方,这给Go语言的字符串操作带来了极大的灵活性;而第二个字段则是字符串的长度,也很好理解。讲完了Go的字符串结构,那么我们用一张图,总结一下字符串、[]byte、[]rune三种类型之间的转换过程:<br><img src="/img/remote/1460000022020981" alt="" title=""><br>个人认为,使用[]rune来做string的底层存储结构理论上来说也是可以的。但是由于rune为4个字节,只对中文比较友好;对于英文字符来说,灵活度较差。而我们使用英文字符的频率更高,所以Go就选择了[]byte切片类型作为底层存储类型。</p>
<h3>字符串的不可变性</h3>
<p>Go语言的字符串是不可变的。那么怎么理解这个不可变性呢?答案是Go语言官方禁止str[0] = 'a',这种直接对字符串中的字符做修改操作。那么,为什么要这样做呢?<br>我们知道,字符串底层是用一个[]byte存储的。个人理解,如果不同字符串所要表示的字面量相同,不同字符串就可以复用这个字面量的底层存储空间。那么,如何最大化的复用呢?就源于这个字符串“不可变性”的约定。<br>在计算机领域,有一个很经典的存储空间复用机制COW(copy on write)。举一个简单的例子:假设某两个字符串均为:“hello世界”,当我们仅仅对字符串进行只读操作:比如赋值、读取数据,是不会重新分配内存的;而对字符串进行连接等写操作,由于写操作之后两个字符串并不再相同,实在没办法再复用下去了,我们就会为连接后的新字符串分配新的存储空间,并用字符串结构体中的指针str字段,指向这块新的存储空间,这样才能正确表示并存储两个不同的字符串。Go语言字符串的不可变性最大化的成全了COW机制,同时也能够体现出在底层stringStruct结构设计,指针所带来的的灵活性,我们感受一下:</p>
<pre><code class="go">package main
import (
"fmt"
"unsafe"
)
type stringStruct struct {
str unsafe.Pointer
len int
}
func main() {
a := "hello世界"
b := a
pa := (*stringStruct)(unsafe.Pointer(&a))
pb := (*stringStruct)(unsafe.Pointer(&b))
// 0x10cd9cd 0x10cd9cd
fmt.Println(pa.str, pb.str)
b = a[:5]
pa = (*stringStruct)(unsafe.Pointer(&a))
pb = (*stringStruct)(unsafe.Pointer(&b))
// 0x10cd9cd 0x10cd9cd
fmt.Println(pa.str, pb.str)
b += "baiyan"
pa = (*stringStruct)(unsafe.Pointer(&a))
pb = (*stringStruct)(unsafe.Pointer(&b))
// 0x10cd9cd 0xc000016060
fmt.Println(pa.str, pb.str)
}</code></pre>
<p>这里unsafe.Pointer相当于C语言中的void *,可以将某个指针转换为任一指定类型。这里我们指定一个stringStruct,也就是Go字符串的底层存储结构。<br>我们重点关注以下几行代码:</p>
<pre><code class="go"> b := a // 只读,复用
b = a[:5] // 只读,复用
b += "baiyan" // 写,无法继续复用</code></pre>
<p>通过我们最终打印stringStruct中的str字段的地址,我们发现前两个只读操作打印的地址均相同,说明变量a和b会复用同一个底层[]byte;而进行字符串连接操作之后,b变量最终还是与a变量分离,进行内存拷贝,使用两个独立的[]byte:<br><img src="/img/remote/1460000022020980" alt="" title=""><br>除此之外,COW机制也体现了一个“懒”的思想,把分配内存空间这种耗时操作推迟到最晚(也就是修改后必须分离)的时候才完成,减少了内存分配的次数、最大化复用同一个底层数组的时间。<br>我们再回顾之前字符串的不可变性,它给多个字符串、共享相同的底层数据结构带来了最大程度的优化。同时也保证了在Go的多协程状态下,操作字符串的安全性。</p>
<h2>下期预告</h2>
<p>【Go语言踩坑系列(三)】数组与切片</p>
<h2>关注我们</h2>
<p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p>
<p><img src="/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【Go语言踩坑系列(一)】基本数据类型
https://segmentfault.com/a/1190000022016091
2020-03-14T17:06:12+08:00
2020-03-14T17:06:12+08:00
NoSay
https://segmentfault.com/u/nosay
0
<h2>声明</h2>
<p>本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。</p>
<h2>要点</h2>
<p>本文只关注Go语言的基本类型:如整型、浮点型、常量相关的内容。字符串、数组和切片等高级类型会在下一篇文章中讲述。</p>
<h2>包</h2>
<p>初始化顺序:当前包级别变量 -> 导入外部包的init() -> 当前包内的init() -> main()。通常可将一个包导入但是不使用的方式,初始化某些配置数据。<br> 下面这段代码会运行config包和model包下的init()方法:</p>
<pre><code class="go">import (
"cmdb-bg/cmd"
_ "cmdb-bg/config"
_ "cmdb-bg/model"
)</code></pre>
<h2>零值</h2>
<p>我们都知道,当我们仅仅声明一个变量、但未对其进行初始化的时候,Go会给每种变量类型赋一个零值:</p>
<ul>
<li>整型:0</li>
<li>浮点型:0</li>
<li>bool型:false</li>
</ul>
<pre><code class="go">func main() {
var a int
var b float64
var c bool
fmt.Println(a, b, c) // 0 0 false
}</code></pre>
<h2>赋值与类型推断</h2>
<p>如果你之前已经使用了":="对某个变量进行了声明与初始化,如果你想再次为这个变量进行重新赋值,切记不要加":"</p>
<pre><code class="go">func main() {
a := 1
a := 2
fmt.Println(a) // 报错:no new variables on left side of :=
a = 2 // ok
}</code></pre>
<pre><code class="go">func main() {
var a int = 1
for a >= 1 {
a := 2 // 无限循环
a = 2 // 正常执行
a = a-2
fmt.Println(a)
}
}</code></pre>
<p>Go中可以用如下方式高效交换两个变量的值:</p>
<pre><code class="go">func main() {
a := 0
b := 1
a, b = b, a // 交换,不需要使用临时变量
fmt.Println(a, b) // 1, 0
}</code></pre>
<ul><li>Go的new()返回的是一个地址,而不是值本身:</li></ul>
<pre><code class="go">func main() {
a := new(int)
fmt.Println(a) // 0xc000016050
}</code></pre>
<p>if赋值加判断复合语句的作用域:f的作用域会被限制在if大括号所包裹的代码块内。在if的外部并不能使用变量f:</p>
<pre><code class="go">func f1() error {
if f, err := os.Open("abc"); err != nil {
return err
}
fmt.Println(f) // 编译不通过: undefined: f
}
// 解决:
func f1() error {
f, err := os.Open("abc")
if err != nil {
return err
}
f.Close() // ok
}</code></pre>
<h2>运算与类型转换</h2>
<p>int和int32是不同类型,若要把int当成int32来使用,必须进行强制类型转换。其他类型同理。<br>类型断言的使用(暂作了解):</p>
<pre><code class="go">func main() {
// a必须是空接口类型, 任何类型都是interface的实现类,当声明为interface{}时,可以赋值给他任意类型
var a interface{}
a = 2;
// 类型断言会返回两个值
v, ok := a.(int)
// 如果变量a是断言的类型,ok为true,v为被断言变量的值。
// 否则ok为false,v为断言类型的零值
fmt.Println(v, ok) // 2 true
}</code></pre>
<p>如果进行算术运算之后发生了溢出,那么Go会直接丢掉溢出的高位部分。<br>所有基本类型的值都是可以比较的(整型、浮点型),其他高级类型的比较,一部分需要遵循一定规则,而一部分高级类型是禁止比较的。<br>不同数据类型不能直接做运算。不像其他语言,Go语言没有隐式类型转换。要想强制对不同类型做运算,必须进行显式的强制类型转换。转换成同一种类型之后,才能做运算:</p>
<pre><code class="go">func main() {
var a int8 = 100
var b int16 = 100
fmt.Println(a + b) // invalid operation: a + b (mismatched types int8 and int16)
fmt.Println(int16(a) + b) // ok
}</code></pre>
<p>在进行强制类型转换时需要注意:当整数值的类型的有效范围由宽变窄时,会截掉一定数量的高位二进制数。与这个类似的还有把一个浮点数类型的值转换为整数类型值时,浮点数类型的小数部分会被全部截断:</p>
<pre><code class="go">func main() {
var a int16 = 428 //00000001 10101100
fmt.Println(int8(a)) // -84
// 截断高8位为:10101100。Go中用二进制补码表示数值。转成原码为为 11010100 即十进制-84
}</code></pre>
<p>浮点数的精度有限,尽量不要做浮点数运算结果的比较:</p>
<pre><code class="go">func main() {
var f float32 = 16777216 // 1 << 24
fmt.Println(f == f+1) // false
var a float32 = 1.23
var b float32 = 1.25
fmt.Println(a-b) // -0.01999998
}</code></pre>
<p>iota常量让用二进制位做标记更简单了。在第一个声明的常量所在的行,iota将会被置为0,然后在每一个有常量声明的行加一:</p>
<pre><code class="go">const (
FlagUp Flags = 1 << iota // 第一种标记
FlagBroadcast // 第二种标记
FlagLoopback // 第三种标记
FlagPointToPoint // 第四种标记
FlagMulticast // 第五种标记
)</code></pre>
<h2>下期预告</h2>
<p>【Go语言踩坑系列(二)】字符串</p>
<h2>关注我们</h2>
<p>欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~</p>
<p><img src="/img/bVbESbH" alt="Nosay" title="Nosay"></p>
【2019年度总结】重生
https://segmentfault.com/a/1190000021456562
2019-12-31T20:46:08+08:00
2019-12-31T20:46:08+08:00
NoSay
https://segmentfault.com/u/nosay
4
<p>baiyan</p>
<p>2019的主题叫做重生。</p>
<h3>反思</h3>
<p>1月。刚刚结束为期五个月的实习,我回到杭州暂做休整。这五个月,是我一生中成长最快的五个月,也是我一生中受到打击最大的五个月。在校期间,我通过两年时间积累起来的自信一落千丈,我感受到我的能力是那样一文不值、身体是如此的弱不禁风。那一刻我才明白,我不过是一只井底之蛙罢了。我折服于雷总能力的广度与深度、叹于伟滨哥、栋哥对一位合格后端工程师的要求。感谢你们,教会了我面对问题,如何学习、如何思考、如何总结,这对我今后的职业生涯有着莫大的帮助。最后,我也很高兴、很荣幸能够认识grape同学,在成长与奋斗的路上,有一位优秀战友的陪伴,实在是太幸福了。<br>实习期间写过的一个bug,把2018了写成2016我也是服了......<br><img src="/img/remote/1460000021456572" alt="" title=""><br>一件事,你只知道怎么做,并把它按时完成,是远远不够的。你需要知道为什么这样做,同一个问题,有没有其他更好的解决方案,每种方案的比较优势在哪里。“会当凌绝顶,一览众山小”。感谢你们,让我能够从更高层次,新奇的角度、更宏观地领略那些栩栩如生的、我从未见过的大好河山。虽然实习的路上布满荆棘,但是我内心深处对编程的热爱,与用一行一行代码垒成高山的这份成就感,能够驱使我一直坚定不移地走下去。而且我不仅要走下去,而且要走的更快、走得更好。2018有太多太多值得回忆的汗水与泪水,就只能先暂且停笔了。<br>2月回到久违的家中,却又不能和父母吐露太多过去在精神上以及身体上所经历的一切苦难与挫折,此时此刻我只希望他们能够平安、健康。其实这就足够了。所有过往的经历都告诉我,身体健康就是P0优先级的事情。实现任何人生目标的前提,首先需要一个生龙活虎的你。2018年,随着工作上的压力及生活琐事的困扰,我的身体状况不断恶化,血压一度升到170,甚至要靠降压药维持,心脏功能也受到了很大的损伤,甚至出现了胸闷等轻度抑郁的症状。直到2018年底,我的身体已无法支持如此高的工作强度,我无法继续实习下去。所以,这篇文章的主题叫做“重生”,从2019年1月开始,除了毕业论文与答辩,我没有继续进行繁重的脑力劳动,取而代之的是旅游与运动,并且重拾我的音乐及电竞事业,这就要从3月开始说起了。</p>
<h3>调整</h3>
<p>3月,一个春暖花开的季节。我从家中启程,回到学校,享受学生时代仅剩的最后一个快乐学期,所以我会切换到比较轻松愉快的写作风格哈哈。但是,我不再想把它叫做一个学期,它更是一个让你去完成四年来未尽心愿的一个契机。开始重拾荒废了半年的羽毛球,基本每天泡在球馆。这段时间,我开始做旅行计划,启程领略祖国的大好河山。首先,我们跟着大佬的脚步,自驾来到西湖太子湾公园(大佬帅气的背影):<br><img src="/img/remote/1460000021456573" alt="" title=""><br>因为我们觉得自驾游实在是太香,所以去湘湖野餐也被妥妥的安排(又是大佬帅气的侧颜):<br><img src="/img/remote/1460000021456725" alt="" title=""><br>然后我们还去了LGD杭州主场:<br><img src="/img/remote/1460000021456565" alt="" title=""><br>但是,每当想起来比我优秀的同事还在努力研究源码、学习技术,我这样一直无休止的玩下去,是让我相当有负罪感的事情。所以,看到之前优秀的同事们都在通过写技术文章提高自己的影响力,所以,抱着试试看的心态,在2019年4月18日,我在segmentfault上发表了第一篇技术文章,也是在我的职业生涯中,一项从0到1的一个里程碑式突破:<br><a href="https://segmentfault.com/a/1190000018909215">【PHP7源码学习】PHP内存管理1笔记</a><br>当然了,那时的文笔还是相当稚嫩,以至于在发出文章之前,我都要在心理思考许久,会不会有人看,会不会有人评论我写的不对、不好。在经过一番心理斗争之后,以及好兄弟grape的鼓励下,我还是咬牙发出了第一篇文章。让我欣慰的是,我担心的事情根本就没有出现,因为根本没什么人看......但是换一个角度说,幸亏没什么人看,不然,我可能更加不敢迈开这一步。目前为止,grape同学和我共同发表了51篇文章,收获了42次点赞、26个粉丝,并且开通了属于我们两个人的微信公众号。我佩服当初决定开始写作的勇气。我会更加用心地写下去,形成一个技术-写作-收获-技术的完整闭环,在技术的道路收获果实,继续前进。<br>这是为了其中一篇文章的写作所打的草稿:<br><img src="/img/remote/1460000021456570" alt="" title=""><br>随着文章越发越多,我的自信心也开始逐渐恢复。过了半个月,我们创建了“LNMPR源码学习”微信公众号,将自认为写的还算可以的文章同步到微信公众号中,也收获了一些粉丝(很多都是自己人哈哈)。<br>5-6月的主题是毕业旅行。我的毕业旅行第一弹是一次广度优先的旅行,而第二次就是深度优先了。第一次毕业旅行中,我最好的朋友雨辰和我一同,从杭州出发,途径武汉、重庆、成都、都江堰、西安、华山、洛阳,共7个城市。而第二次毕业旅行,我和世钰小伙子决定赴四年前那呼伦贝尔之约。在我的旅行中,飞机这个交通工具是不会出现的。这源于我看着火车长大的童年,也源于我看过的一句广告语:“在乎的不是目的地,而是沿途的风景“:<br><img src="/img/remote/1460000021456569" alt="" title=""><br><img src="/img/remote/1460000021456567" alt="" title=""><br>至于景点内部的图我就不发了,毕竟网上这么多专业人士拍的比我强多了。唯一有一点遗憾的是,在满洲里国门俯瞰俄罗斯的时候,我真的很想踏上国门下方这条西伯利亚大铁路,由一名旁观者,变成火车上的一员,从这里开始继续下一段到莫斯科的旅程。<br><img src="/img/remote/1460000021456566" alt="" title=""><br>回到杭州之后,至于毕业,除了对相处了四年之久的好兄弟们有所不舍之外,并没有让我太多留恋的人和事。每当想到那些上课水、给分高、期末给划重点却讲不出什么内容的老师受到学生们热捧的时候,我就想赶紧离开这个地方。这样一来,认真备课、讲课的那些严格的老师就会越来越少,上课讲笑话的老师却越来越多,这种恶性循环每一天都在上演。虽然有些人说,师傅领进门,修行在个人。但是,师傅这个门都没领进来,个人怎么开始修行呢?修行的高度又能有多高呢?</p>
<h3>新生</h3>
<p>由于之前秋招的offer还是在北京,所以我又要回到让我百感交集、又爱又恨的帝都了。这是我人生的一个新的开始,但又不算是一个全新的开始。毕竟经历了去年的魔鬼训练,再次面对北京这个老朋友,我已经无所畏惧了。入职之后,由于我的技术栈和公司还是比较匹配的,所以刚开始并没有适应方面的问题,也得到了领导及同事的鼓励,这对我自信心方面的恢复还是有很大促进作用的。我不再像去年那样畏手畏脚,小心翼翼地写bug了。由于工作环境还算轻松,而且有很多应届的小伙伴,这非常有助于我调整心态、从上一阶段的人生谷底慢慢爬上来,找回自己的节奏。感谢你们对我的认可与鼓励。我会用心记住,在我成长路上的每一座灯塔。<br>当然,仅仅工作是远远不够的,grape同学和我还是那样的上进(成功感动了自己)在结束了redis源码的写作之后,我们决定暂停写作计划,回归实践。于是,我们制定了一个2019下半年至2020年“宏伟”的开发计划......<br><img src="/img/remote/1460000021456568" alt="" title=""><br>可能大家会觉得没什么技术含量,让大家见笑了。这其中很大一部分灵感都是来源于工作中其余大佬同事们的经验与成果,而我又无法在工作中一一去实现那些基础服务。所以,我们此次创建业余项目的目的,就在于亲力亲为地去模仿、并完成这些服务,将工作中学到的知识内化,进而变成自己的东西。<br>在项目开发中,我们的后端使用了:PHP+Yaf+Nos(我们二人组的名字叫Nos,取自grape同学和我的姓名)。Nos框架是我们为了解决Yaf自带类库过少的问题,所以我们自己造轮子,能够兼顾Yaf的性能和开发的效率。而前端呢采用Vue + ElementUI,后续其他微服务如果有高并发和高性能的需求,可能还会引入Go,总之我们也不会盲目的跟风模仿,最好的就是最适合的,技术选型中的超前投资往往是不明智的,其投入产出比也并不会很高。<br>在下半年业余项目的开发过程中,让我受益最大的就是,我对前端有了一个全新的认识,我抛弃了之前对前端老三样 + jQuery的架构的认识,而领略到了组件化开发的思想。这种前端思维方式的转变,对我来说是一个全新的认识。但是,虽然它表面上看起来比较”新“,但是我们通常说的组件,其实就是一个代码复用的单元,就是函数的思想。父组件给子组件传值的props,其实就是在一个函数中调用另一个函数,然后将函数的形参传递进去。那么子组件如何向父组件传值,在Vue中是通过事件触发的方式,而在函数中就是return的形式。而Vuex的作用其实就相当于全局变量的作用,解决了非父子组件间传值的问题。所以,我不太认可父子组件的说法,而更认可调用组件和被调用组件这种说法。其实这就是编程方法论中的“变”与“不变”的重要性。所以,为了分离“不变”的代码,我们又开发了一个前端的common项目,把我们两个人所能够复用的,比如登录注册组件,分离到common项目进行统一管理。在需要使用的业务线中通过npm引入,实现了对组件的复用与对“不变”部分的抽离。我们其实就像提取公因子那样,把不变的部分单独提取出来,其本质上都是通过复用达到提高可维护性、进而提高开发效率的目的,Vue的组件化开发思想也是如此。<br>由于前端对于我来说,也仅仅是一个副语言而已,可能我对Vue的了解并没有那么深。我只是想通过这个例子告诉大家,在计算机技术中,许多编程思想其实是相通的。很多专业名词其实看起来比较复杂,其实,如果你能够抓住很多名词之间的共同点,并抓住其本质,我相信就可以触类旁通,举一反三,并将这种思想运用到工作中。这也是我为什么要读源码的目的。很多人说读源码没有用,离工作太远。但我认为,学习源码,就是学习源码中那些优秀的编程思想,以及对某种数据结构的权衡取舍的过程。比如MySQL为什么使用B+树而不是普通的二叉树,Redis的sorted set底层什么时候用ziplist,什么时候用skiplist,每种结构都有什么比较优势,我相信这种技术选型的过程,是通往架构师的必经之路吧。技术学习是一场马拉松。技术的潮流每天都在更新迭代。学习一门语言、或者是一门新鲜的技术固然没错。但是,5年后、甚至10年后,这些技术可能已经走下神坛。但是,如果你学习的是编程的思想而不仅仅拘泥于某门语言,那么你就会成为一棵常青树,不管他出了Go语言也好,还是Run、Stop语言也好,我相信经过这种通用能力的培养,你一定会驾轻就熟、信手拈来了,这同样也是一种对“变”与不变作出权衡取舍的过程。</p>
<h3>展望</h3>
<p>这篇总结写着写着,笔锋一转,好像回到了日常写技术文章的状态。虽然我的核心竞争力在于技术,但技术,也只是我漫漫人生长河中的沧海一粟。除了学习技术、立足于生存之外,我们也不要忘记精神上的滋养。学会读书、读人更为重要。最近在读朱光潜的《谈美书简》,其中“无所为而为”的思想令我感触颇深。这里第一个为是四声,第二个为是二声。那么第一个为,是为了什么呢,其实作者将做事的思想分为两种,一种是实用主义,一种是美学主义,同时作者又将实用思想与艺术思想做了一个对比,讲述了同一个事物,当你的脑海中持有不同的思想时候,你看到的景象,也会完全不同。我们在做一件事、用代码描绘一个项目、一个产品的时候,不要去想太多的利益纠纷,工资、升职、投入产出比这些琐事,而把这个项目、产品当做一件艺术品,而你就是一个艺术家。当你以这种无所顾忌、心无旁骛的美学心态去打磨每一个页面、每一个产品的时候、当你只管用打磨一件艺术品那样的心态,将它们做到你心中的极致的时候,工资这些“身外之物”其实就是水到渠成的事情。这里,我只是举一个小小的例子,其实读书不仅仅能够促进我们树立职场上的一个良好心态,更能够影响我们生活中的方方面面,使整个人的气质得以升华。<br>读书的同时,我们还要学会读人。工作中,每天你都要和人打交道,这是任何行业都无法避免的。良好的沟通是成功的一半。而每个人,却又是不一样的,你怎样恰到好处地和不同的人,建立不同的沟通桥梁,也是一个很大的难题。我觉得沟通的关键,就在于“换位思考”这四个字。如果你能够用心的体会对方的感受和需要,并且给出符合对方感受和需要的答案,你就基本掌握了读人这门广而深的学问了。<br>2019整体对我来说,相当于一个缓冲区。在去年的这个时候,我也并没有给自己树立多么宏伟的目标。我觉得,先把身体和心理调整好是2019的第一要务。但是很遗憾,由于2019年也发生了不少令我情绪波动比较大的事情,所以我还并没有完全调整回来,2020,我们继续吧。<br>最后是2020我对自己的要求:</p>
<ul>
<li>每天至少阅读1小时技术以外的书籍并记录读书笔记</li>
<li>每周去健身房运动2-3次</li>
<li>深入研究MySQL,再读《MySQL技术内幕》</li>
<li>读Yaf框架源码,继续产出文章并同步SegmentFault和公众号</li>
<li>SF粉丝达到50+</li>
<li>熟练掌握Go语言</li>
<li>NDP平台上线</li>
<li>在日报系统中记录每天的成长</li>
<li>减少无意义的信息摄入</li>
<li>尊重身边的每一个人</li>
</ul>
<p>每日精进、刻意练习、摒弃杂念。2020,加油。</p>
<blockquote>本文参与了 <a href="https://segmentfault.com/a/1190000021354599">SegmentFault思否征文「2019 总结」</a>,欢迎正在阅读的你也加入。</blockquote>
【PHP7源码分析】奇妙的json_encode()
https://segmentfault.com/a/1190000020893651
2019-11-03T18:23:07+08:00
2019-11-03T18:23:07+08:00
NoSay
https://segmentfault.com/u/nosay
3
<p>baiyan</p>
<h2>json_encode()的奇怪输出</h2>
<p>最近在工作中碰到了一个现象:对于一个以数字为索引的PHP数组,在数组索引下标分别为连续和不连续的情况下,我们在分别对其进行json_encode()之后,得到了两种不一样的输出结果。看下面一段代码:</p>
<pre><code class="php"><?php
$arr = [4, 5, 6];
echo json_encode($arr);
unset($arr[1]);
echo PHP_EOL;
echo json_encode($arr);</code></pre>
<p>我们首先初始化一个数组,然后将其索引位置为1的元素去掉。由于PHP在unset()之后,并不会对数组的数字索引进行重新组织,导致该索引数组的下标不再连续。运行这段代码,输出结果如下:</p>
<pre><code class="php">[4,5,6]
{"0":4,"2":6}</code></pre>
<p>我们可以看到,在数组的数字索引连续的情况下,输出了一个json数组;而在数字索引不连续的情况下,输出了一个json对象,而并不是我们预期json数组。那么,在PHP源码层面中是如何实现的?PHP底层如何判断数组是否连续?这种处理方式是否合理呢?</p>
<h2>json_encode()源码分析</h2>
<p>接下来我们通过gdb来看一下PHP源码层面中,json_encode()对数组类型的编码处理。首先找到json_encode()函数的源码实现:</p>
<pre><code class="c">static PHP_FUNCTION(json_encode)
{
......
// 初始化encoder结构体(在具体encode阶段才会用到)
php_json_encode_init(&encoder);
// 执行json_encode()逻辑
php_json_encode_zval(&buf, parameter, (int)options, &encoder);
......
}</code></pre>
<p>这个php_json_encode_zval()函数是json_encode()的核心实现,我们启动gdb并在这里打一个断点:<br><img src="/img/remote/1460000020893654" alt="" title=""><br>运行上面这段代码,我们发现已经执行到了断点处。使用n命令继续往下执行:<br><img src="/img/remote/1460000020893655" alt="" title=""><br>首先进入了一个switch条件选择,它会判断PHP变量的类型,然后执行相应的case。我们这里是数组类型,用宏IS_ARRAY表示。完整的php_json_encode_zval()方法代码如下:</p>
<pre><code class="c">int php_json_encode_zval(smart_str *buf, zval *val, int options, php_json_encoder *encoder) /* {{{ */
{
again:
switch (Z_TYPE_P(val))
{
case IS_NULL:
smart_str_appendl(buf, "null", 4);
break;
case IS_TRUE:
smart_str_appendl(buf, "true", 4);
break;
case IS_FALSE:
smart_str_appendl(buf, "false", 5);
break;
case IS_LONG:
smart_str_append_long(buf, Z_LVAL_P(val));
break;
case IS_DOUBLE:
if (php_json_is_valid_double(Z_DVAL_P(val))) {
php_json_encode_double(buf, Z_DVAL_P(val), options);
} else {
encoder->error_code = PHP_JSON_ERROR_INF_OR_NAN;
smart_str_appendc(buf, '0');
}
break;
case IS_STRING:
return php_json_escape_string(buf, Z_STRVAL_P(val), Z_STRLEN_P(val), options, encoder);
case IS_OBJECT:
if (instanceof_function(Z_OBJCE_P(val), php_json_serializable_ce)) {
return php_json_encode_serializable_object(buf, val, options, encoder);
}
/* fallthrough -- Non-serializable object */
case IS_ARRAY: {
/* Avoid modifications (and potential freeing) of the array through a reference when a
* jsonSerialize() method is invoked. */
zval zv;
int res;
ZVAL_COPY(&zv, val);
res = php_json_encode_array(buf, &zv, options, encoder);
zval_ptr_dtor_nogc(&zv);
return res;
}
case IS_REFERENCE:
val = Z_REFVAL_P(val);
goto again;
default:
encoder->error_code = PHP_JSON_ERROR_UNSUPPORTED_TYPE;
if (options & PHP_JSON_PARTIAL_OUTPUT_ON_ERROR) {
smart_str_appendl(buf, "null", 4);
}
return FAILURE;
}
return SUCCESS;
}</code></pre>
<h3>判断传入参数的数据类型</h3>
<p>我们现在关注IS_ARRAY这个case。他首先定义了一个zval,然后将我们传入的PHP参数变量拷贝到新的zval中,避免修改我们原本传入的zval。接着,正如我们上图gdb中所示,php_json_encode_array()这个核心方法被调用,看方法名,我们就知道应该是专门处理参数为数组的情况,我们s进去,这里应该就是具体的判断逻辑了:<br><img src="/img/remote/1460000020893656" alt="" title=""><br>进入到php_json_encode_array()函数中,这里又判断了一次zval的类型是否为IS_ARRAY。为什么要这样做呢?这里是因为当变量为对象的时候,即IS_OBJECT,也会调用这个方法来进行encode处理。然后进入到这句最重要的判断逻辑:</p>
<pre><code class="c">r = (options & PHP_JSON_FORCE_OBJECT) ? PHP_JSON_OUTPUT_OBJECT : php_json_determine_array_type(val);</code></pre>
<h3>判断调用者是否传了可选参数</h3>
<p>我们知道,json_encode()函数有一个可选参数,来强制指定编码后返回的json类型,或者一些附加的编码选项等等。下面是json_encode()的官方文档:<br><img src="/img/remote/1460000020893657" alt="" title=""><br>关注这个JSON_FORCE_OBJECT,是指将索引数组也按照JSON对象的形式输出而非一个JSON数组。这个判断逻辑表示,如果用户调用方法时强制指定了option为PHP_JSON_FORCE_OBJECT,那么该三元运算符的返回值r将被置为PHP_JSON_OUTPUT_OBJECT宏的值,为常量1。否则如果用户没有显式指定输出的格式为JSON对象,就要进一步调用php_json_determine_array_type()方法来做最终的确定。由于我们并没有传参数进去,所以我们就对应这种情况。果然,我们的gdb按照我们的预期执行到了该方法,我们继续s进去:<br><img src="/img/remote/1460000020893658" alt="" title=""></p>
<h3>真相大白</h3>
<p>php_json_determine_array_type()看这个方法名,就知道它最终决定了输出的类型是JSON数组还是对象。那么这里应该就能够解释我们最初对于索引非连续数组却输出JSON对象的疑问了。首先这里判断了当前数组的元素个数是否大于0,如果大于0才需要进行判断。然后进行到了一句最最重要的判断:</p>
<pre><code class="c">if (HT_IS_PACKED(myht) && HT_IS_WITHOUT_HOLES(myht)) {
return PHP_JSON_OUTPUT_ARRAY;
}</code></pre>
<p>gdb中直接跳过了这个if,说明这里的if判断条件为false。这个if调用了两个宏。我们分别来看一下:</p>
<h4>HT_IS_PACKED</h4>
<p>讲到这个宏,就不得不讲一下PHP数组中的PACKED ARRAY和HASH ARRAY的概念。<br>PHP数组的所有元素,均存储在一块连续的内存空间中。这块内存空间的每一个单元,叫做bucket。每一个数组元素都存放在这个bucket中。我们访问PHP数组中的元素,其实就是访问bucket。在PHP源码中,使用一个arData指针变量,指向这块内存空间,即这些bucket的起始地址。在C语言中,我们可以通过指针运算或数组下标两种形式来拿到一块内存空间每个存储单元中的元素。那么对于索引为数字的PHP数组,可以方便地将PHP数组中数字索引所对应的数据,直接存放到arData对应的bucket中。举个例子,我们PHP数组中的$arr[0],就可以直接放到底层arData[0]的bucket中,我们unset掉了$arr[1],所以arData[1]的bucket中没有值,然后继续将$arr[2]放到arData[2]的bucket中。这样就构成了一个packed array。可以说,绝大多数的索引为数字的PHP数组都是packed array。那么,hash array在什么时候使用呢?<br>接着数字索引数组来说,如果只有一个数字key且其这个值较大,或者每个key数字之间的间隔较大,导致packed array中间空的bucket过多,内存空间过于浪费,最终还是会退化成hash array。当然对于索引key不是数字的关联数组,必须用hash算法计算出它所在的bucket位置,那么只能是hash array。虽然hash array也需要维护一个索引列表,确保数组的有序性,见:<a href="https://segmentfault.com/a/1190000019964143">【PHP7源码学习】剖析PHP数组的有序性</a>,但是可能没有packed array浪费的空间多。这里其实就是对空间复杂度和时间复杂度作出权衡取舍的一个过程。packed array能够节省内存,优化性能。具体的packed array和hash array的结构这里就不展开讲了。<br>我们知道,我们示例中的数组,其实就是一个packed array,所以第一个宏返回true。</p>
<h4>HT_IS_WITHOUT_HOLES</h4>
<p>这个宏从字面意思上看,就是看这个数组有没有空闲的bucket,看下这个宏的实现:</p>
<pre><code class="c">#define HT_IS_WITHOUT_HOLES(ht) \
((ht)->nNumUsed == (ht)->nNumOfElements)</code></pre>
<p>这里nNumUsed为最后一个使用的bucket的索引,而nNumOfElements是数组中元素的数量。这个宏判断二者是否相等。如果相等,那么自然能够确定bucket中没有空闲的bucket单元,否则就存在空闲的bucket单元。举个例子,在我们unset掉$arr[1]之后,元素的数量要减少一个,nNumOfElements为2。再看nNumUsed,虽然bucket有一个为空,但是并不影响最后一个bucket的索引nNumUsed。所以nNumUsed要比nNumOfElements大1,二者并不相等,最终返回false。</p>
<p>既然没有进这个if判断,就说明不能够以JSON数组的形式来编码了,只能够以JSON对象来进行编码。现在看一下该方法完整的源码:</p>
<pre><code class="c">static int php_json_determine_array_type(zval *val) /* {{{ */
{
int i;
HashTable *myht = Z_ARRVAL_P(val);
i = myht ? zend_hash_num_elements(myht) : 0;
if (i > 0) {
zend_string *key;
zend_ulong index, idx;
if (HT_IS_PACKED(myht) && HT_IS_WITHOUT_HOLES(myht)) {
return PHP_JSON_OUTPUT_ARRAY;
}
idx = 0;
ZEND_HASH_FOREACH_KEY(myht, index, key) {
if (key) {
return PHP_JSON_OUTPUT_OBJECT;
} else {
if (index != idx) {
return PHP_JSON_OUTPUT_OBJECT;
}
}
idx++;
} ZEND_HASH_FOREACH_END();
}
return PHP_JSON_OUTPUT_ARRAY;
}</code></pre>
<p>但是,究竟是在哪里明确地告诉我们,需要返回一个JSON对象的呢?<br>我们看到,在没有进上述的if判断之后,又重新遍历了一遍这个数组的所有bucket,如果key字段有值,即它是一个关联数组,就直接以JSON对象的形式返回;否则如果bucket下标不等于自增的idx,也返回JSON对象类型。显然我们这里的index下标为1的元素已经没有了,二者并不相等,所以就只能返回一个JSON对象了,即PHP_JSON_OUTPUT_OBJECT。到此为止,我们就完成了在源码层面,对PHP代码运行结果的验证。具体编码的过程,不是本文叙述的重点,有兴趣的同学可以深入研究一下后续的编码过程。</p>
<h2>思考</h2>
<p>那么为什么要这样做呢?是否有改进的空间呢?很多同学可能会想到,在json_encode()的判断中,如果bucket之间不连续,可以将其所有的数组索引重新排列,使bucket连续,进而在json_encode()之后,不管数字索引连续与否,都能够输出一个JSON数组,而这些操作对开发者而言是透明的,这种处理方式更能够让我接受。虽然PHP开发者可能认为重建索引会带来比较大的开销,进而采用了这种退而求其次的方法,但是从开发者的角度看,我觉得很多人都不希望在json_encode之后,对于连续和不连续的数组有两种输出结果,而是希望PHP帮助我们重新排列数组的索引。开发者不想、也不需要知道这个索引是不是连续,也不需要知道如果不连续,json_encode()要输出什么奇怪的结果、会有什么风险。这样做,大大增加了开发者的成本。<br>另外,对于真正想让数组数字索引不连续的数组变为连续,可以使用array_merge($arr)的特异功能。你可以只传一个参数进去,就可以得到重新排列的连续的数字索引啦。</p>
【Redis5源码学习】浅析redis命令之scan篇
https://segmentfault.com/a/1190000020622253
2019-10-09T10:30:04+08:00
2019-10-09T10:30:04+08:00
NoSay
https://segmentfault.com/u/nosay
0
<p>Grape</p>
<hr>
<h2>命令语法</h2>
<h4>命令含义:</h4>
<p>增量迭代一个集合元素。</p>
<h4>命令格式:</h4>
<pre><code>SCAN cursor [MATCH pattern] [COUNT count]</code></pre>
<h4>命令实战:</h4>
<h5>基本的执行遍历</h5>
<pre><code>redis 127.0.0.1:6379> scan 0
1) "17"
2) 1) "key:12"
2) "key:8"
3) "key:4"
4) "key:14"
5) "key:16"
6) "key:17"
7) "key:15"
8) "key:10"
9) "key:3"
10) "key:7"
11) "key:1"
redis 127.0.0.1:6379> scan 17
1) "0"
2) 1) "key:5"
2) "key:18"
3) "key:0"
4) "key:2"
5) "key:19"
6) "key:13"
7) "key:6"
8) "key:9"
9) "key:11"</code></pre>
<h5>COUNT选项</h5>
<p>对于增量式迭代命令不保证每次迭代所返回的元素数量,我们可以使用COUNT选项, 对命令的行为进行一定程度上的调整。COUNT 选项的作用就是让用户告知迭代命令, 在每次迭代中应该从数据集里返回多少元素。使用COUNT 选项对于对增量式迭代命令相当于一种提示, 大多数情况下这种提示都比较有效的控制了返回值的数量。</p>
<p>COUNT 参数的默认值为 10 。<br>数据集比较大时,如果没有使用MATCH 选项, 那么命令返回的元素数量通常和 COUNT 选项指定的一样, 或者比 COUNT 选项指定的数量稍多一些。<br>在迭代一个编码为整数集合(intset,一个只由整数值构成的小集合)、 或者编码为压缩列表(ziplist,由不同值构成的一个小哈希或者一个小有序集合)时, 增量式迭代命令通常会无视 COUNT 选项指定的值, 在第一次迭代就将数据集包含的所有元素都返回给用户。<br>注意: <strong>并非每次迭代都要使用相同的 COUNT 值 </strong>,用户可以在每次迭代中按自己的需要随意改变 COUNT 值, 只要记得将上次迭代返回的游标用到下次迭代里面就可以了。</p>
<pre><code>127.0.0.1:6379> scan 0 count 2
1) "12"
2) 1) "user_level_1"
2) "mykey"
127.0.0.1:6379></code></pre>
<h5>MATCH 选项</h5>
<p>类似于KEYS 命令,增量式迭代命令通过给定 MATCH 参数的方式实现了通过提供一个 glob 风格的模式参数, 让命令只返回和给定模式相匹配的元素。</p>
<pre><code>redis 127.0.0.1:6379> sadd myset 1 2 3 foo foobar feelsgood
(integer) 6
redis 127.0.0.1:6379> sscan myset 0 match f*
1) "0"
2) 1) "foo"
2) "feelsgood"
3) "foobar"
redis 127.0.0.1:6379></code></pre>
<h4>返回值:</h4>
<p>SCAN, SSCAN, HSCAN 和 ZSCAN 命令都返回一个包含两个元素的 multi-bulk <br>回复: 回复的第一个元素是字符串表示的无符号 64 位整数(游标),回复的第二个元素是另一个 multi-bulk 回复, 包含了本次被迭代的元素。</p>
<h2>源码分析</h2>
<p>此篇以scan命令为例。</p>
<h4>命令入口</h4>
<pre><code>/* The SCAN command completely relies on scanGenericCommand. */
void scanCommand(client *c) {
unsigned long cursor;
if (parseScanCursorOrReply(c,c->argv[1],&cursor) == C_ERR) return;
scanGenericCommand(c,NULL,cursor);
}</code></pre>
<h4>处理游标</h4>
<pre><code>
/* 尝试解析存储在对象“o”中的扫描游标:如果游标有效,
* 则将其作为无符号整数存储到*cursor中,并返回C_OK。否则返回C_ERR并向客户机发送错误。
* 此处o->ptr存储我们输入的游标
*/
int parseScanCursorOrReply(client *c, robj *o, unsigned long *cursor) {
char *eptr;
/* 使用strtoul(),因为我们需要一个无符号long,
* 所以getLongLongFromObject()不会覆盖整个游标空间。
*/
errno = 0;
*cursor = strtoul(o->ptr, &eptr, 10);
if (isspace(((char*)o->ptr)[0]) || eptr[0] != '\0' || errno == ERANGE)
{
addReplyError(c, "invalid cursor");
return C_ERR;
}
return C_OK;
}
</code></pre>
<h4>scan的公用函数</h4>
<pre><code>void scanGenericCommand(client *c, robj *o, unsigned long cursor) {
int i, j;
list *keys = listCreate();
listNode *node, *nextnode;
long count = 10;
sds pat = NULL;
int patlen = 0, use_pattern = 0;
dict *ht;
/* 对象必须为空(以迭代键名),或者对象的类型必须设置为集合,排序集合或散列。*/
serverAssert(o == NULL || o->type == OBJ_SET || o->type == OBJ_HASH ||
o->type == OBJ_ZSET);
/* 将i设置为第一个选项参数。前一个是游标。在对象为空时第一个参数在第2个位置,否则为第三个位置,例如:scan 0 ,sscan myset 0 match f*; */
i = (o == NULL) ? 2 : 3; /* Skip the key argument if needed. */
</code></pre>
<p>scan的实际操作一共分为4步,下边我们来看下这四步。</p>
<h4>step1:解析命令选项</h4>
<pre><code>/* Step 1:解析选项. */
while (i < c->argc) {
j = c->argc - i;
// count选项,注意是从第二个开始
if (!strcasecmp(c->argv[i]->ptr, "count") && j >= 2) {
if (getLongFromObjectOrReply(c, c->argv[i+1], &count, NULL) //获取所传递的值count值并赋值给count,因为在count关键字后边是count的值,所以为c->argv[i+1].
!= C_OK)
{
goto cleanup; //清理list等
}
//如果count的值为1,返回错误。清空在函数开头创建的list。
if (count < 1) {
addReply(c,shared.syntaxerr);
goto cleanup;
}
i += 2;
} else if (!strcasecmp(c->argv[i]->ptr, "match") && j >= 2) {
// match选项,同样是从第二个开始
pat = c->argv[i+1]->ptr; //获取到匹配规则
patlen = sdslen(pat);
/* 如果模式完全是“*”,那么它总是匹配的,所以这相当于禁用它。也就是说这种情况下此模式可有可无 */
use_pattern = !(pat[0] == '*' && patlen == 1);
i += 2;
} else {
addReply(c,shared.syntaxerr);
goto cleanup;
}
}</code></pre>
<p>此步骤主要是对命令的解析,解析出count和match的值以及对相应变量的赋值,从而在下文过滤步骤中进行处理。</p>
<h4>step2:遍历集合构造list</h4>
<pre><code> /* Step 2: 遍历集合。
*
*请注意,如果对象是用ziplist、intset或任何其他非哈希表的表示进行编码的,则可以肯定它也是由少量元素组成的。因此,为了避免获取状态,我们只需在一次调用中返回对象内部的所有内容,将游标设置为0表示迭代结束。 */
/* 处理哈希表的情况. 对应o的不同类型*/
ht = NULL;
if (o == NULL) {
ht = c->db->dict;
} else if (o->type == OBJ_SET && o->encoding == OBJ_ENCODING_HT) {
ht = o->ptr;
} else if (o->type == OBJ_HASH && o->encoding == OBJ_ENCODING_HT) {
ht = o->ptr;
count *= 2; /* We return key / value for this type. */
} else if (o->type == OBJ_ZSET && o->encoding == OBJ_ENCODING_SKIPLIST) {
zset *zs = o->ptr;
ht = zs->dict;
count *= 2; /* We return key / value for this type. */
}
if (ht) { //一般的存储,不是intset, ziplist
void *privdata[2];
/*我们将迭代的最大次数设置为指定计数的10倍,因此如果哈希表处于病态状态(非常稀疏地填充),
我们将避免以返回没有或很少元素为代价来阻塞太多时间。 */
long maxiterations = count*10;
/* 我们向回调传递两个指针:一个是它将向其中添加新元素的列表,
另一个是包含dictionary的对象,以便能够以类型相关的方式获取更多数据。 */
privdata[0] = keys;
privdata[1] = o;
do {
//一个个扫描,从cursor开始,然后调用回调函数将数据设置到keys返回数据集里面。
cursor = dictScan(ht, cursor, scanCallback, NULL, privdata);
} while (cursor &&
maxiterations-- &&
listLength(keys) < (unsigned long)count);
} else if (o->type == OBJ_SET) { //如果是set,将这个set里面的数据全部返回,因为它是压缩的intset,会很小的。
int pos = 0;
int64_t ll;
while(intsetGet(o->ptr,pos++,&ll))
listAddNodeTail(keys,createStringObjectFromLongLong(ll));
cursor = 0;
} else if (o->type == OBJ_HASH || o->type == OBJ_ZSET) {
//ziplist或者hash,字符串表示的数据结构,不会太大。
unsigned char *p = ziplistIndex(o->ptr,0);
unsigned char *vstr;
unsigned int vlen;
long long vll;
while(p) { //扫描整个键,然后集中返回一条。并且返回cursor为0表示没东西了。其实这个就等于没有遍历
ziplistGet(p,&vstr,&vlen,&vll);
listAddNodeTail(keys,
(vstr != NULL) ? createStringObject((char*)vstr,vlen) :
createStringObjectFromLongLong(vll));
p = ziplistNext(o->ptr,p);
}
cursor = 0;
} else {
serverPanic("Not handled encoding in SCAN.");
}</code></pre>
<p>此步骤根据不同的格式做出不同的处理,将扫描出来的元素放在list集合中,以方便过滤与取数。</p>
<h4>step3:过滤元素</h4>
<pre><code>/* Step 3: 过滤元素.此处是遍历上文构造的list */
node = listFirst(keys);
while (node) {
robj *kobj = listNodeValue(node);
nextnode = listNextNode(node);
int filter = 0;
/* 如果它不匹配的模式则过滤,此处的过滤是在上文给出. */
if (!filter && use_pattern) {
if (sdsEncodedObject(kobj)) {
if (!stringmatchlen(pat, patlen, kobj->ptr, sdslen(kobj->ptr), 0))
filter = 1;
} else {
char buf[LONG_STR_SIZE];
int len;
serverAssert(kobj->encoding == OBJ_ENCODING_INT);
len = ll2string(buf,sizeof(buf),(long)kobj->ptr);
if (!stringmatchlen(pat, patlen, buf, len, 0)) filter = 1;
}
}
/* 如果key过期,过滤. */
if (!filter && o == NULL && expireIfNeeded(c->db, kobj)) filter = 1;
/* 如果需要过滤,删除元素及其已设置的值. */
if (filter) {
decrRefCount(kobj);
listDelNode(keys, node);
}
/* 如果这是一个散列或排序集,我们有一个键-值元素的平面列表,因此如果这个元素被过滤了,
那么删除这个值,或者如果它没有被过滤,那么跳过它:我们只匹配键。*/
if (o && (o->type == OBJ_ZSET || o->type == OBJ_HASH)) {
node = nextnode;
nextnode = listNextNode(node);
if (filter) {
kobj = listNodeValue(node);
decrRefCount(kobj);
listDelNode(keys, node);
}
}
node = nextnode;
}
</code></pre>
<p>根据match参数过滤返回值,并且如果这个键已经过期也会直接过滤掉。最后返回元素。</p>
<h4>step4:返回消息给客户端+清理</h4>
<pre><code> /* Step 4: 返回消息给客户端. */
addReplyMultiBulkLen(c, 2);
addReplyBulkLongLong(c,cursor);
addReplyMultiBulkLen(c, listLength(keys));
while ((node = listFirst(keys)) != NULL) {
robj *kobj = listNodeValue(node);
addReplyBulk(c, kobj);
decrRefCount(kobj);
listDelNode(keys, node);
}
//清理操作,清楚list等结构
cleanup:
listSetFreeMethod(keys,decrRefCountVoid);
listRelease(keys);
}
</code></pre>
<p>综上所述,scan可以分为四步:</p>
<ul>
<li>解析count和match参数.如果没有指定count,默认返回10条数据</li>
<li>开始迭代集合,如果是key保存为ziplist或者intset,则一次性返回所有数据,没有游标(游标值直接返回0).由于redis设计只有数据量比较小的时候才会保存为ziplist或者intset,所以此处不会影响性能.</li>
<li>游标在保存为hash的时候发挥作用,具体入口函数为dictScan,详情可见<a href="https://segmentfault.com/a/1190000019967687">dictScan原理</a>
</li>
<li>根据match参数过滤返回值,并且如果这个键已经过期也会直接过滤掉(redis中键过期之后并不会立即删除,此处涉及到redis的两种过期删除机制,惰性删除和定期删除)</li>
<li>返回结果到客户端,是一个数组,第一个值是游标,第二个值是具体的键值对</li>
</ul>
<h2>扩展</h2>
<h3>查找大key的方法:</h3>
<h4>bigkeys参数</h4>
<pre><code>redis-cli 提供一个bigkeys参数,可以扫描redis中的大key</code></pre>
<p>执行结果:</p>
<pre><code>root@grape ~]# redis-cli --bigkeys
# Scanning the entire keyspace to find biggest keys as well as
# average sizes per key type. You can use -i 0.1 to sleep 0.1 sec
# per 100 SCAN commands (not usually needed).
[00.00%] Biggest string found so far 'testLuaSet' with 11 bytes
[00.00%] Biggest string found so far 'number' with 18 bytes
-------- summary -------
Sampled 2 keys in the keyspace!
Total key length in bytes is 16 (avg len 8.00)
Biggest string found 'number' has 18 bytes
2 strings with 29 bytes (100.00% of keys, avg size 14.50)
0 lists with 0 items (00.00% of keys, avg size 0.00)
0 sets with 0 members (00.00% of keys, avg size 0.00)
0 hashs with 0 fields (00.00% of keys, avg size 0.00)
0 zsets with 0 members (00.00% of keys, avg size 0.00)
</code></pre>
<p>此参数命令比较简单,使用scan命令去遍历所有的键,对每个键根据其类型执行"STRLEN","LLEN","SCARD","HLEN","ZCARD"这些命令获取其长度或者元素个数。<br>另外该方法有两个缺点:</p>
<ul>
<li>1.线上使用:虽然scan命令通过游标遍历建空间并且在生产上可以通过对从服务执行该命令,但毕竟是一个线上操作</li>
<li>2.set,zset,list以及hash类型只能获取有多少个元素。但其实元素多的不一定占用空间大</li>
</ul>
<h4>通过RDB文件</h4>
<p>在redis中定义了一些opcode(1字节),去标记opcode之后保存的是什么类型的数据,在这些类型中有一个value-type值类型,如下图:</p>
<p><img src="/img/bVbyGTn?w=824&h=342" alt="clipboard.png" title="clipboard.png"></p>
<p>value_type就是值类型这一列,括号中的数字就是保存到rdb文件中时的实际使用数字。<br>我们可以写代码解析rdb文件,通过value_type去获取每个value的大小。<br>在这里我们推荐一个开源软件:godis-cli-bigkey<br>详情见github:<a href="https://link.segmentfault.com/?enc=9Y8wyGEmWqb4RAebRhBYfw%3D%3D.o9II7ameer6qM2JxwXXY5fFW9aje7nxU5FTgBl2Z3eXgrB37hlmPdLsEsr0aDtE%2F" rel="nofollow">https://github.com/erpeng/god...</a></p>
<h3>scan的优缺点</h3>
<ul>
<li>提供键空间的遍历操作,支持游标,复杂度O(1), 整体遍历一遍只需要O(N);</li>
<li>提供结果模式匹配;</li>
<li>支持一次返回的数据条数设置,但仅仅是个hints,有时候返回的会多;</li>
<li>弱状态,所有状态只需要客户端需要维护一个游标;</li>
<li>无法提供完整的快照遍历,也就是中间如果有数据修改,可能有些涉及改动的数据遍历不到;</li>
<li>每次返回的数据条数不一定,极度依赖内部实现;</li>
<li>返回的数据可能有重复,应用层必须能够处理重入逻辑;</li>
<li>count是每次扫描的key个数,并不是结果集个数。count要根据扫描数据量大小而定,Scan虽然无锁,但是也不能保证在超过百万数据量级别搜索效率;count不能太小,网络交互会变多,count要尽可能的大。在搜索结果集1万以内,建议直接设置为与所搜集大小相同</li>
</ul>
<h2>参考文章</h2>
<ul>
<li><a href="https://link.segmentfault.com/?enc=14Dkh2b8I%2BAQBhzPocZVtg%3D%3D.xQf9e5Vz2nb0w2Rb6fI0g2YvXa7UOTiCnXSR7BjGuJ1W5CyMG59bwYsDQagxecDbi6za8MFpWFQCzxORT7RmYQ%3D%3D" rel="nofollow">Redis Scan的使用方式以及Spring redis的坑</a></li>
<li><a href="https://segmentfault.com/a/1190000018218584?utm_source=coffeephp.com">Redis scan命令原理</a></li>
</ul>
【Redis5源码学习】浅析redis命令之restore篇
https://segmentfault.com/a/1190000020611404
2019-10-08T12:00:21+08:00
2019-10-08T12:00:21+08:00
NoSay
https://segmentfault.com/u/nosay
0
<p>Grape</p>
<hr>
<h2>命令语法</h2>
<p>命令含义:反序列化给定的序列化值,并将它和给定的 key 关联。</p>
<h3>命令格式:</h3>
<pre><code>RESTORE key ttl serialized-value [REPLACE] [ABSTTL] [IDLETIME seconds] [FREQ frequency]</code></pre>
<h3>命令实战</h3>
<pre><code>redis> DEL mykey
0
redis> RESTORE mykey 0 "\n\x17\x17\x00\x00\x00\x12\x00\x00\x00\x03\x00\
x00\xc0\x01\x00\x04\xc0\x02\x00\x04\xc0\x03\x00\
xff\x04\x00u#<\xc0;.\xe9\xdd"
OK
redis> TYPE mykey
list
redis> LRANGE mykey 0 -1
1) "1"
2) "2"
3) "3"</code></pre>
<h3>返回值</h3>
<p>如果反序列化成功那么返回 OK ,否则返回一个错误。</p>
<h2>源码分析</h2>
<p>源码分析部分我们分为几个部分来讲解。</p>
<h3>参数处理</h3>
<pre><code>void restoreCommand(client *c) {
long long ttl, lfu_freq = -1, lru_idle = -1, lru_clock = -1;
rio payload;
int j, type, replace = 0, absttl = 0;
robj *obj;
/* 解析参数 */
for (j = 4; j < c->argc; j++) {
int additional = c->argc-j-1;
if (!strcasecmp(c->argv[j]->ptr,"replace")) {
replace = 1;
} else if (!strcasecmp(c->argv[j]->ptr,"absttl")) {
absttl = 1;
} else if (!strcasecmp(c->argv[j]->ptr,"idletime") && additional >= 1 &&
lfu_freq == -1)
{
if (getLongLongFromObjectOrReply(c,c->argv[j+1],&lru_idle,NULL)
!= C_OK) return;
if (lru_idle < 0) {
addReplyError(c,"Invalid IDLETIME value, must be >= 0");
return;
}
lru_clock = LRU_CLOCK();
j++; /* Consume additional arg. */
} else if (!strcasecmp(c->argv[j]->ptr,"freq") && additional >= 1 &&
lru_idle == -1)
{
if (getLongLongFromObjectOrReply(c,c->argv[j+1],&lfu_freq,NULL)
!= C_OK) return;
if (lfu_freq < 0 || lfu_freq > 255) {
addReplyError(c,"Invalid FREQ value, must be >= 0 and <= 255");
return;
}
j++; /* Consume additional arg. */
} else {
addReply(c,shared.syntaxerr);
return;
}
}
</code></pre>
<p>在上边我们提到了restore命令格式,我们可以看到,在第四个参数开始都是可选参数,所以解析参数中是从j=4开始遍历的,在遍历的过程中会根据不同的参数做不同的操作。<br>我们依次来看下这四个命令:</p>
<ol>
<li>Replace:判断如果是replace字段,将标识位replace置为1。</li>
<li>Absttl: 判断如果是absttl字段,将标识位absttl置为1。如果使用了ABSTTL修饰符,ttl则应表示密钥将在其中终止的绝对 Unix时间戳(以毫秒为单位)。</li>
<li>Idletime&&freq: 这两个参数在object命令中有很详细的解释。在objec中,ideltime是返回自存储在指定键处的对象处于空闲状态以来的秒数(读或写操作未请求)。freq是返回存储在指定键处的对象的对数访问频率计数器。在这段命令中解析到这两个命令时,ideltime设置了lru_clock时钟值。在freq设置lru_freq,设置频率且判断是否在0-255之间。</li>
</ol>
<h3>校验&&对应模式操作</h3>
<pre><code>/* 此处是确保这个key是否存在,这个操作仅在replace等于0的时候进行*/
if (!replace && lookupKeyWrite(c->db,c->argv[1]) != NULL) {
addReply(c,shared.busykeyerr);
return;
}
/* 检查ttl合法,规则是是否小于0且是否可转为数字*/
if (getLongLongFromObjectOrReply(c,c->argv[2],&ttl,NULL) != C_OK) {
return;
} else if (ttl < 0) {
addReplyError(c,"Invalid TTL value, must be >= 0");
return;
}
// 检查RDB版本和数据校验和。如果它们不匹配,则返回错误。
if (verifyDumpPayload(c->argv[3]->ptr,sdslen(c->argv[3]->ptr)) == C_ERR)
{
addReplyError(c,"DUMP payload version or checksum are wrong");
return;
}
rioInitWithBuffer(&payload,c->argv[3]->ptr);
if (((type = rdbLoadObjectType(&payload)) == -1) ||
((obj = rdbLoadObject(type,&payload)) == NULL))
{
addReplyError(c,"Bad data format");
return;
}
// 如果是replace模式,删除key
if (replace) dbDelete(c->db,c->argv[1]);
</code></pre>
<h3>新增key</h3>
<pre><code> /* Create the key and set the TTL if any */
dbAdd(c->db,c->argv[1],obj); //如果key存在,则报错
if (ttl) {
if (!absttl) ttl+=mstime();
setExpire(c,c->db,c->argv[1],ttl);
}
//创建新的key。
//如果存在ttl就设置.
// 如果ttl为0,则创建密钥时不会有任何过期,否则将设置指定的过期时间(以毫秒为单位)
objectSetLRUOrLFU(obj,lfu_freq,lru_idle,lru_clock);
//设置频率和时间范围限制值
signalModifiedKey(c->db,c->argv[1]);
addReply(c,shared.ok);
server.dirty++;
}
</code></pre>
<h2>拓展</h2>
<h3>LFU 最近最不常用页面置换算法</h3>
<p>Least Frequently Used algorithm.LFU是首先淘汰一定时期内被访问次数最少的页!</p>
<p>算法根据数据的历史访问频率来淘汰数据,其核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。<br>具体的算法流程如下所示:</p>
<p><img src="/img/bVbyD60?w=1116&h=718" alt="clipboard.png" title="clipboard.png"></p>
<h3>LRU 最近最近最久未使用置换算法</h3>
<p>Least Recently Used algorithm.LRU是首先淘汰最长时间未被使用的页面!</p>
<p>算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。<br>具体的算法流程如下所示:<br><img src="/img/bVbyD6K?w=1624&h=660" alt="clipboard.png" title="clipboard.png"></p>
<h3>FIFO 先进先出置换算法</h3>
<p>FIFO(First in First out),先进先出。<br>其实在操作系统的设计理念中很多地方都利用到了先进先出的思想,比如作业调度(先来先服务),为什么这个原则在很多地方都会用到呢?因为这个原则简单、且符合人们的惯性思维,具备公平性,并且实现起来简单,直接使用数据结构中的队列即可实现。</p>
【Redis5源码学习】浅析redis命令之rename篇
https://segmentfault.com/a/1190000020594655
2019-10-04T21:12:05+08:00
2019-10-04T21:12:05+08:00
NoSay
https://segmentfault.com/u/nosay
1
<p>baiyan</p>
<h2>命令语法</h2>
<p>命令含义:将 key改名为newkey<br>命令格式:</p>
<pre><code class="c">RENAME key newkey</code></pre>
<p>命令实战:</p>
<pre><code class="c">127.0.0.1:6379> keys *
1) "kkk"
2) "key1"
127.0.0.1:6379> rename kkk key1
OK
127.0.0.1:6379> keys *
1) "key1"
127.0.0.1:6379> rename kkk kkk
(error) ERR no such key</code></pre>
<p>返回值: 改名成功时提示 OK ,失败时候返回一个错误</p>
<h2>源码分析</h2>
<h3>主要流程</h3>
<p>rename命令的处理函数是renameCommand():</p>
<pre><code class="c">void renameCommand(client *c) {
renameGenericCommand(c,0);
}</code></pre>
<p>renameCommand()函数调用了底层通用重命名函数:</p>
<pre><code class="c">void renameGenericCommand(client *c, int nx) {
robj *o;
long long expire;
int samekey = 0;
// 重命名之前和之后的键名相同,置samekey标志为1,不做处理
if (sdscmp(c->argv[1]->ptr,c->argv[2]->ptr) == 0) samekey = 1;
// 如果重命名之前的键不存在,直接返回
if ((o = lookupKeyWriteOrReply(c,c->argv[1],shared.nokeyerr)) == NULL)
return;
// 如果置了samekey标志为1,代表重命名前后的键名相同,那么什么都不做,直接返回OK
if (samekey) {
addReply(c,nx ? shared.czero : shared.ok);
return;
}
incrRefCount(o); // 由于查找到了o,引用计数++
expire = getExpire(c->db,c->argv[1]); // 获取重命名之前键的过期时间
if (lookupKeyWrite(c->db,c->argv[2]) != NULL) { // 如果重命名之后的键已经存在
if (nx) { // 是否是执行的renamenx命令
decrRefCount(o);
addReply(c,shared.czero);
return;
}
/* 重命名之后的键已经存在,需要删除这个已存在的键 */
dbDelete(c->db,c->argv[2]);
}
dbAdd(c->db,c->argv[2],o); // 到这里重命名之后的键一定不存在了,可以添加这个键
if (expire != -1) setExpire(c,c->db,c->argv[2],expire); // 如果之前设置了过期时间,同样给新键设置过期时间
dbDelete(c->db,c->argv[1]); // 新键创建完毕,需删除之前的键
signalModifiedKey(c->db,c->argv[1]); // 发出修改键信号
signalModifiedKey(c->db,c->argv[2]); // 发出修改键信号
notifyKeyspaceEvent(NOTIFY_GENERIC,"rename_from",
c->argv[1],c->db->id); // 键空间事件触发
notifyKeyspaceEvent(NOTIFY_GENERIC,"rename_to",
c->argv[2],c->db->id); // 键空间事件触发
server.dirty++;
addReply(c,nx ? shared.cone : shared.ok);
}</code></pre>
<p>我们首先整理一下这个命令的思路,如果让我们自己去实现重命名一个键这个命令,应该怎么做呢?<br>首先我们会判断一些边界条件。我们知道,键是放在键空间字典里的。假设我们现在有key1-value1和key2-value2。现在需要把key1重命名为key2,即重命名之后生成值为key2-value1的键值对。考虑以下边界情况:</p>
<ul>
<li>重命名之前的键名key1不存在</li>
<li>重命名之后的键名key2已存在</li>
<li>重命名之前为key1,之后仍为key1,即名称没有变化</li>
</ul>
<p>对于第一种情况,如果重命名之前的键不存在,很简单,这种操作是不能够执行的。直接和客户端报告错误即可。<br>对于第二种情况,如果重命名之后的键已经存在了,redis选择先查找出value1,然后删掉key2-value2键值对。目前的变量状态仅仅有一个value1,和一个我们输入的key2。接下来,我们直接往数据库中添加key2-value1即可。最后删掉老的key1-value1键值对即可。<br>对于第三种情况,不作任何处理即可。</p>
<h3>可能存在的问题</h3>
<p>由于redis会在上面的第二种情况,即重命名之后的键存在的情况下,选择先后将key2-value2、key1-value1删除。所以,当碰到要删除的key-value对数据量非常大的时候,往往会造成单进程的redis的阻塞状态,造成对外服务的不可用。所以,往往在日常开发中,这个命令会被禁止使用,和之前讲过的keys命令有异曲同工之妙。在使用过程中我们需要格外注意。</p>
【Redis5源码学习】浅析redis命令之persist篇
https://segmentfault.com/a/1190000020589667
2019-10-03T16:03:48+08:00
2019-10-03T16:03:48+08:00
NoSay
https://segmentfault.com/u/nosay
0
<p>Grape</p>
<hr>
<h2>命令语法</h2>
<p>命令含义:移除给定key的生存时间,将这个 key 从『易失的』(带生存时间 key )转换成『持久的』(一个不带生存时间、永不过期的 key )。<br>命令格式:</p>
<pre><code>PERSIST key</code></pre>
<p>命令实战:</p>
<pre><code>redis> SET mykey "Hello"
OK
redis> EXPIRE mykey 10
(integer) 1
redis> TTL mykey
(integer) 10
redis> PERSIST mykey
(integer) 1
redis> TTL mykey
(integer) -1
redis> </code></pre>
<p>返回值: <br> 当生存时间移除成功时,返回 1 .<br> 如果 key 不存在或 key 没有设置生存时间,返回 0 .</p>
<h2>源码分析</h2>
<h3>命令入口:</h3>
<pre><code>/* PERSIST key */
void persistCommand(client *c) {
//查找这个key,调用lookupKeyWrite函数
if (lookupKeyWrite(c->db,c->argv[1])) {
//如果这个key存在,调用删除函数,调用dictGenericDelete函数
if (removeExpire(c->db,c->argv[1])) {
addReply(c,shared.cone);
server.dirty++;
} else {
addReply(c,shared.czero);
}
} else {
addReply(c,shared.czero);
}
}</code></pre>
<p>此处是persist命令的入口,我们可以看到这个命令在执行的过程中有一下几个阶段:1.查找你要执行persist命令的key,看是否存在,如果不存在则直接返回客户端信息。如果存在,这调用删除函数移除过期时间,删除成功返回给客户端成功信息,aof标志加1。</p>
<h3>判断key的可用性</h3>
<pre><code>/* Lookup a key for write operations, and as a side effect, if needed, expires
* the key if its TTL is reached.
*
* Returns the linked value object if the key exists or NULL if the key
* does not exist in the specified DB. */
/* 查找这个key
*/
robj *lookupKeyWrite(redisDb *db, robj *key) {
//首先查找这个key是否过期,过期则删除,此函数在之前的几篇文章中已经介绍过,此处不在赘述。
expireIfNeeded(db,key);
在上一个步骤的基础上,如果此键被删除则返回null,否则返回这个键的值
return lookupKey(db,key,LOOKUP_NONE);
}
int removeExpire(redisDb *db, robj *key) {
/* An expire may only be removed if there is a corresponding entry in the
* main dict. Otherwise, the key will never be freed. */
serverAssertWithInfo(NULL,key,dictFind(db->dict,key->ptr) != NULL);
return dictDelete(db->expires,key->ptr) == DICT_OK;
}</code></pre>
<p>这个过程是在判断这个key是否存在,它分为两个部分,首先他会查找这个key是否过期,如果过期则删除,然后去查询这个key,反之直接查找这个key,此处又一个疑问,为什么不是先查找这个key是否存在再去判断是否过期?我的想法:redis中存储过期时间和key-value是两个字典,在expire字典中存在的值一定在key-value字典中存在,那么在expire过期的时候就涉及reids的惰性删除机制,为了满足这个机制,reids在设计的时候会先对key的过期时间做判断,然后再去判断这个key是否存在,此时如果对redis的删除机制感兴趣的话,请查阅<a href="https://link.segmentfault.com/?enc=QTlGYSYnED0jzLO5hFF%2Fhg%3D%3D.itFGR2R793No1YH3dEfi%2FywhFpUjcfPwTY8YxNV2iZYCxHmTE0qh74CLyYs0T%2FxNwBtsyMxq%2ByfX%2BUs5r0UKHjHEtQqwwmeDPxdrR%2FscLX1ZnDUhdes2hbCdgUQVoGu2" rel="nofollow">关于Redis数据过期策略</a>。</p>
<h3>真正移除的过程</h3>
<pre><code>/* Search and remove an element. This is an helper function for
* dictDelete() and dictUnlink(), please check the top comment
* of those functions. */
static dictEntry *dictGenericDelete(dict *d, const void *key, int nofree) {
uint64_t h, idx;
dictEntry *he, *prevHe;
int table;
//我们知道在redis中的两个ht,ht[0]和ht[1],如果这两个ht中没有已使用空间,直接return null
if (d->ht[0].used == 0 && d->ht[1].used == 0) return NULL;
if (dictIsRehashing(d)) _dictRehashStep(d);
//根据key来获取他的hash值
h = dictHashKey(d, key);
//遍历ht[0]和ht[1],如果找到则删除他的key-value,此处因为是删除过期时间,我们可以看到外层调用函数传入的是db->expires和key,则此块是从expire的字典中删除, for语句中是删除的过程,很简单,遍历,如果找到了删除,同时释放空间。
for (table = 0; table <= 1; table++) {
idx = h & d->ht[table].sizemask;
he = d->ht[table].table[idx];
prevHe = NULL;
while(he) {
if (key==he->key || dictCompareKeys(d, key, he->key)) {
/* Unlink the element from the list */
if (prevHe)
prevHe->next = he->next;
else
d->ht[table].table[idx] = he->next;
if (!nofree) {
dictFreeKey(d, he);
dictFreeVal(d, he);
zfree(he);
}
d->ht[table].used--;
return he;
}
prevHe = he;
he = he->next;
}
if (!dictIsRehashing(d)) break;
}
return NULL; /* not found */
}
</code></pre>
<p>此处是redis真正此处过期过期时间的步骤,此处只要设计dict的一些操作,例如遍历dict的ht[0],ht[1]和删除一个dict的值。大致的思路是先找到这个key然后进行释放。因为此处在前面文章已经有所描述,故直接放上传送门:<a href="https://segmentfault.com/a/1190000020451197">dict是如何find一个元素的</a>。</p>
【Redis5源码学习】浅析redis命令之randomkey篇
https://segmentfault.com/a/1190000020547849
2019-10-01T21:01:21+08:00
2019-10-01T21:01:21+08:00
NoSay
https://segmentfault.com/u/nosay
0
<p>baiyan</p>
<h2>命令语法</h2>
<p>命令含义:从当前选定数据库随机返回一个key<br>命令格式:</p>
<pre><code class="c">RANDOMKEY</code></pre>
<p>命令实战:</p>
<pre><code class="c">127.0.0.1:6379> keys *
1) "kkk"
2) "key1"
127.0.0.1:6379> randomkey
"key1"
127.0.0.1:6379> randomkey
"kkk"</code></pre>
<p>返回值: 随机的键;如果数据库为空则返回nil</p>
<h2>源码分析</h2>
<h3>主体流程</h3>
<p>keys命令对应的处理函数是randomKeyCommand():</p>
<pre><code class="c">void randomkeyCommand(client *c) {
robj *key; // 存储获取到的key
if ((key = dbRandomKey(c->db)) == NULL) { // 调用核心函数dbRandomKey()
addReply(c,shared.nullbulk); // 返回nil
return;
}
addReplyBulk(c,key); // 返回key
decrRefCount(key); // 减少引用计数
}</code></pre>
<h3>随机键生成以及过期判断</h3>
<p>randomKeyCommand()调用了dbRandomKey()函数来真正生成一个随机键:</p>
<pre><code class="c">robj *dbRandomKey(redisDb *db) {
dictEntry *de;
int maxtries = 100;
int allvolatile = dictSize(db->dict) == dictSize(db->expires);
while(1) {
sds key;
robj *keyobj;
de = dictGetRandomKey(db->dict); // 获取随机的一个dictEntry
if (de == NULL) return NULL; // 获取失败返回NULL
key = dictGetKey(de); // 获取dictEntry中的key
keyobj = createStringObject(key,sdslen(key)); // 根据key字符串生成robj
if (dictFind(db->expires,key)) { // 去过期字典里查找这个键
...
if (expireIfNeeded(db,keyobj)) { // 判断键是否过期
decrRefCount(keyobj); // 如果过期了,删掉这个键并减少引用计数
continue; // 当前键过期了不能返回,只返回不过期的键,进行下一次随机生成
}
}
return keyobj;
}
}</code></pre>
<p>那么这一层的主逻辑又调用了dictGetRandomKey(),获取随机的一个dictEntry。假设我们已经获取到了随机生成的dictEntry,我们随后取出key。由于不能返回过期的key,所以我们需要先判断键是否过期,如果过期了就不能返回了,直接continue;如果不过期就可以返回。</p>
<h3>真正获取随机键的算法</h3>
<p>那么我们继续跟进dictGetRandomKey()函数,看一下究竟使用了什么算法,来随机生成dictEntry:</p>
<pre><code class="c">dictEntry *dictGetRandomKey(dict *d)
{
dictEntry *he, *orighe;
unsigned long h;
int listlen, listele;
if (dictSize(d) == 0) return NULL; // 传进来的字典为空,根本不用生成
if (dictIsRehashing(d)) _dictRehashStep(d); // 执行一次rehash操作
if (dictIsRehashing(d)) { // 如果正在rehash,注意要保证从两个哈希表中均匀分配随机种子
do {
h = d->rehashidx + (random() % (d->ht[0].size +d->ht[1].size - d->rehashidx)); //计算随机哈希值,这个哈希值一定是在rehashidx的后部
he = (h >= d->ht[0].size) ? d->ht[1].table[h - d->ht[0].size] : d->ht[0].table[h];// 根据上面计算的哈希值拿到对应的bucket
} while(he == NULL); // 一直循环计算,取最后一个计算结果不为空的bucket
} else { // 不在rehash,只有一个哈希表
do {
h = random() & d->ht[0].sizemask; // 直接计算哈希值
he = d->ht[0].table[h]; // 取出哈希表上第h个bucket
} while(he == NULL); // 一直循环计算,取最后一个计算结果不为空的bucket
}
// 现在我们得到了一个不为空的bucket,而这个bucket的后面还挂接了一个或多个dictEntry(链地址法解决哈希冲突),所以同样需要计算一个随机索引,来判断究竟访问哪一个dickEntry链表结点
listlen = 0;
orighe = he;
while(he) {
he = he->next;
listlen++; // 计算链表长度
}
listele = random() % listlen; // 随机数对链表长度取余,确定获取哪一个结点
he = orighe;
while(listele--) he = he->next; // 从前到后遍历这个bucket上的链表,找到这个结点
return he; // 最终返回这个结点
}</code></pre>
<p>这个函数首先会进行字典为空的判断。然后会进行一个单步rehash操作,这一点和调用如dictAdd()等字典函数的效果是一样的,都是渐进式rehash技术的一部分。在这里我们首先复习一下字典的整体结构:<br><img src="/img/remote/1460000019967692" alt="" title=""><br>由于rehash会影响随机数种子的生成,所以根据当前字典是否正在进行rehash操作,需要分两种情况讨论:<br><strong>第一种:正在进行rehash操作。</strong> 那么当前字典的结构为:有一部分键在第一个哈希表上、其余的键在第二个哈希表上。为了均匀分配两个哈希表可能被取到的概率,需要将两个哈希表结合考虑。其算法为:</p>
<pre><code class="c">h = d->rehashidx + (random() % (d->ht[0].size + d->ht[1].size - d->rehashidx)); //计算随机哈希值,这个哈希值一定是在rehashidx的后部</code></pre>
<p>这里将一个随机数对两个哈希表大小之和减去rehashidx取余。这样的取余操作可以保证这个哈希值会随机落在索引<strong>大于</strong>rehashidx位置的bucket上。因为rehashidx表示rehash的进度。这个rehashidx表示在第一个哈希表上在这个索引之前的数据,即[0, rehashidx-1],这个闭区间上的数据已经在被rehash到第二个哈希表上了。而大于等于这个rehashidx的元素仍在第一个哈希表上。所以,这样就保证了任何一个结果h上的bucket,都是非空有值的。接下来只需要判断这个h值在哪个哈希表上,然后去哈希表上对应位置上的bucket取值即可:</p>
<pre><code class="c">he = (h >= d->ht[0].size) ? d->ht[1].table[h - d->ht[0].size] : d->ht[0].table[h];</code></pre>
<p><strong>第二种:没有进行rehash操作。</strong> 那么所有键都在唯一一个第一个字典上,这种情况就非常简单了,可以直接对字典长度求余,或者对字典的sizemask进行按位与运算,都可以保证计算后的结果落在哈希表内。redis选择的是后者:</p>
<pre><code class="c">h = random() & d->ht[0].sizemask; // 通过对sizemask的按位与运算计算哈希值
he = d->ht[0].table[h]; // 取出哈希表上第h个bucket</code></pre>
<p>接下来,我们找到了一个非空的bucket,但是还没有结束。由于可能存在<strong>哈希冲突</strong>,redis采用链地址法解决哈希冲突,所以会在一个bucket后面挂接多个dictEntry,形成一个链表。所以,还需要思考究竟要取哪一个链表结点上的dictEntry。这个算法就比较简单了,直接利用random()的结果,对链表长度求余即可:</p>
<pre><code class="c">listele = random() % listlen; // 随机数对链表长度取余,确定获取哪一个结点
while(listele--) he = he->next; // 从前到后遍历这个bucket上的链表,找到这个结点</code></pre>
<p>到此为止,我们就找到了一个随机bucket上的一个随机dictEntry结点,那么就可以返回给客户端啦。</p>
【Redis5源码学习】浅析redis命令之object篇
https://segmentfault.com/a/1190000020515393
2019-09-27T16:24:41+08:00
2019-09-27T16:24:41+08:00
NoSay
https://segmentfault.com/u/nosay
0
<p>baiyan</p>
<h2>命令使用</h2>
<p>命令含义:查看指定key的一些信息,一般用于调试或查看内部编码使用<br>命令格式:</p>
<pre><code class="c">OBJECT subcommand [key]</code></pre>
<p>OBJECT有4个可选的子命令subcommand:</p>
<ul>
<li>OBJECT REFCOUNT:查看当前键的引用计数</li>
<li>OBJECT ENCODING:查看当前键的编码</li>
<li>OBJECT IDLETIME:查看当前键的空转时间</li>
<li>OBJECT FREQ:查看当前键最近访问频率的对数</li>
<li>OBJECT HELP:查看OBJECT命令的帮助信息</li>
</ul>
<p>命令实战:</p>
<pre><code class="c">127.0.0.1:6379> set key1 value1
OK
127.0.0.1:6379> object refcount key1
(integer) 1
127.0.0.1:6379> object encoding key1
"embstr"
127.0.0.1:6379> object idletime key1
(integer) 20
127.0.0.1:6379> object idletime key1
(integer) 23
127.0.0.1:6379> object help
1) OBJECT <subcommand> arg arg ... arg. Subcommands are:
2) ENCODING <key> -- Return the kind of internal representation used in order to store the value associated with a key.
3) FREQ <key> -- Return the access frequency index of the key. The returned integer is proportional to the logarithm of the recent access frequency of the key.
4) IDLETIME <key> -- Return the idle time of the key, that is the approximated number of seconds elapsed since the last access to the key.
5) REFCOUNT <key> -- Return the number of references of the value associated with the specified key.</code></pre>
<p>返回值:REFCOUNT 和 IDLETIME 返回数字;ENCODING 返回相应的编码类型<br>注:idletime指该键空闲的时间,而空闲指没有被读取也没有被写入。set、get、ttl、expire命令都会重置idletime为0</p>
<h2>源码分析</h2>
<p>整个命令可以分为参数校验、查找字典两步。同样的,object命令的入口是objectCommand():</p>
<pre><code class="c">void objectCommand(client *c) {
robj *o;
// 如果执行OBJECT help命令,打印帮助信息
if (c->argc == 2 && !strcasecmp(c->argv[1]->ptr,"help")) {
const char *help[] = {
"ENCODING <key> -- Return the kind of internal representation used in order to store the value associated with a key.",
"FREQ <key> -- Return the access frequency index of the key. The returned integer is proportional to the logarithm of the recent access frequency of the key.",
"IDLETIME <key> -- Return the idle time of the key, that is the approximated number of seconds elapsed since the last access to the key.",
"REFCOUNT <key> -- Return the number of references of the value associated with the specified key.",
NULL
};
addReplyHelp(c, help); // 直接返回帮助信息
} else if (!strcasecmp(c->argv[1]->ptr,"refcount") && c->argc == 3) { // 如果执行OBJECT refcount key命令
if ((o = objectCommandLookupOrReply(c,c->argv[2],shared.nullbulk)) // 去键空间中查找该键的robj对象
== NULL) return;
addReplyLongLong(c,o->refcount); // 取出robj的refcount字段并返回
} else if (!strcasecmp(c->argv[1]->ptr,"encoding") && c->argc == 3) { // 如果执行OBJECT encoding key命令
if ((o = objectCommandLookupOrReply(c,c->argv[2],shared.nullbulk)) // 去键空间中查找该键的robj对象
== NULL) return;
addReplyBulkCString(c,strEncoding(o->encoding)); // 取出robj的encoding字段并返回
} else if (!strcasecmp(c->argv[1]->ptr,"idletime") && c->argc == 3) { // 如果执行OBJECT idletime key命令
if ((o = objectCommandLookupOrReply(c,c->argv[2],shared.nullbulk))// 去键空间中查找该键的robj对象
== NULL) return;
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) { // 如果开启了LFU淘汰策略,就不会跟踪空转时间,无法使用命令
addReplyError(c,"An LFU maxmemory policy is selected, idle time not tracked. Please note that when switching between policies at runtime LRU and LFU data will take some time to adjust.");
return;
}
addReplyLongLong(c,estimateObjectIdleTime(o)/1000);
} else if (!strcasecmp(c->argv[1]->ptr,"freq") && c->argc == 3) { //如果执行OBJECT freq key命令
if ((o = objectCommandLookupOrReply(c,c->argv[2],shared.nullbulk))
== NULL) return;
if (!(server.maxmemory_policy & MAXMEMORY_FLAG_LFU)) {
addReplyError(c,"An LFU maxmemory policy is not selected, access frequency not tracked. Please note that when switching between policies at runtime LRU and LFU data will take some time to adjust.");
return;
}
addReplyLongLong(c,LFUDecrAndReturn(o));
} else { // 不是这几种子命令中的一个,直接报错
addReplySubcommandSyntaxError(c);
}
}</code></pre>
<h3>参数校验</h3>
<p>由于OBJECT命令有多个子命令,所以需要进行参数校验,来判断是哪种子命令类型:</p>
<pre><code class="c">...
else if (!strcasecmp(c->argv[1]->ptr,"refcount") && c->argc == 3) { // 如果执行OBJECT refcount key命令
...</code></pre>
<h3>字典查找</h3>
<p>无论是哪种子命令类型,都是访问键的robj结构中的基础字段信息。要想获得这些信息,需要去字典中先去将该键结构查找出来:</p>
<pre><code class="c"> if ((o = objectCommandLookupOrReply(c,c->argv[2],shared.nullbulk))== NULL) {
return;
}</code></pre>
<p>这里通过调用objectCommandLookupOrReply()函数,实现了对键的查找:</p>
<pre><code class="c">robj *objectCommandLookupOrReply(client *c, robj *key, robj *reply) {
robj *o = objectCommandLookup(c,key);
if (!o) addReply(c, reply);
return o;
}</code></pre>
<pre><code class="c">robj *objectCommandLookup(client *c, robj *key) {
dictEntry *de;
// 去字典键空间中查找键
if ((de = dictFind(c->db->dict,key->ptr)) == NULL) return NULL;
return (robj*) dictGetVal(de); //根据键查找值,并强转为robj类型
}</code></pre>
<p>找到键之后,直接通过引用robj中的某个字段(如refcount)就能够得到当前键引用计数的信息并返回,命令执行结束:</p>
<pre><code class="c">addReplyLongLong(c,o->refcount);</code></pre>
<h2>扩展</h2>
<h3>redis中的数据结构编码</h3>
<p>先看下面一个例子:</p>
<pre><code class="c">redis> set foo 1000
OK
redis> object encoding foo
"int"
redis> append foo bar
(integer) 7
redis> get foo
"1000bar"
redis> object encoding foo
"raw"</code></pre>
<p>以上例子表明,redis会根据输入的数据自动选择时间复杂度以及空间复杂度最低的数据结构来存储数据。如上例所示,在foo键的值为1000的时候,redis会选择int结构存储;而在其尾部追加bar之后,其值成为了“1000bar",就无法再使用int类型来存储了,故redis只能退一步使用raw结构来存储。<br>在切换数据结构的临界点的选择上,redis根据每种底层数据结构的增删改查的时间复杂度及空间复杂度,做了大量的权衡取舍。redis一共有五种基础数据结构,这五种数据结构的编码方式有如下几种选择:</p>
<ul>
<li>字符串可以被编码为int、embstr或raw。int为字符串可以转化为整数时采用,embstr在字符串较短时使用,raw可以表示任意长度的字符串</li>
<li>列表可以被编码为 ziplist 或 linkedlist 。ziplist 是为节约大小较小的列表空间而作的特殊表示。</li>
<li>集合可以被编码为 intset 或者 hashtable 。intset 是只储存数字的小集合的特殊表示。</li>
<li>哈希表可以编码为 zipmap 或者 hashtable 。zipmap 是小哈希表的特殊表示。</li>
<li>有序集合可以被编码为 ziplist 或者 skiplist 格式。ziplist 用于表示小的有序集合,而 skiplist 则用于表示任意大小的有序集合。</li>
</ul>
<p>具体的每种数据结构的优缺点我们已经讨论过,不在此赘述。</p>
【Redis5源码学习】浅析redis命令之migrate篇
https://segmentfault.com/a/1190000020509705
2019-09-27T09:42:59+08:00
2019-09-27T09:42:59+08:00
NoSay
https://segmentfault.com/u/nosay
1
<p>baiyan</p>
<h2>命令使用</h2>
<p>命令含义:将 key 原子性地从当前实例传送到目标实例的指定数据库上,一旦传送成功, key 保证会出现在目标实例上,而当前实例上的 key 会被删除<br>命令格式:</p>
<pre><code class="c">MIGRATE host port key|"" destination-db timeout [COPY] [REPLACE] [KEYS key [key ...]]</code></pre>
<p>命令实战:将键key1、key2、key3批量迁移到本机6380端口的redis实例上,并存储到目标实例的第0号数据库,超时时间为1000毫秒。可选项COPY如果表示不移除源实例上的 key ,REPLACE选项表示替换目标实例上已存在的 key 。KEYS选项表示可以同时批量传送多个keys(但前面的key参数的位置必须设置为空)</p>
<pre><code class="c">127.0.0.1:6379> migrate 127.0.0.1 6380 "" 0 5000 KEYS key1 key2 key3
OK</code></pre>
<p>返回值:迁移成功时返回 OK ,否则返回错误</p>
<h2>源码分析</h2>
<p>migrate命令的执行过程可分为参数校验、连接建立、组装数据、发送数据、处理返回五个阶段。同样的,migrate命令的处理函数为migrateCommand():</p>
<h3>参数校验</h3>
<pre><code class="c">void migrateCommand(client *c) {
migrateCachedSocket *cs; // 连接另一个实例的socket
int copy = 0, replace = 0, j; // 是否开启copy及replace选项标记
char *password = NULL; // 密码
long timeout; // 超时时间
long dbid; // 数据库id
robj **ov = NULL; /* 要迁移的对象 */
robj **kv = NULL; /* 键名 */
robj **newargv = NULL;
rio cmd, payload; // 重要,存储目标实例执行的命令及DUMP的payload
int may_retry = 1;
int write_error = 0;
int argv_rewritten = 0;
/* 支持同时传输多个key. */
int first_key = 3; /* 第一个键参数的位置. */
int num_keys = 1; /* 默认只传送一个key. */
/* 校验其他选项,从COPY选项开始校验 */
for (j = 6; j < c->argc; j++) {
int moreargs = j < c->argc-1;
if (!strcasecmp(c->argv[j]->ptr,"copy")) { // 如果命令参数等于copy,开启copy选项
copy = 1;
} else if (!strcasecmp(c->argv[j]->ptr,"replace")) { // 如果命令参数等于replace,开启replace选项
replace = 1;
} else if (!strcasecmp(c->argv[j]->ptr,"auth")) { // 如果命令参数等于auth,开启auth选项
if (!moreargs) { // 参数数量超出规定数量,报错
addReply(c,shared.syntaxerr);
return;
}
j++;
password = c->argv[j]->ptr;
} else if (!strcasecmp(c->argv[j]->ptr,"keys")) { // 如果设置了keys参数,表明要同时传输多个keys值过去
if (sdslen(c->argv[3]->ptr) != 0) { // 如果开启了keys选项,前面key参数的位置必须设置为空
addReplyError(c,
"When using MIGRATE KEYS option, the key argument"
" must be set to the empty string");
return;
}
first_key = j+1;
num_keys = c->argc - j - 1;
break; /*现在first_key值指向keys的第一个值.,并将num_keys设置为keys的数量 */
} else {
addReply(c,shared.syntaxerr);
return;
}
}
/* 选择的db和超时时间数据校验,看是否是合法的数字格式 */
if (getLongFromObjectOrReply(c,c->argv[5],&timeout,NULL) != C_OK ||
getLongFromObjectOrReply(c,c->argv[4],&dbid,NULL) != C_OK)
{
return;
}
if (timeout <= 0) timeout = 1000;
/* 接下来会检查是否有可以迁移的键 */
ov = zrealloc(ov,sizeof(robj*)*num_keys);
kv = zrealloc(kv,sizeof(robj*)*num_keys);
int oi = 0;
/* 检查所有的键,判断输入的键中,是否存在合法的键来进行迁移 */
for (j = 0; j < num_keys; j++) {
if ((ov[oi] = lookupKeyRead(c->db,c->argv[first_key+j])) != NULL) { // 去键空间字典中查找该键,如果该键没有超时
kv[oi] = c->argv[first_key+j]; // 将未超时的键存到kv数组中,说明当前key是可以migrate的;否则如果超时就无法进行migrate
oi++;
}
}
num_keys = oi; // 更新当前可migrate的key总量
if (num_keys == 0) { // 如果没有可以迁移的key,那么给客户端返回“NOKEY"字符串
zfree(ov); zfree(kv);
addReplySds(c,sdsnew("+NOKEY\r\n"));
return;
}</code></pre>
<p>刚开始执行migrate命令的时候,由于migrate参数很多,需要对其逐个做校验。尤其是在启用keys参数同时迁移多个keys的时候,需要进行参数的动态判断。同时需要判断是否有合法的键来进行迁移。只有没有过期的键才能够迁移,否则不进行迁移,最大化节省系统资源。</p>
<h3>连接建立</h3>
<p>假如我们要从当前6379端口上的redis实例迁移到6380端口上的redis实例,我们必然要建立一个socket连接:</p>
<pre><code class="c">try_again:
write_error = 0;
/* 连接建立 */
cs = migrateGetSocket(c,c->argv[1],c->argv[2],timeout);
if (cs == NULL) {
zfree(ov); zfree(kv);
return;
}</code></pre>
<p>我们看到,在主流程中调用了migrateGetSocket()函数创建了一个socket,这里是一个带缓存的socket。我们暂时不跟进这个函数,后面我会以扩展的形式来跟进。</p>
<h3>组装数据</h3>
<p>基于这个socket,我们可以将数据以TCP协议中规定的字节流形式传输到目标实例上。这就需要一个序列化的过程了。6379实例需要将keys序列化,6380需要将数据反序列化。这就需要借助我们之前讲过的DUMP命令和RESTORE命令,分别来进行序列化和反序列化了。<br>redis并没有立即进行DUMP将key序列化,而是首先组装要在目标redis实例上所要执行的命令,比如AUTH/SELECT/RESTORE等命令。要想在目标实例上执行命令,那么必须同样基于之前建立的socket连接,以当前的redis实例作为客户端,往与目标redis实例建立的TCP连接中,写入按照redis协议封装的命令集合(如*2 \r\n SELECT \r\n $1 \r\n 1 \r\n)。redis使用了自己封装的I/O抽象层rio,它实现了一个I/O缓冲区。通过读取其缓冲区中的数据,就可以往我们在建立socket的时候生成的fd中写入数据啦。首先redis会建立一个rio缓冲区,并按照redis数据传输协议所要求的格式,组装要在目标实例上执行的redis命令:</p>
<pre><code class="c"> // 初始化一个rio缓冲区
rioInitWithBuffer(&cmd,sdsempty());
/* 组装AUTH命令 */
if (password) {
serverAssertWithInfo(c,NULL,rioWriteBulkCount(&cmd,'*',2)); // 按照redis协议写入一条命令开始的标识\*2。表示命令一共有2个参数
serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,"AUTH",4)); // 写入$4\r\n AUTH \r\n
serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,password, sdslen(password))); // 同上,按照协议格式写入密码
}
/* 在目标实例上选择数据库 */
int select = cs->last_dbid != dbid; /* 判断是否已经选择过数据库,如果选择过就不用再次执行SELECT命令 */
if (select) { // 如果没有选择过,需要执行SELECT命令选择数据库
serverAssertWithInfo(c,NULL,rioWriteBulkCount(&cmd,'*',2)); // 同上,写入开始表示\*2
serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,"SELECT",6)); // 同上,写入$6\r\n SELECT \r\n
serverAssertWithInfo(c,NULL,rioWriteBulkLongLong(&cmd,dbid)); // 写入$1\r\n 1 \r\n
}</code></pre>
<p>那么接下来需要进行DUMP的序列化操作了。由于序列化操作耗时较久,所以可能出现这种情况:在之前第一次检测是否超时的时候没有超时,但是由于这次序列化操作时间较久,执行期间,这个键超时了,那么redis简单粗暴地丢弃该超时键,直接放弃迁移这个键:</p>
<pre><code class="c"> int non_expired = 0; // 暂存新的未过期的键的数量
/* 如果在DUMP的过程中过期了,直接continue. */
for (j = 0; j < num_keys; j++) {
long long ttl = 0;
long long expireat = getExpire(c->db,kv[j]);
if (expireat != -1) {
ttl = expireat-mstime();
if (ttl < 0) {
continue;
}
if (ttl < 1) ttl = 1;
}
/* 经过上面的筛选之后,都是最新的、没有过期的键,这些键可以最终被迁移了. */
kv[non_expired++] = kv[j];</code></pre>
<p>然后,在目标实例上最终我们需要执行RESTORE命令,将之前经过DUMP序列化的字节流反序列化,过程和上面同理:</p>
<pre><code class="c"> serverAssertWithInfo(c,NULL,
rioWriteBulkCount(&cmd,'*',replace ? 5 : 4)); // 同上,写入开始表示\*5或4
if (server.cluster_enabled) // 如果集群模式开启
serverAssertWithInfo(c,NULL,
rioWriteBulkString(&cmd,"RESTORE-ASKING",14));
else
serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,"RESTORE",7)); // 同上,写入$7 RESTORE \r\n
serverAssertWithInfo(c,NULL,sdsEncodedObject(kv[j]));
serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,kv[j]->ptr,
sdslen(kv[j]->ptr))); // 将所有需要反序列化的key写入
serverAssertWithInfo(c,NULL,rioWriteBulkLongLong(&cmd,ttl)); // 写入过期时间</code></pre>
<p>接下来,我们就需要最终执行DUMP命令,将我们需要传输的所有键等数据序列化了,这里redis调用了createDumpPayload()来创建一个DUMP载荷,这就是最终序列化好的数据:</p>
<pre><code class="c"> createDumpPayload(&payload,ov[j],kv[j]); // 序列化数据
serverAssertWithInfo(c,NULL,
rioWriteBulkString(&cmd,payload.io.buffer.ptr,
sdslen(payload.io.buffer.ptr))); // 将序列化数据存到rio cmd中等待发送
sdsfree(payload.io.buffer.ptr);
if (replace)
serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,"REPLACE",7)); // replace选项开启</code></pre>
<h3>发送数据</h3>
<p>目前,我们需要发送的、按照redis协议组装好的所有序列化好的命令及数据都存放在了cmd这个rio结构体变量缓存中。我们当前的6379redis实例仿佛就是一个客户端,而要传输的目标实例6380就是一个服务端。接下来就需要读取缓存并且往直前建立好的socket中写入数据,将数据最终传输至目标实例:</p>
<pre><code class="c"> errno = 0;
{
sds buf = cmd.io.buffer.ptr;
size_t pos = 0, towrite;
int nwritten = 0;
while ((towrite = sdslen(buf)-pos) > 0) {
towrite = (towrite > (64*1024) ? (64*1024) : towrite); //按照64K的块大小来发送
nwritten = syncWrite(cs->fd,buf+pos,towrite,timeout); // 往socket fd中写入数据(数据来源于rio的缓存)
if (nwritten != (signed)towrite) {
write_error = 1;
goto socket_err;
}
pos += nwritten;
}
}</code></pre>
<h3>处理返回</h3>
<p>在目标redis上分别执行AUTH、SELECT、RESTORE命令,RESTORE命令会反序列化并将key写入目标实例。那么这几个命令执行完毕之后,我们如何知道它们是否执行成功呢?同样的,目标redis 6380实例在执行完命令之后,也会有相应的返回值,我们需要根据返回值来判断命令是否执行成功、是否将key成功迁移完成:</p>
<pre><code class="c"> char buf0[1024]; /* 存储AUTH命令返回值. */
char buf1[1024]; /* 存储SELECT命令返回值 */
char buf2[1024]; /* 存储RESTORE命令返回值. */
/* 从socket fd中读取AUTH命令返回值. */
if (password && syncReadLine(cs->fd, buf0, sizeof(buf0), timeout) <= 0)
goto socket_err;
/* 从socket fd中读取SELECT命令返回值. */
if (select && syncReadLine(cs->fd, buf1, sizeof(buf1), timeout) <= 0)
goto socket_err;
int error_from_target = 0;
int socket_error = 0;
int del_idx = 1;
/* 迁移完成之后需要将原有实例上的key删除 */
if (!copy) newargv = zmalloc(sizeof(robj*)*(num_keys+1));
for (j = 0; j < num_keys; j++) {
/* 从socket fd中读取RESTORE命令返回值 */
if (syncReadLine(cs->fd, buf2, sizeof(buf2), timeout) <= 0) {
socket_error = 1;
break;
}
if ((password && buf0[0] == '-') ||
(select && buf1[0] == '-') ||
buf2[0] == '-')
{
if (!error_from_target) {
...
} else {
if (!copy) { // 没有开启copy选项,需要删除原有实例的键
...
/* 删除原有实例上的键 */
dbDelete(c->db,kv[j]);
...
}
}
}
...
/* 如果发生socket错误,关闭连接 */
if (socket_error) migrateCloseSocket(c->argv[1],c->argv[2]);
...
sdsfree(cmd.io.buffer.ptr); // 释放cmd的rio缓冲区
zfree(ov); zfree(kv); zfree(newargv); // 释放存储key的robj结构体
return;</code></pre>
<p>综上,migrate命令就执行完成了。我们总结一下它的执行过程:</p>
<blockquote><ul>
<li>命令参数校验</li>
<li>按照redis协议组装目标实例上需要执行的命令</li>
<li>将要传输的key序列化</li>
<li>创建socket连接</li>
<li>通过socket连接将命令及数据传输至目标实例</li>
<li>目标实例执行命令并存储相应的key</li>
<li>处理目标实例的返回值</li>
<li>如果失败执行重试逻辑,如果成功则执行完毕</li>
</ul></blockquote>
<h2>扩展</h2>
<h3>缓存socket的实现</h3>
<p>在migrate命令执行过程中,调用了migrateGetSocket()创建socket。redis借助字典结构,实现了缓存socket,避免了多次创建socket所带来的开销:</p>
<pre><code class="c">migrateCachedSocket* migrateGetSocket(client *c, robj *host, robj *port, long timeout) {
int fd;
sds name = sdsempty();
migrateCachedSocket *cs;
/* 查找字典中是否有相应 ip:port 的缓存socket. */
name = sdscatlen(name,host->ptr,sdslen(host->ptr));
name = sdscatlen(name,":",1);
name = sdscatlen(name,port->ptr,sdslen(port->ptr));
// 查找字典
cs = dictFetchValue(server.migrate_cached_sockets,name);
if (cs) { // 如果找到了,说明之前创建过ip:port的socket
sdsfree(name);
cs->last_use_time = server.unixtime;
return cs; // 直接返回缓存socket
}
/* 如果在字典中没有找到,说明没有缓存,需要重新创建. */
/* 判断是否缓存的socket过多,最大为64个 */
if (dictSize(server.migrate_cached_sockets) == MIGRATE_SOCKET_CACHE_ITEMS) {
/* 如果字典中缓存的socket过多,需要随机删除一些 */
dictEntry *de = dictGetRandomKey(server.migrate_cached_sockets);
cs = dictGetVal(de);
close(cs->fd);
zfree(cs);
dictDelete(server.migrate_cached_sockets,dictGetKey(de));
}
/* 创建socket */
fd = anetTcpNonBlockConnect(server.neterr,c->argv[1]->ptr,
atoi(c->argv[2]->ptr));
if (fd == -1) {
sdsfree(name);
addReplyErrorFormat(c,"Can't connect to target node: %s",
server.neterr);
return NULL;
}
anetEnableTcpNoDelay(server.neterr,fd);
/* 检查是否在超时时间内创建完成 */
if ((aeWait(fd,AE_WRITABLE,timeout) & AE_WRITABLE) == 0) {
sdsfree(name);
addReplySds(c,
sdsnew("-IOERR error or timeout connecting to the client\r\n"));
close(fd);
return NULL;
}
/* 将新创建的socket加入缓存并返回给调用者 */
cs = zmalloc(sizeof(*cs));
cs->fd = fd;
cs->last_dbid = -1;
cs->last_use_time = server.unixtime;
// 将新创建的socket加入字典,缓存起来等待下次使用
dictAdd(server.migrate_cached_sockets,name,cs);
return cs;
}</code></pre>
【Redis5源码学习】浅析redis命令之move篇
https://segmentfault.com/a/1190000020499577
2019-09-26T10:36:05+08:00
2019-09-26T10:36:05+08:00
NoSay
https://segmentfault.com/u/nosay
0
<p>Grape</p>
<hr>
<h3>命令语法</h3>
<p>命令含义:将当前数据库的 key 移动到给定的数据库 db 当中。<br>命令注释:如果当前数据库(源数据库)和给定数据库(目标数据库)有相同名字的给定 key ,或者 key 不存在于当前数据库,那么 MOVE 没有任何效果。因此,也可以利用这一特性,将 MOVE 当作锁(locking)原语(primitive)。<br>命令格式:</p>
<pre><code> MOVE key db</code></pre>
<p>命令实战:</p>
<pre><code> # key 存在于当前数据库
redis> SELECT 0 # redis默认使用数据库 0,为了清晰起见,这里再显式指定一次。
OK
redis> SET song "secret base - Zone"
OK
redis> MOVE song 1 # 将 song 移动到数据库 1
(integer) 1
redis> EXISTS song # song 已经被移走
(integer) 0
redis> SELECT 1 # 使用数据库 1
OK
redis:1> EXISTS song # 证实 song 被移到了数据库 1 (注意命令提示符变成了"redis:1",表明正在使用数据库 1)
(integer) 1
# 当 key 不存在的时候
redis:1> EXISTS fake_key
(integer) 0
redis:1> MOVE fake_key 0 # 试图从数据库 1 移动一个不存在的 key 到数据库 0,失败
(integer) 0
redis:1> select 0 # 使用数据库0
OK
redis> EXISTS fake_key # 证实 fake_key 不存在
(integer) 0
# 当源数据库和目标数据库有相同的 key 时
redis> SELECT 0 # 使用数据库0
OK
redis> SET favorite_fruit "banana"
OK
redis> SELECT 1 # 使用数据库1
OK
redis:1> SET favorite_fruit "apple"
OK
redis:1> SELECT 0 # 使用数据库0,并试图将 favorite_fruit 移动到数据库 1
OK
redis> MOVE favorite_fruit 1 # 因为两个数据库有相同的 key,MOVE 失败
(integer) 0
redis> GET favorite_fruit # 数据库 0 的 favorite_fruit 没变
"banana"
redis> SELECT 1
OK
redis:1> GET favorite_fruit # 数据库 1 的 favorite_fruit 也是
"apple"
</code></pre>
<p>返回值<br>移动成功返回 1 ,失败则返回 0 。</p>
<h3>源码分析</h3>
<p>moveCommand函数,这个是move命令的入口函数:</p>
<pre><code>void moveCommand(client *c) {
robj *o;
redisDb *src, *dst;
int srcid;
long long dbid, expire;
//判断集群模式是否开启
if (server.cluster_enabled) {
addReplyError(c,"MOVE is not allowed in cluster mode");
return;
}
//从客户端信息中获取当前db信息
src = c->db;
srcid = c->db->id;
//c->argv是参数数组,argv[1]存储的是移动的key,argv[2]存储的是目标数据库
//getLongLongFromObject获取目标数据库id,强转为int类型
//判断条件因此为强转字符串为int,判断是否在dbid的范围内,切换数据库到目标数据库
if (getLongLongFromObject(c->argv[2],&dbid) == C_ERR ||
dbid < INT_MIN || dbid > INT_MAX ||
selectDb(c,dbid) == C_ERR)
{
addReply(c,shared.outofrangeerr);
return;
}
//获取目标数据库信息
dst = c->db;
//切换到原数据库
selectDb(c,srcid); /* Back to the source DB */
//判断目标数据库和原数据库是否一致
if (src == dst) {
addReply(c,shared.sameobjecterr);
return;
}
/* 检查这个key是否存在原数据库并其信息*/
o = lookupKeyWrite(c->db,c->argv[1]);
if (!o) {
addReply(c,shared.czero);
return;
}
//获取这个key的过期时间,没有则返回-1
expire = getExpire(c->db,c->argv[1]);
//查询这个key在目标数据库是否存在,不存在则返回错误信息
if (lookupKeyWrite(dst,c->argv[1]) != NULL) {
addReply(c,shared.czero);
return;
}
//把这个key以及这个对象加入到目标数据库
dbAdd(dst,c->argv[1],o);
if (expire != -1) setExpire(c,dst,c->argv[1],expire);
incrRefCount(o);
/*移动完成,删除原数据库 */
dbDelete(src,c->argv[1]);
server.dirty++;
addReply(c,shared.cone);
}</code></pre>
<p>dbAdd函数:在move命令中我们要向目标数据库中添加key,这个命令就是关键。</p>
<pre><code>void dbAdd(redisDb *db, robj *key, robj *val) {
//复制key
sds copy = sdsdup(key->ptr);
//把这个key插入到dict中,copy中是key,val是key对应的值
int retval = dictAdd(db->dict, copy, val);
serverAssertWithInfo(NULL,key,retval == DICT_OK);
if (val->type == OBJ_LIST ||
val->type == OBJ_ZSET)
signalKeyAsReady(db, key);
if (server.cluster_enabled) slotToKeyAdd(key);
}</code></pre>
<p>dictAdd函数:dbAdd中调用此函数,向dict增加entry。</p>
<pre><code>int dictAdd(dict *d, void *key, void *val)
{
//向dict插入一个key,返回entry
dictEntry *entry = dictAddRaw(d,key,NULL);
if (!entry) return DICT_ERR;
//设置这个entry的值
dictSetVal(d, entry, val);
return DICT_OK;
}
</code></pre>
<h3>GDB过程</h3>
<p>首先设置key为kkkk的值为2,然后执行move命令</p>
<pre><code>127.0.0.1:6380> set kkkk 2
OK
127.0.0.1:6380> select 0
OK
127.0.0.1:6380> move kkkk 1</code></pre>
<p>1.我们先打印客户端传入的参数,可以看到,argv的三个元素依次为 move,kkkk,1:</p>
<pre><code>(gdb) p (char*)c->argv[0].ptr
$10 = 0x7f175b820ae3 "move"
(gdb) p (char*)c->argv[1].ptr
$11 = 0x7f175b820afb "kkkk"
(gdb) p (char*)c->argv[2].ptr
$12 = 0x7f175b820acb "1"</code></pre>
<p>2.接着我们来到getLongLongFromObject这个函数,在上文我们说过了这个函数的作用是把数据强转为int型。在之前的文章中已经做过讲述,此处不再赘述。然后走到第二个判断条件判断dbid的范围,最后是切换到目标数据库,符合上文推理:</p>
<pre><code>(gdb) n
934 if (getLongLongFromObject(c->argv[2],&dbid) == C_ERR ||
(gdb) n
935 dbid < INT_MIN || dbid > INT_MAX ||
(gdb)
936 selectDb(c,dbid) == C_ERR)
(gdb) </code></pre>
<p>3.打印原数据库和目标数据库信息,我们可以看到原数据库id为0,目标数据库id为1</p>
<pre><code>(gdb) p *src
$14 = {dict = 0x7f175b80b360, expires = 0x7f175b80b3c0, blocking_keys = 0x7f175b80b420,
ready_keys = 0x7f175b80b480, watched_keys = 0x7f175b80b4e0, id = 0, avg_ttl = 0,
defrag_later = 0x7f175b80f330}
(gdb) p *dst
$15 = {dict = 0x7f175b80b540, expires = 0x7f175b80b5a0, blocking_keys = 0x7f175b80b600,
ready_keys = 0x7f175b80b660, watched_keys = 0x7f175b80b6c0, id = 1, avg_ttl = 0,
defrag_later = 0x7f175b80f360}</code></pre>
<p>4.在将当前数据库实例赋值给dst之后切回原数据库,并判断目标数据库和原数据库是否一致</p>
<pre><code>942 selectDb(c,srcid); /* Back to the source DB */
(gdb)
946 if (src == dst) {</code></pre>
<p>5.查看这个key是否存在,如果存在则返回这个对象,我们看一下返回的值,发现这个key的值的类型为0,值为1,然后获取他的expire</p>
<pre><code>(gdb) n
952 o = lookupKeyWrite(c->db,c->argv[1]);
(gdb)
953 if (!o) {
(gdb) p o
$2 = (robj *) 0x7f175b80ac80
(gdb) p *o
$3 = {type = 0, encoding = 1, lru = 9180225, refcount = 2147483647, ptr = 0x1}
(gdb) p $3.ptr
$4 = (void *) 0x1
(gdb) p (char*)$3.ptr
$5 = 0x1 <Address 0x1 out of bounds>
(gdb) p (char)$3.ptr
$6 = 1 '\001’
(gdb) n
957 expire = getExpire(c->db,c->argv[1]);</code></pre>
<p>6.接下来是判断这个key在目标数据库是否存在,在此因为目标数据库不存在,跳过if语句</p>
<pre><code>(gdb)
960 if (lookupKeyWrite(dst,c->argv[1]) != NULL) {
7.接下来是向目标数据库增加这个key,此处过程已经在源码分析中讲解, 故此出只贴出执行流程。
173 void dbAdd(redisDb *db, robj *key, robj *val) {
(gdb) n
174 sds copy = sdsdup(key->ptr);
(gdb)
173 void dbAdd(redisDb *db, robj *key, robj *val) {
(gdb)
174 sds copy = sdsdup(key->ptr);
(gdb)
175 int retval = dictAdd(db->dict, copy, val);
(gdb)
177 serverAssertWithInfo(NULL,key,retval == DICT_OK);
(gdb)
178 if (val->type == OBJ_LIST ||
(gdb)
181 if (server.cluster_enabled) slotToKeyAdd(key);
(gdb)
182 }</code></pre>
<p>8 然后就是判断是否存在expire。存在则设置,增加引用计数,到此目标数据库的key已经建立。与此同时,我们需要删除原数据库的key</p>
<pre><code>965 if (expire != -1) setExpire(c,dst,c->argv[1],expire);
(gdb)
966 incrRefCount(o);
(gdb) n
969 dbDelete(src,c->argv[1]);</code></pre>
<p>9.我们打印目标数据库的dict,发现kkkk这个刚开始设置的已经存在。而原来的key已经不在。</p>
<pre><code>(gdb) p *dst
$19 = {dict = 0x7f175b80b540, expires = 0x7f175b80b5a0, blocking_keys = 0x7f175b80b600, ready_keys = 0x7f175b80b660,
watched_keys = 0x7f175b80b6c0, id = 1, avg_ttl = 0, defrag_later = 0x7f175b80f360}
(gdb) p (char*)$19.dict.ht.table.key
$20 = 0x7f175b809931 “kkkk”
(gdb) p (char*)($21.dict.ht.table+0).key
$31 = 0x7f175b809921 "dddd"
(gdb) p (char*)($21.dict.ht.table+2).key
$32 = 0x7f175b8098f9 “key1"</code></pre>
<p>10.最后是响应返回客户端信息。</p>
<h3>拓展</h3>
<ol>
<li>
<p>Redis多数据库:根据我们讲解的move命令可以看出,redis是多命令的,在move执行时,我们会进行select<br> 0来设置数据库,redis默认是0号数据库,我们可以通缩select命令来选择数据库,一个redis实例最多可以提供16个数据库,下标分别是从0-15,。命令如下所示:</p>
<pre><code>select 1
#选择连接1号数据库 </code></pre>
</li>
<li>
<p>redis事务,在redis中可以使用multi exec discard 这三个命令来实现事务。在事务中,所有命令会被串行化顺序执行,事务执行期间redis不会为其他客户端提供任何服务,从而保证事务中的命令都被原子化执行</p>
<ul>
<li>multi 开启事务,这后边执行的命令都会被存到命令的队列当中</li>
<li>exec 相当于关系型数据库事务中的commit,提交事务</li>
<li>
<p>discard 相当于关系型数据库事务中的rollback,回滚操作 举个例子:</p>
<pre><code>127.0.0.1:6380> set user grape //设置一个值
OK
127.0.0.1:6380> get user
"grape"
127.0.0.1:6380> multi //开启事务
OK
127.0.0.1:6380> set user xiaoming
QUEUED
127.0.0.1:6380> discard //回滚
OK
127.0.0.1:6380> get user
"grape" // 值不变
127.0.0.1:6380>
127.0.0.1:6380> set grape 123 //设置一个值
OK
127.0.0.1:6380> multi //开启事务
OK
127.0.0.1:6380> incr grape
QUEUED
127.0.0.1:6380> exec //执行事务
1) (integer) 124
127.0.0.1:6380> get grape
"124" //值改变
127.0.0.1:6380></code></pre>
</li>
</ul>
</li>
<li>
<p>redis锁</p>
<ul>
<li>悲观锁: 数据被外界修改保守态度(悲观), 因此, 在整个数据处理过程中, 将数据处理锁定状态. 实现方式: 在对任意记录修改前, 先尝试为该记录加上排他锁, 如果加锁失败, 说明该记录正在被修改, 当前查询可能要等待或抛出异常, 如果成功加锁, 那么就可以对记录做修改</li>
<li>乐观锁: 乐观锁假设认为数据一般情况下不会造成冲突, 所以在数据进行提交更新的时候, 才会正式对数据的冲突进行检测, 如果发现冲突了, 则返回错误信息</li>
</ul>
</li>
</ol>
<p>此处我们以move命令来分析,假设redis数据库里现在有一个key a的值为10, 同一时刻有两个redis客户端(客户端1, 客户端2)对a进行了move操作, 那么结果会如何呢? 我们发现,后边那个执行失败了。但是他并没有报错,为什么呢?在两个客户端对同一个key进行操作时有一个先后顺序,第一个在进行move之后,第二个在执行时已经没有这个key了会失败。这也就是说我们可以利用这一特性,将 MOVE 当作锁(locking)原语(primitive)。在代码里我们可以来实现锁,move命令本身是没有锁实现的,我们在源码里也并没有看到。</p>
<pre><code>127.0.0.1:6380> keys *
1) "dddd"
2) "grape"
3) "key1"
4) "user"
127.0.0.1:6380> move grape 1
(integer) 1
(55.51s)
127.0.0.1:6380>
127.0.0.1:6380> keys *
1) "dddd"
2) "grape"
3) "key1"
4) "user"
127.0.0.1:6380> move grape 1
(integer) 0
(66.41s)</code></pre>
<p>对于redi锁的实现,建议阅读:<a href="https://link.segmentfault.com/?enc=2JsLleLwyoAsR23q90EqwQ%3D%3D.jE8tjYW0l5saNlQ%2BMBCAfoI8c7c6%2FCscSYhOFXTwdY5agQ9HRueIsIbcZxHNZbPDs9QiQrZZvAQuFizYjZNg7Njd82ie%2BpasBhM7pL6XWO4%3D" rel="nofollow">解锁 Redis 锁的正确姿势</a></p>
【Redis5源码学习】浅析redis命令之expire篇
https://segmentfault.com/a/1190000020459009
2019-09-22T16:05:03+08:00
2019-09-22T16:05:03+08:00
NoSay
https://segmentfault.com/u/nosay
0
<p>Grape</p>
<hr>
<h3>命令语法</h3>
<p>命令含义:为给定 key 设置生存时间,当 key 过期时(生存时间为 0 ),它会被自动删除。<br>命令格式:</p>
<pre><code>EXPIRE key seconds
</code></pre>
<p>命令实战:</p>
<pre><code>redis> EXPIRE cache_page 30000 # 更新过期时间
(integer) 1
</code></pre>
<p>返回值:</p>
<p>设置成功返回 1 。<br>当 key 不存在或者不能为 key 设置生存时间时(比如在低于 2.1.3 版本的 Redis 中你尝试更新 key 的生存时间),返回 0 。</p>
<h3>源码分析:</h3>
<p>expire对应的函数是expireCommand:</p>
<pre><code>/* EXPIRE key seconds */
void expireCommand(client *c) {
// 调用通用处理函数
expireGenericCommand(c,mstime(),UNIT_SECONDS);
}
/*这是expire、pexpire、expireat和pexpireat的通用命令实现。因为commad第二个参数可以是相对的,
也可以是绝对的,所以“base time”参数用来表示基本时间是什么(对于命令的at变量,或者相对过期的当前时间)。
单位是单位秒或者单位毫秒,仅用于argv[2]参数。基本时间总是以毫秒为单位指定的。*/
void expireGenericCommand(client *c, long long basetime, int unit) {
robj *key = c->argv[1], *param = c->argv[2];
long long when; /*when被设置为毫秒. */
/* 取出param中的整数值或者尝试将param中的数据尽可能转换成整数值存在when中,
成功返回OK失败则返回ERR*/
if (getLongLongFromObjectOrReply(c, param, &when, NULL) != C_OK)
return;
/*如果传入的过期时间是以秒为单位的,那么将它转换为毫秒*/
if (unit == UNIT_SECONDS) when *= 1000;
when += basetime;
/* 查询一下该键是否存在,不存在给客户端返回信息 */
if (lookupKeyWrite(c->db,key) == NULL) {
addReply(c,shared.czero);
return;
}
/*
* 在载入AOF数据时,或者服务器为附属节点时,
* 即使 EXPIRE 的 TTL 为负数,或者 EXPIREAT 提供的时间戳已经过期,
* 服务器也不会主动删除这个键,而是等待主节点发来显式的 DEL 命令。
*/
if (when <= mstime() && !server.loading && !server.masterhost) {
//进入这个函数的条件:when 提供的时间已经过期,未载入数据且服务器为主节点(注意主服务器的masterhost==NULL)
robj *aux;
/*删除该键,此处可以看del命令的解析,在del命令解析中有分析redis同步和异步删除的策略决定,此处不再赘述*/
int deleted = server.lazyfree_lazy_expire ? dbAsyncDelete(c->db,key) :
dbSyncDelete(c->db,key);
serverAssertWithInfo(c,key,deleted);
server.dirty++;
/* Replicate/AOF this as an explicit DEL or UNLINK. */
/* 传播 DEL 或者unlink命令到AOF或者从服务器 */
aux = server.lazyfree_lazy_expire ? shared.unlink : shared.del;
/*修改客户端的参数数组*/
rewriteClientCommandVector(c,2,aux,key);
/* 发送键更改通知 */
signalModifiedKey(c->db,key);
notifyKeyspaceEvent(NOTIFY_GENERIC,"del",key,c->db->id);
addReply(c, shared.cone);
return;
} else {
// 设置键的过期时间
// 如果服务器为附属节点,或者服务器正在载入,根据上个if中的条件来推断,至少when提供时间过期为附属节点就会设置
// 这点猜测在redis中从属节点不去主动做删除操作,除非主节点同步del命令
// 那么这个 when 有可能已经过期的
setExpire(c,c->db,key,when);
addReply(c,shared.cone);
signalModifiedKey(c->db,key);
notifyKeyspaceEvent(NOTIFY_GENERIC,"expire",key,c->db->id);
server.dirty++;
return;
}
}
//设置expire
void setExpire(client *c, redisDb *db, robj *key, long long when) {
dictEntry *kde, *de;
/* Reuse the sds from the main dict in the expire dict */
/*首先从dict中查找,这个过程就是从db的dict根据key来查找的过程,首先会判断是否有安全迭代器,如果没有就会 进行rehash,防止哈
*希表混乱。然后进行hash,获取索引值。通过索引我们可以遍历dict的链表来拿到值。这里会有dict上有hash值一样的情况,在这里
*redis是用while循环来比较,知道key相同返回这一项。
*/
kde = dictFind(db->dict,key->ptr);
serverAssertWithInfo(NULL,key,kde != NULL);
/* dictAddRaw()的变通,只是多了一个如果有key则返回的一个过程,如果存在则返回为null并返回当前项,反之将key进行hash索引
*index增加到dict中并返回dict。
*/
de = dictAddOrFind(db->expires,dictGetKey(kde));
/* 设置键的过期时间
* 这里是直接使用整数值来保存过期时间,不是用 INT 编码的 String 对象
*/
dictSetSignedIntegerVal(de,when);
int writable_slave = server.masterhost && server.repl_slave_ro == 0;
if (c && writable_slave && !(c->flags & CLIENT_MASTER))
rememberSlaveKeyWithExpire(db,key);
}</code></pre>
<p>我们gdb一个合法的例子来看一看他的流程:</p>
<ul>
<li>首先我们来进入gdb的server和cli<br><img src="/img/bVbx0qB?w=717&h=56" alt="clipboard.png" title="clipboard.png"><br><img src="/img/bVbx0qG?w=760&h=172" alt="clipboard.png" title="clipboard.png">
</li>
<li>我们可以看到进入到getLongLongFromObjectOrReply后做的动作就是 取出param中的整数值或者尝试将param中的数据尽可能转换成整数值存在when,打印when的值为我们设置的50000. <br><img src="/img/bVbx0sJ?w=911&h=150" alt="clipboard.png" title="clipboard.png"><br><img src="/img/bVbx0sp?w=851&h=330" alt="clipboard.png" title="clipboard.png">
</li>
<li>判断是否为秒,是则转化为毫秒,接着加上basetime,结果为,unix时间转化为2019-09-23 05:30:15,(大约13小时之后)符合预期,下一步 <br><img src="/img/bVbx0sN?w=320&h=53" alt="clipboard.png" title="clipboard.png">
</li>
<li>接下来是查找是否有key,有则进行下一步</li>
<li>因为我们是正常进行过期设置,所以应该走的是else语句进行设置,设置就是从redisDb中找到我们的key,然后更新expire时间,具体实现看上方代码分析:<br><img src="/img/bVbx0sU?w=723&h=76" alt="clipboard.png" title="clipboard.png">
</li>
<li>接下来就是发信号通知等给客户端。 <br><img src="/img/bVbx0s9?w=633&h=131" alt="clipboard.png" title="clipboard.png">
</li>
<li>我们继续c执行命令,从客户端收到执行成功 <br><img src="/img/bVbx0te?w=411&h=47" alt="clipboard.png" title="clipboard.png">
</li>
</ul>
<h4>比较</h4>
<p>redis此类命令有三种:EXPIREAT,PEXPIRE,PEXPIREAT<br>EXPIREAT 的作用和 EXPIRE 类似,都用于为 key 设置生存时间。<br>不同在于 EXPIREAT 命令接受的时间参数是 UNIX 时间戳(unix timestamp)。<br>而PEXPIRE,PEXPIREAT和EXPIRE和EXPIREAT的区别在于PEXPIRE,PEXPIREAT是以毫秒为单位,而后者用的是以秒为单位。</p>
<h3>业务用处</h3>
<p>在这里我简单的列举几点大家仅供参考:</p>
<ol>
<li>限时的优惠活动信息</li>
<li>网站数据缓存(对于一些需要定时更新的数据,例如:积分排行榜)</li>
<li>手机验证码</li>
<li>限制网站访客访问频率(例如:1分钟最多访问10次)</li>
</ol>
【Redis5源码学习】浅析redis命令之keys篇
https://segmentfault.com/a/1190000020458561
2019-09-22T14:11:28+08:00
2019-09-22T14:11:28+08:00
NoSay
https://segmentfault.com/u/nosay
1
<p>baiyan</p>
<h2>命令语法</h2>
<p>命令含义:查找并返回所有符合给定模式 pattern 的 key <br>命令格式:</p>
<pre><code class="c">KEYS pattern</code></pre>
<p>命令实战:</p>
<pre><code class="c">127.0.0.1:6379> keys *
1) "kkk"
2) "key1"</code></pre>
<p>返回值: 根据pattern匹配后的所有键的集合</p>
<h2>源码分析</h2>
<p>keys命令对应的处理函数是keysCommand():</p>
<pre><code class="c">void keysCommand(client *c) {
dictIterator *di;
dictEntry *de;
sds pattern = c->argv[1]->ptr; // 获取我们输入的pattern
int plen = sdslen(pattern), allkeys;
unsigned long numkeys = 0;
void *replylen = addDeferredMultiBulkLength(c);
di = dictGetSafeIterator(c->db->dict); // 初始化一个安全迭代器
allkeys = (pattern[0] == '*' && pattern[1] == '\0'); // 判断是否返回全部keys的集合
while((de = dictNext(di)) != NULL) { // 遍历整个键空间字典
sds key = dictGetKey(de); // 获取key值
robj *keyobj;
// 如果是返回全体键的集合,或者当前键与我们给定的pattern匹配,那么添加到返回列表
if (allkeys || stringmatchlen(pattern,plen,key,sdslen(key),0)) {
keyobj = createStringObject(key,sdslen(key));
if (!keyIsExpired(c->db,keyobj)) { // 筛选出没有过期的键
addReplyBulk(c,keyobj); // 添加到返回列表
numkeys++; // 返回键的数量++
}
decrRefCount(keyobj);
}
}
dictReleaseIterator(di); // 释放安全迭代器
setDeferredMultiBulkLength(c,replylen,numkeys); // 设置返回值的长度
}</code></pre>
<p>由于我们使用了keys *命令,需要返回所有键的集合。我们首先观察这段代码,它会使用一个安全迭代器,来遍历整个键空间字典。在遍历的同时,筛选出那些匹配我们pattern以及非过期的键,然后返回给客户端。由于其遍历的时间复杂度是和字典的大小成正比的,这样就会导致一个问题,当键非常多的时候,这个键空间字典可能会非常大,我们一口气使用keys把字典从上到下遍历一遍,会消耗非常多的时间。由于redis是单进程的应用,长时间执行keys命令会阻塞redis进程,造成redis服务对外的不可用状态。所以,很多公司都会禁止开发者使用keys命令,这可能导致redis服务长时间不可用。参考案例:<a href="https://link.segmentfault.com/?enc=QK%2BPtIcuJ4noL6SFN3rNDg%3D%3D.vyX7fBuE3vQjLafwD9cHa6R7fB6OKSr9OKpdOyYL5GWJGLhBaCO03lPVmWlIuTeKDvetiQsG0rFFJE3PYGLQQA%3D%3D" rel="nofollow">Redis的KEYS命令引起RDS数据库雪崩,RDS发生两次宕机,造成几百万的资金损失</a><br>那么可能大家会问了,我如果换上其他范围比较小的pattern去替换之前的*,不就可以避免一次性去遍历全部的键空间了吗?但是我们看上面的源码,由于它是在遍历到每一个key的时候,都会去判断当前key是否与传入的pattern所匹配,所以,并不是我们想象中的,只遍历我们传入的pattern的键空间元素集合,而需要遍历完整的键空间集合,在遍历的同时筛选出符合条件的key值。其实遍历的初始范围并没有缩小,其时间复杂度仍然为O(N),N为键空间字典的大小。</p>
<h2>扩展</h2>
<h3>安全迭代器与非安全迭代器</h3>
<p>在keys命令的遍历过程中,涉及到了安全迭代器的概念。与之相对的,还有非安全迭代器。那么,迭代器是如何工作的,安全与非安全的区别有是什么呢?我们首先来看迭代器的存储结构:</p>
<pre><code class="c">typedef struct dictIterator {
dict *d; // 指向所要遍历的字典
long index; // 哈希表中bucket的索引位置
int table, safe; // table索引(参考dict结构只能为0或1),以及迭代器是否安全的标记
dictEntry *entry, *nextEntry; // 存储当前entry和下一个entry
long long fingerprint; // 指纹,只在非安全迭代的情况下做校验
} dictIterator;</code></pre>
<p>为了让大家能够看明白index和table字段的作用,我们又要贴上dict的结构了:<br><img src="/img/remote/1460000019967692?w=2628&h=2601" alt="" title=""><br>其中的table字段只能为0或1。0是正常状态下会使用的哈希表,1是rehash过程中需要用到的过渡哈希表。而index就是每个哈希表中01234567这个索引了。迭代器中的safe字段就是用来区分迭代器类型是安全还是非安全的。所谓安全就是指在遍历的过程中,对字典的操作不会影响遍历的结果;而非安全的迭代器可能会由于rehash等操作,导致其遍历结果会有所误差,但是它的性能更好。</p>
<h3>怎么做才会安全</h3>
<p>在redis中,安全迭代器通过直接禁止rehash操作,来让迭代器变得安全。那么,为什么禁止rehash操作就安全了呢?我们都知道,rehash操作是渐进式的。每执行一个命令,才会做一个rehash。rehash操作会同时使用字典的两个table。我们考虑这样一种情况:假设迭代器当前正在遍历第一个table,此时进度已经到了索引index为3的位置,而某一个元素还没有进行rehash,我们已经遍历过了这个元素。那么rehash和遍历同时进行,假设rehash完毕,这个元素到了第二个table的index为33的位置上。而目前迭代器的进度仅仅到了第二个table的index为13的位置,还没有遍历到index为33的位置上。那么如果继续遍历,由于这个元素已经在第一个table中遍历过一次,那么现在会被不可避免地遍历第二次。也就是说,由于rehash导致同一个元素被遍历了两次,这就是为什么rehash会影响迭代器的遍历结果。为了解决以上问题,redis通过在安全迭代器运行期间禁止rehash操作,来保证迭代器是安全的。那么究竟redis是如何判断当前是否有安全迭代器在运行,进而来禁止rehash操作的呢?我们首先回顾一下dict的结构:</p>
<pre><code class="c">typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2]; // 两个table的指针
long rehashidx; // rehash标志,如果是-1则没有在rehash
unsigned long iterators; // 当前运行的安全迭代器的数量
} dict;</code></pre>
<p>我们看到,字典结构中的iterators字段用来描述安全迭代器的数量。如果有一个安全迭代器在运行,那么这个字段就会++。这样,在迭代的过程中,字典会变得相对稳定,避免了一个元素被遍历多次的问题。如果当前有一个安全迭代器在运行,iterator字段必然不会为0。当这个字段为0的时候,才能进行rehash操作:</p>
<pre><code class="c">static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d,1);
}</code></pre>
<p>其实,除了安全迭代器这种简单粗暴地禁止rehash操作之外,redis还提供了SCAN这种更高级的遍历方式。它通过一种更为复杂以及巧妙的算法,来保证了即使在rehash过程中,也能保证遍历的结果不重不漏。这就保证了rehash操作以及遍历操作能够并发执行,同时也避免了keys在遍历当键空间很大的时候超高的时间复杂度会导致redis阻塞的问题,大大提高了效率。</p>
<h3>安全迭代器一定安全吗</h3>
<p>那么继续思考,仅仅不进行rehash操作就能够保证迭代器是安全的了吗?由于redis是单进程的应用,所以我们在执行keys命令的时候,会阻塞其他所有命令的执行。所以,在迭代器进行遍历的时候,我们外部是无法通过执行命令,来对键空间字典进行增删改操作的。但是redis内部的一些时间事件会有修改字典的可能性。比如:每隔一段时间扫描某个键是否已经过期,过期了则把它从键空间中删除。这一点,我认为即使是安全迭代器,也是无法避免可能在遍历期间对字典进行操作的的。比如在遍历期间,redis某个时间事件把还没有遍历到的元素删除了,那么后续迭代器再去继续遍历,就无法遍历到这个元素了。那么如何解决这个问题呢?除非redis内部根本不在遍历期间触发事件并执行处理函数,否则这些操作所导致遍历结果的细微误差,redis是无法避免的。</p>
<h3>迭代器遍历的过程</h3>
<p>抛开上面这些细节,我们接下来看一下具体的遍历逻辑。首先我们需要初始化安全迭代器:</p>
<pre><code class="c">dictIterator *dictGetIterator(dict *d)
{
dictIterator *iter = zmalloc(sizeof(*iter));
iter->d = d;
iter->table = 0;
iter->index = -1;
iter->safe = 0;
iter->entry = NULL;
iter->nextEntry = NULL;
return iter;
}</code></pre>
<p>如果是安全迭代器,除了需要初始化以上字段之外,还需要将safe字段设置为1:</p>
<pre><code class="c">dictIterator *dictGetSafeIterator(dict *d) {
dictIterator *i = dictGetIterator(d); // 调用上面的方法初始化其余字段
i->safe = 1; // 初始化safe字段
return i;
}</code></pre>
<p>回到开始的keys命令,它调用的就是dictGetSafeIterator()函数来初始化一个安全迭代器。接下来,keys命令会循环调用dictNext()方法对所有键空间字典中的元素做遍历:</p>
<pre><code class="c">dictEntry *dictNext(dictIterator *iter)
{
while (1) {
// 进入这个if的两种情况:
// 1. 这是迭代器第一次运行
// 2. 当前索引链表中的节点已经迭代完
if (iter->entry == NULL) {
// 指向被遍历的哈希表,默认为第一个哈希表
dictht *ht = &iter->d->ht[iter->table];
// 仅仅第一次遍历时执行(index初始化值为-1)
if (iter->index == -1 && iter->table == 0) {
// 如果是安全迭代器(safe == 1),那么更新iterators计数器
if (iter->safe)
iter->d->iterators++;
// 如果是不安全迭代器,那么计算指纹
else
iter->fingerprint = dictFingerprint(iter->d);
}
// 更新索引,继续遍历下一个bucket上的元素
iter->index++;
// 如果迭代器的当前索引大于当前被迭代的哈希表的大小
// 那么说明这个哈希表已经迭代完毕
if (iter->index >= (signed) ht->size) {
// 如果正在进行rehash操作,说明第二个哈希表也正在使用中
// 那么继续对第二个哈希表进行遍历
if (dictIsRehashing(iter->d) && iter->table == 0) {
iter->table++;
iter->index = 0;
ht = &iter->d->ht[1];
// 如果没有rehash,则不需要遍历第二个哈希表
} else {
break;
}
}
// 如果进行到这里,说明这个哈希表并未遍历完成
// 更新节点指针,指向下个索引链表的表头节点(index已经++过了)
iter->entry = ht->table[iter->index];
} else {
// 执行到这里,说明正在遍历某个bucket上的链表(为了解决冲突会在一个bucket后面挂接多个dictEntry,组成一个链表)
iter->entry = iter->nextEntry;
}
// 如果当前节点不为空,那么记录下该节点的下个节点的指针(即next)
// 因为安全迭代器在运行的时候,可能会将迭代器返回的当前节点删除,这样就找不到next指针了
if (iter->entry) {
iter->nextEntry = iter->entry->next;
return iter->entry;
}
}
// 遍历完成
return NULL;
}</code></pre>
<p>具体的遍历过程已以注释的形式给出了。代码中又有一个新的概念:fingerprint指纹,下面我们讨论一下指纹的概念。</p>
<h3>指纹的作用</h3>
<p>在dictNext()遍历函数中,有这样一段代码:</p>
<pre><code class="c">if (iter->safe) { // 如果是安全迭代器(safe == 1),那么更新iterators计数器
iter->d->iterators++;
} else { // 如果是不安全迭代器,那么计算指纹
iter->fingerprint = dictFingerprint(iter->d);
}</code></pre>
<p>我们看到,当迭代器是非安全的情况下,它会验证一个指纹。顾名思义,非安全的意思就是在遍历的时候可以进行rehash操作,这样就会导致遍历结果可能出现重复等问题。为了正确地识别这种问题,redis采用了指纹机制,即在遍历之前采集一次指纹,在遍历完成之后再次采集指纹。如果两次指纹比对一致,就说明遍历结果没有因为rehash操作的影响而改变。那么具体如何去验证指纹呢?验证指纹的本质其实就是判断字典是否因为rehash操作发生了变化:</p>
<pre><code class="c">long long dictFingerprint(dict *d) {
long long integers[6], hash = 0;
int j;
integers[0] = (long) d->ht[0].table;
integers[1] = d->ht[0].size;
integers[2] = d->ht[0].used;
integers[3] = (long) d->ht[1].table;
integers[4] = d->ht[1].size;
integers[5] = d->ht[1].used;
for (j = 0; j < 6; j++) {
hash += integers[j];
/* For the hashing step we use Tomas Wang's 64 bit integer hash. */
hash = (~hash) + (hash << 21); // hash = (hash << 21) - hash - 1;
hash = hash ^ (hash >> 24);
hash = (hash + (hash << 3)) + (hash << 8); // hash * 265
hash = hash ^ (hash >> 14);
hash = (hash + (hash << 2)) + (hash << 4); // hash * 21
hash = hash ^ (hash >> 28);
hash = hash + (hash << 31);
}
return hash;
}</code></pre>
<p>我们看到,指纹验证就是基于字典的table、size、used等字段来进行的。如果这几个字段发生了改变,就代表rehash操作正在执行或已执行完毕。一旦有rehash操作在执行,那么有可能就会导致遍历结果受到影响。所以,非安全迭代器的指纹验证能够很好地发现rehash操作对遍历结果产生影响的可能性。</p>
【Redis5源码学习】浅析redis命令之exists篇
https://segmentfault.com/a/1190000020451197
2019-09-21T09:38:30+08:00
2019-09-21T09:38:30+08:00
NoSay
https://segmentfault.com/u/nosay
0
<p>baiyan</p>
<h2>命令语法</h2>
<p>命令含义:判断键是否存在。如果过期则不存在,不过期则存在<br>命令格式:</p>
<pre><code class="c">EXISTS key1 key2 ... keyN</code></pre>
<p>命令实战:</p>
<pre><code class="c">127.0.0.1:6379> set key1 value1
OK
127.0.0.1:6379> exists key1
(integer) 1</code></pre>
<p>返回值: 存在键的数量</p>
<h2>源码分析</h2>
<p>exists命令对应的处理函数是existsCommand():</p>
<pre><code class="c">void existsCommand(client *c) {
long long count = 0;
int j;
for (j = 1; j < c->argc; j++) {
// 去键空间字典寻找给定key是否存在,存在则count++
if (lookupKeyRead(c->db,c->argv[j])) count++;
}
// 返回找到key的数量
addReplyLongLong(c,count);
}</code></pre>
<p>同样地,我们使用gdb -p来观察exists命令的执行过程,gdb的过程这里不再赘述。我们在existsCommand处打一个断点,然后在客户端中执行命令:</p>
<pre><code class="c">127.0.0.1:6379> exists key1</code></pre>
<p>观察服务端,已经执行到我们的断点处了:</p>
<pre><code class="c">Breakpoint 1, existsCommand (c=0x7fb459ca3540) at db.c:496
496 void existsCommand(client *c) {
(gdb) n
500 for (j = 1; j < c->argc; j++) {
(gdb)
497 long long count = 0;
(gdb)
501 if (lookupKeyRead(c->db,c->argv[j])) count++;
(gdb) s</code></pre>
<p>我们看到,在入口函数existsCommand()中,遍历了所有的命令参数,即我们传入的key1,这里会调用lookupKeyRead()函数,去键空间中查找键是否过期,如果过期,则返回0,不存在。反之键存在,返回1。跟进这个函数调用:</p>
<pre><code class="c">lookupKeyRead (key=0x7fb462229ad0, db=0x7fb46221a800) at db.c:144
144 return lookupKeyReadWithFlags(db,key,LOOKUP_NONE);
(gdb) s
lookupKeyReadWithFlags (db=0x7fb46221a800, key=0x7fb462229ad0, flags=flags@entry=0) at db.c:100
100 robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {
(gdb) n
103 if (expireIfNeeded(db,key) == 1) {
(gdb) n
133 val = lookupKey(db,key,flags);
(gdb) n
</code></pre>
<p>这个函数直接调用了lookupKeyReadWithFlags(),然后在lookupKeyReadWithFlags函数内部,调用了我们熟悉的expireIfNeeded(),显然在我们调试过程中,并没有进这个if,因为我们的键并没有过期,所以肯定返回0。由于键并没有过期,那么在这里应该直接返回了。但是由于它是一个通用查找函数,还需要返回查找后的值,所以继续调用lookupKey()函数,去键空间中查找key1对应的值value1。继续往下执行:</p>
<pre><code class="c">133 val = lookupKey(db,key,flags);
(gdb) n
134 if (val == NULL)
(gdb) p val
$1 = (robj *) 0x7fb46220e100
(gdb) p *val
$2 = {type = 0, encoding = 8, lru = 8745377, refcount = 1, ptr = 0x7fb46220e113}
137 server.stat_keyspace_hits++;
(gdb)
139 }
(gdb)
existsCommand (c=0x7fb459ca3540) at db.c:501
501 if (lookupKeyRead(c->db,c->argv[j])) count++;</code></pre>
<p>我们看到,在查找完成之后,会判断是否为NULL,显然这里不会为NULL。打印val的值,是一个redisObj结构,这里ptr指向的sds就是我们的value1了。然后,redis会统计一个数据,是在键空间中查找到value的次数,这里由于我们找到了,将其++,然后函数会将查找到的value1返回,最外层判断不为NULL,count++,命令执行结束。</p>
<h2>扩展</h2>
<h3>字典的查找</h3>
<p>在exists命令执行过程中,一个核心的函数调用就是lookupKey()。这个函数查找一个键对应的value值。如果存在,返回该value值,否则返回NULL:</p>
<pre><code class="c">robj *lookupKey(redisDb *db, robj *key, int flags) {
dictEntry *de = dictFind(db->dict,key->ptr); // 找到当前key-value对所在的dictEntry
if (de) {
robj *val = dictGetVal(de); // 是一个宏,直接返回dictEntry中的val字段
...
return val;
} else {
return NULL;
}
}</code></pre>
<p>这个函数会首先调用dictFind(),直接返回这个key所在dict中的dictEntry。我们跟进这个函数:</p>
<pre><code class="c">static dictEntry *dictFind(dict *ht, const void *key) {
dictEntry *he; // dictEntry结构的指针
unsigned int h; // 存储哈希值
if (ht->size == 0) return NULL; // 字典为空,直接返回不存在
h = dictHashKey(ht, key) & ht->sizemask; // 计算哈希值
he = ht->table[h]; // 访问字典对应哈希值位置上的元素,它是一个指针,指向dictEntry结构
// 遍历链地址法解决冲突的链表
while(he) {
if (dictCompareHashKeys(ht, key, he->key)) // 挨个比较当前dictEntry的key值是否等于我们要找的key值
return he; // 找到了,直接返回
he = he->next; // 没找到,继续往后遍历链表
}
return NULL; // 遍历到链表尾部还没有找到,说明没有该元素
}</code></pre>
<p>在说明该函数查找流程之前,我们回顾一下字典的存储结构:<br><img src="/img/remote/1460000019967692?w=2628&h=2601" alt="" title=""><br>代码中的he = ht->table[h]访问的就是我们**table这个指针。它是一个二级指针,可以看成一个一维数组,每个数组中的元素都是一个dictEntry的指针。我们访问到了第h个(计算后的哈希值)元素的bucket位置上,它也是一个dictEntry指针,指向真正存储key-value对的结构。由于需要解决哈希冲突问题,所以一个bucket后面会以链地址法,通过next指针字段,挂接多个dictEntry,这就是上面代码为什么需要遍历的原因。每遍历一个dictEntry,我们都要比较一下当前dictEntry上的key值是否与我们要比较的key值是否相等,如果相等就找到了我们要找的key-value对。反之如果遍历到链表尾部都没找到,那就说明没有这个key-value对了:<br><img src="/img/remote/1460000020451222?w=723&h=295" alt="" title=""><br>我们回顾一下dictEntry的结构:</p>
<pre><code class="c">typedef struct dictEntry {
void *key; // 指向存储key值的结构
union {
void *val; // 指向存储value值的结构
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next; // 链地址法解决冲突的指针,指向下一个dictEntry结构
} dictEntry;</code></pre>
<p>在找完之后,该函数会返回一个dictEntry结构的指针,我们调用dictGetVal宏,就能访问到dictEntry中的value值啦:</p>
<pre><code class="c">#define dictGetVal(he) ((he)->v.val)</code></pre>
<p>我们看到,这个宏访问了dictEntry的val字段,成功拿到了当前key对应的value值。</p>
<h2>参考资料</h2>
<p><a href="https://segmentfault.com/a/1190000019967687">【Redis5源码学习】2019-04-19 字典dict</a></p>
【Redis5源码学习】浅析redis命令之dump篇
https://segmentfault.com/a/1190000020441163
2019-09-20T10:26:52+08:00
2019-09-20T10:26:52+08:00
NoSay
https://segmentfault.com/u/nosay
1
<p>Grape</p>
<hr>
<h3>官方文档</h3>
<p>DUMP key<br>序列化给定 key ,并返回被序列化的值,使用 RESTORE 命令可以将这个值反序列化为 Redis 键。<br>序列化生成的值有以下几个特点:</p>
<ul>
<li>它带有 64 位的校验和,用于检测错误, RESTORE 在进行反序列化之前会先检查校验和。</li>
<li>值的编码格式和 RDB 文件保持一致。</li>
<li>RDB 版本会被编码在序列化值当中,如果因为 Redis 的版本不同造成 RDB 格式不兼容,那么 Redis 会拒绝对这个值进行反序列化操作。</li>
</ul>
<p>序列化的值不包括任何生存时间信息。<br>可用版本:>= 2.6.0<br>时间复杂度:<br>查找给定键的复杂度为 O(1) ,对键进行序列化的复杂度为 O(N*M) ,其中 N 是构成 key 的 Redis 对象的数量,而 M 则是这些对象的平均大小。<br>如果序列化的对象是比较小的字符串,那么复杂度为 O(1) 。<br>返回值:如果 key 不存在,那么返回 nil。否则,返回序列化之后的值。</p>
<pre><code>redis> SET greeting "hello, dumping world!"
OK
redis> DUMP greeting
"\x00\x15hello, dumping world!\x06\x00E\xa0Z\x82\xd8r\xc1\xde"
redis> DUMP not-exists-key
(nil)
</code></pre>
<p>我们可以看到,dump命令就是为了序列化给定的key。那么什么是序列化呢?我们看下序列化的定义:序列化(Serialization)将对象的状态信息转换为可以存储或传输的形式的过程。在序列化期间,对象将其当前状态写入到临时或持久性存储区。以后,可以通过从存储区中读取或反序列化对象的状态,重新创建该对象。目的是为了对象可以跨平台存储,和进行网络传输。<br>命令的使用很简单,就是dump key,而dump通常和RESTORE配合使用,序列化与反序列化。如果想更多的了解序列化相关的知识,推荐阅读:<a href="https://link.segmentfault.com/?enc=5YBbBLxE%2F6EizswYVRLaIg%3D%3D.hnnLfD%2BPWHDgY%2FT8O9PxG12wmoy7%2FbGaRc%2BC8A9oA5U%2BZjPfgZUF1H8%2BrvBbYJZd" rel="nofollow">序列化理解起来很简单</a>。</p>
<h3>源码分析</h3>
<p>首先我们贴上源码:</p>
<pre><code>/* DUMP keyname
* DUMP is actually not used by Redis Cluster but it is the obvious
* complement of RESTORE and can be useful for different applications. */
void dumpCommand(client *c) {
robj *o, *dumpobj;
rio payload;
/* 检查key是否存在 */
if ((o = lookupKeyRead(c->db,c->argv[1])) == NULL) {
addReply(c,shared.nullbulk);
return;
}
/*创建dump负载. */
createDumpPayload(&payload,o);
/* 传输给客户端 */
dumpobj = createObject(OBJ_STRING,payload.io.buffer.ptr);
addReplyBulk(c,dumpobj);
decrRefCount(dumpobj);
return;
}</code></pre>
<p>接下来我们慢慢分析。<br>Dump命令的核心是创建dump负载,所以我们的核心就在于这个过程,首先我们简单描述下大致流程:首先检查我们要序列化的key是否存在,若存在则创建dump负载,然后传输给客户端。</p>
<ol>
<li>检查key是否存在,主要通过lookupkeyRead来实现,大致就是通过key去redis DB中检查是否存在,若存在则向下执行,否则向客户端发送信息。通常我们在dump一个不存在的key的时候我们会得到的结果是一个nil。</li>
<li>
<p>创建dump负载,这块是dump命令的核心,具体的实现代码如下:</p>
<pre><code> void createDumpPayload(rio *payload, robj *o) {
unsigned char buf[2];
uint64_t crc;
/* Serialize the object in a RDB-like format. It consist of an object type
byte followed by the serialized object. This is understood by RESTORE.
以类似于rdb的格式序列化对象。它由对象类型字节和序列化对象组成。
*/
rioInitWithBuffer(payload,sdsempty());
/*将给定的对象的类型写到rdb中,失败报错*/
serverAssert(rdbSaveObjectType(payload,o));
/*将给定的对象写到rdb中,失败报错*/
serverAssert(rdbSaveObject(payload,o));
/* Write the footer, this is how it looks like:
----------------+---------------------+---------------+
... RDB payload | 2 bytes RDB version | 8 bytes CRC64 |
----------------+---------------------+---------------+
RDB version and CRC are both in little endian.
/* RDB版本,被分为两个字节保存,表示为0-65535 */
buf[0] = RDB_VERSION & 0xff;
buf[1] = (RDB_VERSION >> 8) & 0xff;
/*sdscatlen函数是扩展长度,并追加字符串
payload->io.buffer.ptr = sdscatlen(payload->io.buffer.ptr,buf,2);
/* 计算CRC校验码,共8个字节 */
crc = crc64(0,(unsigned char*)payload->io.buffer.ptr,
sdslen(payload->io.buffer.ptr));
/*对于目标机是大端字节序的机器,进行字节码的转换,
提供了16byte、32byte、64byte字节的转换。
在intset\ziplist\zipmap三种数据结构中使用,
使得不同字节序机器生成的rdb文件格式都是统一的(小端字节序),便于兼容。*/
memrev64ifbe(&crc);
payload->io.buffer.ptr = sdscatlen(payload->io.buffer.ptr,&crc,8);</code></pre>
<p>} <br> 最后生成的序列化对象格式就是下边这个格式:<br> +-------------+---------------------+---------------+<br> | RDB payload | 2 bytes RDB version | 8 bytes CRC64 | <br> +-------------+---------------------+———————+</p>
</li>
<li>发送信息到客户端,最后一块我们可以认为是消息传递,把序列好的信息传递给客户端。此块在后边我会写一个专题来介绍。</li>
</ol>
<p><strong>如果读者有兴趣,一定亲手gdb试一试!!</strong></p>
<h3>拓展阅读</h3>
<ul>
<li><a href="https://link.segmentfault.com/?enc=J7hEEgCACcYyuZDypZqNXg%3D%3D.RUb1HjeYlYD4WcOn3bS4msTerkrtd5UFufaDm9kyDr5%2B%2F0pFxjsxXCbck18bh%2Fyb" rel="nofollow">rdb持久化</a></li>
<li><a href="https://link.segmentfault.com/?enc=3LiFbZ3KsKTqjhTeojuSlA%3D%3D.VE3fpI9XjtyJw37qf3HaemEYHPk8NczLHjZ9xf6LmWPBsWYnbyjJUcviWaCU%2BSzrtxAgTCcSYRtnBLXoLoxMrA%3D%3D" rel="nofollow">redis转化大小端分析</a></li>
</ul>
【Redis5源码学习】浅析redis命令之del篇
https://segmentfault.com/a/1190000020438191
2019-09-19T22:11:27+08:00
2019-09-19T22:11:27+08:00
NoSay
https://segmentfault.com/u/nosay
1
<p>baiyan</p>
<h2>命令语法</h2>
<p>命令含义:删除一个键所对应的值<br>命令格式:</p>
<pre><code class="c">DEL [key1 key2 …]</code></pre>
<p>命令实战:</p>
<pre><code class="c">127.0.0.1:6379> del key1
(integer) 1</code></pre>
<p>返回值:被删除 key 的数量</p>
<h2>源码分析</h2>
<p>首先我们开启一个redis客户端,使用gdb -p redis-server的端口。由于del命令对应的处理函数是delCommand(),所以在delCommand处打一个断点,然后在redis客户端中执行以下几个命令:</p>
<pre><code class="c">127.0.0.1:6379> set key1 value1 EX 100
OK
127.0.0.1:6379> get key1
"value1"
127.0.0.1:6379> del key1</code></pre>
<p>首先设置一个键值对为key1-value1、过期时间为100秒的键值对,然后在100秒之内对其进行删除,执行del key1,,删除这个还没有过期的键。我们看看redis服务端是如何执行的:</p>
<pre><code class="c">Breakpoint 1, delCommand (c=0x7fb46230dec0) at db.c:487
487 delGenericCommand(c,0);
(gdb) s
delGenericCommand (c=0x7fb46230dec0, lazy=0) at db.c:468
468 void delGenericCommand(client *c, int lazy) {
(gdb) n
471 for (j = 1; j < c->argc; j++) {
(gdb) p c->argc
$1 = 2
(gdb) p c->argv[0]
$2 = (robj *) 0x7fb462229800
(gdb) p *$2
$3 = {type = 0, encoding = 8, lru = 8575647, refcount = 1, ptr = 0x7fb462229813}</code></pre>
<p>我们看到,delCommand()直接调用了delGenericCommand(c,0),貌似是一个封装好的通用删除函数,我们s进去,看下内部的执行流程。</p>
<pre><code class="c">void delGenericCommand(client *c, int lazy) {
int numdel = 0, j;
for (j = 1; j < c->argc; j++) {
expireIfNeeded(c->db,c->argv[j]); //看这个键是否过期,过期需要额外做一些其他操作
int deleted = lazy ? dbAsyncDelete(c->db,c->argv[j]) : dbSyncDelete(c->db,c->argv[j]); // 异步或同步删除
if (deleted) {
signalModifiedKey(c->db,c->argv[j]); // 事务相关
notifyKeyspaceEvent(NOTIFY_GENERIC, "del",c->argv[j],c->db->id); //发布/订阅相关通知
server.dirty++; //AOF持久化相关
numdel++; //删除键的数量++,返回删除的数量就是这个值
}
}
addReplyLongLong(c,numdel);
}</code></pre>
<p>首先直接进行了一个for循环,循环次数为c->argc,我们打印出来是2。看这个变量名我们可以猜测,可能是del和key1这两个命令参数。我们打印c->argv[0],发现它是一个redis-object,然后我们看它的encoding所对应的类型为8,即embstr类型,一种优化版的字符串存储形式。为了查看它的内容,将该指针强转为char *:</p>
<pre><code class="c">(gdb) p *((char *)($3.ptr))
$4 = 100 'd'
(gdb) p *((char *)($3.ptr+1))
$6 = 101 'e'
(gdb) p *((char *)($3.ptr+2))
$7 = 108 'l'
(gdb) p *((char *)($3.ptr+3))</code></pre>
<p>然后打印下一个参数:</p>
<pre><code class="c">(gdb) p *c->argv[1]
$10 = {type = 0, encoding = 8, lru = 8575647, refcount = 1, ptr = 0x7fb4622297e3}
(gdb) p *(char *)($10.ptr)
$11 = 107 'k'
(gdb) p *((char *)($10.ptr+1))
$12 = 101 'e'
(gdb) p *((char *)($10.ptr+2))
$13 = 121 'y'
(gdb) p *((char *)($10.ptr+3))
$14 = 49 '1'</code></pre>
<p>我们看到,c->argv数组中存储的就是del和key1的值。但是下面的for循环是从1开始,没有包含命令的名称,所以只对key1这个参数进行了一次循环。随后,调用expireIfNeeded()函数:</p>
<pre><code class="c">472 expireIfNeeded(c->db,c->argv[j]);
(gdb) s
expireIfNeeded (db=0x7fb46221a800, key=0x7fb4622297d0) at db.c:1167
1167 int expireIfNeeded(redisDb *db, robj *key) {
(gdb) n
1168 if (!keyIsExpired(db,key)) return 0;
(gdb)
1187 }
(gdb)</code></pre>
<p>我们发现,它判断了键是否过期,如果没有过期,那就直接返回0。那么看来,对于删除命令,过期和非过期的删除是有区别的。先跳过这里,继续往下走:</p>
<pre><code class="c">473 int deleted = lazy ? dbAsyncDelete(c->db,c->argv[j]) : dbSyncDelete(c->db,c->argv[j]);</code></pre>
<p>这里有一个lazy的参数,lazy参数决定了后面是调用dbAsyncDelete()还是dbSyncDelete()函数,我们打印一下这个值:</p>
<pre><code class="c">(gdb) p lazy
$15 = 0</code></pre>
<p>lazy的值为0。看字面意思,好像代表不需要懒删除的意思,非懒删除对应的是dbSyncDelete()函数,即同步删除。这里我们可以知道,非懒删除对应同步删除。我们跟进dbSyncDelete(c->db,c->argv[j]):</p>
<pre><code class="c">int dbSyncDelete(redisDb *db, robj *key) {
if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr); //删除key1对应的过期时间字典entry
if (dictDelete(db->dict,key->ptr) == DICT_OK) { // 删除字典中的key1-value1键值对
if (server.cluster_enabled) slotToKeyDel(key);
return 1;
} else {
return 0;
}
}</code></pre>
<p>在redis中,过期时间和数据是分别存在redis数据库下面的两个字典中的。字典的结构我们已讨论过。在过期时间字典中,key就是我们的key key1;而键空间字典会存储真正的key-value对。所以要去字典中分别删除过期时间以及数据的值。具体的删除逻辑我们先不深入去了解,只知道过期时间和key-value对是存在字典dict中就好:</p>
<pre><code class="c">typedef struct redisDb {
...
dict *dict; // 键空间字典,保存数据库中所有键值对
dict *expires // 过期时间字典,保存键的过期时间
...
} redisDb;</code></pre>
<p>接续往下走,删除成功后,deleted变量值为1,会执行下面的if,并调用两个函数,做一些事务、发布/订阅的操作,我们不深入讲解这两块,然后会将删除的数量++,然后将结果存到输出缓冲区,命令执行结束。</p>
<h2>扩展</h2>
<h3>懒删除(lazy delete)</h3>
<p>之前我们的例子是调用了dbSyncDelete()方法,是同步来进行删除操作的。但是如果lazy的值为true,即如果开启了懒删除策略,就会调用dbAsyncDelete()方法:</p>
<pre><code class="c">#define LAZYFREE_THRESHOLD 64
int dbAsyncDelete(redisDb *db, robj *key) {
// 从过期键字典中删除过期键的时间
if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
// 找到key在键空间字典中的位置指针
dictEntry *de = dictUnlink(db->dict,key->ptr);
if (de) {
// 通过键指针获取值
robj *val = dictGetVal(de);
// 计算并判断是否需要懒删除。如果只删除一个很小的key,不需要采用懒删除策略,直接同步删除即可。
size_t free_effort = lazyfreeGetFreeEffort(val);
// 当代价超过阈值64的时候,就会将懒删除任务分发给后台线程去做,不阻塞主进程
if (free_effort > LAZYFREE_THRESHOLD && val->refcount == 1) {
atomicIncr(lazyfree_objects,1); //懒删除对象数量++
bioCreateBackgroundJob(BIO_LAZY_FREE,val,NULL,NULL); // 下发懒删除任务
dictSetVal(db->dict,de,NULL);
}
}
if (de) {
dictFreeUnlinkedEntry(db->dict,de);
if (server.cluster_enabled) slotToKeyDel(key);
return 1;
} else {
return 0;
}
}</code></pre>
<p>我们可以看到,在懒删除之前需要计算当前的key-value对是否适合懒删除。当懒删除超出阈值64的时候才会开启懒删除策略。所以,我们知道,它使用异步线程对删除的键值对,进行延后内存回收。那么为什么要这样做呢?原因是如果所要删除的键值对所占用的内存空间非常大,一次性做同步删除的时间是非常久的,这样会导致主进程一直处于阻塞状态,无法为外部提供服务。所以,为了解决这个问题,redis在4.0版本中提出了懒删除的概念,通过异步线程的方式,解决了大key删除时的阻塞问题。异步线程在Redis内部有一个特别的名称,它就是BIO,全称是Background IO,意思是在背后默默干活的IO线程,它就是懒删除的载体与核心。</p>
<h2>参考资料</h2>
<p><a href="https://segmentfault.com/a/1190000017394458">【Redis源码分析】Redis 懒删除(lazy free)简史</a></p>
【业务学习】关于MySQL order by limit 走错索引的探讨
https://segmentfault.com/a/1190000020399424
2019-09-16T20:58:29+08:00
2019-09-16T20:58:29+08:00
NoSay
https://segmentfault.com/u/nosay
0
<p>Grape</p>
<hr>
<h2>描述</h2>
<p>今天在跑脚本的时候发现了几条慢查询,根据之前的经验实属不应该,后来经过查找资料和分析出来结果,在这里简单记录一下。</p>
<p>首先,我的sql是这个样子:</p>
<pre><code>select `id` from `xxx` force index(idx_d_t) where `date` = '2019-09-11' AND `time_flag` < '20190911220000' order by id asc;
</code></pre>
<p>索引是下边这个样子:</p>
<pre><code>KEY `inx_t_d` (`date`,`time_flag`);</code></pre>
<p>按照我之前的理解这条sql是可以走这个索引的,但是他没有,他选择了主键索引。</p>
<h2>分析</h2>
<p>看到这是个慢查询,我起手一个explain,结果如下:</p>
<p><img src="/img/bVbxKU9?w=1009&h=60" alt="clipboard.png" title="clipboard.png"></p>
<p>看到这个结果我肯定不服啊,为什么是走的主键索引,因此开始了百度谷歌之旅。<br>刚开始我找到了一个自认为比较正确的方法,在某度上找了一篇文章说orderby之前有范围查找的会走orderby之后的索引,反之走orderby之前的索引,我试了一下,哎,不错,我把范围查询改成了等值查询,是走了我的索引了,但是我看了一眼行数,一脸懵逼,为什么这么多行?这不是我想要的</p>
<p><img src="/img/bVbxKVd?w=1062&h=65" alt="clipboard.png" title="clipboard.png"></p>
<p>然后我profiling(大家可以自行百度)查了下时间,发现Creating sort index这哥们占用了九成的时间,这时候我敏锐的察觉到了这个排序有问题,(该吃饭了)不行,继续查! <br>继续查,上某哥,哎你别说,某哥大法还是好,终于找到了一个大佬的分析,具体是什么原因呢?</p>
<p>首次,我强制走我的联合索引看下情况:</p>
<p><img src="/img/bVbxMat?w=1438&h=105" alt="clipboard.png" title="clipboard.png"></p>
<p>看到上图会发现有个差别就是Using filesort这玩意儿,这玩意儿是个什么东西呢?简单的说filesort 是通过相应的排序算法将取得的数据在内存中进行排序。俗话说有对比才有伤害,抓到敌人的小辫子就接近了胜利,我们继续看。</p>
<p>fliesort有两种排序方式:</p>
<ol>
<li>双路排序:首先根据相应的条件取出相应的排序字段和可以直接定位行数据的行指针信息,然后在 sort buffer 中进行排序。</li>
<li>单路排序:是一次性取出满足条件行的所有字段,然后在 sort buffer 中进行排序。</li>
</ol>
<p>什么时候用到这两种呢?MySQL 主要通过比较所设定的系统参数 max_length_for_sort_data 的大小和 Query 语句所取出的字段类型大小总和来判定需要使用哪一种排序算法。如果 max_length_for_sort_data 更大,则使用第二种优化后的算法,反之使用第一种算法。<strong>很显然应该尽可能让 MySQL 选择使用第二种单路算法来进行排序。这样可以减少大量的随机 IO 操作,很大幅度地提高排序工作的效率。</strong></p>
<p>上文分析的排序时间过长很可能就和这个有关系了,继续查资料分析,问题的关键就在于为什么会filesort。</p>
<h2>结论</h2>
<p><strong>在执行语句的时候,因为数据量较大,MySQL优化器认为走联合索引不好,默认选择了第一个更慢的执行计划,它的理由是走主键索引不需要内存排序,候选的 idx_d_t被淘汰。优化器认为主键索引不用排序比联合索引要好,所以导致了这种情况,</strong><br><strong>那我们该怎么做,在这里我只列出我的解决方法,他认为主键更好,那么我们就给他更好的,我们更改idx_d_t这个索引,由date,time_flag改成,id,date,time_flag,这样就解决问题了。</strong><br>如图:</p>
<p><img src="/img/bVbxKYw?w=1852&h=172" alt="clipboard.png" title="clipboard.png"></p>
<p>最后总结一下,就是优化器会尽量避免走file_sort,这样可能会导致一些问题。</p>
<p>以上分析若有差错,还望不吝指教!感谢。</p>
<p>参考文章:</p>
<ul><li><a href="https://link.segmentfault.com/?enc=uNvQmBCx1aReZEp3uFJgiQ%3D%3D.HbG5a61SinDaWD%2FZNxvil7AGnGpQgEC0XIRMKPqi22tnYB7tU9xDZsjzytAtNHkCZXBizUdT6mehXNVnGUpKdA%3D%3D" rel="nofollow">MySQL order by limit 走错索引</a></li></ul>
【Redis5源码学习】浅析redis命令执行的生命周期
https://segmentfault.com/a/1190000020386517
2019-09-15T14:46:59+08:00
2019-09-15T14:46:59+08:00
NoSay
https://segmentfault.com/u/nosay
2
<p>baiyan</p>
<h2>引入</h2>
<p>首先看一张我们非常熟悉的redis命令执行图:<br><img src="/img/remote/1460000020386520?w=309&h=61" alt="" title=""><br>那么思考这样一个问题,当我们连接了redis服务端之后,然后输入并执行某条redis命令:如set key1 value1。这条命令究竟是如何被发送到redis服务端的,redis服务端又是如何解析,并作出相应处理,并返回执行成功的呢?</p>
<h2>客户端到服务端的命令传输(请求)</h2>
<p>redis在TCP协议基础之上,封装了自己的一套协议规范,方便服务端与客户端去接收与解析数据,划清命令参数之间的边界,方便最终对以TCP字节流传输的数据进行处理。下面我们使用tcpdump来捕获redis-cli发送命令时的数据包:</p>
<pre><code>tcpdump port 6379 -i lo -X</code></pre>
<p>这时,我们在客户端中输入set key1 value1命令。在tcpdump中捕获的数据包如下:<br><img src="/img/remote/1460000020386521?w=609&h=287" alt="" title=""><br>第一个是客户端发送命令到redis服务端时的数据包,而第二个是redis服务端响应给客户端的数据包。我们首先只看第一个数据包,它从客户端43856端口发送到redis服务端的6379端口。首先前20个字节是IP头部,后32字节是TCP头部(由于TCP头部后面存在可选项)。<br>我们主要关注从“2a33”开始的数据信息,从这里开始就是redis具体的数据格式了。从右边对数据的一个ASCII码翻译也可以看到set、key1、value1的字样,中间还有一些用.表示的字符,那么这里,我们根据抓包结果分析一下redis数据传输的协议格式。</p>
<ul>
<li>2a33:0x2a是字符"*"的ASCII码值,0x33是"3"的ASCII码值(十进制值是51)</li>
<li>0d0a:0d是"r"的ASCII码值,0a是"n"的ASCII码值</li>
<li>7365:是"s"和"e"的ASCII码值</li>
<li>740d:是"t"和"r"的ASCII码值</li>
<li>0a24:是"n"和"$"的ASCII码值</li>
<li>340d:是"4"和"r"的ASCII码值</li>
<li>0a6b:是"n"和"k"的ASCII码值</li>
<li>6579:是"e"和"y"的ASCII码值</li>
<li>310d:是"1"和"r"的ASCII码值</li>
<li>0a24:是"n"和"$"的ASCII码值</li>
<li>360d:是”6"和"r"的ASCII码值</li>
<li>0a76:是"n"和"v"的ASCII码值</li>
<li>616c:是"a"和"l"的ASCII码值</li>
<li>7565:是"u"和"e"的ASCII码值</li>
<li>310d:是"1"和"r"的ASCII码值</li>
<li>0a: 是"n"的ASCII码值</li>
</ul>
<p>看到这里,我们是否能够发现以下规律:</p>
<blockquote><ul>
<li>redis以"*"作为标志,表示命令的开始。在*后面紧跟的数字代表参数的个数(set key1 value1有3个参数所以为3)</li>
<li>redis以"$"作为命令参数的开始,后面紧跟的数字代表参数的长度(如key1的长度为4所以为$4)</li>
<li>redis以"rn"作为参数之间的分隔符,方便解析TCP字节流数据时定位边界位置</li>
</ul></blockquote>
<p>综合来看,客户端向服务端发送的redis数据包格式如下:</p>
<pre><code>*3 \r\n set \r\n $4 \r\n key1 \r\n $6 \r\n value1 \r\n</code></pre>
<p>相比FastCGI协议,redis仅仅使用几个分隔符和特殊字符,就完成了对命令的传输语法及数据格式的规范化,同时服务端通过其中定义好的分隔符,也能够方便高效地从字节流数据中解析并读取出正确的数据。这种通信协议简单高效,能够满足redis对高性能的要求。</p>
<h2>服务端对命令的处理</h2>
<p>既然命令已经通过redis数据传输协议安全地送达到了服务端,那么,服务端就要开始对传输过来的字节流数据进行处理啦。由于我们在协议中清晰地定义了每个参数的边界(\r\n),所以,redis服务端解析起来也非常轻松。</p>
<h3>第一步:回调函数的使用</h3>
<p>redis是典型事件驱动程序。为了提高单进程的redis的性能,redis采用IO多路复用技术来处理客户端的命令请求。redis会在创建客户端实例的时,指定服务端接收到客户端命令请求的事件时,所要执行的事件处理函数:</p>
<pre><code class="c">client *createClient(int fd) {
client *c = zmalloc(sizeof(client));
if (fd != -1) {
anetNonBlock(NULL,fd); //设置非阻塞
anetEnableTcpNoDelay(NULL,fd); //设置不采用Nagle算法,避免半包与粘包现象
if (server.tcpkeepalive)
anetKeepAlive(NULL,fd,server.tcpkeepalive); //设置keep-alive
//注意这里创建了一个文件事件。当客户端读事件就绪的时候,回调readQueryFromClient()函数
if (aeCreateFileEvent(server.el,fd,AE_READABLE,readQueryFromClient, c) == AE_ERR) {
close(fd);
zfree(c);
return NULL;
}
}
...
}</code></pre>
<p>为了暂存客户端请求到服务端的字节流数据,redis封装了一个接收缓冲区,来缓存从套接字中读取的数据。后续的命令处理流程从缓冲区中读取命令数据并处理即可。缓冲区的好处在于不用一直维持读写套接字。在后续的流程中,我们只需要从缓冲区中读取数据,而不是仍从套接字中读取。这样就可以提前释放套接字,节省资源。缓冲区的建立与使用就是在之前讲过的客户端回调函数readQueryFromClient()中完成的:</p>
<pre><code class="c">void readQueryFromClient(aeEventLoop *el, int fd, void *privdata, int mask) {
...
qblen = sdslen(c->querybuf); //获取缓冲区长度
if (c->querybuf_peak < qblen) c->querybuf_peak = qblen;
c->querybuf = sdsMakeRoomFor(c->querybuf, readlen); //创建一个sds结构作为缓冲区
nread = read(fd, c->querybuf+qblen, readlen); //从套接字中读取数据到缓冲区中暂存
...
//真正地处理命令
processInputBufferAndReplicate(c);
}</code></pre>
<h3>第二步:分发器的使用</h3>
<p>这段代码创建并往缓冲区中写入了字节流数据,然后调用processInputBufferAndReplicate()去真正地处理命令。processInputBufferAndReplicate()函数中只是简单的调用了==processInputBuffer()函数。由于我们之前的缓冲区中已经有了客户端发给服务端的字节流数据,所以我们需要在这一层进行数据初步的筛选与处理:</p>
<pre><code class="c">void processInputBuffer(client *c) {
// 如果缓冲区还没有处理完,继续循环处理
while(c->qb_pos < sdslen(c->querybuf)) {
...
// 对字节流数据进行定制化分发处理
if (c->reqtype == PROTO_REQ_INLINE) { //如果是INLINE类型的请求
if (processInlineBuffer(c) != C_OK) break; //调用processInlineBuffer解析缓冲区数据
} else if (c->reqtype == PROTO_REQ_MULTIBULK) {//如果是MULTIBULK类型的请求
if (processMultibulkBuffer(c) != C_OK) break; //调用processMultibulkBuffer解析缓冲区数据
} else {
serverPanic("Unknown request type");
}
// 开始处理具体的命令
if (c->argc == 0) { //命令参数为0个,非法
resetClient(c);
} else { //命令参数不为0,合法
// 调用processCommand()真正处理命令
if (processCommand(c) == C_OK) { //
...
}
}
}
}</code></pre>
<p>看到这里,读者可能会有些疑惑。什么是INLINE、什么是MULTIBULK?在redis中,有两种请求命令类型:</p>
<blockquote><ul>
<li>INLINE类型:简单字符串格式,如ping命令</li>
<li>MULTIBULK类型:字符串数组格式。如set、get等等大部分命令都是这种类型</li>
</ul></blockquote>
<p>这个函数其实就是一个分发器。由于底层的字节流数据是无规则的,所以我们需要根据客户端的reqtype字段,去区分请求字节流数据属于那种请求类型,进而分发到对应的函数中进行处理。由于我们经常执行的命令都是MULTIBULK类型,我们也以MULTIBULK类型为例。对于set、get这种MULTIBULK请求类型,会被分发到processMultibulkBuffer()函数中进行处理。</p>
<h3>第三步:检查接收缓冲区的数据完整性</h3>
<p>在开启TCP的Nagle算法时,TCP会将多个redis命令请求的数据包合并或者拆分发送。这样就会出现在一个数据包中命令不完整、或者一个数据包中包含多个命令的情况。为了解决这个问题,processMultibulkBuffer()函数保证,当只有在缓冲区中包含一个完整请求时,这个函数才会成功解析完字节流中的命令参数,并返回成功状态码。否则,会break出外部的while循环,等待下一次事件循环再从套接字中读取剩余的数据,再进行对命令的解析。这样就保证了redis协议中的数据的完整性,也保证了实际命令参数的完整性。</p>
<pre><code class="c">int processMultibulkBuffer(client *c) {
while(c->multibulklen) {
...
/* 读取命令参数字节流 */
if (sdslen(c->querybuf)-c->qb_pos < (size_t)(c->bulklen+2)) { //如果$后面代表参数长度的数字与实际命令长度不匹配(+2的位置是\r\n),说明数据不完整,直接跳出循环,等待下一次读取剩余数据
break;
} else { //命令完整,进行一些执行命令之前的初始化工作
if (c->qb_pos == 0 && c->bulklen >= PROTO_MBULK_BIG_ARG && sdslen(c->querybuf) == (size_t)(c->bulklen+2)) {
c->argv[c->argc++] = createObject(OBJ_STRING,c->querybuf);
sdsIncrLen(c->querybuf,-2);
c->querybuf = sdsnewlen(SDS_NOINIT,c->bulklen+2);
sdsclear(c->querybuf);
} else {
c->argv[c->argc++] =
createStringObject(c->querybuf+c->qb_pos,c->bulklen);
c->qb_pos += c->bulklen+2;
}
c->bulklen = -1;
c->multibulklen--; //处理下一个命令参数
}
}
}</code></pre>
<h3>第四步:真正地处理命令</h3>
<p>我们回到外层。当我们成功执行processMultibulkBuffer()函数之后,说明当前命令已经完整,可以对命令进行处理了。我们想一下,加入要我们去设计根据不同的命令,调用不同的处理函数,从而完成不同的功能,我们应该怎么做呢?想了想,我们可以简单写出以下代码:</p>
<pre><code class="c">if (command == "get") {
doGetCommand(); //get命令处理函数
} else if (command == "set") {
doSetCommand(); //set命令处理函数
} else {
printf("非法命令")
}</code></pre>
<p>以上代码非常简单,只是根据我们得到的不同命令请求,分发到不同的命令处理函数中进行定制化处理。那么redis其实也是同样的道理,那究竟redis是怎么做的呢:</p>
<pre><code class="c">int processCommand(client *c) {
//如果是退出命令直接返回
if (!strcasecmp(c->argv[0]->ptr,"quit")) {
addReply(c,shared.ok);
c->flags |= CLIENT_CLOSE_AFTER_REPLY;
return C_ERR;
}
//去字典里查找命令,并把要执行的命令处理函数赋值到c结构体中的cmd字段
c->cmd = c->lastcmd = lookupCommand(c->argv[0]->ptr);
// 返回值校验
if (!c->cmd) { //没有找到该命令
flagTransaction(c);
sds args = sdsempty();
int i;
for (i=1; i < c->argc && sdslen(args) < 128; i++)
args = sdscatprintf(args, "`%.*s`, ", 128-(int)sdslen(args), (char*)c->argv[i]->ptr);
addReplyErrorFormat(c,"unknown command `%s`, with args beginning with: %s",
(char*)c->argv[0]->ptr, args);
sdsfree(args);
return C_OK;
} else if ((c->cmd->arity > 0 && c->cmd->arity != c->argc) || //命令参数不匹配
(c->argc < -c->cmd->arity)) {
flagTransaction(c);
addReplyErrorFormat(c,"wrong number of arguments for '%s' command",
c->cmd->name);
return C_OK;
}
// 真正执行命令
if (c->flags & CLIENT_MULTI &&
c->cmd->proc != execCommand && c->cmd->proc != discardCommand &&
c->cmd->proc != multiCommand && c->cmd->proc != watchCommand)
{
queueMultiCommand(c);
addReply(c,shared.queued);
} else { //真正执行命令
call(c,CMD_CALL_FULL); //核心函数
c->woff = server.master_repl_offset;
if (listLength(server.ready_keys))
handleClientsBlockedOnKeys();
}
return C_OK;
}</code></pre>
<p>在这个函数中,最重要的就是lookupCommand()函数和call()函数的调用了。在redis中,所有命令都存储在一个字典中,这个字典长这样:</p>
<pre><code class="c">struct redisCommand redisCommandTable[] = {
{"module",moduleCommand,-2,"as",0,NULL,0,0,0,0,0},
{"get",getCommand,2,"rF",0,NULL,1,1,1,0,0},
{"set",setCommand,-3,"wm",0,NULL,1,1,1,0,0},
{"setnx",setnxCommand,3,"wmF",0,NULL,1,1,1,0,0},
{"setex",setexCommand,4,"wm",0,NULL,1,1,1,0,0},
{"psetex",psetexCommand,4,"wm",0,NULL,1,1,1,0,0},
{"append",appendCommand,3,"wm",0,NULL,1,1,1,0,0},
{"strlen",strlenCommand,2,"rF",0,NULL,1,1,1,0,0},
{"del",delCommand,-2,"w",0,NULL,1,-1,1,0,0},
{"unlink",unlinkCommand,-2,"wF",0,NULL,1,-1,1,0,0},
{"exists",existsCommand,-2,"rF",0,NULL,1,-1,1,0,0},
{"setbit",setbitCommand,4,"wm",0,NULL,1,1,1,0,0},
{"getbit",getbitCommand,3,"rF",0,NULL,1,1,1,0,0},
{"bitfield",bitfieldCommand,-2,"wm",0,NULL,1,1,1,0,0},
{"setrange",setrangeCommand,4,"wm",0,NULL,1,1,1,0,0},
{"getrange",getrangeCommand,4,"r",0,NULL,1,1,1,0,0},
{"substr",getrangeCommand,4,"r",0,NULL,1,1,1,0,0},
{"incr",incrCommand,2,"wmF",0,NULL,1,1,1,0,0},
{"decr",decrCommand,2,"wmF",0,NULL,1,1,1,0,0},
{"mget",mgetCommand,-2,"rF",0,NULL,1,-1,1,0,0},
{"rpush",rpushCommand,-3,"wmF",0,NULL,1,1,1,0,0},
{"lpush",lpushCommand,-3,"wmF",0,NULL,1,1,1,0,0},
{"rpushx",rpushxCommand,-3,"wmF",0,NULL,1,1,1,0,0},
{"lpushx",lpushxCommand,-3,"wmF",0,NULL,1,1,1,0,0},
{"linsert",linsertCommand,5,"wm",0,NULL,1,1,1,0,0},
{"rpop",rpopCommand,2,"wF",0,NULL,1,1,1,0,0},
{"lpop",lpopCommand,2,"wF",0,NULL,1,1,1,0,0},
{"brpop",brpopCommand,-3,"ws",0,NULL,1,-2,1,0,0},
{"brpoplpush",brpoplpushCommand,4,"wms",0,NULL,1,2,1,0,0},
{"blpop",blpopCommand,-3,"ws",0,NULL,1,-2,1,0,0},
{"llen",llenCommand,2,"rF",0,NULL,1,1,1,0,0},
{"lindex",lindexCommand,3,"r",0,NULL,1,1,1,0,0},
{"lset",lsetCommand,4,"wm",0,NULL,1,1,1,0,0},
{"lrange",lrangeCommand,4,"r",0,NULL,1,1,1,0,0},
{"ltrim",ltrimCommand,4,"w",0,NULL,1,1,1,0,0},
{"lrem",lremCommand,4,"w",0,NULL,1,1,1,0,0},
...
};</code></pre>
<p>我们可以看到,这个字典是所有命令的集合,我们调用lookupCommand就是从这里获取命令及命令的相关信息的。它是一个结构体数组,包含所有命令名称、命令处理函数、参数个数、以及种种标记。其实这里就相当于一个配置信息的维护,以及命令道处理函数名称的映射关系,从而很好的解决了我们一开始使用if-else来分发命令处理函数的难以维护、可扩展性差的问题。<br>在我们成功在字典中找到一个命令的处理函数之后,我们只需要去调用相应的命令处理函数就好啦。上面最后的call()函数中就对相应的命令处理函数进行了调用,并返回调用结果给客户端。比如,setCommand()就是set命令的实际处理函数:</p>
<pre><code class="c">void setCommand(client *c) {
int j;
robj *expire = NULL;
int unit = UNIT_SECONDS;
int flags = OBJ_SET_NO_FLAGS;
for (j = 3; j < c->argc; j++) {
char *a = c->argv[j]->ptr;
robj *next = (j == c->argc-1) ? NULL : c->argv[j+1];
if ((a[0] == 'n' || a[0] == 'N') &&
(a[1] == 'x' || a[1] == 'X') && a[2] == '\0' &&
!(flags & OBJ_SET_XX))
{
flags |= OBJ_SET_NX;
} else if ((a[0] == 'x' || a[0] == 'X') &&
(a[1] == 'x' || a[1] == 'X') && a[2] == '\0' &&
!(flags & OBJ_SET_NX))
{
flags |= OBJ_SET_XX;
} else if ((a[0] == 'e' || a[0] == 'E') &&
(a[1] == 'x' || a[1] == 'X') && a[2] == '\0' &&
!(flags & OBJ_SET_PX) && next)
{
flags |= OBJ_SET_EX;
unit = UNIT_SECONDS;
expire = next;
j++;
} else if ((a[0] == 'p' || a[0] == 'P') &&
(a[1] == 'x' || a[1] == 'X') && a[2] == '\0' &&
!(flags & OBJ_SET_EX) && next)
{
flags |= OBJ_SET_PX;
unit = UNIT_MILLISECONDS;
expire = next;
j++;
} else {
addReply(c,shared.syntaxerr);
return;
}
}
c->argv[2] = tryObjectEncoding(c->argv[2]);
setGenericCommand(c,flags,c->argv[1],c->argv[2],expire,unit,NULL,NULL);
}</code></pre>
<p>这个函数首先对NX、EX参数进行了判断及处理,最终调用了setGenericCommand(),来执行set命令的通用逻辑部分:</p>
<pre><code class="c">void setGenericCommand(client *c, int flags, robj *key, robj *val, robj *expire, int unit, robj *ok_reply, robj *abort_reply) {
long long milliseconds = 0; /* initialized to avoid any harmness warning */
if (expire) {
if (getLongLongFromObjectOrReply(c, expire, &milliseconds, NULL) != C_OK)
return;
if (milliseconds <= 0) {
addReplyErrorFormat(c,"invalid expire time in %s",c->cmd->name);
return;
}
if (unit == UNIT_SECONDS) milliseconds *= 1000;
}
if ((flags & OBJ_SET_NX && lookupKeyWrite(c->db,key) != NULL) ||
(flags & OBJ_SET_XX && lookupKeyWrite(c->db,key) == NULL))
{
addReply(c, abort_reply ? abort_reply : shared.nullbulk);
return;
}
setKey(c->db,key,val);
server.dirty++;
if (expire) setExpire(c,c->db,key,mstime()+milliseconds);
notifyKeyspaceEvent(NOTIFY_STRING,"set",key,c->db->id);
if (expire) notifyKeyspaceEvent(NOTIFY_GENERIC,
"expire",key,c->db->id);
addReply(c, ok_reply ? ok_reply : shared.ok);
}</code></pre>
<p>最终会调用addReply()通用返回函数,应该是要把执行结果返回给客户端了。我们看看该函数里面做了些什么:</p>
<pre><code class="c">void addReply(client *c, robj *obj) {
if (prepareClientToWrite(c) != C_OK) return;
if (sdsEncodedObject(obj)) {
if (_addReplyToBuffer(c,obj->ptr,sdslen(obj->ptr)) != C_OK)
_addReplyStringToList(c,obj->ptr,sdslen(obj->ptr));
} else if (obj->encoding == OBJ_ENCODING_INT) {
char buf[32];
size_t len = ll2string(buf,sizeof(buf),(long)obj->ptr);
if (_addReplyToBuffer(c,buf,len) != C_OK)
_addReplyStringToList(c,buf,len);
} else {
serverPanic("Wrong obj->encoding in addReply()");
}
}</code></pre>
<p>我们仔细阅读这段代码,好像并没有找到执行结果是什么时候返回给客户端的。在这个函数中,只是将返回结果添加到了输出缓冲区中,一个命令就执行完了。那么究竟是什么时候返回的呢?是否还记得在介绍开启事件循环时,提到函数beforesleep()会在每次事件循环阻塞等待文件事件之前执行,主要执行一些不是很费时的操作,比如过期键删除操作,向客户端返回命令回复等。这样,就可以减节省返回执行结果时的网络通信开销,将同一个客户端上的多个命令的多次返回,对多个命令做一个缓存,最终一次性统一返回,减少了返回的次数,提高了性能。</p>
<h2>客户端到服务端的命令传输(响应)</h2>
<p>执行完set key1 value1命令之后,我们得到了一个"OK"的返回,代表命令执行成功。其实我们仔细观察上面返回的第二个数据包,其实底层是一个"+OK"的返回值。那么为什么要有一个+号呢?因为除了我们上面讲过的set命令,还有get命令、lpush命令等等,他们的返回值都是不一样的。get会返回数据集合、lpush会返回一个整数,代表列表的长度等等。一个字符串的表示是远远不能满足需要的。所以在redis通信协议中,一共定义了五种返回值结构。客户端通过每种返回结构的第一个字符,来判断是哪种类型的返回值:</p>
<blockquote><ul>
<li>状态回复:第一个字符是“+”;例如,SET命令执行完毕会向客户端返回“+OK\r\n”。</li>
<li>错误回复:第一个字符是“-”;例如,当客户端请求命令不存在时,会向客户端返回“-ERR unknown command 'testcmd'”。</li>
<li>整数回复:第一个字符是“:”;例如,INCR命令执行完毕向客户端返回“:100\r\n”。</li>
<li>批量回复:第一个字符是“$”;例如,GET命令查找键向客户端返回结果“$5\r\nhello\r\n”,其中$5表示返回字符串长度。</li>
<li>多条批量回复:第一个字符是“”;例如,LRANGE命令可能会返回多个多个值,格式为“3\r\n$6\r\nvalue1\r\n$6rnvalue2rn$6\r\nvalue3\r\n”,与命令请求协议格式相同,“\*3”表示返回值数目,“$6”表示当前返回值字符串长度,多个返回值用“\r\n”分隔开。</li>
</ul></blockquote>
<p>我们执行set命令就是第一种类型,即状态回复。客户端通过+号,就能够知道这是状态回复,从而就知道该如何读取后面的字节流内容了。</p>
<h2>总结</h2>
<p>至此,我们就走完了一个redis命令的完整生命周期,同时也了解了redis通信协议的格式与规范。接下来,我将会深入每一个命令的实现,大家加油。</p>
<h2>参考资料</h2>
<ul><li><a href="https://segmentfault.com/a/1190000017104165#articleHeader15">【Redis源码分析】Redis命令处理生命周期</a></li></ul>
【业务学习】2019-05-09 http1.1&2.0的基本原理
https://segmentfault.com/a/1190000020252678
2019-09-01T16:23:17+08:00
2019-09-01T16:23:17+08:00
NoSay
https://segmentfault.com/u/nosay
3
<p>Grape<br>全部视频:<a href="https://segmentfault.com/a/1190000018488313">https://segmentfault.com/a/11...</a></p>
<hr>
<h3>http的定义</h3>
<pre><code> HTTP是基于客户端/服务端(C/S)的架构模型,通过一个可靠的链接来交换信息,是一个无状态的请求/响应协议。</code></pre>
<h3>http的结构</h3>
<p><img src="/img/bVbw8Nf?w=800&h=680" alt="clipboard.png" title="clipboard.png"></p>
<p>我们目前用到最多的是http1.x协议,header和body我们都不陌生,那么startline是什么呢?startline是我们所说的request_line或status_line,也就是<br>GET /HTTP/1.1或者HTTP/1.1 200 OK这种字段。<br>在叙述http的各种工作方式之前,我们先熟悉一下TCP/IP模型:</p>
<p><img src="/img/bVbw8Ng?w=1464&h=531" alt="clipboard.png" title="clipboard.png"></p>
<h3>http的发展:</h3>
<h4>http1.0</h4>
<p>Http1.0:http1.0是默认没有keep-alive的,在数据请求的时候会先进性应用层以下的处理过程才会到应用层,在这里我们只说传输层和应用层,在http1.0中,每一次的请求都会进行建立tcp连接(三次握手),然后进行http请求,这样每次与服务器交互,都需要新开一个连接!我们的每个链接的请求链路都如下图所示:</p>
<p><img src="/img/bVbw8Nh?w=505&h=541" alt="clipboard.png" title="clipboard.png"></p>
<p>想象一下,每发起一个请求就经过上述如此长的一个流程,这将耗去多少资源。</p>
<h4>http1.1</h4>
<p>基于这个问题,我们的http迎来了http1.1版本,1.1版本相对于1.0版本有什么改动呢?</p>
<ul>
<li>http增加了host字段</li>
<li>HTTP 1.1中引入了Chunked transfer-coding,范围请求,实现断点续传(实际上就是利用HTTP消息头使用分块传输编码,将实体主体分块传输)</li>
<li>
<p>HTTP 1.1管线化(pipelining)理论,客户端可以同时发出多个HTTP请求,而不用一个个等待响应之后再请求</p>
<ul>
<li>注意:这个pipelining仅仅是限于理论场景下,大部分桌面浏览器仍然会选择默认关闭HTTP pipelining!</li>
<li>所以现在使用HTTP1.1协议的应用,都是有可能会开多个TCP连接的!</li>
</ul>
</li>
</ul>
<p>Http1.1基于上述耗费资源的问题给予了根本的处理,默认长链接,什么意思呢? 不去在每一个http请求的时候都去进行http连接,只建立一个tcp链接去处理多个请求,当然,这里的每个请求是串行的,即只是不用去进行tcp连接,还是得排队,并且这样子可能会引起线头阻塞(例如发送100个请求,第一个被阻塞导致后边99个也不能请求)问题。对于http1.1默认的工作模式如下图所示:</p>
<p><img src="/img/bVbw8Ni?w=800&h=741" alt="clipboard.png" title="clipboard.png"></p>
<p>到这我们想象一下这种模式好么,有什么缺点?可以优化吗?<br>在此之上我们已经抛出了两个问题1.需要排队 2.可能引起线头阻塞。对于第一个问题,http1.1已经给出了解决方案,即pipline,而第二个问题刚开始有一种过渡的方案,即spdy协议(google推出的一项增强http的协议,功能包括数据流的多路复用、请求优先级以及HTTP报头压缩,有兴趣的可以研究一下),然后再到现在的http2.0。<br>首先我们先说一下什么是pipline,pipline是一项实现了多个http请求但不需要等待响应就能够写进同一个socket的技术,仅有http1.1规范支持http管线化,1.0并不支持。什么意思呢?我们看上图是在一个tcp连接的串行的去处理,那么开启了pipline之后就会变成下边这个样子:</p>
<p><img src="/img/bVbw8Nj?w=494&h=532" alt="clipboard.png" title="clipboard.png"></p>
<p>我们可以看到发送http请求不再是先发送然后等待response再发送下个请求了,这样子我们可以看成是所有的请求统一开始,但是这有一个问题,HTTP Pipelining其实是把多个HTTP请求放到一个TCP连接中一一发送,而在发送过程中不需要等待服务器对前一个请求的响应;只不过,客户端还是要按照发送请求的顺序来接收响应!这就导致了它虽然解决了排队问题,但是他也仅仅是解决了单方排队的问题,最后接受数据还是按照请求的顺序来接受相应,为什么呢?因为他们不知道哪个是第一个哪个是第二个。这样同样会存在线头阻塞的问题。<br>总结一下就是在http1.0的时候我们是流水线,一个接一个的完成任务,http1.1的时候呢我们工人的能力提升了,可以一次发出多个工作需求了,但是还没有掌握技巧,还是得按照条例等待工作全部到来的时候一个一个按顺序处理。</p>
<h4>http2.0</h4>
<p>接下来就是我们的http2.0,看他如何解决了之前的问题。解决线头阻塞,在http2.0中其实是用了一个stream的结构,给每一个stream分配一个标示即streamId就可以来解决线头阻塞的问题。那么http2究竟是何方神圣呢?<br>首先,说起http2,我们不得不提一下https,http2是基于https的一个协议,对于https我找了一篇写的比较好的文章,<a href="https://link.segmentfault.com/?enc=%2FYkAnf8X6L9ccXxT7278nQ%3D%3D.Frp6ov92GXPdr%2B47WFcF07zLhmAI0HHuxCdjv5RczR1AmX2PEKFIdoNSYUZ%2Fyndn" rel="nofollow">Wireshark 抓包理解 HTTPS 请求流程</a>。<br>文章开头我们对比了http1和http2的结构,看起来好像完全不一样,但是实际上并非如此,http2以帧作为最小单位,看了下边的图我们会发现原来http2只是做了层封装,其实本质还是headers和body,只不过http2是以更高级功能更多的方式进行了展示。</p>
<p><img src="/img/bVbw8Nk?w=1105&h=291" alt="clipboard.png" title="clipboard.png"></p>
<h4>http1.x vs http2.0</h4>
<p>关于http2好在哪里,那我们得从http1坏出发,因为有对比才会有伤害。</p>
<ul>
<li>http1连接数限制,对于同一个域名,浏览器最多只能同时创建 6~8 个 TCP 连接 (不同浏览器不一样)。为了解决数量限制,出现了 域名分片 技术,其实就是资源分域,将资源放在不同域名下 (比如二级子域名下),这样就可以针对不同域名创建连接并请求,以一种讨巧的方式突破限制,但是滥用此技术也会造成很多问题,比如每个 TCP 连接本身需要经过 DNS 查询、三步握手、慢启动等,还占用额外的 CPU 和内存,对于服务器来说过多连接也容易造成网络拥挤、交通阻塞等。那么,http2是怎么做的呢?http2采用了多路复用技术,在一个 TCP 连接上,我们可以向对方不断发送帧,每帧的 stream_id标明这一帧属于哪个流,然后在对方接收时,根据 stream_id拼接每个流的所有帧组成一整块数据。把 HTTP/1.1 每个请求都当作一个流,那么多个请求变成多个流,请求响应数据分成多个帧,不同流中的帧交错地发送给对方,这就是 HTTP/2 中的多路复用。同时呢,我们知道http1的body长度是在header带过来的,那么如果是以http2的形式去传输肯定会出问题,所以http2将body上架了length字段,每一个流都有自己的长度,最后根据流的头部长度是否等于各个流的长度来确定是否合包。同时呢,这样分包合包也解决了线头阻塞的问题。那么问题又来了,怎么确定没有丢包?同一个stream秩序有没有乱?这点tcp会保证包的有序性且保证了包不会丢失。</li>
<li>Header 内容多,而且每次请求 Header 不会变化太多,没有相应的压缩传输优化方案。http2在此用hpack算法来压缩首部长度,其原理就是维护一个静态索引表和动态索引表的索引空间,hpack其原理就是匹配当前连接存在的索引空间,若某个键值已存在,则用相应的索引代替首部条目,比如 “:method: GET” 可以匹配到静态索引中的 index 2,传输时只需要传输一个包含 2 的字节即可;若索引空间中不存在,则用字符编码传输,字符编码可以选择哈夫曼编码,然后分情况判断是否需要存入动态索引表中,以这种形式节省了很多的空间。</li>
<li>明文传输不安全。http1使用明文传输,不安全。那么http2就用二进制分帧层来解决这个问题,帧是数据传输的最小单位,以二进制传输代替原本的明文传输,原本的报文消息被划分为更小的数据帧。</li>
<li>为了尽可能减少请求数,需要做合并文件、雪碧图、资源内联等优化工作,但是这无疑造成了单个请求内容变大延迟变高的问题,且内嵌的资源不能有效地使用缓存机制。对于这种情况,http2推出了服务端推送,浏览器发送一个请求,服务器主动向浏览器推送与这个请求相关的资源,这样浏览器就不用发起后续请求,其主要是针对资源内联做出的优化。</li>
<li>应用层的重置连接,对于 HTTP/1 来说,是通过设置 tcp segment 里的 reset flag 来通知对端关闭连接的。这种方式会直接断开连接,下次再发请求就必须重新建立连接。HTTP/2 引入 RST_STREAM 类型的 frame,可以在不断开连接的前提下取消某个 request 的 stream,表现更好。</li>
</ul>
<h3>扩展</h3>
<p>关于http我们就说这么多,如果想了解的更多读者可以自行用wireshark抓包看一下。推荐两个比较好的抓包工具:wireshark和charles。</p>
<p>参考文章:</p>
<ul>
<li><a href="https://segmentfault.com/a/1190000019142090">http协议浅析(二)</a></li>
<li><a href="https://link.segmentfault.com/?enc=80BQTUL00O2MKnOPsv8AtQ%3D%3D.mOobxD4MpzYbgy8oYmpjEU8pIJoKS7NAfkS4fc7%2FwFxYwl7kXpvLrfJ%2BOZhYiiH8ztd%2Bg6g9H1ifguOfzZAiLw%3D%3D" rel="nofollow">HTTP2 详解</a></li>
</ul>
【Redis5源码学习】浅析redis中的IO多路复用与事件机制
https://segmentfault.com/a/1190000020252203
2019-09-01T15:02:19+08:00
2019-09-01T15:02:19+08:00
NoSay
https://segmentfault.com/u/nosay
5
<p>baiyan</p>
<h2>引入</h2>
<p>读这篇文章之前请先阅读:<a href="https://segmentfault.com/a/1190000020194471">浅析服务器并发IO性能提升之路—从网络编程基础到epoll</a>,以更好的理解本文的内容,谢谢。<br>我们知道,我们在使用redis的时候,通过客户端发送一个get命令,就能够得到redis服务端返回的数据。redis是基于传统的C/S架构实现的。它通过监听一个TCP端口(6379)的方式来接收来自客户端的连接,从而进行后续命令的执行,并把执行结果返回给客户端。</p>
<h2>redis是一个合格的服务端程序</h2>
<p>我们先思考一个问题:作为一个合格的服务端程序,我们在命令行输入一个get命令之后,redis服务端是怎么处理这个命令,并把结果返回给客户端的呢?<br>要回答这个问题,我们先回顾上一篇文章中讲过的,客户端与服务器需要分别创建一个套接字表明自己所在的网络地址与端口号,然后基于TCP协议来进行套接字之间的通信。通常情况下,一个服务端程序的socket通信流程如下:</p>
<pre><code class="c">int main(int argc, char *argv[]) {
listenSocket = socket(); //调用socket()系统调用创建一个监听套接字描述符
bind(listenSocket); //绑定地址与端口
listen(listenSocket); //由默认的主动套接字转换为服务器适用的被动套接字
while (1) { //不断循环去监听是否有客户端连接事件到来
connSocket = accept($listenSocket); //接受客户端连接
read(connsocket); //从客户端读取数据,只能同时处理一个客户端
write(connsocket); //返回给客户端数据,只能同时处理一个客户端
}
return 0;
}</code></pre>
<p>在redis中,同样要经过以上几个步骤。与客户端建立连接之后,就会读取客户端发来的命令,然后执行命令,最后通过调用write系统调用,将命令的执行结果返回给客户端。<br>但是这样一个进程只能同时处理一个客户端的连接与读写事件。为了让单进程的服务端应用同时处理多个客户端的事件,我们采用了IO多路复用机制。目前最好的IO多路复用机制就是epoll。回顾我们上一篇文章中最终使用epoll创建的服务器代码:</p>
<pre><code class="c">int main(int argc, char *argv[]) {
listenSocket = socket(AF_INET, SOCK_STREAM, 0); //同上,创建一个监听套接字描述符
bind(listenSocket) //同上,绑定地址与端口
listen(listenSocket) //同上,由默认的主动套接字转换为服务器适用的被动套接字
epfd = epoll_create(EPOLL_SIZE); //创建一个epoll实例
ep_events = (epoll_event*)malloc(sizeof(epoll_event) * EPOLL_SIZE); //创建一个epoll_event结构存储套接字集合
event.events = EPOLLIN;
event.data.fd = listenSocket;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenSocket, &event); //将监听套接字加入到监听列表中
while (1) {
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1); //等待返回已经就绪的套接字描述符们
for (int i = 0; i < event_cnt; ++i) { //遍历所有就绪的套接字描述符
if (ep_events[i].data.fd == listenSocket) { //如果是监听套接字描述符就绪了,说明有一个新客户端连接到来
connSocket = accept(listenSocket); //调用accept()建立连接
event.events = EPOLLIN;
event.data.fd = connSocket;
epoll_ctl(epfd, EPOLL_CTL_ADD, connSocket, &event); //添加对新建立的连接套接字描述符的监听,以监听后续在连接描述符上的读写事件
} else { //如果是连接套接字描述符事件就绪,则可以进行读写
strlen = read(ep_events[i].data.fd, buf, BUF_SIZE); //从连接套接字描述符中读取数据, 此时一定会读到数据,不会产生阻塞
if (strlen == 0) { //已经无法从连接套接字中读到数据,需要移除对该socket的监听
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL); //删除对这个描述符的监听
close(ep_events[i].data.fd);
} else {
write(ep_events[i].data.fd, buf, str_len); //如果该客户端可写 把数据写回到客户端
}
}
}
}
close(listenSocket);
close(epfd);
return 0;
}</code></pre>
<p>redis基于原有的select、poll与epoll机制,结合自己独特的业务需求,封装了自己的一套事件处理函数,我们把它叫做ae(a simple event-driven programming library)。而redis具体使用select、epoll还是mac上的kqueue技术,redis会首先进行判断,然后选择性能最优的那个:</p>
<pre><code class="c">/* Include the best multiplexing layer supported by this system.
* The following should be ordered by performances, descending. */
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
#ifdef HAVE_EPOLL
#include "ae_epoll.c"
#else
#ifdef HAVE_KQUEUE
#include "ae_kqueue.c"
#else
#include "ae_select.c"
#endif
#endif
#endif</code></pre>
<p>因为 select 函数是作为 POSIX 标准中的系统调用,在不同版本的操作系统上都会实现,所以将其作为兜底方案。为了讲述方便,后面的文章均使用epoll机制来讲解。</p>
<h2>redis中的IO多路复用</h2>
<p>当我们在命令行中启动一个redis-server的时候,redis其实做了和我们之前写的epoll服务器类似的操作,重点的函数调用有以下三个:</p>
<pre><code class="c">int main(int argc, char **argv) {
...
initServerConfig(); //初始化存储服务端信息的结构体
...
initServer(); //初始化redis事件循环并调用epoll_create与epoll_ctl。创建socket、bind、listen、accept都在这个函数中进行调用,并注册调用后返回的监听描述符和连接描述符
...
aeMain(); //执行while(1)事件循环,并调用epoll_wait获取已就绪的描述符,并调用对应的handler
...
}</code></pre>
<p>接下来我们一个一个来看:</p>
<h3>initServerConfig()</h3>
<p>redis服务端的所有信息都存储在一个redisServer结构体中,这个结构体字段非常多,比如服务端的套接字信息(如地址和端口),还有很多支持redis其他功能如集群、持久化等的配置信息都存储在这个结构体中。这个函数调用就是对redisServer结构体的所有字段进行初始化并赋一个初始值。由于我们这次讲解的是事件与IO多路复用机制在redis中的应用,所以我们只关注其中的几个字段即可。</p>
<h3>initServer()</h3>
<p>这个函数调用是我们的重中之重。初始化完服务器的相关信息之后,就需要进行套接字的创建、绑定、监听并与客户端建立连接了。在这个函数中,进行了我们常说的创建socket、bind、listen、accept、epoll_create、epoll_ctl调用,我们可以对照上文的epoll服务器,逐步了解redis的事件机制。initServer()的主要函数调用如下:</p>
<pre><code class="c">void initServer(void) {
...
server.el = aeCreateEventLoop(server.maxclients+CONFIG_FDSET_INCR);
...
if (server.port != 0 && listenToPort(server.port,server.ipfd,&server.ipfd_count) == C_ERR)
exit(1);
...
for (j = 0; j < server.ipfd_count; j++) {
if (aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE, acceptTcpHandler,NULL) == AE_ERR){
serverPanic("Unrecoverable error creating server.ipfd file event.");
}
}
...
}</code></pre>
<p>我们按照从上到下的顺序解读这几行关键代码:</p>
<h4>aeCreateEventLoop()</h4>
<p>在redis中,有一个aeEventLoop的概念,它来管理所有相关的事件描述字段、存储已注册的事件、已就绪的事件:</p>
<pre><code class="c">typedef struct aeEventLoop {
int stop; //标识事件循环(即while(1))是否结束
aeFileEvent *events; //存储已经注册的文件事件(文件事件即客户端连接与读写事件)
aeFiredEvent *fired; //存储已就绪的文件事件
aeTimeEvent *timeEventHead; //存储时间事件(时间事件后面再讲)
void *apidata; /* 存储epoll相关信息 */
aeBeforeSleepProc *beforesleep; //事件发生前需要调用的函数
aeBeforeSleepProc *aftersleep; //事件发生后需要调用的函数
} aeEventLoop;</code></pre>
<p>redis将所有通过epoll_wait()返回的就绪描述符都存储在fired数组中,然后遍历这个数组,并调用对应的事件处理函数,一次性处理完所有事件。在aeCreateEventLoop()函数中,对这个管理所有事件信息的结构体字段进行了初始化,这里面也包括调用epoll_create(),对epoll的epfd进行初始化:</p>
<pre><code class="c">aeEventLoop *aeCreateEventLoop(int setsize) {
aeEventLoop *eventLoop;
int i;
if ((eventLoop = zmalloc(sizeof(*eventLoop))) == NULL) goto err;
eventLoop->events = zmalloc(sizeof(aeFileEvent)*setsize);
eventLoop->fired = zmalloc(sizeof(aeFiredEvent)*setsize);
if (eventLoop->events == NULL || eventLoop->fired == NULL) goto err;
eventLoop->setsize = setsize;
eventLoop->lastTime = time(NULL);
eventLoop->timeEventHead = NULL;
eventLoop->timeEventNextId = 0;
eventLoop->stop = 0;
eventLoop->maxfd = -1;
eventLoop->beforesleep = NULL;
eventLoop->aftersleep = NULL;
if (aeApiCreate(eventLoop) == -1) goto err; //调用aeApiCreate(),内部会调用epoll_create()
for (i = 0; i < setsize; i++)
eventLoop->events[i].mask = AE_NONE;
return eventLoop;
}</code></pre>
<p>在aeApiCreate()函数中,调用了epoll_create(),并将创建好的epfd放到eventLoop结构体的apidata字段保管:</p>
<pre><code class="c">typedef struct aeApiState {
int epfd;
struct epoll_event *events;
} aeApiState;
static int aeApiCreate(aeEventLoop *eventLoop) {
aeApiState *state = zmalloc(sizeof(aeApiState));
if (!state) return -1;
state->events = zmalloc(sizeof(struct epoll_event)*eventLoop->setsize);
if (!state->events) {
zfree(state);
return -1;
}
state->epfd = epoll_create(1024); /* 调用epoll_create初始化epoll的epfd */
if (state->epfd == -1) {
zfree(state->events);
zfree(state);
return -1;
}
eventLoop->apidata = state; //将创建好的epfd放到eventLoop结构体的apidata字段保管
return 0;
}</code></pre>
<h4>listenToPort()</h4>
<p>在创建完epfd之后,我们就要进行socket创建、绑定、监听的操作了,这几步在listenToPort()函数来进行:</p>
<pre><code class="c">int listenToPort(int port, int *fds, int *count) {
if (server.bindaddr_count == 0) server.bindaddr[0] = NULL;
for (j = 0; j < server.bindaddr_count || j == 0; j++) { //遍历所有的ip地址
if (server.bindaddr[j] == NULL) { //还没有绑定地址
...
} else if (strchr(server.bindaddr[j],':')) { //绑定IPv6地址
...
} else { //绑定IPv4地址,一般会进到这个if分支中
fds[*count] = anetTcpServer(server.neterr,port,server.bindaddr[j], server.tcp_backlog); //真正的绑定逻辑
}
...
}
return C_OK;
}</code></pre>
<p>redis会先进行绑定ip地址类型的判断,我们一般是IPv4,所以一般会走到第三个分支,调用anetTcpServer()函数来进行具体的绑定逻辑:</p>
<pre><code class="c">static int _anetTcpServer(char *err, int port, char *bindaddr, int af, int backlog)
{
...
if ((rv = getaddrinfo(bindaddr,_port,&hints,&servinfo)) != 0) {
anetSetError(err, "%s", gai_strerror(rv));
return ANET_ERR;
}
for (p = servinfo; p != NULL; p = p->ai_next) {
if ((s = socket(p->ai_family,p->ai_socktype,p->ai_protocol)) == -1) //调用socket()创建一个监听套接字
continue;
if (af == AF_INET6 && anetV6Only(err,s) == ANET_ERR) goto error;
if (anetSetReuseAddr(err,s) == ANET_ERR) goto error;
if (anetListen(err,s,p->ai_addr,p->ai_addrlen,backlog) == ANET_ERR) s = ANET_ERR; //调用bind()与listen()绑定端口并转化为服务端被动套接字
goto end;
}
}</code></pre>
<p>在调用socket()系统调用创建了套接字之后,需要进一步调用bind()与listen(),这两步是在anetListen()函数内部实现的:</p>
<pre><code class="c">static int anetListen(char *err, int s, struct sockaddr *sa, socklen_t len, int backlog) {
if (bind(s,sa,len) == -1) { //调用bind()绑定端口
anetSetError(err, "bind: %s", strerror(errno));
close(s);
return ANET_ERR;
}
if (listen(s, backlog) == -1) { //调用listen()将主动套接字转换为被动监听套接字
anetSetError(err, "listen: %s", strerror(errno));
close(s);
return ANET_ERR;
}
return ANET_OK;
}</code></pre>
<p>看到这里,我们知道redis和我们写过的epoll服务器一样,都是需要进行套接字创建、绑定、监听的过程。</p>
<h4>aeCreateFileEvent</h4>
<p>在redis中,把客户端连接事件、读写事件统称为文件事件。我们刚才完成了socket创建、bind、listen的过程。目前我们已经有了一个监听描述符,那么我们需要首先将监听描述符添加到epoll的监听列表,以监听客户端的连接事件。在initServer()中,通过调用aeCreateFileEvent(),同时指定了它的事件处理函数acceptTcpHandler()来实现对客户端连接事件的处理:</p>
<pre><code class="c"> for (j = 0; j < server.ipfd_count; j++) {
if (aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE, acceptTcpHandler,NULL) == AE_ERR){
serverPanic("Unrecoverable error creating server.ipfd file event.");
}
}</code></pre>
<p>跟进aeCreateFileEvent()函数,发现其内部进一步调用了aeApiAddEvent()函数:</p>
<pre><code class="c">int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask, aeFileProc *proc, void *clientData) {
if (fd >= eventLoop->setsize) {
errno = ERANGE;
return AE_ERR;
}
aeFileEvent *fe = &eventLoop->events[fd];
if (aeApiAddEvent(eventLoop, fd, mask) == -1)
return AE_ERR;
fe->mask |= mask;
if (mask & AE_READABLE) fe->rfileProc = proc;
if (mask & AE_WRITABLE) fe->wfileProc = proc;
fe->clientData = clientData;
if (fd > eventLoop->maxfd)
eventLoop->maxfd = fd;
return AE_OK;
}</code></pre>
<pre><code class="c">static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) {
aeApiState *state = eventLoop->apidata;
struct epoll_event ee = {0};
int op = eventLoop->events[fd].mask == AE_NONE ?
EPOLL_CTL_ADD : EPOLL_CTL_MOD;
ee.events = 0;
mask |= eventLoop->events[fd].mask;
if (mask & AE_READABLE) ee.events |= EPOLLIN;
if (mask & AE_WRITABLE) ee.events |= EPOLLOUT;
ee.data.fd = fd;
if (epoll_ctl(state->epfd,op,fd,&ee) == -1) return -1; //调用epoll_ctl添加客户端连接事件
return 0;
}</code></pre>
<p>aeApiAddEvent函数会调用epoll_ctl(),将客户端连接事件添加到监听列表。同时,redis会将该事件的处理函数放到aeFileEvent结构体中进行存储:</p>
<pre><code class="c">typedef struct aeFileEvent {
int mask; /* one of AE_(READABLE|WRITABLE|BARRIER) */
aeFileProc *rfileProc; //读事件处理程序
aeFileProc *wfileProc; //写事件处理程序
void *clientData; //客户端数据
} aeFileEvent;</code></pre>
<p>对照之前我们写过的epoll服务端程序,我们已经实现了以下几个步骤:</p>
<pre><code class="c">int main(int argc, char *argv[]) {
listenSocket = socket(AF_INET, SOCK_STREAM, 0); //创建一个监听套接字描述符
bind(listenSocket) //绑定地址与端口
listen(listenSocket) //由默认的主动套接字转换为服务器适用的被动套接字
epfd = epoll_create(EPOLL_SIZE); //创建一个epoll实例
ep_events = (epoll_event*)malloc(sizeof(epoll_event) * EPOLL_SIZE); //创建一个epoll_event结构存储套接字集合
event.events = EPOLLIN;
event.data.fd = listenSocket;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenSocket, &event); //将监听套接字加入到监听列表中
...
}</code></pre>
<p>我们已经实现了对套接字的创建、bind、listen,已通过epoll_create()实现了epfd的创建,并将初始的监听套接字描述符事件添加到了epoll的监听列表中,并为他指定了事件处理函数。下一步,就应该到了while(1)循环调用epoll_wait()的阶段了。通过阻塞调用epoll_wait(),返回所有已经就绪的套接字描述符,触发相应事件,然后对事件进行处理。</p>
<h3>aeMain()</h3>
<p>最后就是通过while(1)循环,等待客户端连接事件的到来啦:</p>
<pre><code class="c">void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
if (eventLoop->beforesleep != NULL)
eventLoop->beforesleep(eventLoop);
aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);
}
}</code></pre>
<p>在eventLoop中,采用stop标志来判定循环是否结束。如果没有结束,那么循环调用aeProcessEvents()。我们猜测,这里面就调用了epoll_wait(),阻塞等待事件的到来,然后遍历所有就绪的套接字描述符,然后调用对应的事件处理函数即可:</p>
<pre><code class="c">int aeProcessEvents(aeEventLoop *eventLoop, int flags)
{
numevents = aeApiPoll(eventLoop, tvp); //调用epoll_wait()
...
}</code></pre>
<p>我们跟进aeApiPoll,来看看epoll_wait()是如何调用的:</p>
<pre><code class="c">static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
aeApiState *state = eventLoop->apidata; //
int retval, numevents = 0;
retval = epoll_wait(state->epfd,state->events,eventLoop->setsize, tvp ? (tvp->tv_sec*1000 + tvp->tv_usec/1000) : -1);
if (retval > 0) {
int j;
numevents = retval;
for (j = 0; j < numevents; j++) {
int mask = 0;
struct epoll_event *e = state->events+j;
if (e->events & EPOLLIN) mask |= AE_READABLE;
if (e->events & EPOLLOUT) mask |= AE_WRITABLE;
if (e->events & EPOLLERR) mask |= AE_WRITABLE;
if (e->events & EPOLLHUP) mask |= AE_WRITABLE;
eventLoop->fired[j].fd = e->data.fd;
eventLoop->fired[j].mask = mask;
}
}
return numevents;
}</code></pre>
<p>首先从eventLoop中拿出之前在aeApiCreate()中创建的epfd与已经注册的事件集合,调用epoll_wait()等待事件们的到来,并返回所有就绪事件的描述符集合。随后,遍历所有就绪的描述符集合,判断它是什么类型的描述符,是可读还是可写的,随后将所有就绪可处理的事件存储到eventLoop中的fired数组中,并把相应数组位置上的可读还是可写标记也一并存储。<br>回到外部调用处,我们现在已经把所有能够处理的事件都放到了fired数组中,那么我们就可以通过遍历这个数组,拿到所有可以处理的事件,然后调用对应的事件处理函数:</p>
<pre><code class="c">int aeProcessEvents(aeEventLoop *eventLoop, int flags)
{
numevents = aeApiPoll(eventLoop, tvp); //调用epoll_wait()
for (j = 0; j < numevents; j++) {
aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd]; //循环拿出所有就绪的事件
int mask = eventLoop->fired[j].mask;
int fd = eventLoop->fired[j].fd;
int fired = 0;
if (!invert && fe->mask & mask & AE_READABLE) {
fe->rfileProc(eventLoop,fd,fe->clientData,mask); //如果该事件是读事件,调用读事件处理函数
fired++;
}
if (fe->mask & mask & AE_WRITABLE) {
if (!fired || fe->wfileProc != fe->rfileProc) {
fe->wfileProc(eventLoop,fd,fe->clientData,mask); //如果该事件是写事件,调用写事件处理函数
fired++;
}
}
}
}
...
}</code></pre>
<p>至于如何区分是客户端连接事件以及读写事件,redis通过指定不同的事件处理函数(如accept事件是acceptTcpHandler事件处理函数),读或写事件又是其他的事件处理函数。通过这层封装,免去了判断套接字描述符类型的步骤,直接调用之前注册的事件处理函数即可、<br>回顾我们之前写过的的epoll服务器,是不是和这一段代码很相似呢?</p>
<pre><code class="c"> while (1) {
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1); //等待返回已经就绪的套接字描述符们
for (int i = 0; i < event_cnt; ++i) { //遍历所有就绪的套接字描述符
if (ep_events[i].data.fd == listenSocket) { //如果是监听套接字描述符就绪了,说明有一个新客户端连接到来
connSocket = accept(listenSocket); //调用accept()建立连接
event.events = EPOLLIN;
event.data.fd = connSocket;
epoll_ctl(epfd, EPOLL_CTL_ADD, connSocket, &event); //添加对新建立的连接套接字描述符的监听,以监听后续在连接描述符上的读写事件
} else { //如果是连接套接字描述符事件就绪,则可以进行读写
strlen = read(ep_events[i].data.fd, buf, BUF_SIZE); //从连接套接字描述符中读取数据, 此时一定会读到数据,不会产生阻塞
if (strlen == 0) { //已经无法从连接套接字中读到数据,需要移除对该socket的监听
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL); //删除对这个描述符的监听
close(ep_events[i].data.fd);
} else {
write(ep_events[i].data.fd, buf, str_len); //如果该客户端可写 把数据写回到客户端
}
}
}
}</code></pre>
<h2>总结</h2>
<p>至此,我们就掌握了redis中的IO多路复用场景。redis把所有连接与读写事件、还有我们没提到的时间事件一起集中管理,并对底层IO多路复用机制进行了封装,最终实现了单进程能够处理多个连接以及读写事件。这就是IO多路复用在redis中的应用。</p>
【业务学习】浅析服务器并发IO性能提升之路 — 从网络编程基础到epoll
https://segmentfault.com/a/1190000020194471
2019-08-26T23:13:16+08:00
2019-08-26T23:13:16+08:00
NoSay
https://segmentfault.com/u/nosay
5
<p>baiyan</p>
<h2>从网络编程基本概念说起</h2>
<p>我们常常使用HTTP协议来传输各种格式的数据,其实HTTP这个应用层协议的底层,是基于传输层TCP协议来实现的。TCP协议仅仅把这些数据当做一串无意义的数据流来看待。所以,我们可以说:<strong>客户端与服务器通过在建立的连接上发送字节流来进行通信</strong>。<br>这种C/S架构的通信机制,需要标识通信双方的网络地址和端口号信息。对于客户端来说,需要知道我的数据接收方位置,我们用网络地址和端口来唯一标识一个服务端实体;对于服务端来说,需要知道数据从哪里来,我们同样用网络地址和端口来唯一标识一个客户端实体。那么,用来唯一标识通信两端的数据结构就叫做套接字。一个连接可以由它两端的套接字地址唯一确定:</p>
<pre><code>(客户端地址:客户端端口号,服务端地址:服务端端口号)</code></pre>
<p>有了通信双方的地址信息之后,就可以进行数据传输了。那么我们现在需要一个规范,来规定通信双方的连接及数据传输过程。在Unix系统中,实现了一套套接字接口,用来描述和规范双方通信的整个过程。</p>
<blockquote><ul>
<li>socket():创建一个套接字描述符</li>
<li>connect():客户端通过调用connect函数来建立和服务器的连接</li>
<li>bind():告诉内核将socket()创建的套接字与某个服务端地址与端口连接起来,后续会对这个地址和端口进行监听</li>
<li>listen():告诉内核,将这个套接字当成服务器这种被动实体来看待(服务器是等待客户端连接的被动实体,而内核认为socket()创建的套接字默认是主动实体,所以才需要listen()函数,告诉内核进行主动到被动实体的转换)</li>
<li>accept():等待客户端的连接请求并返回一个新的已连接描述符</li>
</ul></blockquote>
<h2>最简单的单进程服务器</h2>
<p>由于Unix的历史遗留问题,原始的套接字接口对地址和端口等数据封装并不简洁,为了简化这些我们不关注的细节而只关注整个流程,我们使用PHP来进行分析。PHP对Unix的socket相关接口进行了封装,所有相关套接字的函数都被加上了socket_前缀,并且使用一个资源类型的套接字句柄代替Unix中的文件描述符fd。在下文的描述中,均用“套接字”代替Unix中的文件描述符fd进行阐述。一个PHP实现的简单服务器伪代码如下:</p>
<pre><code class="php"><?php
if (($listenSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP))=== false) {
echo '套接字创建失败';
}
if (socket_bind($listenSocket, '127.0.0.1', 8888) === false) {
echo '绑定地址与端口失败';
}
if (socket_listen($listenSocket) === false) {
echo '转换主动套接字到被动套接字失败';
}
while (1) {
if (($connSocket = socket_accept($listenSocket)) === false) {
echo '客户端的连接请求还没有到达';
} else {
socket_close($listenSocket); //释放监听套接字
socket_read($connSocket); //读取客户端数据,阻塞
socket_write($connSocket); //给客户端返回数据,阻塞
}
socket_close($connSocket);
}</code></pre>
<p>我们梳理一下这个简单的服务器创建流程:</p>
<blockquote><ul>
<li>socket_create():创建一个套接字,这个套接字就代表建立的连接上的一个端点。第一个参数AF_INET为使用的底层协议为IPv4;第二个参数SOCK_STREAM表示使用字节流进行数据传输;第三个参数SQL_TCP代表本层协议为TCP协议。这里创建的套接字只是一个连接上的端点的一个<strong>抽象</strong>概念。</li>
<li>socket_bind():绑定这个套接字到一个具体的服务器地址和端口上,真正<strong>实例化</strong>这个套接字。参数就是你之前创建的一个抽象的套接字,还有你具体的网络地址和端口。</li>
<li>socket_listen():我们观察到只有一个函数参数就是之前创建的套接字。有些同学之前可能认为这一步函数调用完全没有必要。但是它告诉内核,我是一个服务器,将套接字转换为一个被动实体,其实是有很大的作用的。</li>
<li>socket_accept():接收客户端发来的请求。因为服务器启动之后,是不知道客户端什么时候有连接到来的。所以,需要在一个while循环中不断调用这个函数,如果有连接请求到来,那么就会返回一个新的套接字,我们可以通过这个新的套接字进行与客户端的数据通信,如果没有,就只能不断地进行循环,直到有请求到来为止。</li>
</ul></blockquote>
<p>注意,在这里我将套接字分为两类,一个是<strong>监听套接字</strong>,一个是<strong>连接套接字</strong>。注意这里对两种套接字的区分,在下面的讨论中会用到:</p>
<blockquote><ul>
<li>监听套接字:服务器对某个端口进行监听,这个套接字用来表示这个端口($listenSocket)</li>
<li>连接套接字:服务器与客户端已经建立连接,所有的读写操作都要在连接套接字上进行($connSocket)</li>
</ul></blockquote>
<p>那么我们对这个服务器进行分析,它存在什么问题呢?</p>
<blockquote>
<strong>一个这样的服务器进程只能同时处理一个客户端连接与相关的读写操作</strong>。因为一旦有一个客户端连接请求到来,我们对监听套接字进行accept之后,就开启了与该客户端的数据传输过程。在数据读写的过程中,整个进程被该客户端连接独占,当前服务器进程只能处理该客户端连接的读写操作,无法对其它客户端的连接请求进行处理。</blockquote>
<h2>IO并发性能提升之路</h2>
<p>由于上述服务器的性能太烂,无法同时处理多个客户端连接以及读写操作,所以优秀的开发者们想出了以下几种方案,用以提升服务器的效率,分别是:</p>
<blockquote><ul>
<li>多进程</li>
<li>多线程</li>
<li>基于单进程的IO多路复用(select/poll/epoll)</li>
</ul></blockquote>
<h3>多进程</h3>
<p>那么如何去优化单进程呢?很简单,一个进程不行,那搞很多个进程不就可以同时处理多个客户端连接了吗?我们想了想,写出了代码:</p>
<pre><code class="php"><?php
if (($listenSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP))=== false) {
echo '套接字创建失败';
}
if (socket_bind($listenSocket, '127.0.0.1', 8888) === false) {
echo '绑定地址与端口失败';
}
if (socket_listen($listenSocket) === false) {
echo '转换主动套接字到被动套接字失败';
}
for ($i = 0; $i < 10; $i++) { //初始创建10个子进程
if (pcntl_fork() == 0) {
if (($connSocket = socket_accept($listenSocket)) === false) {
echo '客户端的连接请求还没有到达';
} else {
socket_close($listenSocket); //释放监听套接字
socket_read($connSocket); //读取客户端数据
socket_write($connSocket); //给客户端返回数据
}
socket_close($connSocket);
}
}</code></pre>
<p>我们主要关注这个for循环,一共循环了10次代表初始的子进程数量我们设置为10。接着我们调用了pcntl_fork()函数创建子进程。由于一个客户端的connect就对应一个服务端的accept。所以在每个fork之后的10个子进程中,我们均进行accept的系统调用,等待客户端的连接。这样,就可以通过10个服务器进程,同时接受10个客户端的连接、同时为10个客户端提供读写数据服务。<br>注意这样一个细节,由于所有子进程都是预先创建好的,那么请求到来的时候就不用创建子进程,也提高了每个连接请求的处理效率。同时也可以借助进程池的概念,这些子进程在处理完连接请求之后并不立即回收,可以继续服务下一个客户端连接请求,就不用重复的进行fork()的系统调用,也能够提高服务器的性能。这些小技巧在PHP-FPM的实现中都有所体现。其实这种进程创建方式是其三种运行模式中的一种,被称作static(静态进程数量)模式:</p>
<blockquote><ul>
<li>ondemand:按需启动。PHP-FPM启动的时候不会启动任何一个子进程(worker进程),只有客户端连接请求到达时才启动</li>
<li>dynamic:在PHP-FPM启动时,会初始启动一些子进程,在运行过程中视情况动态调整worker数量</li>
<li>static:PHP-FPM启动时,启动固定大小数量的子进程,在运行期间也不会扩容</li>
</ul></blockquote>
<p>回到正题,多进程这种方式的的确确解决了服务器在同一时间只能处理一个客户端连接请求的问题,但是这种基于多进程的客户端连接处理模式,仍存在以下劣势:</p>
<blockquote><ul>
<li>fork()等系统调用会使得进程的上下文进行切换,效率很低</li>
<li>进程创建的数量随着连接请求的增加而增加。比如100000个请求,就要fork100000个进程,开销太大</li>
<li>进程与进程之间的地址空间是私有、独立的,使得进程之间的数据共享变得困难</li>
</ul></blockquote>
<p>既然谈到了多进程的数据共享与切换开销的问题,那么我们能够很快想到解决该问题的方法,就是化多进程为更轻量级的多线程。</p>
<h3>多线程</h3>
<p>线程是运行在进程上下文的逻辑流。一个进程可以包含多个线程,多个线程运行在单一的进程上下文中,因此共享这个进程的地址空间的所有内容,解决了进程与进程之间通信难的问题。同时,由于一个线程的上下文要比一个进程的上下文小得多,所以线程的上下文切换,要比进程的上下文切换效率高得多。线程是轻量级的进程,解决了进程上下文切换效率低的问题。<br>由于PHP中没有多线程的概念,所以我们仅仅把上面的伪代码中创建进程的部分,改成创建线程即可,代码大体类似,在此不再赘述。</p>
<h3>IO多路复用</h3>
<p>前面谈到的都是通过增加进程和线程的数量来同时处理多个套接字。而IO多路复用只需要一个进程就能够处理多个套接字。IO多路复用这个名词看起来好像很复杂很高深的样子。实际上,这项技术所能带来的本质成果就是:<strong>一个服务端进程可以同时处理多个套接字描述符</strong>。</p>
<blockquote><ul>
<li>
<strong>多路</strong>:多个客户端连接(连接就是套接字描述符)</li>
<li>
<strong>复用</strong>:使用单进程就能够实现同时处理多个客户端的连接</li>
</ul></blockquote>
<p>在之前的讲述中,一个服务端进程,只能同时处理一个连接。如果想同时处理多个客户端连接,需要多进程或者多线程的帮助,免不了上下文切换的开销。IO多路复用技术就解决了上下文切换的问题。IO多路复用技术的发展可以分为select->poll->epoll三个阶段。</p>
<p>IO多路复用的核心就是添加了一个<strong>套接字集合管理员</strong>,它可以<strong>同时监听多个套接字</strong>。由于客户端连接以及读写事件到来的随机性,我们需要这个管理员在单进程内部对多个套接字的事件进行合理的调度。</p>
<h4>select</h4>
<p>最早的<strong>套接字集合管理员</strong>是select()系统调用,它可以同时管理多个套接字。select()函数会在某个或某些套接字的状态从不可读变为可读、或不可写变为可写的时候通知服务器主进程。所以select()本身的调用是阻塞的。但是具体哪一个套接字或哪些套接字变为可读或可写我们是不知道的,所以我们需要遍历所有select()返回的套接字来判断哪些套接字可以进行处理了。而这些套接字中又可以分为<strong>监听套接字</strong>与<strong>连接套接字</strong>(上文提过)。我们可以使用PHP为我们提供的socket_select()函数。在select()的函数原型中,为套接字们分了个类:读、写与异常套接字集合,分别监听套接字的读、写与异常事件。:</p>
<pre><code class="php">function socket_select (array &$read, array &$write, array &$except, $tv_sec, $tv_usec = 0) {}</code></pre>
<p>举个例子,如果某个客户单通过调用connect()连接到了服务器的<strong>监听套接字</strong>($listenSocket)上,这个监听套接字的状态就会从不可读变为可读。由于监听套接字只有一个,select()对于监听套接字上的处理仍然是阻塞的。一个监听套接字,存在于整个服务器的生命周期中,所以在select()的实现中并不能体现出其对监听套接字的优化管理。<br>在当一个服务器使用accept()接受多个客户端连接,并生成了多个<strong>连接套接字</strong>之后,select()的管理才能就会体现出来。这个时候,select()的监听列表中有<strong>一个监听套接字</strong>、和与<strong>一堆</strong>客户端建立连接后新创建的<strong>连接套接字</strong>。在这个时候,可能这一堆已建立连接的客户端,都会通过这个连接套接字发送数据,等待服务端接收。假设同时有5个连接套接字都有数据发送,那么这5个连接套接字的状态都会变成可读状态。由于已经有套接字变成了可读状态,select()函数解除阻塞,立即返回。具体哪一个套接字或哪些套接字变为可读或可写我们是不知道的,所以我们需要遍历所有select()返回的套接字,来判断哪些套接字已经就绪,可以进行读写处理。遍历完毕之后,就知道有5个连接套接字可以进行读写处理,这样就实现了同时对多个套接字的管理。使用PHP实现select()的代码如下:</p>
<pre><code class="php"><?php
if (($listenSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP))=== false) {
echo '套接字创建失败';
}
if (socket_bind($listenSocket, '127.0.0.1', 8888) === false) {
echo '绑定地址与端口失败';
}
if (socket_listen($listenSocket) === false) {
echo '转换主动套接字到被动套接字失败';
}
/* 要监听的三个sockets数组 */
$read_socks = array(); //读
$write_socks = array(); //写
$except_socks = NULL; //异常
$read_socks[] = $listenSocket; //将初始的监听套接字加入到select的读事件监听数组中
while (1) {
/* 由于select()是引用传递,所以这两个数组会被改变,所以用两个临时变量 */
$tmp_reads = $read_socks;
$tmp_writes = $write_socks;
$count = socket_select($tmp_reads, $tmp_writes, $except_socks, NULL);
foreach ($tmp_reads as $read) { //不知道哪些套接字有变化,需要对全体套接字进行遍历来看谁变了
if ($read == $listenSocket) { //监听套接字有变化,说明有新的客户端连接请求到来
$connSocket = socket_accept($listenSocket); //响应客户端连接, 此时一定不会阻塞
if ($connSocket) {
//把新建立的连接socket加入监听
$read_socks[] = $connSocket;
$write_socks[] = $connSocket;
}
} else { //新创建的连接套接字有变化
/*客户端传输数据 */
$data = socket_read($read, 1024); //从客户端读取数据, 此时一定会读到数据,不会产生阻塞
if ($data === '') { //已经无法从连接套接字中读到数据,需要移除对该socket的监听
foreach ($read_socks as $key => $val) {
if ($val == $read) unset($read_socks[$key]); //移除失效的套接字
}
foreach ($write_socks as $key => $val) {
if ($val == $read) unset($write_socks[$key]);
}
socket_close($read);
} else { //能够从连接套接字读到数据。此时$read是连接套接字
if (in_array($read, $tmp_writes)) {
socket_write($read, $data);//如果该客户端可写 把数据写回到客户端
}
}
}
}
}
socket_close($listenSocket);</code></pre>
<p>但是,select()函数本身的调用阻塞的。因为select()需要一直等到有状态变化的套接字之后(比如监听套接字或者连接套接字的状态由不可读变为可读),才能解除select()本身的阻塞,继续对读写就绪的套接字进行处理。虽然这里是阻塞的,但是它能够同时返回多个就绪的套接字,而不是之前单进程中只能够处理一个套接字,大大提升了效率<br>总结一下,select()的过人之处有以下几点:</p>
<blockquote><ul>
<li>实现了对多个套接字的同时、集中管理</li>
<li>通过遍历所有的套接字集合,能够获取所有已就绪的套接字,对这些就绪的套接字进行操作不会阻塞</li>
</ul></blockquote>
<p>但是,select()仍存在几个问题:</p>
<blockquote><ul>
<li>select管理的套接字描述符们存在数量限制。在Unix中,一个进程最多同时监听1024个套接字描述符</li>
<li>select返回的时候,并不知道具体是哪个套接字描述符已经就绪,所以需要遍历所有套接字来判断哪个已经就绪,可以继续进行读写</li>
</ul></blockquote>
<p>为了解决第一个套接字描述符数量限制的问题,聪明的开发者们想出了poll这个新套接字描述符管理员,用以替换select这个老管理员,select()就可以安心退休啦。</p>
<h4>poll</h4>
<p><strong>poll解决了select带来的套接字描述符的最大数量限制问题</strong>。由于PHP的socket扩展没有poll对应的实现,所以这里放一个Unix的C语言原型实现:</p>
<pre><code class="c">int poll (struct pollfd *fds, unsigned int nfds, int timeout);</code></pre>
<p>poll的fds参数集合了select的read、write和exception套接字数组,合三为一。poll中的fds没有了1024个的数量限制。当有些描述符状态发生变化并就绪之后,poll同select一样会返回。但是遗憾的是,我们同样不知道具体是哪个或哪些套接字已经就绪,我们仍需要遍历套接字集合去判断究竟是哪个套接字已经就绪,这一点并没有解决刚才提到select的第二个问题。<br>我们可以总结一下,select和poll这两种实现,都需要在返回后,通过遍历所有的套接字描述符来获取已经就绪的套接字描述符。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。<br>为了解决不知道返回之后究竟是哪个或哪些描述符已经就绪的问题,同时避免遍历所有的套接字描述符,聪明的开发者们又发明出了epoll机制,完美解决了select和poll所存在的问题。</p>
<h4>epoll</h4>
<p>epoll是最先进的套接字们的管理员,解决了上述select和poll中所存在的问题。它将一个阻塞的select、poll系统调用拆分成了三个步骤。一次select或poll可以看作是由一次 epoll_create、若干次 epoll_ctl、若干次 epoll_wait构成:</p>
<pre><code class="c">int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);</code></pre>
<blockquote><ul>
<li>epoll_create():创建一个epoll实例。后续操作会使用</li>
<li>epoll_ctl():对套接字描述符集合进行增删改操作,并告诉内核需要监听套接字描述符的什么事件</li>
<li>epoll_wait():等待监听列表中的<strong>连接事件</strong>(监听套接字描述符才会发生)或<strong>读写事件</strong>(连接套接字描述符才会发生)。如果有某个或某些套接字事件已经准备就绪,就会返回这些已就绪的套接字们</li>
</ul></blockquote>
<p>看起来,这三个函数明明就是从select、poll一个函数拆成三个函数了嘛。我们对某套接字描述符的添加、删除、修改操作由之前的代码实现变成了调用epoll_ctl()来实现。epoll_ctl()的参数含义如下:</p>
<blockquote><ul>
<li>epfd:epoll_create()的返回值</li>
<li>op:表示对下面套接字描述符fd所进行的操作。EPOLL_CTL_ADD:将描述符添加到监听列表;EPOLL_CTL_DEL:不再监听某描述符;EPOLL_CTL_MOD:修改某描述符</li>
<li>fd:上面op操作的套接字描述符对象(之前在PHP中是$listenSocket与$connSocket两种套接字描述符)例如将某个套接字<strong>添加</strong>到监听列表中</li>
<li>event:告诉内核需要监听该套接字描述符的什么事件(如读写、连接等)</li>
</ul></blockquote>
<p>最后我们调用epoll_wait()等待连接或读写等事件,在某个套接字描述符上准备就绪。当有事件准备就绪之后,会存到第二个参数epoll_event结构体中。通过访问这个结构体就可以得到所有已经准备好事件的套接字描述符。这里就不用再像之前select和poll那样,遍历所有的套接字描述符之后才能知道究竟是哪个描述符已经准备就绪了,这样减少了一次O(n)的遍历,大大提高了效率。<br>在最后返回的所有套接字描述符中,同样存在之前说过的两种描述符:<strong>监听套接字描述符</strong>和<strong>连接套接字描述符</strong>。那么我们需要遍历所有准备就绪的描述符,然后去判断究竟是监听还是连接套接字描述符,然后视情况做做出accept(监听套接字)或者是read(连接套接字)的处理。一个使用C语言编写的epoll服务器的伪代码如下(重点关注代码注释):</p>
<pre><code class="c">int main(int argc, char *argv[]) {
listenSocket = socket(AF_INET, SOCK_STREAM, 0); //同上,创建一个监听套接字描述符
bind(listenSocket) //同上,绑定地址与端口
listen(listenSocket) //同上,由默认的主动套接字转换为服务器适用的被动套接字
epfd = epoll_create(EPOLL_SIZE); //创建一个epoll实例
ep_events = (epoll_event*)malloc(sizeof(epoll_event) * EPOLL_SIZE); //创建一个epoll_event结构存储套接字集合
event.events = EPOLLIN;
event.data.fd = listenSocket;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenSocket, &event); //将监听套接字加入到监听列表中
while (1) {
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1); //等待返回已经就绪的套接字描述符们
for (int i = 0; i < event_cnt; ++i) { //遍历所有就绪的套接字描述符
if (ep_events[i].data.fd == listenSocket) { //如果是监听套接字描述符就绪了,说明有一个新客户端连接到来
connSocket = accept(listenSocket); //调用accept()建立连接
event.events = EPOLLIN;
event.data.fd = connSocket;
epoll_ctl(epfd, EPOLL_CTL_ADD, connSocket, &event); //添加对新建立的连接套接字描述符的监听,以监听后续在连接描述符上的读写事件
} else { //如果是连接套接字描述符事件就绪,则可以进行读写
strlen = read(ep_events[i].data.fd, buf, BUF_SIZE); //从连接套接字描述符中读取数据, 此时一定会读到数据,不会产生阻塞
if (strlen == 0) { //已经无法从连接套接字中读到数据,需要移除对该socket的监听
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL); //删除对这个描述符的监听
close(ep_events[i].data.fd);
} else {
write(ep_events[i].data.fd, buf, str_len); //如果该客户端可写 把数据写回到客户端
}
}
}
}
close(listenSocket);
close(epfd);
return 0;
}</code></pre>
<p>我们看这个通过epoll实现一个IO多路复用服务器的代码结构,除了由一个函数拆分成三个函数,其余的执行流程基本同select、poll相似。只是epoll会只返回已经就绪的套接字描述符集合,而不是所有描述符的集合,IO的效率不会随着监视fd的数量的增长而下降,大大提升了效率。同时它细化并规范了对每个套接字描述符的管理(如增删改的过程)。此外,它监听的套接字描述符是没有限制的,这样,之前select、poll的遗留问题就全部解决啦。</p>
<h2>总结</h2>
<p>我们从最基本网络编程说起,开始从一个最简单的同步阻塞服务器到一个IO多路复用服务器,我们从头到尾了解到了一个服务器性能提升的思考与实现过程。而提升服务器的并发性能的方式远不止这几种,还包括协程等新的概念需要我们去对比与分析,大家加油。</p>
【业务学习】初识Kafka
https://segmentfault.com/a/1190000020087243
2019-08-15T21:51:52+08:00
2019-08-15T21:51:52+08:00
NoSay
https://segmentfault.com/u/nosay
2
<p>Grape</p>
<hr>
<p>这几天简单学习了一下Kafka,看了一些书,也查了一些资料,结合这些,我简单总结了一下Kafka的一些基础知识,以此作记录~<br>老规矩,抛出我们这篇文章的三个问题:</p>
<ol>
<li>什么是Kafka?</li>
<li>有什么优缺点?</li>
<li>应用范围是什么?</li>
</ol>
<p>大家简单思考一下,如果你知道的话~</p>
<hr>
<h2>1.什么是Kafka?</h2>
<p>首先我们看一下来自百度百科的定义:Kafka是由Apache软件基金会开发的一个开源流处理平台,由Scala和Java编写。Kafka是一种高吞吐量的分布式发布订阅消息系统,它可以处理消费者在网站中的所有动作流数据。 <br> 这里边有两个重点:消息系统和发布订阅。那么接下来我们着重在这两方面叙述。</p>
<h3>消息系统</h3>
<p><strong>什么是消息系统?</strong>简单的说它主要是用来做数据收集处理以及传输的一个系统。数据工程中最具挑战性的部分之一是如何从不同点收集和传输大量数据到分布式系统进行处理和分析。需要通过消息队列正确地分离大量数据,因为如果一部分数据无法传送,则可以在系统恢复时传输和分析其他数据。通常有两种消息排队:点对点(Point to point)和发布者——订阅者(publisher-subscriber)。 对于上述目的,它们都是可靠的和异步的。</p>
<h3>发布订阅:</h3>
<p>说起发布订阅我们先来看下通常存在的两种消息排队中的另一位玩家:点对点。在点对点或一对一中,有一个发件人和正在监听发件人的多个消费者。当一个消费者从队列收到消息时,该特定消息将从队列中消失,而其他消费者无法获得该消息。<br> 发布订阅:发布者向同时收听发布者的多个消费者或订阅者发送消息,并且每个订阅者可以获得相同的消息。数据应通过数据管道传输,数据管道负责整合来自数据源的数据。<br> 也就是说,点对点只能发送给一个人一份信息,而发布订阅式可以发送给不用的人相同的信息。在这里我们不论述哪种方式的好坏,因为,方式的好坏是看业务场景的。</p>
<p>明白了定义,我们要知道Kafka的结构式什么样子的。大概就是以下图示:</p>
<p><img src="/img/bVbwrJq?w=1342&h=732" alt="clipboard.png" title="clipboard.png"><br><img src="/img/bVbwrJs?w=1128&h=760" alt="clipboard.png" title="clipboard.png"><br><img src="/img/bVbwrJw?w=1180&h=648" alt="clipboard.png" title="clipboard.png"></p>
<p>我们看到,很明白的发布订阅者模式,以上三张图也是由浅入深,第一张大概介绍整个Kafka架构,我们可以看到又多个发布者向不同的broker发送消息,然后broker管理不同的topic,最后订阅者去不同的分区消费消息。<br> 当然,你可能还不太明白发布者订阅者之类的术语,下边我来解答几个常见的术语:<br> (1)Topics(主题) :属于特定类别的消息流称为主题。 数据存储在主题中。Topic相当于Queue。主题被拆分成分区。 每个这样的分区包含不可变有序序列的消息。分区被实现为具有相等大小的一组分段文件。 <br> (2)Partition(分区)</p>
<p><img src="/img/bVbwrJA?w=1202&h=554" alt="clipboard.png" title="clipboard.png"></p>
<p>一个Topic可以分成多个Partition,这是为了平行化处理。每个Partition内部消息有序,其中每个消息都有一个offset序号。一个Partition只对应一个Broker,一个Broker可以管理多个Partition。<br> (3)Partition offset(分区偏移) 每个分区消息具有称为 offset 的唯一序列标识。 <br> (4)Replicas of partition(分区备份) 副本只是一个分区的备份。 副本从不读取或写入数据。 它们用于防止数据丢失。 <br> (5)Brokers(经纪人):单个的 Kafka 服务器叫做「中间人」(Broker)。一个 Kafka 中间人,接收生产者发来的消费,分配偏移量,并存储入物理空间中去;同时,中间人还接收消费者的请求,把物理空间里的消息响应回去。<br> (6)Kafka Cluster(Kafka集群) Kafka有多个代理被称为Kafka集群。 可以扩展Kafka集群,无需停机。 这些集群用于管理消息数据的持久性和复制。 <br> (7)Producers(生产者): 生产者是发送给一个或多个Kafka主题的消息的发布者。 生产者向Kafka经纪人发送数据。 每当生产者将消息发布给代理时,代理只需将消息附加到最后一个段文件。实际上,该消息将被附加到分区。 生产者还可以向他们选择的分区发送消息。 <br> (8)Consumers(消费者) :Consumers从经纪人处读取数据。 消费者订阅一个或多个主题,并通过从代理中提取数据来使用已发布的消息。</p>
<ul>
<li>Consumer自己维护消费到哪个offset。</li>
<li>每个Consumer都有对应的group。</li>
<li>group内是queue消费模型:各个Consumer消费不同的 partition,因此一个消息在group内只消费一次。</li>
<li>group间是publish-subscribe消费模型:各个group各自独立消费,互不影响,因此一个消息被每个group消费一次。</li>
<li>如果你对offset感兴趣,推荐关于offset的一篇文章请看:<a href="https://link.segmentfault.com/?enc=gxLUDxtH%2FcH2Dk5EUdFN%2BQ%3D%3D.hrZharJl%2FkLoW6YtNA%2BbGq38eccV6SWjve1teeHA%2BMcL%2B7od5rSu8QLdAquzJhsv" rel="nofollow">Kafka Offset管理</a>
</li>
</ul>
<p>到这我们总结一下,发布订阅系统的大致流程就是生产者生产消息推送到brokers,各个broker将消息发送到不同的topic的分区上,然后再由消费者来进行消费(当然,这是最简单的模式)。<br> 注意:生产者也可以向指定的某个topic由向他们选择的分区发送消息。</p>
<h2>2. 有什么优缺点?</h2>
<p>发布/订阅式的系统有很多,但Kafka出色在哪写方面?</p>
<ol>
<li>解耦 <br> 在项目启动之初来预测将来项目会碰到什么需求,是极其困难的。消息系统在处理过程中间插入了一个隐含的、基于数据的接口层,两边的处理过程都要实现这一接口。这允许你独立的扩展或修改两边的处理过程,只要确保它们遵守同样的接口约束。</li>
<li>冗余 <br> 有些情况下,处理数据的过程会失败。除非数据被持久化,否则将造成丢失。消息队列把数据进行持久化直到它们已经被完全处理,通过这一方式规避了数据丢失风险。许多消息队列所采用的”插入-获取-删除”范式中,在把一个消息从队列中删除之前,需要你的处理系统明确的指出该消息已经被处理完毕,从而确保你的数据被安全的保存直到你使用完毕。</li>
<li>扩展性 <br> 因为消息队列解耦了你的处理过程,所以增大消息入队和处理的频率是很容易的,只要另外增加处理过程即可。不需要改变代码、不需要调节参数。扩展就像调大电力按钮一样简单。</li>
<li>灵活性&峰值处理能力 <br> 在访问量剧增的情况下,应用仍然需要继续发挥作用,但是这样的突发流量并不常见;如果为以能处理这类峰值访问为标准来投入资源随时待命无疑是巨大的浪费。使用消息队列能够使关键组件顶住突发的访问压力,而不会因为突发的超负荷的请求而完全崩溃。</li>
<li>可恢复性 <br> 系统的一部分组件失效时,不会影响到整个系统。消息队列降低了进程间的耦合度,所以即使一个处理消息的进程挂掉,加入队列中的消息仍然可以在系统恢复后被处理。</li>
<li>顺序保证 <br> 在大多使用场景下,数据处理的顺序都很重要。大部分消息队列本来就是排序的,并且能保证数据会按照特定的顺序来处理。Kafka保证一个Partition内的消息的有序性。</li>
<li>缓冲 <br> 在任何重要的系统中,都会有需要不同的处理时间的元素。例如,加载一张图片比应用过滤器花费更少的时间。消息队列通过一个缓冲层来帮助任务最高效率的执行。写入队列的处理会尽可能的快速。该缓冲有助于控制和优化数据流经过系统的速度。</li>
<li>异步通信 <br> 很多时候,用户不想也不需要立即处理消息。消息队列提供了异步处理机制,允许用户把一个消息放入队列,但并不立即处理它。想向队列中放入多少消息就放多少,然后在需要的时候再去处理它们。</li>
<li>持久化到磁盘<br> Kafka实际上将所有记录存储到磁盘中,并且不会在RAM中保留任何内容。它保存在磁盘上的数据格式与从生产者发送过来或者发送给消费者的消息格式是一样的。因为使用了相同的消息格式进行磁盘存储和网络传输,Kafka可以使用零复制技术将消息直接发送给消费者,避免了对生产者已经压缩过的消息进行解压和再压缩。</li>
</ol>
<h2>3. 应用范围是什么?</h2>
<ol>
<li>活动跟踪<br> Kafka可以记录用户访问前端应用的活动日志,这也是 LinkedIn 开发 Kafka 的初衷。Kafka搜集的用户点击鼠标的事件、浏览页面的事件、更改个人主页的事件,均可以用作后端程序处理,使其变成有价值的产物。</li>
<li>系统监控和日志记录<br> 可以向 Kafka 中发送系统的运行日志,通过分析这些日志,可以对系统的各个指标进行评估。同时,Kafka 记录的日志可供其它的日志分析系统消费。</li>
<li>发消息<br> Kafka 可以向其它应用发送中间件的消息,如:数据库有改动,可以将改动的信息发往应用程序。</li>
<li>流式处理<br> Kafka 提供的对数据的流式操作,和 Hadoop 的 Map/Reduce 模型类似,可以做到数据的实时处理。</li>
<li>等</li>
</ol>
<p>参考文章:</p>
<ul>
<li><a href="https://segmentfault.com/a/1190000015697389#articleHeader6">初识 Kafka</a></li>
<li><a href="https://link.segmentfault.com/?enc=GZ3peVI7l556yw0MTvoy8A%3D%3D.Su%2BY72Zy4EZkYYg%2BkBB7GY7Mu%2Fut5aIUabzcbURtECaLmau2UxRMg%2F902zNOz%2BD7LB8XCU6L%2BPlYgG%2BT5Exm4A%3D%3D" rel="nofollow">Kafka基本架构介绍</a></li>
<li>书籍:Kafka权威指南</li>
</ul>
【PHP7源码学习】2019-04-25 PHP生命周期浅析
https://segmentfault.com/a/1190000020030115
2019-08-10T15:28:25+08:00
2019-08-10T15:28:25+08:00
NoSay
https://segmentfault.com/u/nosay
1
<p>Grape</p>
<p>视频传送门:<a href="https://segmentfault.com/a/1190000018488313">【每日学习记录】使用录像设备记录每天的学习</a></p>
<hr>
<p>今天我们来看下PHP的生命周期,我们都知道PHP生命周期有五个步骤,那么在源码层级是怎么去实现PHP生命周期呢?首先,我们抛出本文的几个问题:</p>
<ol>
<li>php的生命周期是什么?每个阶段做了什么?</li>
<li>为什么会有FPM?</li>
<li>cli执行代码和请求经过fpm执行有什么区别?</li>
</ol>
<hr>
<pre><code> 思考ing。。。。</code></pre>
<hr>
<p>好的,接下来我们解释上边三个问题。</p>
<h2>1.什么是php的生命周期?每个阶段做了什么?</h2>
<p>这个问题相信大家都能够回答,php的生命周期有五个步骤:</p>
<pre><code>- php_module_startup:模块初始化
- php_request_startup:请求初始化
- php_execute_script:执行脚本
- php_request_shutdown:请求关闭
- php_module_shutdown:模块关闭</code></pre>
<p>在执行完这个个步骤之后,就走过了PHP的一生,感觉设计者完全借鉴了人的一生去设计的生命周期,出生,成长奋斗,结婚生子,完成理想以及老去,妙啊。<br><strong>那么,对于这五个步骤有什么意义呢?</strong>我们来逐个了解一下。我们拿cli来举例子(入口在sapi/cli/php.ini),我们假设sapi的初始化等步骤已经完成,因为本文重点是PHP生命周期,着着重讲解五个步骤。</p>
<h3>php_module_startup</h3>
<p>看名字就这道这个函数的作用,模块的初始化,即调用每个拓展源码中的的PHP_MINIT_FUNCTION中的方法初始化模块,进行一些模块所需变量的申请,内存分配等。<br> 这一步骤主要完成的工作有以下几点:</p>
<pre><code>- 初始化zend_utility_functions 结构.这个结构是设置zend的函数指针,比如错误处理函数,输出函数,流操作函数等.
- 设置环境变量.
- 加载php.ini配置.
- 加载php内置扩展.
- 写日志.
- 注册php内部函数集.
- 调用 php_ini_register_extensions,加载所有外部扩展
- 开启所有扩展
- 一些清理操作.</code></pre>
<p>我们看一下加载php.ini配置,代码如下:</p>
<pre><code>
/* this will read in php.ini, set up the configuration parameters,
load zend extensions and register php function extensions
to be loaded later */
if (php_init_config() == FAILURE) {
return FAILURE;
}// php_init_config函数会在这里检查所有php.ini配置,并且找到所有加载的模块,添加到php_extension_lists结构中.
/* Register PHP core ini entries */
REGISTER_INI_ENTRIES();//展开后为zend_register_ini_entries(ini_entries, module_number),ini_entries是PHP_INI_BEGIN/END()两个宏生成的配置映射规则数组,通常会把这个操作放到PHP_MINIT_FUNCTION()中。
//注意:此时php.ini已经解析到configuration_hash哈希表中,zend_register_ini_entries()将根据配置name查找这个哈希表,
//如果找到了表明用户在php.ini中配置了该项,
//然后将调用此规则指定的on_modify函数进行赋值,
此处更详细的介绍请看[https://www.kancloud.cn/nickbai/php7/363320]
对于其它的一些操作是怎么实现的,大家可以自行查看源码。</code></pre>
<h3>php_request_startup</h3>
<p>请求初始化阶段, 即接受到客户端的请求后调用每个拓展的PHP_RINIT_FUNCTION中的方法,初始化PHP脚本的执行环境。<br> 在此函数的实现种主要有以下几个函数:</p>
<ul>
<li>zend_interned_strings_activate():初始化内部字符串哈希表</li>
<li>php_output_activate():启动php的输出</li>
<li>zend_activate():激活Zend引擎</li>
<li>sapi_activate():激活SAPI,进行编译器,重置gc,执行器以及词法扫描器。</li>
<li>zend_signal_activate(),处理一些信号</li>
<li>zend_activate_modules():回调各扩展定义的request_startup钩子函数。</li>
</ul>
<h4>php_execute_script</h4>
<p>执行脚本阶段,入口是php_execute_script()。此过程和2一样,均在do_cli函数内完成。首先获取真正执行的文件信息等,把要执行的文件放在included_files列表里边。然后会调用zend_execute_scripts()去真正执行。真正执行的时候就涉及到了编译,执行,op_array之类的概念。编译过程又涉及到词法分析,语法分析和抽象语法树(AST)等概念。执行的话会涉及到opcode的概念。这些概念在之前的文章中已经讲解过具体实现,感兴趣的读者可以自行前往。传送门:<a href="https://segmentfault.com/a/1190000019790316">笔记汇总</a>。</p>
<h3>php_request_shutdown</h3>
<p>请求关闭阶段。在这个阶段总共有16个步骤,在源码里有着明确的注释,无谓就是做一些“清理”操作,我们看下源码怎么做的。</p>
<pre><code>EG(current_execute_data) = NULL;/*EG(current_execute_data) 指向nirvana,因此无法在zend_executor回调函数中安全地访问.*/
php_deactivate_ticks()//清空tick函数
1.php_call_shutdown_functions()//调用注册了register_shutdown_function()的所有可能的shutdown函数
2.zend_call_destructors()//调用所有可能的__destruct() 函数
3.php_output_discard_all()/php_output_end_all()://刷新所有输出缓冲区
4.zend_unset_timeout()//重置max_execution_time(响应发送后不再执行php代码)
5.zend_deactivate_modules()//调用所有扩展RSHUTDOWN函数
6.php_output_deactivate()//关闭输出层(发送设置好的HTTP头文件,清除输出处理程序等)
7.php_free_shutdown_functions()//释放shutdown函数
8.zval_ptr_dtor()//销毁 super-globals
9.php_free_request_globals()//释放request-bound globals
10.zend_deactivate()//关闭扫描仪/执行器/编译器并还原ini条目
11.zend_post_deactivate_modules//调用rshutdown后的所有扩展
12.sapi_deactivate//SAPI相关的shutdown (free stuff)
13.virtual_cwd_deactivate//释放virtual CWD 内存
14.php_shutdown_stream_hashes//破坏流哈希表
15.zend_interned_strings_deactivate()/shutdown_memory_manager():Free Willy (here be crashes)
16.zend_unset_timeout():重置max_execution_time
</code></pre>
<h3>php_module_shutdown</h3>
<p>模块关闭阶段:与模块初始化阶段相反,这个阶段将清理资源、各php模块关闭等操作。具体的代码函数调用不再赘述。</p>
<h2>2. 为什么会有FPM?</h2>
<p>我们在看过cli下生命周期的五个阶段之后会发现一个问题,这种形式好像有个问题,就是它每来一次请求就会有这五个阶段,这样会造成多大的资源浪费啊。那么为了解决这个问题,FPM应运而生,FPM(FastCGI Process Manager)是 PHP FastCGI 运行模式的一个进程管理器。<br>概括来说,fpm的实现就是创建一个 master进程,在master进程中创建并监听socket,然后fork 出多个子进程,这些子进程各自accept请求,子进程的处理非常简单,它在启动后阻塞在accept上,有请求到达后开始读取请求数据,读取完成后开始处理然后再返回,在这期间是不会接收其它请求的,也就是说fpm的子进程同时只能响应一个请求,只有把这个请求处理完成后才会accept下一个请求,这一点与nginx的事件驱动有很大的区别nginx的子进程通过epoll管理套接字,如果一个请求数据还未发送完成则会处理下一个请求,即一个进程会同时连接多个请求,它是非阻塞的模型,只处理活跃的套接字。<br>知道它的工作机制我们就可以想象一下他会如何去改善cli模式下每个请求都完成一次初始化的问题,我们猜测一下,他会在master进程进行一次初始化之后在请求阶段循环,直至结束,这样就达到了不用多次初始化的目的。好的我们看下它是怎么实现的?<br>首先进行fpm_init,此步主要是对fpm进行初始化,加载fpm配置文件,分配用于和worker进行通信的共享内存,创建worker_pool的套接字,启动 master 的事件管理器(fpm 实现了一个事件管理器用于管理 IO、定时事件,其中 IO 事件通过 kqueue、epoll、poll、select 等管理,定时事件就是定时器,一定时间后触发某个事件)等等操作。<br>接下来就是fpm_run的过程,master将fork出worker进程,worker进程返回main()中继续向下执行,后面的流程就是worker进程不断accept请求,然后执行PHP脚本并返回。fpm_run整体流程如下:</p>
<pre><code>1. 等待请求:worker进程阻塞在fcgi_accept_request() 等待请求;
2. 解析请求:fastcgi请求到达后被worker接收,然后开始接收并解析请求数据,直到request数据完全到达;
3. 请求初始化:执行php_request_startup(),此阶段会调用每个扩展的:PHP_RINIT_FUNCTION();
4. 编译、执行:由php_execute_script() 完成 PHP 脚本的编译、执行;
5. 关闭请求:请求完成后执行php_request_shutdown(),此阶段会调用每个扩展的:PHP_RSHUTDOWN_FUNCTION(),然后进入步骤 (1) 等待下一个请求;</code></pre>
<p>在这个阶段,master进程将进入fpm_event_loop()来依赖注册的几个事件进行不同的操作。<br>到此,对于fpm的简单叙述就到此为止了。<strong>可以理解fpm的诞生就是一剂灵丹妙药,拉长了PHP的生命战线</strong>。</p>
<h2>3. cli执行代码和请求经过fpm执行有什么区别?</h2>
<p>其实我觉得这个问题在看过上边两个问题之后答案就已经出来了~,那么这块就让聪明的你来解决啦。</p>
【Redis5源码学习】2019-04-19 字典dict
https://segmentfault.com/a/1190000019967687
2019-08-04T17:40:21+08:00
2019-08-04T17:40:21+08:00
NoSay
https://segmentfault.com/u/nosay
0
<p>baiyan</p>
<p>全部视频:<a href="https://segmentfault.com/a/1190000018488313">【每日学习记录】使用录像设备记录每天的学习</a></p>
<h2>字典是啥</h2>
<p>dict,即字典,也被称为哈希表hashtable。在redis的五大数据结构中,有如下两种情形会使用dict结构:</p>
<blockquote><ul>
<li>hash:数据量小的时候使用ziplist,量大时使用dict</li>
<li>zset:数据量小的时候使用ziplist,数据量大的时候使用skiplist + dict</li>
</ul></blockquote>
<p>结合以上两种情况,我们可以看出,dict也是一种较为复杂的数据结构,通常用在数据量大的情形中。通常情况下,一个dict长这样:<br><img src="/img/remote/1460000019967690?w=2405&h=2294" alt="" title=""><br>在这个哈希表中,每个存储单元被称为一个桶(bucket)。我们向这个dict(hashtable)中插入一个"name" => "baiyan"的key-value对,假设对这个key “name”做哈希运算结果为3,那么我们寻找这个hashtable中下标为3的位置并将其插入进去,得到如图所示的情形。我们可以看到,dict最大的优势就在于其查找的时间复杂度为O(1),是任何其它数据结构所不能比拟的。我们在查找的时候,首先对key ”name“进行哈希运算,得到结果3,我们直接去dict索引为3的位置进行查找,即可得到value ”baiyan“,时间复杂度为O(1),是相当快的。</p>
<h2>redis中的字典</h2>
<h3>基本结构</h3>
<p>在redis中,在普通字典的基础上,为了方便进行扩容与缩容,增加了一些描述字段。还是以上面的例子为基础,在redis中存储结构如下图所示:<br><img src="/img/remote/1460000019967691?w=3072&h=2228" alt="" title=""><br>dictht的结构如下:</p>
<pre><code class="c">typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;</code></pre>
<p>在dictht中,真正存储数据的地方是**table这个dictEntry类型二级指针。我们可以把它拆分来看,首先第一个指针可以代表一个一维数组,即哈希表。而后面的指针代表,在每个一维数组(哈希表)的存储单元中,存储的都是一个dictEntry类型的指针,这个指针就指向我们存储key-value对的dictEntry类型结构的所在位置,如上图所示。<br>存储最终key-value对的dictEntry的结构如下:</p>
<pre><code class="c">typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;</code></pre>
<p>一个存储key-value对的entry,最主要还是这里的key和value字段。由于存储在dict中的key和value可以是字符串、也可以是整数等等,所以在这里均用一个void * 指针来表示。我们注意到最后有一个也是同类型dictEntry的next指针,它就是用来解决我们经常说的<strong>哈希冲突</strong>问题。</p>
<h3>哈希冲突</h3>
<p>当我们对不同的key进行哈希运算之后结果相同时,就碰到了哈希冲突的问题。常用的两种哈希冲突的解决方案有两种:开放定址法与链地址法。redis使用的是后者。通过这个next指针,我们就可以将哈希值相同的元素都串联起来,解决哈希冲突的问题。注意在redis的源码实现中,在往dict插入元素的时使用的是链表的<strong>头插法</strong>,即将新元素插到链表的头部,这样就不用每次遍历到链表的末尾进行插入,降低了插入的时间复杂度。</p>
<h3>链地址法所带来的问题</h3>
<p>假设我们一直往dict中插入元素,那么这个哈希表的所有bucket都会被占满,而且在链地址法解决哈希冲突的过程中,每个bucket后面的链表会非常长。这样一来,这个链表的时间复杂度就会逐渐退化成O(n)。对于整体的dict而言,其查询效率就会大大降低。为了解决数据量过大导致dict性能下降的问题,我们需要对其进行<strong>扩容</strong>,来满足后续插入元素的存储需要。</p>
<h3>分而治之的rehash</h3>
<ul>
<li>在通常情况下,我们会对哈希表做一个2倍的扩容,即由2->4,4->8等等。假设我们的一个dict中已经存储了好多数据,我们还需要向这个dict中插入一大堆数据。在后续插入大量数据的过程中,由于我们需要解决dict性能下降的问题,我们需要对其进行扩容。由于扩容的时候,需要对所有key-value对重新进行哈希运算,并重新分配到相应的bucket位置上,我们称这个过程为为<strong>rehash</strong>。</li>
<li>在rehash过程中,需要做大量的哈希运算操作,其开销是相当大、而且花费的时间是相当长的。由于redis是单进程、单线程的架构,在执行rehash的过程中,由于其开销大、时间长,会导致redis进程阻塞,进而无法为线上提供数据存储服务,对外部会返回redis服务不可用。为了解决一次性rehash所导致的redis进程阻塞的问题,利用<strong>分而治之</strong>的编程思想,将一次rehash操作分散到多个步骤当中去减小rehash给redis进程带来的资源占用。举一个例子,可能会在后续的get、set操作中,进行一次rehash操作。为了实现这种操作,redis其实设计了<strong>两个哈希表</strong>,一个就是我们之前讲过的对外部提供存取服务的哈希表,而另一个就专门用来做rehash操作。这种分而治之的思想,将一次大数据量的rehash操作分散到多次完成,叫做<strong>渐进式rehash</strong>:</li>
</ul>
<p><img src="/img/remote/1460000019967692?w=2628&h=2601" alt="" title=""></p>
<ul><li>目前是刚刚要进行rehash的状态。我们可以看到,在之前画的图的基础上,我们加入了一个新的结构dict,其中的ht[2]字段就负责指向两个哈希表。下面一个哈希表的大小为之前的大小8*2=16,没有任何元素。关于dict的结构如下:</li></ul>
<pre><code class="c">typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehash进程标识。如果值为-1则不在rehash,否则在进行rehash */
unsigned long iterators; /* number of iterators currently running */
} dict;</code></pre>
<ul><li>注意其中的rehashidx字段,它代表我们进行rehash的进程。注意我们每次进行get或set等命令的时候,rehash就会进行一次,即把一个在原来哈希表ht[0]上的元素挪到新哈希表ht[1]中,注意一次只移动一个元素,移动完成之后,rehashidx就会+1,直到原来哈希表上所有的元素都挪到新哈希表上为止。rehash完成之后,新哈希表ht[1]就会被置为ht[0],为线上提供服务。而原来的哈希表ht[0]就会被销毁。rehash的源码如下:</li></ul>
<pre><code class="c">int dictRehash(dict *d, int n) {
int empty_visits = n*10; /* Max number of empty buckets to visit. */
if (!dictIsRehashing(d)) return 0;
while(n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
/* Note that rehashidx can't overflow as we are sure there are more
* elements because ht[0].used != 0 */
assert(d->ht[0].size > (unsigned long)d->rehashidx);
while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}
de = d->ht[0].table[d->rehashidx];
/* 将老的哈希表ht[0]中的元素移动到新哈希表ht[1]中 */
while(de) {
uint64_t h;
nextde = de->next;
/* 计算新哈希表ht[1]的索引下标*/
h = dictHashKey(d, de->key) & d->ht[1].sizemask; //哈希算法
de->next = d->ht[1].table[h];
d->ht[1].table[h] = de;
d->ht[0].used--;
d->ht[1].used++;
de = nextde;
}
d->ht[0].table[d->rehashidx] = NULL;
d->rehashidx++;
}
/* 检查是否rehash完成,若完成则置rehashidx为-1 */
if (d->ht[0].used == 0) {
zfree(d->ht[0].table);
d->ht[0] = d->ht[1];
_dictReset(&d->ht[1]);
d->rehashidx = -1;
return 0;
}
/* More to rehash... */
return 1;
}</code></pre>
<h2>rehash过程中可能带来的问题</h2>
<h3>rehash对查找的影响</h3>
<p>如果在rehash的过程中(例如容量由4扩容到8),如果需要查找一个元素。首先我们会计算哈希值(假设为3)去找老的哈希表ht[0],如果我们发现位置3上已经没有了元素,说明这个元素已经被rehash过了,到新的哈希表上对应的位置3或7上寻找即可。</p>
<h3>rehash对遍历的影响</h3>
<h4>问题</h4>
<p>试想这么一种情况:在rehash之前,我们使用SCAN命令对dict进行第一次遍历;而rehash结束之后我们进行第二次SCAN遍历,会发生什么情况?<br>在讨论这个问题之前,我们先熟悉一下SCAN命令。我们知道在我们执行keys这种返回所有key值的命令,由于所有key加在一块是相当多的,如果一次性全部把它遍历完成,能够让单进程的redis阻塞相当长的时间,在这段时间里都无法对外提供服务。为了解决这个问题,SCAN命令横空出世。它并不是一次性将所有的key都返回,而是每次返回一部分key并记录一下当前遍历的进度,这里用一个游标去记录。下次再次运行SCAN命令的时候,redis会从游标的位置开始继续往下遍历。SCAN命令实际上也是一种分而治之的思想,这样一次遍历一小部分,直到遍历完成。SCAN命令官方解释如下:</p>
<blockquote>SCAN 命令是一个基于游标的<strong>迭代器</strong>: SCAN 命令每次被调用之后, 都会向用户返回一个新的游标,用户在下次迭代时需要使用这个新游标作为 SCAN 命令的游标参数, 以此来延续之前的迭代过程。</blockquote>
<p>SCAN命令的使用方法如下:</p>
<pre><code class="c">redis 127.0.0.1:6379> scan 0
1) "17"
2) 1) "key:12"
2) "key:8"
3) "key:4"
4) "key:14"
5) "key:16"
6) "key:17"
7) "key:15"
8) "key:10"
9) "key:3"
10) "key:7"
11) "key:1"
redis 127.0.0.1:6379> scan 17
1) "0"
2) 1) "key:5"
2) "key:18"
3) "key:0"
4) "key:2"
5) "key:19"
6) "key:13"
7) "key:6"
8) "key:9"
9) "key:11"</code></pre>
<blockquote>
<li><ul><li>在上面这个例子中,第一次迭代使用0作为游标,表示开始一次新的迭代。第二次迭代使用的是第一次迭代时返回的游标,也即是命令回复第一个元素的值17 。</li></ul></li>
<ul>
<li>从上面的示例可以看到, SCAN 命令的回复是一个包含两个元素的数组,第一个数组元素是用于进行下一次迭代的新游标,而第二个数组元素则是一个数组,这个数组中包含了所有被迭代的元素。</li>
<li>在第二次调用 SCAN 命令时,命令返回了游标0,这表示迭代已经结束,整个数据集(collection)已经被完整遍历过了。</li>
<li>以0作为游标开始一次新的迭代,一直调用 SCAN 命令,直到命令返回游标0,我们称这个过程为一次完整遍历。</li>
</ul>
</blockquote>
<p>回到正题,我们来解决之前的问题。 我们简化一下dict的结构,只留下两个基本的哈希表结构,我们现在有4个元素:12、13、14、15,假设哈希算法为取余。</p>
<ul><li>假设现在我们在没有rehash之前,对其使用SCAN命令,基于我们之前讲过的知识点,由于SCAN是基于游标的增量遍历,我们假设这个SCAN命令只遍历到游标为1的位置就停止了:</li></ul>
<p><img src="/img/remote/1460000019967693?w=3458&h=1491" alt="" title=""></p>
<ul>
<li>我们得到第一次遍历的结果为:12</li>
<li>开始进行rehash。</li>
<li>rehash结束,我们再次使用SCAN命令对其进行遍历。由于上次返回的游标为1,我们从1的位置继续遍历,只不过这次要在新的哈希表中进行遍历了:</li>
</ul>
<p><img src="/img/remote/1460000019967694?w=2865&h=2671" alt="" title=""></p>
<ul><li>第二次SCAN命令遍历的结果为:12、13、14、15</li></ul>
<p>那么我们将两次SCAN的结果合起来,为12、12、13、14、15。我们发现,元素12被多遍历了一次,与我们的预期不符。所以我们得出结论:<strong>在rehash过程中执行SCAN命令会导致遍历结果出现冗余</strong>。</p>
<h4>解决方案</h4>
<p>为了解决扩容和缩容进行rehash的过程中重复遍历的问题,redis对哈希表的下标做出了如下变化(v就是哈希表的下标):</p>
<pre><code class="c">v = rev(v);
v++;
v = rev(v);</code></pre>
<p>首先将游标倒置,加一后,再倒置,也就是我们所说的“高位++”的操作。这里的这几步操作是来通过前一个下标,计算出哈希表下一个bucket的下标。举一个例子:最开始00这个bucket不用动,之前经过正常的低位++之后,00的后面应该为01。然而现在是高位++,原来01的位置的下标就会变成10.......以此类推。最终,哈希表的下标就会由原来顺序的00、01、10、11变成了00、10、01、11,如图所示:<br><img src="/img/remote/1460000019967695?w=3112&h=1701" alt="" title=""><br>这样就能够保证我们多次执行SCAN命令就不会重复遍历了吗?接下来就是见证奇迹的时刻:</p>
<ul><li>首先还是没进行rehash之前,对其进行SCAN。同样的,我们假设这个SCAN命令只遍历到游标为1的位置就停止了:</li></ul>
<p><img src="/img/remote/1460000019967696?w=2918&h=2219" alt="" title=""></p>
<ul>
<li>我们得到第一次遍历的结果:12</li>
<li>
<p>开始进行rehash</p>
<ul><li>rehash结束,我们再次使用SCAN命令对其进行遍历。注意这里,上次返回的游标为2,我们从2的位置继续遍历,也是要在新的哈希表中进行遍历了:</li></ul>
</li>
</ul>
<p><img src="/img/remote/1460000019967697?w=2829&h=2736" alt="" title=""></p>
<ul><li>我们可以看到,经过一个小的下标的修改,就能够解决rehash所带来的SCAN重复遍历的问题。对dict进行遍历的源码如下:</li></ul>
<pre><code class="c">unsigned long dictScan(dict *d,
unsigned long v,
dictScanFunction *fn,
dictScanBucketFunction* bucketfn,
void *privdata)
{
dictht *t0, *t1;
const dictEntry *de, *next;
unsigned long m0, m1;
if (dictSize(d) == 0) return 0;
// 如果SCAN的时候没有进行rehash
if (!dictIsRehashing(d)) {
t0 = &(d->ht[0]);
m0 = t0->sizemask;
/* Emit entries at cursor */
if (bucketfn) bucketfn(privdata, &t0->table[v & m0]);
de = t0->table[v & m0];
while (de) { //遍历同一个bucket上后面挂接的链表
next = de->next;
fn(privdata, de);
de = next;
}
/* Set unmasked bits so incrementing the reversed cursor
* operates on the masked bits */
v |= ~m0;
/* Increment the reverse cursor */
v = rev(v); //反转v
v++; //反转之后即为高位++
v = rev(v); //再反转回来,得到下一个游标值
// 如果SCAN的时候正在进行rehash
} else {
t0 = &d->ht[0];
t1 = &d->ht[1];
/* Make sure t0 is the smaller and t1 is the bigger table */
if (t0->size > t1->size) {
t0 = &d->ht[1];
t1 = &d->ht[0];
}
m0 = t0->sizemask;
m1 = t1->sizemask;
/* Emit entries at cursor */
if (bucketfn) bucketfn(privdata, &t0->table[v & m0]);
de = t0->table[v & m0];
while (de) { //遍历同一个bucket上后面挂接的链表
next = de->next;
fn(privdata, de);
de = next;
}
/* Iterate over indices in larger table that are the expansion
* of the index pointed to by the cursor in the smaller table */
do {
/* Emit entries at cursor */
if (bucketfn) bucketfn(privdata, &t1->table[v & m1]);
de = t1->table[v & m1];
while (de) { //遍历同一个bucket上后面挂接的链表
next = de->next;
fn(privdata, de);
de = next;
}
/* Increment the reverse cursor not covered by the smaller mask.*/
v |= ~m1;
v = rev(v); //反转v
v++; //反转之后即为高位++
v = rev(v); //再反转回来,得到下一个游标值
/* Continue while bits covered by mask difference is non-zero */
} while (v & (m0 ^ m1));
}
return v;
}</code></pre>
<p>有关rehash过程对SCAN的影响,限于篇幅仅仅展示这种情况。更多的情形请参考:<a href="https://segmentfault.com/a/1190000018218584?utm_source=coffeephp.com">Redis scan命令原理</a></p>