Skip to content

06|映射

映射(map)是无序的键值对集合,查找接近常数时间。运维和后端开发里,映射常用于存储主机标签、HTTP 请求头、配置项、路由表这类键值对数据。映射是引用类型,使用前要初始化,读写不存在的键时返回零值而不是报错。并发场景下,普通映射不是线程安全的,需要额外保护。

一、声明与初始化

映射用 make 初始化:

go
package main

import "fmt"

func main() {
	labels := make(map[string]string)
	labels["env"] = "prod"
	labels["team"] = "ops"

	fmt.Println(labels["env"]) // prod
}

声明时直接初始化:

go
labels := map[string]string{
	"env":  "prod",
	"team": "ops",
}

键类型必须是可比较的类型(==!= 能工作的类型),比如 stringint 和各种指针。切片、映射和函数不能作为键,因为它们的值不能直接比较。

二、读取与判断存在性

读取不存在的键时,返回该值类型的零值,不会报错:

go
value := labels["region"] // "region" 不存在,返回 ""

这个行为有隐患:空字符串可能是键不存在,也可能是键存在但值就是空字符串。用"逗号 ok"语法区分:

go
value, ok := labels["region"]
if !ok {
	fmt.Println("key not found")
} else {
	fmt.Println("value:", value)
}

oktrue 表示键存在,false 表示键不存在。遍历映射、删除键、判断存在性时都应该用这个模式。

如果只需要判断键是否存在,不关心值,用空标识符:

go
if _, ok := labels["region"]; !ok {
	fmt.Println("region not set")
}

三、删除

删除键用 delete

go
delete(labels, "team")

delete 对不存在的键执行也不会报错,是安全操作。删除后键值对立即从映射中移除,但底层内存不一定立即释放——Go 的运行时会延迟回收,映射重新变小时才会缩容。

四、遍历

range 遍历映射:

go
labels := map[string]string{
	"env":  "prod",
	"team": "ops",
}

for key, value := range labels {
	fmt.Println(key, value)
}

range 遍历映射的顺序不固定,每次运行可能不同。如果需要固定顺序,先取出所有键排序后再遍历:

go keys
	for k := range labels {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	for _, k := range keys {
		fmt.Println(k, labels[k])
	}

五、nil 映射

映射是引用类型,未初始化的映射是 nilnil 映射可以读取(返回零值),但不能写入:

go
var labels map[string]string        // nil 映射
value := labels["env"]              // 不会 panic,返回 ""
labels["env"] = "prod"              // panic: assignment to entry in nil map

使用映射前必须确保它已经被初始化。函数返回映射时,返回 nil 表示"没有数据",调用方读取不会 panic,但写入会。

六、并发安全

普通映射不是并发安全的。多个 goroutine 同时读写同一个映射会触发 fatal error: concurrent map writes

go
var counts = make(map[string]int)

for i := 0; i < 10; i++ {
	go func() {
		counts["a"]++ // 危险:并发写
	}()
}

并发场景下保护映射的方式:

  • sync.Mutexsync.RWMutex 加锁保护读写
  • sync.Map(特定并发读写场景下性能更好,但 API 和普通映射不同)
  • 每个 goroutine 操作自己的映射,最后合并结果

sync.Map 适合读多写少、键的类型不一致的场景。普通业务逻辑里,Mutex + 普通映射更直观。

七、映射作为函数参数

映射是引用类型,函数内部修改会影响原映射:

go
func addLabel(m map[string]string) {
	m["region"] = "bj"
}

func main() {
	labels := map[string]string{"env": "prod"}
	addLabel(labels)
	fmt.Println(labels["region"]) // bj
}

如果需要函数内部的修改不影响原映射,先 copy 一份:

go
func processLabels(original map[string]string) map[string]string {
	copy := make(map[string]string, len(original))
	for k, v := range original {
		copy[k] = v
	}
	// 修改 copy,不影响 original
	return copy
}

八、常见错误

nil 映射写入

go
var labels map[string]string
labels["env"] = "prod" // panic

必须先用 make 初始化。

遍历时修改映射

遍历映射时删除当前键是安全的,但不能新增键——新增键可能导致遍历顺序变化,某些键被跳过或重复访问:

go
for k := range labels {
	delete(labels, k) // 安全
	labels["new"] = "v" // 不安全,遍历行为不确定
}

需要遍历时修改,先收集所有键再处理。

用映射的值类型判断存在性

go
if labels["env"] == "" {
	fmt.Println("env not set")
}

如果 "env" 的值恰好是空字符串,上面的判断会误判为键不存在。始终用"逗号 ok"语法判断存在性。

并发读写的竞争

即使只是并发读和写(不是并发写),也可能触发 concurrent map iteration and map write。映射在遍历过程中如果被其他 goroutine 写入,会 panic。遍历前加读锁,或遍历前先 copy 一份数据。