年前最后一次上线因为for range的坑而失败了

前言

之前就听说Go 里面写循环要谨慎再谨慎,作为新晋Go菜鸟开发,一直以来在涉及到有使用循环的地方,都是会重点测试来保证不出问题,说来也奇怪,虽然一直在河边走,但是从来没有湿过脚,直到最近一次上线,有个逻辑我仅仅就删了四行代码,直接导致循环逻辑出错,从而上线失败。

痛定思痛,这里就好好的回顾一下这次遇到的for range 的问题,以及其它for range的坑,希望自己在2024年可以不再犯这种低级失误。

正文

一. 问题说明

背景就是我们这边一个应用会部署在一个联邦集群,一个联邦集群下会有两个或者三个集群,我有一个逻辑就是会去查询出一个应用部署的集群的信息,实际的代码不方便展示出,这里用示例代码进行说明。

首先查询出来的每个集群,可以用下面结构体表示。

go 复制代码
type Cluster struct {
    Name   string
}

然后为了方便展示,我需要将上述结构转换为下面的结构。

go 复制代码
type ClusterDetail struct {
    Name  string
    Alias string
}

也就是为每个集群添加一个中文别名,所以我的场景就是我先查询出来一个应用部署的所有集群Cluster 的切片,然后把集群Cluster 的切片转换为ClusterDetail的指针的切片,就这么简单,下面先给出之前没有问题的逻辑的示例代码。

java 复制代码
type Cluster struct {
    Name string
}

type ClusterDetail struct {
    Name  string
    Alias string
}

func testCase1() {
    cluster0 := Cluster{"Cluster-0"}
    cluster1 := Cluster{"Cluster-1"}
    cluster2 := Cluster{"Cluster-2"}

    clusters := []Cluster{cluster0, cluster1, cluster2}

    clusterDetails := make([]*ClusterDetail, 0, len(clusters))
    for index, cluster := range clusters {
        clusterDetail := ClusterDetail{
            Name:  cluster.Name,
            Alias: "集群-" + cast.ToString(index),
        }
        clusterDetails = append(clusterDetails, &clusterDetail)
    }

    for _, clusterDetail := range clusterDetails {
        fmt.Printf("%v: %v\n", clusterDetail.Name, clusterDetail.Alias)
    }
}

运行出来的结果也是符合预期的,如下所示。

txt 复制代码
Cluster-0: 集群-0
Cluster-1: 集群-1
Cluster-2: 集群-2

然后呢,这次有一个需求之外的小改动,就是之前是:

go 复制代码
[]Cluster -> []*ClusterDetail

现在要变成:

go 复制代码
[]Cluster -> map[string]*Cluster

所以我的实现就变成下面这样了。

go 复制代码
type Cluster struct {
    Name string
}

type ClusterDetail struct {
    Name  string
    Alias string
}

func testCase2() {
    cluster0 := Cluster{"Cluster-0"}
    cluster1 := Cluster{"Cluster-1"}
    cluster2 := Cluster{"Cluster-2"}

    clusters := []Cluster{cluster0, cluster1, cluster2}

    clusterMap := make(map[string]*Cluster)
    for index, cluster := range clusters {
        clusterMap["集群-" + cast.ToString(index)] = &cluster
    }

    for alias, cluster := range clusterMap {
        fmt.Printf("%v:%v\n", alias, cluster.Name)
    }
}

运行结果如下所示。

txt 复制代码
集群-0:Cluster-2
集群-1:Cluster-2
集群-2:Cluster-2

毫无疑问,这是有问题的,你问我为什么不自测,其实我是自测了的,但是我拿来自测的应用,只有两个集群且其中一个还被隔离了,而上述逻辑在只有一个集群情况下,测出来的结果就是对的,又因为这个改动不是需求里面的,测试同学没有写案例,就最终导致上线以后发现情况不对造成回退。

二. 原因分析

稍微有点经验的人,都知道这个问题的具体原因,就是因为:Go中的for range的循环变量只会进行一次声明和内存地址分配

就像下面的循环变量cluster ,只会进行一次声明和内存地址分配,在循环中只会更新cluster的值,而不会重新声明。

go 复制代码
for index, cluster := range clusters {
    clusterMap["集群-" + cast.ToString(index)] = &cluster
}

这就导致对cluster 取地址,取的永远都是同一个地址,并且当循结束时,这个地址的值为切片中的最后一个元素,这也就是为什么clusterMap的值全都一样。

那如何解决呢,其实很好解决,就是增加一个中间变量并且每次循环时将循环变量赋值给中间变量,取地址时取中间变量的地址,示例代码如下所示。

go 复制代码
type Cluster struct {
    Name string
}

type ClusterDetail struct {
    Name  string
    Alias string
}

func testCase3() {
    cluster0 := Cluster{"Cluster-0"}
    cluster1 := Cluster{"Cluster-1"}
    cluster2 := Cluster{"Cluster-2"}

    clusters := []Cluster{cluster0, cluster1, cluster2}

    clusterMap := make(map[string]*Cluster)
    for index, cluster := range clusters {
        clusterTemp := cluster
        clusterMap["集群-" + cast.ToString(index)] = &clusterTemp
    }

    for alias, cluster := range clusterMap {
        fmt.Printf("%v:%v\n", alias, cluster.Name)
    }
}

运行结果如下。

txt 复制代码
集群-0:Cluster-0
集群-1:Cluster-1
集群-2:Cluster-2

三. 举一反三

这次的问题很低级,背后的原因却很简单,但是为了不再犯相同的错误,我咨询了一波身边的Go 语言牢手,又归纳了如下几种for range 容易踩坑的场景,虽然场景各不相同,但是核心指导思想都是一样的,即:Go中的for range的循环变量只会进行一次声明和内存地址分配

1. 协程场景

首先看如下一个例子。

go 复制代码
type Cluster struct {
    Name string
}

type ClusterDetail struct {
    Name  string
    Alias string
}

func testCase4() {
    cluster0 := Cluster{"Cluster-0"}
    cluster1 := Cluster{"Cluster-1"}
    cluster2 := Cluster{"Cluster-2"}

    clusters := []Cluster{cluster0, cluster1, cluster2}

    for _, cluster := range clusters {
        go func() {
            time.Sleep(time.Second)
            fmt.Println(cluster.Name)
        }()
    }

    time.Sleep(time.Second * 2)
}

输出结果会是什么呢,毫无疑问是下面这样。

txt 复制代码
Cluster-2
Cluster-2
Cluster-2

这是因为for 循环里面起的协程会先睡1秒再打印循环变量cluster ,但是当1秒到时,main 协程早就跑完循环了,此时循环变量cluster的值已经被更新为了切片中最后一个元素,所以三个子协程打印的内容都是一样的。还是熟悉的味道,解决方案如下。

go 复制代码
type Cluster struct {
    Name string
}

type ClusterDetail struct {
    Name  string
    Alias string
}

func testCase5() {
    cluster0 := Cluster{"Cluster-0"}
    cluster1 := Cluster{"Cluster-1"}
    cluster2 := Cluster{"Cluster-2"}

    clusters := []Cluster{cluster0, cluster1, cluster2}

    for _, cluster := range clusters {
        clusterTemp := cluster
        go func() {
            time.Sleep(time.Second)
            fmt.Println(clusterTemp.Name)
        }()
    }

    time.Sleep(time.Second * 2)
}

输出结果如下。

txt 复制代码
Cluster-2
Cluster-1
Cluster-0

2. 闭包场景

首先看如下一个例子。

go 复制代码
func testCase6() {
    cluster0 := Cluster{"Cluster-0"}
    cluster1 := Cluster{"Cluster-1"}
    cluster2 := Cluster{"Cluster-2"}

    clusters := []Cluster{cluster0, cluster1, cluster2}

    printClusterFuncts := make([]func(), 0, len(clusters))

    for index, cluster := range clusters {
        printClusterFuncts = append(printClusterFuncts, func() {
            fmt.Printf("%v: %v\n", index, cluster.Name)
        })
    }

    for _, printClusterFunct := range printClusterFuncts {
        printClusterFunct()
    }
}

输出结果长下面这样。

txt 复制代码
2: Cluster-2
2: Cluster-2
2: Cluster-2

都知道闭包有一个经典的定义:闭包=函数+引用环境 ,这里的函数就是for 循环里创建的匿名函数,引用环境就是在匿名函数中引用了循环变量indexcluster,也就是所有闭包都引用了相同的局部变量。还是那个熟悉的味道,解决方案如下。

闭包说人话的理解:在匿名函数中引用了该匿名函数外的全局变量或局部变量

go 复制代码
func testCase7() {
    cluster0 := Cluster{"Cluster-0"}
    cluster1 := Cluster{"Cluster-1"}
    cluster2 := Cluster{"Cluster-2"}

    clusters := []Cluster{cluster0, cluster1, cluster2}

    printClusterFuncts := make([]func(), 0, len(clusters))

    for index, cluster := range clusters {
        indexTemp := index
        clusterTemp := cluster
        printClusterFuncts = append(printClusterFuncts, func() {
            fmt.Printf("%v: %v\n", indexTemp, clusterTemp.Name)
        })
    }

    for _, printClusterFunct := range printClusterFuncts {
        printClusterFunct()
    }
}

输出结果如下。

txt 复制代码
0: Cluster-0
1: Cluster-1
2: Cluster-2

总结

使用for range 时,要时刻谨记go中的forange的循环变量只会进行一次声明和内存地址分配,循环时,如果要保存每次循环的值,请不要直接保存循环变量,请一定增加一个中间变量并且每次循环时将循环变量赋值给中间变量,然后保存中间变量就好了。

然后还想说,要对代码心存敬畏之心,十分自信以为必不会出问题的地方,往往就是出问题的地方。


如果觉得本文对你有帮助,麻烦点个赞,添加个收藏并点个关注吧,谢谢。

相关推荐
CodeSheep18 分钟前
中国四大软件外包公司
前端·后端·程序员
千寻技术帮19 分钟前
10370_基于Springboot的校园志愿者管理系统
java·spring boot·后端·毕业设计
风象南19 分钟前
Spring Boot 中统一同步与异步执行模型
后端
聆风吟º21 分钟前
【Spring Boot 报错已解决】彻底解决 “Main method not found in class com.xxx.Application” 报错
java·spring boot·后端
乐茵lin29 分钟前
golang中 Context的四大用法
开发语言·后端·学习·golang·编程·大学生·context
步步为营DotNet1 小时前
深度探索ASP.NET Core中间件的错误处理机制:保障应用程序稳健运行
后端·中间件·asp.net
bybitq1 小时前
Go中的闭包函数Closure
开发语言·后端·golang
吴佳浩9 小时前
Python入门指南(六) - 搭建你的第一个YOLO检测API
人工智能·后端·python
踏浪无痕9 小时前
JobFlow已开源:面向业务中台的轻量级分布式调度引擎 — 支持动态分片与延时队列
后端·架构·开源
Pitayafruit9 小时前
Spring AI 进阶之路05:集成 MCP 协议实现工具调用
spring boot·后端·llm