Go · map 底层与并发

hmap + 2^B 桶数组 + 渐进式搬迁 — 8 槽桶 / tophash 快筛 / 双倍与等量扩容; 并发读写是 fatal 不是 panic, recover 救不了

指向桶数组 展开此桶 触发后 runtime.hmap — map 头 map[k]v 的运行时真身 count — 元素总数 B — 桶数 = 2^B noverflow — 溢出桶计数 oldbuckets — 搬迁中旧桶 nevacuate — 搬迁进度游标 hash0 — 哈希种子(防碰撞) map 本体就是 *hmap — 拷贝 map 只拷指针 buckets — 桶数组 哈希低 B 位选中下标 0 1 2 3 4 5 6 7 8 9 … … … 共 2^B 个 bmap hash(key) 低 B 位选中此桶 扩容触发 — 两条路 负载因子 count / 2^B > 6.5 平均每桶 6.5 个元素 → 桶数翻倍 B+1 常规扩容: 容量真不够了 溢出桶太多 (约等于 2^B 个) 大量插入+删除把数据打散 → 等量扩容 不翻倍, 只重排整理碎片 触发时只分配新桶, 一条数据不搬 — 见右下渐进搬迁 bmap — 单桶 8 槽布局 tophash = 哈希高 8 位, 先比指纹再比 key tophash[8] 0x3a 0x77 · 0x05 · · 0xc2 · key[8] uid42 uid77 · uid05 · · uid99 · value[8] 9.9 3.2 · 7.7 · · 1.5 · 命中: 低B位定桶 → tophash[i] 相等 → memcmp(key) 确认 overflow 指针 8 槽全满 → 链到溢出桶 → tophash 相等只是指纹相同, 还要比 key 本体防哈希撞车 溢出桶 同 bmap 结构 装不下的第 9+ 个 再溢出… 链太长 = 扩容信号 (等量扩容的诱因) 渐进式搬迁 — 摊到每次写 oldbuckets newbuckets 双倍: 2^(B+1) 每次写顺带搬 1~2 个桶, nevacuate 记游标 期间 old 与 new 并存: 内存双份峰值 访问先查 new, 未搬迁的回退 old 桶 全搬完 oldbuckets 置 nil, 内存回落 均摊后单次写近似 O(1), 没有全量停顿 并发读写 — fatal error, 不是 panic fatal error: concurrent map read and map write runtime.throw 直接终止整个进程 — recover 救不了 一个 goroutine 在写 + 任一 goroutine 在读/写同一 map = 触发 runtime 内建检测 (hmap flags 读写计数), 触发即崩, 没有重试余地 上线前 go test -race 必跑; 预发环境常驻 -race 抓窄窗口 正解 — 按读写比三选一 读多写少 → sync.Map: 读走只读层, 无锁 通用 → sync.Mutex / RWMutex + map 写极热 → 分片锁: N 片各自互斥 读快照 → atomic.Pointer 整表替换 选型细节见下方场景 2 / 3 / 9 Legend hmap / 桶结构 扩容触发 渐进式搬迁 致命错误 正确解法 溢出桶 / 空槽

本质: 一个指针一张表

  • • hmap 只存元数据, 数据在 2^B 个桶里
  • • 每桶 8 槽, 满了挂溢出桶成链
  • • 查找: 哈希 → 定桶 → tophash → memcmp
  • • map 拷贝是浅拷贝: 两个名字共享一张表

扩容是渐进的艺术

  • • 负载因子 > 6.5 → 桶数翻倍
  • • 溢出桶过多 → 等量重排不翻倍
  • • 搬迁摊到每次写, 单写近似 O(1)
  • • 期间 old/new 并存, 内存有双峰

并发读写 = fatal

  • • 不是 panic: recover 救不了
  • • runtime.throw 直接终止进程
  • • 正解: 锁 / sync.Map / 分片三选一
  • • 上线前 -race 必跑, 预发常驻

💡 一句话理解

map 就是"一个头加一片桶": hmap 只存 count/B/指针这些元数据, 真正的键值躺在 2^B 个桶里, 每桶 8 槽, 桶满了挂溢出桶。查找 = 哈希一次 + 低 B 位定桶 + tophash 指纹快筛 + 精确比对 key。扩容不搬家—— 分配新桶后搬迁摊到后续每次写操作, 所以单次写看起来还是 O(1)。代价是 map 完全不受并发保护: 一个在读一个在写, runtime 直接 fatal error, 整个进程退出, 连 recover 的机会都没有。

🧠 必知必会 必考 & 必会

map 的真身
变量本体是 *runtime.hmap: 元数据 + 指向桶数组的指针。把 map 赋给另一个变量只拷指针, 两个名字共享同一张表, 一处改两处可见。
a := map[string]int{"x": 1}
b := a                            // 关键: 只拷 *hmap 指针
b["x"] = 99
fmt.Println(a["x"])               // → 99, 共享同一张表
查找四步
哈希一次 → 低 B 位定桶 → tophash 快筛 8 槽 → memcmp key 确认, 命中取对应 value 槽; 桶内找不到再沿溢出链找。
v := m[key]
// ① 哈希 key  ② 低 B 位定桶 (2^B 个桶)
// ③ tophash 快筛 8 槽  ④ memcmp key 确认
// 关键: 桶内未命中 → 沿溢出桶链继续找
tophash
哈希值的高 8 位存在桶头, 一次缓存行访问筛掉不匹配的槽 —— map 快的核心。指纹相等不代表命中, 最终必须比 key 本体。
m := map[string]int{"a": 1}
fmt.Println(m["a"])          // → 1
// tophash = 哈希高 8 位存桶头, 一次筛掉不匹配槽
// 关键: 指纹相等 ≠ 命中, 最终必须比 key 本体
key 可比较性
slice/map/func 不能做 key(编译错); 接口做 key 时若动态类型不可比较, 运行时 panic: hash of unhashable type。
var _ map[[]int]int     // 编译错: invalid map key type
m := map[any]int{}       // 接口做 key: 编译过
m[[]int{1}] = 1          // 运行时 panic: hash of unhashable type []int
负载因子 6.5
count / 2^B > 6.5 触发双倍扩容(B+1): 平均每桶 6.5 个元素, 留 1.5 槽余量吸收哈希不均。
m := make(map[int]int)          // B=0: 1 个桶 8 槽
for i := 0; i < 7; i++ { m[i] = i }
// 关键: count/2^B > 6.5 即第 7 个触发翻倍扩容
// 留 1.5 槽余量吸收哈希不均
等量扩容
溢出桶数量接近 2^B 时触发: B 不变, 数据重新排布进新桶。目的是压缩溢出链(插入删除churn 打散数据), 不是扩容量。
// B 不变, 数据重排进新桶 — 不是扩容量
for i := 0; i < 1e6; i++ {
    m[key(i)] = i; delete(m, key(i)) // churn 打散数据
}
// 关键: 溢出桶 ≈ 2^B 时等量重排, 压缩溢出链
渐进式搬迁
扩容只分配新桶, 数据搬迁摊到后续每次写(一次搬 1~2 桶), nevacuate 记进度; 期间 old/new 并存, 访问先查新桶再回退旧桶。
// 扩容只分配新桶, 搬迁摊到后续每次写 (1~2 桶/次)
// nevacuate 记游标; 期间 old/new 并存: 内存双份峰值
// 关键: 访问先查 new, 未搬迁的回退 old 桶
// 全搬完 oldbuckets 置 nil, 均摊后单写近似 O(1)
遍历无序
起点桶和槽位随机化, 是刻意设计(防止业务依赖遍历序)。要有序输出: 先取 keys 排序再遍历。
m := map[string]int{"a": 1, "b": 2}
for k, v := range m { fmt.Println(k, v) } // 顺序每次可能不同
// 关键: 要有序 → 先取 keys 排序再遍历
逗号 ok
v, ok := m[k] 是判存在的唯一可靠方式 —— 零值完全可能是合法业务值, 单返回值版本区分不了"没有"和"是零"。
m := map[string]int{"cnt": 0}
v := m["cnt"]              // → 0, 但分不清"没有"和"是零"
v, ok := m["cnt"]            // v=0, ok=true
_, miss := m["none"]           // 关键: miss=false 才是"没有"
delete 不缩容
只清 tophash 和槽位, 桶数组不回缩。海量键删完内存不降, 想释放只能重建一张新 map 再整体替换。
for k := range big { delete(big, k) }
fmt.Println(len(big))         // → 0, 但桶数组内存不降
nm := make(map[K]V, need)    // 关键: 重建新表整体替换
nil map 语义
读返回零值不报错, 写直接 panic: assignment to entry in nil map。声明与初始化要成对出现。
var m map[string]int
fmt.Println(m["k"])           // → 0, 读不报错
m["k"] = 1                 // panic: assignment to entry in nil map
不可寻址
&m[k] 编译错: 扩容搬迁会让元素地址失效, 语言层面直接禁止。要修改就存指针或读-改-写回。
_ = &m["k"]              // 编译错: cannot take the address
v := m["k"]; v.field++; m["k"] = v // 对: 读-改-写回
mp := map[string]*T{}      // 关键: 或存指针
并发三选一
读多写少键集稳定 → sync.Map; 通用 → mutex+map; 写热点 → 分片锁。另外 make(map, hint) 预分配能整段省掉扩容搬迁链。
var sm sync.Map              // 读多写少: 读走只读层, 无锁
var mu sync.RWMutex; m := map[string]int{} // 通用
m := make(map[K]V, n)         // 关键: hint 预分配省扩容链

🏭 生产实战 real world

场景 1 · 商品详情本地缓存: RWMutex + 过期时间戳

DB 扛不住大促读流量, 需要进程内缓存; 多 goroutine 读写同一张 map 是 fatal 事故的起点:

type entry struct {
    val      []byte
    expireAt int64 // unix nano: 惰性过期, 读时才判, 不起清扫 goroutine
}

type LocalCache struct {
    mu   sync.RWMutex          // 写锁全量, 读锁共享 — 读远多于写才划算
    data map[string]entry
    ttl  time.Duration
}

func (c *LocalCache) Get(key string) ([]byte, bool) {
    c.mu.RLock()
    e, ok := c.data[key]
    c.mu.RUnlock()
    if !ok || time.Now().UnixNano() > e.expireAt {
        return nil, false // miss 时上层 singleflight 回源防击穿(见 sync 页)
    }
    return e.val, true
}

RUnlock 尽早释放, 别捏着锁做业务; 过期靠时间戳惰性判定, 零后台任务。

场景 2 · 配置表热更新: sync.Map 读多写少

运营后台每天改几次灰度开关, 而读端每请求都查 —— 典型的 sync.Map 甜区:

var configMap sync.Map // key: 开关名 → 配置对象

// 发布端: 低频写(每天几次)
func UpdateConfig(name string, cfg Config) {
    configMap.Store(name, cfg) // 原子替换, 读端立即可见
}

// 消费端: 每请求读, QPS 5w — Load 走只读层, 无锁
func GetConfig(name string) Config {
    if v, ok := configMap.Load(name); ok {
        return v.(Config)
    }
    return DefaultConfig // 未加载兜底, 别让空配置悄悄上线
}
// 为什么不用 RWMutex: 读也要抢原子计数, 热点在多核间弹跳;
// sync.Map 读路径纯只读 map, 写走 dirty 晋升 — 各得其所

键集稳定 + 读多写少, sync.Map 比锁版 map 吞吐高数倍; 写一多立刻反转(见坑 10)。

场景 3 · 高并发实时计数: 分片锁 ShardedMap

全站 QPS 埋点, 几百个 goroutine 同时 Inc, 单把锁的排队成为瓶颈:

const shardBits = 4 // 2^4 = 16 片: 把锁冲突率降到 ~1/16

type ShardedCounter struct {
    shards [1 << shardBits]struct {
        mu sync.Mutex
        n  int64
    }
}

func (c *ShardedCounter) Inc(key uint64) {
    s := &c.shards[key&(1<<shardBits-1)] // 低位选片, 各锁各的
    s.mu.Lock()
    s.n++
    s.mu.Unlock()
}
// 压测(8 核全写): 单锁 map ~120w ops/s, p99 随核数恶化;
// 16 分片 ~890w ops/s 接近线性 — 分片数取核数附近的 2 的幂

读端聚合时对各片分别加锁求和; 若还要遍历全部键, 分片 + 一把全局读锁配合。

场景 4 · 大促导入 50 万 SKU: hint 预分配消掉扩容毛刺

事故复盘: 往空 map 灌 50 万键, 写入期间扩容十余次, 每次分配新桶 + 渐进搬迁, 导入 380ms 且 p99 毛刺:

rows, err := db.QueryContext(ctx, `SELECT sku, price FROM t_price`)
if err != nil {
    return fmt.Errorf("query prices: %w", err)
}
defer rows.Close()

n := 500_000 // 来自 COUNT(*) 或容量规划, 宁准勿大
prices := make(map[string]Price, n) // hint 一步到位: 桶一次分够

for rows.Next() {
    var p Price
    if err := rows.Scan(&p.SKU, &p.Cents); err != nil {
        return fmt.Errorf("scan: %w", err)
    }
    prices[p.SKU] = p
}
// 改后 210ms 且无尖刺; hint 超实际 10 倍以上 = 白占内存的空桶

规则: 能预估规模就传 hint, 拿不准就别传, 让 runtime 自己涨。

场景 5 · 报表 groupby: map[K][]V 聚合模板

按城市聚合订单是最常见的 map 用法, 两个细节: nil 切片可直接 append, 输出顺序要自己排:

groups := make(map[string][]Order, len(orders)/4) // 粗估分组长
for _, o := range orders {
    groups[o.City] = append(groups[o.City], o) // nil 切片可 append, 无需判空
}

// 需要稳定输出顺序: 取出 keys 排序, 绝不依赖遍历序
keys := make([]string, 0, len(groups))
for k := range groups {
    keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
    flush(groups[k]) // 下游按序消费, 测试可复现
}

聚合类代码重复出现的"排序 keys"三行, 值得抽成 sortKeys 工具函数。

场景 6 · 日志去重集合: map[K]struct{} 零内存 value

亿级日志行去重, 只需要"存在性", value 用 bool 每键白多占一槽:

seen := make(map[string]struct{}, len(logs))
dup := 0
for _, line := range logs {
    if _, ok := seen[line]; ok { // 逗号 ok: 与"空串也是合法行"区分
        dup++
        continue
    }
    seen[line] = struct{}{}
    write(line)
}
// bool 做 value: 亿级键多 ~100MB; struct{} 是零宽类型,
// 桶里只占 tophash + key 槽, value 区域完全不占

set 语义永远写 map[K]struct{}, 这是 Go 的肌肉记忆。

场景 7 · 嵌套 map 初始化陷阱: 工具函数收口

var 声明的嵌套 map 直接赋值必 panic(assignment to entry in nil map), 初始化逻辑要一处收口:

// var m map[string]map[string]float64; m["a"]["x"] = 1 直接 panic:
// 外层是 nil, 赋值目标不存在 — 且内层同样要 make

func ensure(m map[string]map[string]float64, top string) map[string]float64 {
    sub, ok := m[top]
    if !ok {
        sub = make(map[string]float64, 8) // 每层都要独立 make
        m[top] = sub                 // 外层键先落位
    }
    return sub
}

// 用法: 入口 m = make(...) 一次, 之后
//   ensure(m, "shop")["qps"] += 1 — 初始化收口, 处处安全

更深的层级建议换成"组合键"扁平 map: m[shop+"\x00"+metric] 或结构体 key, 少一层维护。

场景 8 · 千级小 LRU: map 定位 + list 序

热点参数缓存要"满了淘汰最久未用", 手搓版三件套看得清机制:

type Cache struct {
    cap int
    ll  *list.List               // container/list: 头 = 最新
    m   map[string]*list.Element // key → 节点, O(1) 定位
}

func (c *Cache) Get(k string) (any, bool) {
    if el, ok := c.m[k]; ok {
        c.ll.MoveToFront(el) // 访问即续期
        return el.Value.(pair).v, true
    }
    return nil, false
}
// 生产别手搓: hashicorp/golang-lru / ristretto 自带淘汰回调与
// 内存预算; 自研版只用于理解机制, 或千级以内小缓存

map 负责"找到", list 负责"排序", 各干一件事 —— 这是所有 LRU 的骨架。

场景 9 · 路由表热更新: atomic.Pointer 整表快照替换

分钟级同步一次全量路由, 读端要求零锁零停顿 —— 不改旧表, 整表换指针:

type Router struct {
    rules atomic.Pointer[map[string]Backend] // 整表一个指针
}

func (r *Router) Swap(newRules map[string]Backend) {
    // 深拷贝构建新表: 不在旧表上原地改, 读端才看不到半新半旧
    snapshot := make(map[string]Backend, len(newRules))
    for k, v := range newRules {
        snapshot[k] = v
    }
    r.rules.Store(&snapshot) // 单指针原子写 = 发布瞬间
}

func (r *Router) Pick(path string) Backend {
    m := *r.rules.Load() // 拿到的是不可变快照, 并发读安全
    return m[path]
}
// 前提: 快照发布后任何人不再修改它 — 违反即数据竞争

热更新零停顿; 旧表等读端自然流失后被 GC, 无需引用计数。

场景 10 · 100 万单据汇总: 每 worker 私有 map, 最后合并

反直觉的结论: 多核汇总最快的做法是"不共享" —— 各算各的, 单线程合并一轮:

func tally(files []string) map[string]int {
    n := runtime.NumCPU()
    parts := make([]map[string]int, n)
    var wg sync.WaitGroup
    for i := 0; i < n; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            local := make(map[string]int, 1<<12) // 私有 map: 零锁竞争
            for _, f := range files[i*len(files)/n : (i+1)*len(files)/n] {
                countFile(local, f)
            }
            parts[i] = local
        }(i)
    }
    wg.Wait()
    total := make(map[string]int, len(parts[0])*n) // 合并只做一轮
    for _, p := range parts {
        for k, v := range p {
            total[k] += v
        }
    }
    return total
}

共享锁版在 8 核下被锁排队拖慢数倍; "分而治之 + 一次合并"是 map 聚合的并发正解。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 并发读写整个进程退出 — 服务突然整体消失, 日志只剩 fatal error: concurrent map read and map write. 原因: runtime 检测到读写并发, throw 直接终止进程, recover 救不了. 正解: 加锁/sync.Map/分片, CI 与预发常跑 go test -race。
go func() {
    for { _ = m["k"] }           // 错: 一个在读
}()
m["k"] = 1                       // + 一个在写 → fatal error
// 对: 加锁/sync.Map/分片, CI 与预发常跑 -race
坑 2 · 把遍历顺序当有序 — 测试偶过偶挂, 按"遍历取第一条"实现的逻辑数据错乱. 原因: 遍历起点桶与槽随机化. 正解: keys 取出后 sort.Slice 再消费。
for k := range m {
    first = k; break           // 错: 遍历取第一条, 起点"随机"
}
keys := make([]string, 0, len(m))
for k := range m { keys = append(keys, k) }
sort.Strings(keys)                 // 对: 排序后再消费
坑 3 · delete 之后内存不降 — 清掉 900 万键 RSS 纹丝不动. 原因: delete 只清槽位, 桶数组永不缩容. 正解: 低峰期重建 map(按需大小新表拷贝替换)释放驻留。
for k := range big { delete(big, k) }  // 错: RSS 不降
nm := make(map[K]V, n)                // 对: 重建新表
for k, v := range src { nm[k] = v }
big = nm                                // 整体替换释放驻留
坑 4 · 对 map 元素取地址 — &m[k] 编译报 cannot take the address. 原因: 扩容搬迁会使元素地址失效, 语言层面禁止. 正解: 值改存 *T, 或取拷贝改完写回。
_ = &m[k]                       // 错: cannot take the address
v := m[k]; v.field++; m[k] = v  // 对: 读-改-写回
mp := map[K]*T{}; mp[k].field++ // 对: 或存指针
坑 5 · 嵌套 map 忘初始化 — 运行时 panic: assignment to entry in nil map. 原因: 外层/内层任一是 nil 就直接赋值. 正解: 每层 make, 用工具函数收口(场景 7)。
var m map[string]map[string]int
m["a"]["x"] = 1                  // 错: panic: assignment to entry in nil map
m = map[string]map[string]int{}   // 对: 每层 make
m["a"] = map[string]int{}; m["a"]["x"] = 1
坑 6 · key 携带不可比较类型 — map[[]int]int 编译不过; 接口做 key 且动态类型是 slice 时运行时 panic: hash of unhashable type. 正解: key 用 string/整数, 复合 key 拼串或定义结构体。
var _ map[[]int]int          // 错: 编译不过 invalid map key type
m := map[string]int{}          // 对: key 用 string/整数
type K struct{ A string; B int }  // 对: 复合 key 定义结构体
_ = map[K]int{}
坑 7 · 忽略逗号 ok — v := m[k] 不存在时拿零值, 被当合法的 0/"" 参与计算, 账对不上但无报错. 正解: 存在性判断必须 v, ok := m[k]。
v := m["qty"]               // 错: 不存在拿 0 参与计算
v, ok := m["qty"]             // 对: 存在性判断
if !ok { return errors.New("missing qty") }
坑 8 · 遍历中插入删除 — 结果不稳定: 新键可能本轮出现也可能不出现. 原因: 插入可能触发搬迁使遍历位置失效, 规范只保证删除当前 key 安全. 正解: 两阶段, 先收集要动的 keys 再改。
for k := range m {
    delete(m, k)               // 删当前 key: 规范保证安全
    m[newKey(k)] = 1           // 错: 新键出现与否不确定
}
// 对: 两阶段, 先收集要动的 keys 再改
坑 9 · 改不动结构体值 — m[k].field += 1 编译错 cannot assign. 原因: 取出的 value 是拷贝, 改的是副本. 正解: map[string]*T 存指针, 或整值读-改-写回。
m[k].field += 1               // 错: cannot assign to struct field
m2 := map[K]*T{}              // 对: 存指针
m2[k].field++                    // 通过指针改到本体
坑 10 · sync.Map 用在写多场景 — 换上后 QPS 反而掉三成. 原因: 写走 dirty 层 + miss 计数触发整层晋升, 开销比锁大. 正解: 写多用 mutex+map, sync.Map 只留给读多写少。
var sm sync.Map
for i := 0; i < 1e6; i++ {     // 错: 写走 dirty+晋升, 比锁慢
    sm.Store(i, i)
}
mu.Lock(); im[k] = v; mu.Unlock() // 对: 写多用 mutex+map
坑 11 · hint 一次给太大 — make(map, 10_000_000) "预防性"预分配, 内存直接多占几十 MB 空桶. 正解: hint 贴近真实规模; 拿不准就不传。
m := make(map[string]int, 10_000_000) // 错: 白占空桶
m := make(map[string]int, 50_000)     // 对: 贴近真实规模
// 拿不准就不传, 让 runtime 自己涨
坑 12 · json 序列化出 null — 接口偶发 "data": null, 前端 .map() 直接崩. 原因: nil map 序列化为 null, 空 map 才是 {}. 正解: 声明即 make, 或返回前判 nil 换空 map。
var m map[string]int
b, _ := json.Marshal(m)          // 错: → null, 前端 .map() 崩
m = map[string]int{}             // 对: 声明即 make
b, _ = json.Marshal(m)           // → {}
坑 13 · set 用 bool 占额外内存 — 亿级键多出 ~1GB. 原因: bool 也占 value 槽. 正解: map[K]struct{}, 零宽 value 在桶内不占数据区。
seen := map[string]bool{}      // 错: bool 也占 value 槽
seen := map[string]struct{}{}  // 对: 零宽 value
if _, ok := seen[k]; ok {}       // 判存在同款
坑 14 · interface 做 key — 哈希走动态类型派发, 还要装箱, 大 key 集下开销可见. 正解: 用具体类型 key; 必须接口时收敛到少数几个类型。
m := map[any]int{}           // 错: 哈希走动态派发+装箱
m := map[string]int{}          // 对: 具体类型 key
坑 15 · 大 map 重建期间内存双峰 — 换表瞬间 old+new 两个桶数组并存, 大 map 有 OOM 风险. 正解: 分批拆小表或低峰执行, 容量规划预留 ~2 倍峰值。
nm := make(map[K]V, len(old))   // old+new 并存
for k, v := range old { nm[k] = v } // 错: 大表换瞬间有 OOM 风险
old = nil                        // 对: 尽早弃旧表
// 对: 分批拆小表或低峰执行, 预留 ~2 倍峰值
坑 16 · 循环变量是共享拷贝(1.22 前) — for range 里起 goroutine 全拿到最后一个 key. 原因: 1.22 前循环变量复用同一地址. 正解: 升 1.22+, 或循环内 k := k 显式复制。
for k := range m {
    k := k                   // 对: 1.22 前显式复制
    go func() { use(k) }()   // 错: 不复制则全拿最后一个 key
}
坑 17 · 线上偶发 fatal 无法复现 — 本地怎么压都不触发. 原因: 并发窗口极窄, 撞上才崩. 正解: 预发常驻 -race(内存代价 5~10 倍只付在测试环境)。
// 错: 本地压不触发就当没有 — 窄窗口撞上才崩
// 对: CI 必跑 go test -race ./...
//     预发常驻 -race: 内存 5~10 倍只付在测试环境
坑 18 · 写入延迟毛刺 — 平时 1ms 的写偶发 50ms, 尾延迟超标. 原因: 触发扩容/搬迁的单次写要干额外的活. 正解: make(map, hint) 预分配削掉大部分扩容点, 实时路径避开大 map 写。
m := make(map[string]int)      // 错: 撞扩容的写要多干活
m := make(map[string]int, n)    // 对: hint 削掉扩容点
// 实时路径避开大 map 写
坑 19 · 合并两个 map 选错遍历对象 — 遍历大表往小表里塞, 写放大十倍还触发扩容. 正解: 遍历小的写入大的, 目标 make(dst, len(a)+len(b)) 预留容量。
for k, v := range big { small[k] = v }  // 错: 写放大触发扩容
dst := make(map[K]V, len(a)+len(b))    // 对: 预留容量
for k, v := range small { dst[k] = v }    // 遍历小的写大的
坑 20 · nil map 读零值掩盖 bug — 读 nil map 不报错拿零值, 初始化遗漏安静地烂在线上. 原因: nil map 只有写才 panic, 读合法. 正解: 声明必初始化; 可疑读点判 m == nil 让问题尽早暴露。
var m map[string]int
v := m["k"]               // → 0, 不报错: 初始化遗漏安静烂线上
m["k"] = 1                // 只有写才 panic
// 对: 声明必初始化; 可疑读点判 m == nil