Go 社招基础:缓存、多态、协程池与并发读写

2022-02-11T10:00:00+08:00 | 24分钟阅读 | 更新于 2022-02-11T10:00:00+08:00

@

学习目标

读完本章后,你将能够:

  1. 使用 map + sync.RWMutex 手写线程安全缓存,并扩展支持 TTL 过期和 LRU 淘汰策略。
  2. 利用 Go 的 duck typing 接口实现多态,写出可扩展的动物叫声示例并解释 Go 多态与 Java/C++ 的区别。
  3. 使用 buffered channel 实现 worker pool 模式,包括任务提交、worker 消费和优雅退出机制。
  4. 对比单机锁、Redis 分布式锁、数据库乐观锁与悲观锁三种并发读写方案的适用场景。
  5. 使用 container/list + map 手写 LRU 缓存,理解 Get/Put/Remove 的完整逻辑与淘汰流程。

前置知识: Go 基本语法、sync 包(Mutex/RWMutex/WaitGroup)、channel、interface、context 包基本用法。

动手做 3 件事:

  • 手写一个带 TTL 的缓存,写测试验证过期逻辑正确性。
  • 实现 worker pool 处理 100 个任务,验证优雅退出后所有任务都完成。
  • 用 container/list + map 手写 LRU,设置容量为 3,验证淘汰顺序符合预期。

Go 社招基础缓存多态与协程池并发读写map + RWMutex 缓存TTL + LRU 淘汰interface 隐式多态worker pool 优雅退出单机锁 vs 分布式锁乐观锁 vs 悲观锁

一、实现简单缓存

1.1 用生活类比先建立直觉

类比:想象一个快递柜。你存包裹(Put),取包裹(Get)。多人同时存取需要一个管理员(锁),防止两个人同时开同一个柜子。快递柜还有容量限制——满了就要淘汰最久没取的包裹(LRU)。有些包裹有保质期,到期自动清理(TTL)。

graph TD
    A["Client 请求
Get 或 Set"] --> B{"缓存中有
该 key"} B -->|命中且未过期| C["返回缓存值"] B -->|未命中| D["查数据源
DB 或 API"] D --> E["写入缓存
Set key=value"] E --> F["启动 TTL 定时器
到期自动删除"] F --> C B -->|命中但已过期| D

桥接:缓存的核心就是 map 存储 + 锁保护并发 + 可选的 TTL/LRU 策略。从简单到复杂,我们分三步实现:基础版(map + RWMutex)、进阶版(带 TTL)、高级版(LRU 淘汰,详见第五节)。

1.2 工程要点

基础版:map + sync.RWMutex

package main

import (
    "fmt"
    "sync"
)

// Cache 线程安全的简单缓存
type Cache struct {
    mu    sync.RWMutex
    store map[string]string
}

// NewCache 创建缓存
func NewCache() *Cache {
    return &Cache{
        store: make(map[string]string),
    }
}

// Set 写入缓存
func (c *Cache) Set(key, value string) {
    // 步骤1:写锁,保证同一时刻只有一个 goroutine 写入
    c.mu.Lock()
    defer c.mu.Unlock()
    c.store[key] = value
}

// Get 读取缓存
func (c *Cache) Get(key string) (string, bool) {
    // 步骤2:读锁,允许多个 goroutine 同时读
    c.mu.RLock()
    defer c.mu.RUnlock()
    val, ok := c.store[key]
    return val, ok
}

// Delete 删除缓存
func (c *Cache) Delete(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    delete(c.store, key)
}

func main() {
    cache := NewCache()

    // 步骤3:并发写入
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            cache.Set(fmt.Sprintf("key%d", n), fmt.Sprintf("value%d", n))
        }(i)
    }
    wg.Wait()

    // 步骤4:并发读取
    for i := 0; i < 10; i++ {
        if val, ok := cache.Get(fmt.Sprintf("key%d", i)); ok {
            fmt.Printf("key%d = %s\n", i, val)
        }
    }
}
复习提示:⚠️ 新手必踩的坑: sync.RWMutex 的读锁不能升级为写锁。如果先 RLock 再 Lock,会死锁。需要先 RUnlock 再 Lock。

进阶版:带 TTL 的缓存

package main

import (
    "fmt"
    "sync"
    "time"
)

// cacheItem 缓存条目
type cacheItem struct {
    value    string
    expireAt time.Time
}

// TTLCache 带 TTL 的线程安全缓存
type TTLCache struct {
    mu    sync.RWMutex
    store map[string]*cacheItem
}

func NewTTLCache() *TTLCache {
    return &TTLCache{
        store: make(map[string]*cacheItem),
    }
}

// Set 写入缓存,ttl 为存活时间
func (c *TTLCache) Set(key, value string, ttl time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()

    // 步骤1:设置过期时间
    c.store[key] = &cacheItem{
        value:    value,
        expireAt: time.Now().Add(ttl),
    }

    // 步骤2:启动定时器,到期后自动删除
    time.AfterFunc(ttl, func() {
        c.mu.Lock()
        delete(c.store, key)
        c.mu.Unlock()
    })
}

// Get 读取缓存,如果已过期返回 false
func (c *TTLCache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()

    item, ok := c.store[key]
    if !ok {
        return "", false
    }

    // 步骤3:检查是否过期
    if time.Now().After(item.expireAt) {
        return "", false
    }
    return item.value, true
}

func main() {
    cache := NewTTLCache()

    // 步骤4:写入 TTL 为 2 秒的缓存
    cache.Set("name", "Alice", 2*time.Second)

    // 步骤5:立即读取,命中
    if val, ok := cache.Get("name"); ok {
        fmt.Println("before expiry:", val)
    }

    // 步骤6:等待 3 秒后读取,未命中
    time.Sleep(3 * time.Second)
    if _, ok := cache.Get("name"); !ok {
        fmt.Println("after expiry: key not found")
    }
}
复习提示:⚠️ 新手必踩的坑: 上面 TTL 实现中,如果同一个 key 被重复 Set,旧定时器仍会触发并可能删除新值。生产环境应使用 time.NewTimer 配合 Reset 或记录版本号来避免此问题。

二、实现多态

2.1 用生活类比先建立直觉

类比:想象一个万能遥控器(interface)。你不需要知道设备是电视、音响还是空调——只要设备支持"开机"和"关机"两个按钮(方法),遥控器就能控制它。Go 的接口就是这个万能遥控器:它只关心"你能做什么",不关心"你是什么"。

这就是 duck typing:如果一个类型实现了接口定义的所有方法,它就自动满足该接口,不需要显式声明。

graph TD
    A["Animal 接口
Speak 和 Name"] --> B["Dog
实现 Speak 返回 Woof"] A --> C["Cat
实现 Speak 返回 Meow"] A --> D["Cow
实现 Speak 返回 Moo"] B --> E["通过接口统一调用
无需关心具体类型"] C --> E D --> E

桥接:Go 的多态通过 interface 实现,与 Java/C++ 的关键区别是"隐式实现"——struct 不需要 implements 关键字声明,只要方法签名匹配就自动满足接口。这让代码解耦更彻底。

2.2 工程要点

动物叫声的完整多态示例:

package main

import "fmt"

// 步骤1:定义接口,规定Animal必须实现的方法
type Animal interface {
    Speak() string
    Name() string
}

// 步骤2:Dog 实现Animal接口(隐式实现,无需implements关键字)
type Dog struct {
    name string
}

func (d Dog) Speak() string {
    return "Woof!"
}

func (d Dog) Name() string {
    return d.name
}

// 步骤3:Cat 实现Animal接口
type Cat struct {
    name string
}

func (c Cat) Speak() string {
    return "Meow!"
}

func (c Cat) Name() string {
    return c.name
}

// 步骤4:Cow 实现Animal接口
type Cow struct {
    name string
}

func (c Cow) Speak() string {
    return "Moo!"
}

func (c Cow) Name() string {
    return c.name
}

func main() {
    // 步骤5:将不同类型放入同一个 interface 切片
    animals := []Animal{
        Dog{name: "Buddy"},
        Cat{name: "Whiskers"},
        Cow{name: "Bessie"},
    }

    // 步骤6:通过接口统一调用,实现多态
    for _, animal := range animals {
        fmt.Printf("%s says: %s\n", animal.Name(), animal.Speak())
    }
}
复习提示:⚠️ 新手必踩的坑: Go 的 interface 是隐式实现的,这意味着你无法在编译时强制一个 struct 实现某个接口。如果需要强制检查,可以用编译期断言:var _ Animal = Dog{}。如果 Dog 缺少方法,编译器会报错。

Go 多态与 Java/C++ 的区别:

特性GoJavaC++
实现方式隐式(duck typing)显式(implements)显式(虚函数继承)
关键字无implementsvirtual override
继承无类继承单继承+接口多继承
空接口interface{} 可接任何类型Objectvoid*

三、消息处理协程池

协程池的详细实现已在 04-Go-Goroutine调度与锁.md 中完整讲解(第11节),包含:

  • 完整可运行的 WorkerPool 代码
  • 优雅退出机制(close channel + WaitGroup)
  • 背压控制原理
  • 常见问题与解决方案

本章只保留面试要点总结:

  • 核心结构:buffered channel(任务队列)+ N 个 worker goroutine + WaitGroup(优雅退出)
  • 优雅退出:close(channel) 让 worker 的 range 循环自然结束,wg.Wait() 确保所有任务处理完毕
  • 背压控制:有界队列容量决定并发上限,防止下游被打爆
  • 面试加分点:提到"协程池解决的是并发度上限问题,而非 goroutine 本身代价"(goroutine 很便宜)

Worker Pool 架构图(任务队列 + N Worker + WaitGroup)

Producer:提交任务tasks chan Taskbuffered 任务队列Worker-1for v := range tasksWorker-2for v := range tasksWorker-N并发度上限 = Nsync.WaitGroupShutdown 时 wg.Wait() 等所有 worker 退出(无 goroutine 泄漏)Shutdown()close(tasks) + wg.Wait()提交任务分发(有界队列=并发上限)close 后 range 读完剩余再退出worker 退出前 wg.Done()优雅退出:先停提交,再等处理完

看图要点:worker pool 三件套 = buffered channel(任务队列,容量即并发上限)+ N 个 worker goroutine(for v := range tasks 消费)+ WaitGroup(优雅退出)。close(tasks) 后 channel 不再收新任务、但已发送的值仍可被 range 读完,worker 处理完自然退出,wg.Wait() 确认无泄漏。面试追问:任务队列满了怎么办(阻塞/丢弃/动态扩容的 trade-off);为什么用 channel 而非每任务开 goroutine(防止 goroutine 爆炸,池化控制并发度);和 errgroup 的区别(errgroup 任一失败立即取消其余)。

// Redis 分布式锁的 Go 调用逻辑(伪代码,展示调用模式)
// 实际使用需要引入 redis 客户端库如 github.com/redis/go-redis/v9
func acquireLock(rdb RedisClient, key, token string, ttl time.Duration) bool {
    // 步骤3:SET key token NX EX ttl
    ok, err := rdb.SetNX(context.Background(), key, token, ttl).Result()
    return err == nil && ok
}

func releaseLock(rdb RedisClient, key, token string) bool {
    // 步骤4:Lua 脚本原子性释放
    script := "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"
    result, err := rdb.Eval(context.Background(), script, []string{key}, token).Result()
    return err == nil && result.(int64) == 1
}
复习提示:⚠️ 新手必踩的坑: Redis 分布式锁释放时必须用 Lua 脚本做"检查 token + 删除"的原子操作。如果先 GET 检查再 DEL,两步之间锁可能已过期并被别人获取,导致误删。另外,业务执行时间超过锁 TTL 时需要锁续期(watchdog 机制)。

方案3:数据库乐观锁(version 号)

-- 步骤1:查询时获取当前 version
SELECT id, stock, version FROM products WHERE id = 1001;
-- 假设查到 stock=10, version=3

-- 步骤2:更新时带上 version 条件,CAS 思想
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1001 AND version = 3;
-- 如果 affected_rows = 0,说明被别人改过,需要重试

方案4:数据库悲观锁(SELECT FOR UPDATE)

-- 步骤1:事务中加排他锁,其他事务必须等待
BEGIN;
SELECT * FROM products WHERE id = 1001 FOR UPDATE;
-- 此时其他事务的 SELECT FOR UPDATE 会被阻塞

-- 步骤2:安全地更新数据
UPDATE products SET stock = stock - 1 WHERE id = 1001;

-- 步骤3:提交事务,释放锁
COMMIT;
复习提示:⚠️ 新手必踩的坑: 悲观锁在事务未提交前会一直持有行锁,如果事务执行时间长会导致其他请求阻塞。乐观锁在冲突率高时重试频繁,性能反而不如悲观锁。选择方案前要评估冲突率。

五、LRU 实现详解

5.1 用生活类比先建立直觉

类比:想象你的书架只能放 3 本书。每当你取一本书来读,就把它放到最前面(最近使用)。当书架满了要放新书时,把最后面那本(最久没读的)扔掉。这就是 LRU(Least Recently Used)——淘汰最久未使用的。

实现的关键是"O(1) 查找 + O(1) 调整顺序"。map 负责O(1)查找,双向链表负责O(1)调整顺序。

graph LR
    A["Map 哈希表
key 到节点指针"] --> B["Doubly Linked List
双向链表"] B --> C["Front 最近使用"] B --> D["Back 最久未使用"] D -->|容量超限| E["淘汰 Evicted"]

桥接:LRU = map + 双向链表。map 的 value 指向链表节点,每次 Get/Put 把节点移到链表头部,淘汰时删除链表尾部。Go 标准库 container/list 提供了现成的双向链表。

5.2 工程要点

完整的 LRU 实现(Get/Put/Remove):

package main

import (
    "container/list"
    "fmt"
)

// entry LRU 缓存条目
type entry struct {
    key   string
    value string
}

// LRU LRU 缓存
type LRU struct {
    capacity int                   // 最大容量
    cache    map[string]*list.Element // key 到链表节点的映射
    list     *list.List            // 双向链表,front 是最近使用
}

// NewLRU 创建 LRU 缓存
func NewLRU(capacity int) *LRU {
    return &LRU{
        capacity: capacity,
        cache:    make(map[string]*list.Element),
        list:     list.New(),
    }
}

// Get 读取缓存,命中则移到队首
func (l *LRU) Get(key string) (string, bool) {
    if elem, ok := l.cache[key]; ok {
        // 步骤1:移到队首(标记为最近使用)
        l.list.MoveToFront(elem)
        return elem.Value.(*entry).value, true
    }
    return "", false
}

// Put 写入缓存
func (l *LRU) Put(key, value string) {
    if elem, ok := l.cache[key]; ok {
        // 步骤2:key 已存在,更新值并移到队首
        l.list.MoveToFront(elem)
        elem.Value.(*entry).value = value
        return
    }

    // 步骤3:新 key,创建条目放在队首
    elem := l.list.PushFront(&entry{key: key, value: value})
    l.cache[key] = elem

    // 步骤4:超过容量,淘汰队尾(最久未使用)
    if l.list.Len() > l.capacity {
        oldest := l.list.Back()
        if oldest != nil {
            l.list.Remove(oldest)
            delete(l.cache, oldest.Value.(*entry).key)
        }
    }
}

// Remove 主动删除指定 key
func (l *LRU) Remove(key string) bool {
    if elem, ok := l.cache[key]; ok {
        // 步骤5:从链表和 map 中同时删除
        l.list.Remove(elem)
        delete(l.cache, key)
        return true
    }
    return false
}

// Len 返回当前缓存大小
func (l *LRU) Len() int {
    return l.list.Len()
}

func main() {
    // 步骤6:创建容量为 3 的 LRU 缓存
    lru := NewLRU(3)

    // 步骤7:写入 3 个 key
    lru.Put("a", "value-a")
    lru.Put("b", "value-b")
    lru.Put("c", "value-c")

    // 步骤8:访问 a,使 a 移到队首(b 变成最久未使用)
    lru.Get("a")

    // 步骤9:写入 d,容量超限,淘汰队尾的 b
    lru.Put("d", "value-d")

    // 步骤10:验证结果
    fmt.Println("a:", lru.Get("a")) // 命中
    fmt.Println("b:", lru.Get("b")) // 未命中(已被淘汰)
    fmt.Println("c:", lru.Get("c")) // 命中
    fmt.Println("d:", lru.Get("d")) // 命中
    fmt.Println("len:", lru.Len())  // 3
}
复习提示:⚠️ 新手必踩的坑: LRU 的 Get 和 Put 操作都不是线程安全的。如果在并发场景使用,需要外层加 sync.Mutex 包装。另外,list.Element.Value 是 interface{} 类型,类型断言 .(*entry) 的开销不可忽视——在高性能场景可以考虑用第三方库如 hashicorp/golang-lru。

LRU 操作复杂度:

操作时间复杂度说明
GetO(1)map 查找 + 链表 MoveToFront
PutO(1)map 插入 + 链表 PushFront,可能淘汰尾部
RemoveO(1)map 删除 + 链表 Remove
LenO(1)链表 Len() 直接返回

六、基础语法编译错误:缺左花括号与 import 位置

7.1 用生活类比先建立直觉

把 Go 源文件想象成一份"标准合同":合同开头必须立刻盖章(函数声明的左花括号 {),条款清单(import)必须写在合同首页的抬头区,不能塞进正文的某个章节里。Go 编译器是个死板的法务——格式不对直接打回,连"意思到了"都不行。

graph TB
    V["合法结构"] --> V1["package main"]
    V1 --> V2["import (...)
文件顶层"] V2 --> V3["func main() {
左花括号紧跟或换行均可"] V3 --> V4[" 函数体"] IV["非法结构"] --> IV1["func main()
漏写 {"] IV1 --> IV2[" fmt.Println(...)
→ unexpected semicolon"] IV3["func main() {"] --> IV4[" import (...)
写在函数内 → 编译错误"]

桥接: Go 对"声明的位置"要求很严。函数体必须由 { 开启(可以写在 func main() 同一行末尾,也可以换行,但不能省略);import 只能出现在文件顶层(包级),写进函数体会直接编译失败。

7.2 工程要点

问题 64:漏写函数左花括号

// 错误写法:func 后面直接换行写语句,缺 {
func main()
    fmt.Println("hello world")
// 编译报错:syntax error: unexpected semicolon or newline before {
// 正确写法:补上 {
func main() {
    fmt.Println("hello world") // works
}

问题 70:import 写在函数内部

// 错误写法:import 出现在 func main 体内
func main() {
    import (
        "fmt"
    )
    fmt.Println("x")
}
// 编译报错:import statement outside top-level block
// 正确写法:import 放到文件顶层;未使用的包用空白导入或变量引用"消费"掉
package main

import (
    _ "fmt" // 空白导入:只为触发包的 init,不实际使用其导出名
    "log"
    "time"
)

var _ = log.Println // 用变量引用"消费"导入,避免 "imported and not used"

func main() {
    _ = time.Now // 同理
}
复习提示:⚠️ 新手必踩的坑: Go 不允许"导入了却不用"。如果一个包你只想触发它的 init() 副作用,用 _ "pkg" 空白导入;如果只是临时不用又不想删,可写 var _ = pkg.Something 占位消费。漏写 { 和把 import 写进函数体,是两个最容易被忽略的"格式类编译错误"。

考点总结:

  • 函数声明 func f() { 的左花括号不能省略;Go 自动插入分号规则会让"漏 {“变成 unexpected semicolon or newline before {。
  • import 必须位于文件顶层(包级声明区),不能写在函数内部。
  • 导入未使用的包会编译报错;用 _ "pkg" 或 var _ = pkg.X 可以合法"消费"掉。

七、变量声明、未使用变量与短声明 :=

8.1 用生活类比先建立直觉

把局部变量想象成"租的储物柜”:你租了一个柜子(var one int)却从来不去取东西,房东(编译器)会直接投诉你——Go 规定"声明了的局部变量必须被使用",否则编译报错。而 := 是"开新柜子的快捷方式":它要求至少有一个新柜子才能用;如果全是已开的旧柜子,系统会拒绝。

graph TB
    A["声明方式"] --> B["var x int
显式声明"] A --> C["x := 1
短声明:至少 1 个新变量"] A --> D["x = 1
赋值:左右都必须已存在"] E["编译规则"] --> F["未使用的局部变量 → 报错"] E --> G[":= 同一作用域重声明
需至少 1 个新变量"] E --> H["函数形参未使用 → 不报错"]

桥接: Go 的"未使用变量"检查只针对函数内的局部变量,函数参数即使不用也不报错。短声明 := 在同一作用域可以"半重声明"——只要左侧至少有一个新变量名,其余已存在的变量会被重新赋值而非新建。

8.2 工程要点

问题 67:未使用的局部变量 = 编译错误

func main() {
    var one int         // 声明了却没使用 → 编译错误:one declared but not used
    two := 2
    var three int
    three = 3
}
// 修复:显式"消费"掉,或用 _ 丢弃
func main() {
    var one int
    _ = one           // 用空白标识符丢弃,编译通过
    two := 2
    fmt.Println(two)
    var three int
    three = 3
    one = three       // 真正使用
}

⚠️ 注意:函数形参未使用不会报错(如下),唯独函数体内声明的局部变量必须被使用。

func unusedParam(arg string) { // arg 没用也不报错
    fmt.Println("no compile error")
}

问题 76 / 83:= 与 := 的区别

func main() {
    one := 0
    // one := 1      // 错误:no new variables on left side of :=
    one, two := 1, 2 // OK:two 是新变量,one 被"半重声明"(重新赋值)
    one, two = two, one // OK:纯赋值,左右都已存在
    fmt.Println(one, two)
}
func main() {
    x := 1
    fmt.Println(x)    // 1
    i, x := 2, 2      // i 是新的,x 在同一作用域被重新赋值
    fmt.Println(i, x) // 2 2
    fmt.Println(x)    // 2(x 已被 := 重新绑定)
}

考点总结:

  • 函数体内声明的局部变量若从未被读取,编译报错;函数形参未使用不报错。
  • 用 _ = v 可"消费"掉不想用的变量;_ 本身不占存储。
  • := 是"声明+赋值":同一作用域重用时,左侧至少要有 1 个新变量名,其余变量被重新赋值。
  • = 是纯赋值,要求左右两侧变量都已存在。

八、常量 const 与 const 块的值/类型继承

9.1 用生活类比先建立直觉

const 块像一张"带默认值联动的表格":第一行你填了"类型"和"值",下面留空的行会自动沿用上一行的类型与值,直到遇到重新填写的行。这种"向下继承"让成组的常量可以只写一次类型。

graph LR
    R["const 块"] --> A["x uint16 = 120
类型/值都写全"] A --> B["y
继承 uint16 + 120"] B --> C["s = "abc"
重新写:string + abc"] C --> D["z
继承 string + abc"]

桥接: Go 的常量可以是"无类型常量"(untyped,如 const x = 123),在赋值时才决定具体类型。const 块里如果某一行只写名字不写值和类型,它会继承上一行的类型与值。

9.2 工程要点

问题 79:无类型常量

func main() {
    const x = 123  // 无类型整型常量
    const y = 1.23 // 无类型浮点常量
    fmt.Println(x) // 123
}

问题 80:const 块的继承

const (
    x uint16 = 120
    y               // 继承 x 的类型 uint16 与值 120
    s = "abc"
    z               // 继承 s 的类型 string 与值 "abc"
)

func main() {
    fmt.Printf("%T %v\n", y, y) // uint16 120
    fmt.Printf("%T %v\n", z, z) // string abc
}

考点总结:

  • 常量可写作无类型(untyped),在使用处才确定类型,赋值时更灵活。
  • const 块中"只写名字"的行会继承上一行的类型与值。
  • 用 fmt.Printf("%T") 可以打印常量/变量的静态/动态类型,是排查类型问题的利器。

九、数值类型别名:byte≡uint8、rune≡int32

10.1 用生活类比先建立直觉

byte 和 uint8 就像一个员工的"两个工牌"——户口本写的是"uint8",公司门禁卡写的是"byte",其实是同一个人,刷哪张都能进。rune 和 int32 同理。因为它们底层是同一类型,所以彼此之间可以直接赋值、运算,不需要任何转换。

graph LR
    U8["uint8"] --- B["byte
同一类型别名"] I32["int32"] --- R["rune
同一类型别名"]

桥接: Go 预定义了几个"类型别名":byte 就是 uint8,rune 就是 int32(rune 用来表示 Unicode 码点,可读性更好)。别名之间完全等价,可直接混用。

10.2 工程要点

问题 78:byte 与 uint8 互通

func test(x byte) {
    fmt.Println(x)
}

func main() {
    var a byte = 0x11
    var b uint8 = a     // 直接赋值,无需转换(byte 即 uint8)
    var c uint8 = a + b // 直接运算
    test(c)             // c 是 uint8,传给 byte 参数也合法
}

考点总结:

  • byte 是 uint8 的别名,rune 是 int32 的别名,二者完全等价。
  • 别名类型之间可以直接赋值、参与运算、作为函数参数互传,不用显式转换。
  • 处理文本字符时用 rune(码点),处理原始字节时用 byte,语义更清晰。

十、复合字面量中的尾逗号

11.1 用生活类比先建立直觉

Go 的复合字面量(slice、map、struct 字面量)像个"强迫症清单":每写一项就得在后面加逗号,哪怕这是最后一项、后面紧跟着收尾的 ] 或 }。这种"处处逗号"的规则其实是 gofmt 自动格式化的好搭档——它保证你在某行后插入新项时,上一行不用改。

graph TB
    A["复合字面量"] --> B["[]int{1, 2, 3,}"]
    A --> C["元素换行写时
每行末尾必须逗号"] A --> D["map[string]int{"a":1,}"] A --> E["gofmt 自动补全
尾逗号"]

桥接: 在 Go 里,复合字面量的最后一个元素后面允许(甚至换行写时要求)有尾逗号。这不是语法糖,而是和 gofmt 配合的设计:换行排版时不会因"最后一行要不要逗号"而纠结。

11.2 工程要点

问题 77:尾逗号合法

func main() {
    // 步骤1:slice 字面量,尾逗号始终合法
    a := []int{1, 2, 3,}
    fmt.Println(a) // [1 2 3]

    // 步骤2:元素换行写时,行尾必须有逗号(gofmt 会自动补)
    b := []int{
        1,
        2,
        3, // 这个尾逗号是合法的,gofmt 下也推荐使用
    }
    fmt.Println(b)

    // 步骤3:map 字面量同理
    m := map[string]int{
        "a": 1,
        "b": 2, // 尾逗号合法
    }
    fmt.Println(m)
}

考点总结:

  • 复合字面量(slice/map/struct 字面量)中,尾逗号(trailing comma)始终合法。
  • 当元素换行书写时,每行的末尾都推荐/要求加逗号,这正好和 gofmt 的自动格式化一致。
  • 这避免了"在末尾追加元素时要回头改上一行逗号"的麻烦。

十一、自增自减是语句,不是表达式

12.1 用生活类比先建立直觉

在 C/Java 里,i++ 像"取快递顺便签收"——它既能让 i 加 1,又能把"加之前的值"当作结果用(a = i++)。但在 Go 里,i++ 像"只签收不返还"——它是一个纯动作,执行完就结束,没有任何返回值。所以你不能把它塞进别的 expression 里。

graph LR
    C["其他语言"] --> C1["a = i++
表达式,有返回值"] G["Go"] --> G1["i++
语句,无返回值"] G1 --> G2["不能用于 data[i++]、a:=i++、if i++>0"]

桥接: Go 刻意砍掉了 ++/-- 的表达式用法,只保留后置形式 i++/i-- 作为独立语句。这消除了 C 里 i++ + ++i 这类未定义行为的歧义,也让代码意图更单一。

12.2 工程要点

问题 82:data[i++] 非法

func main() {
    data := []int{1, 2, 3}
    i := 0
    // fmt.Println(data[i++]) // 编译错误:i++ 不是表达式,不能当索引

    // 正确:先自增,再使用
    i++
    fmt.Println(data[i]) // 2
}

考点总结:

  • Go 只有后置 i++ / i--,没有前置 ++i。
  • i++ 是语句(statement),没有返回值,不能出现在表达式上下文:如 a := i++、x[i++]、if i++ > 0、return i++ 都非法。
  • 需要"先用后加"时,老老实实拆成两行。

十二、位运算:^(取反/异或)与 &^(按位清空)

13.1 用生活类比先建立直觉

^ 运算符有"双重人格":

  • 夹在两个数字中间时,它是"异或裁判"——两位相同判 0,不同判 1。
  • 单独站在数字前面时,它是"黑白颠倒师"——把每一位 0 变 1、1 变 0(按位取反)。

&^ 则是一块"橡皮擦":第二个操作数(y)的哪位是 1,结果(z)的对应位就被擦成 0;y 的位是 0,则保留 x 的原位。

graph TB
    A["^ 一元
按位取反"] --> A1["^a = -(a+1) 补码"] B["^ 二元
异或 XOR"] --> B1["相同=0 不同=1"] C["&^ 按位与非"] --> C1["y 的位=1 → 结果位清 0"] D["| 按位或"] --> D1["任一位=1 → 结果=1"]

桥接: Go 没有 ~ 取反符号,按位取反用一元 ^。二元 ^ 是异或,&^ 是"AND NOT"(按位清空),| 是按位或,& 是按位与,<</>> 是位移。

13.2 工程要点

问题 85:~2 非法;^ 与 &^ 的正确用法

// fmt.Println(~2) // 编译错误:invalid character U+007E '~',Go 没有 ~ 取反符
func main() {
    // 步骤1:一元 ^ = 按位取反(补码下 ^a = -(a+1))
    var a int8 = 3
    var b uint8 = 3
    var c int8 = -3
    fmt.Printf("^%b=%b %d\n", a, ^a, ^a) // ^11=-100 -4
    fmt.Printf("^%b=%b %d\n", b, ^b, ^b) // ^11=11111100 252
    fmt.Printf("^%b=%b %d\n", c, ^c, ^c) // ^-11=10 2

    // 步骤2:二元 ^ = 异或
    var x int8 = 3
    var y int8 = 5
    fmt.Printf("a^c: %08b\n", x^y) // 00000110 = 6

    // 步骤3:&^ = AND NOT(y 的位为 1 则结果位清 0)
    var m uint8 = 214 // 11010110
    var n uint8 = 92  // 01011100
    fmt.Printf("m&^n: %08b\n", m&^n) // 10000010
}

考点总结:

  • Go 没有 ~ 取反运算符;按位取反用一元 ^(补码下 ^a = -(a+1))。
  • 二元 ^ 是异或(XOR);&^ 是按位与非(y 的某位为 1,则结果对应位清 0)。
  • 其他位运算:| 或、& 与、<</>> 位移,左右操作数都必须是整数类型。

十三、switch 不自动穿透,需显式 fallthrough

14.1 用生活类比先建立直觉

C 语言的 switch 像"多米诺骨牌"——推倒第一张(匹配到一个 case),会一路带倒后面所有 case,除非你手动 break。Go 的 switch 则像"独立抽屉"——匹配到一个 case 执行完就立刻结束整个 switch,绝不会顺手打开下一个抽屉;如果你想"连着开下一个",必须显式写 fallthrough。

graph TB
    G["Go switch"] --> G1["case 1 命中"] --> G2["执行后直接结束
不穿透"] G --> G3["需 fallthrough 才进下一个 case"] C["C switch"] --> C1["case 1 命中"] --> C2["默认继续向下
直到 break"]

桥接: Go 默认每个 case 自带"隐式 break",这避免了 C 里忘记写 break 导致的意外穿透 bug。多个值共用一个分支时,用逗号 case 1, 2: 合并,而不是靠穿透。

14.2 工程要点

问题 89:空 case 不穿透;fallthrough 显式穿透

func main() {
    isMatch := func(i int) bool {
        switch i {
        case 1:
            // 空体:命中后直接结束 switch(不穿透)
        case 2:
            return true
        }
        return false
    }
    fmt.Println(isMatch(1)) // false(case 1 空,未穿透到 case 2)
    fmt.Println(isMatch(2)) // true

    // 用 fallthrough 显式穿透
    isMatch2 := func(i int) bool {
        switch i {
        case 1:
            fallthrough
        case 2:
            return true
        }
        return false
    }
    fmt.Println(isMatch2(1)) // true(穿透到 case 2)

    // 多个值合并到一个分支
    match := func(i int) bool {
        switch i {
        case 1, 2:
            return true
        }
        return false
    }
    fmt.Println(match(1)) // true
    fmt.Println(match(2)) // true
}
复习提示:⚠️ 新手必踩的坑: fallthrough 会无条件穿透到下一个 case,不会再判断下一个 case 的条件。所以 case 1: fallthrough; case 2: ... 即使 i 不等于 2 也会执行 case 2 的代码。它只应穿透到"逻辑连续"的下一个 case。

考点总结:

  • Go 的 switch 默认不穿透,每个 case 自带隐式 break;空 case 体执行完就结束。
  • 想穿透到下一个 case,必须写 fallthrough(它忽略下一 case 的条件,无条件进入)。
  • 多个值共用逻辑时,用 case 1, 2: 逗号合并,比 fallthrough 更安全清晰。

十四、自测题与动手练习

自测题(5 道)

面试官
为什么基础缓存用 sync.RWMutex 而不是 sync.Mutex?什么场景下 RWMutex 反而更慢?
候选人
缓存是读多写少的场景:RWMutex 允许多个读并发(多个 goroutine 同时 RLock),只有写时才独占;而 Mutex 读写互斥,读也会阻塞写、写也会阻塞读,吞吐更差。
RWMutex 反而更慢的情况:一是写很频繁,写锁会阻塞所有读,且 RWMutex 内部要维护读者计数,竞争更重;二是读操作极短,加读锁/释放锁的固定开销占比变大,此时简单的 Mutex 反而更轻。
面试官
Go 的 interface 实现多态与 Java 的 implements 有什么本质区别?var _ Animal = Dog{} 这行代码的作用是什么?
候选人
Go 的 interface 是隐式实现(结构化类型 / duck typing):struct 不需要声明 implements 某个接口,只要它拥有接口要求的全部方法,就自动满足该接口;Java 是显式声明 implements,编译期强制检查。
也正因隐式实现,Go 无法在编译期强制一个 struct 实现某接口。var _ Animal = Dog{} 是编译期断言:把 Dog{} 赋值给 Animal 类型的空白变量,若 Dog 缺少某个方法,这行就编译失败,从而提前暴露"没实现接口"的错误。
面试官
worker pool 中 close(p.tasks) 之后,正在 range p.tasks 的 worker 会发生什么?为什么 Shutdown 能保证所有任务都被处理完?
候选人
close 之后 channel 不再能发送新值,但已发送的值仍可被 range 读完;当 channel 为空且已关闭,for v := range p.tasks 会正常退出而不阻塞。所以 worker 会先消费完剩余任务,再退出。
Shutdown 先 close(tasks) 阻止新任务提交,再用 WaitGroup 等待所有 worker 退出(每个 worker 处理完、退出前 Done),从而确保所有已提交任务都被处理完、没有 goroutine 泄露。
面试官
数据库乐观锁和悲观锁分别适合什么场景?如果冲突率从 1% 升到 50%,哪种方案更合适?
候选人
悲观锁在事务开始前就加锁(如 select ... for update / 行锁),适合冲突率高、写重、需要强一致的场景,但持有锁期间会阻塞其他请求。乐观锁读取版本号、提交时校验版本,冲突则重试,适合冲突率低、读多写少;冲突率高时频繁重试反而性能差。
冲突率从 1% 升到 50%:乐观锁在 50% 冲突率下重试极频繁,性能反不如悲观锁,所以此时应改选悲观锁。
面试官
LRU 缓存中,为什么用"map + 双向链表"而不是"map + 数组"?container/list 的 MoveToFront 时间复杂度是多少?
候选人
map 提供 O(1) 按 key 查找节点;双向链表在已知节点指针时,MoveToFront、删除节点都是 O(1);而"map + 数组"在中间删除/移动元素是 O(n)。所以"map + 双向链表"能实现 O(1) 的 Get/Put。container/list 的 MoveToFront 是 O(1)(只做双向链表指针的指针调整)。

动手练习(3 个)

  1. 缓存实战: 手写一个带 TTL 的缓存,要求支持 Set(key, value, ttl)、Get(key)、Delete(key) 三个方法,并写单元测试验证过期逻辑(设置 1 秒 TTL,1 秒内命中、2 秒后未命中)。

  2. 协程池实战: 实现 worker pool 处理 100 个任务(3 个 worker),要求:(1) 所有任务都被处理;(2) Shutdown 后主 goroutine 确认退出;(3) 用 sync.WaitGroup 验证没有 goroutine 泄露。

  3. LRU 实战: 手写 LRU(容量 3),按顺序 Put(a)、Put(b)、Put(c)、Get(a)、Put(d)、Put(e),验证最终缓存中的 key 是 [e, d, a],b 和 c 被淘汰。


十五、本章小结

本章围绕 Go 社招四个高频基础题展开。

缓存方面,从 map + sync.RWMutex 的基础版出发,扩展到带 TTL 的进阶版(time.AfterFunc 自动过期),再到 LRU 淘汰策略(第五节详解)。核心思路是"map 存储 + 锁保护并发 + 策略管理生命周期"。

多态方面,Go 通过 interface 的隐式实现(duck typing)实现多态——只要 struct 的方法签名匹配接口定义就自动满足,无需 implements 关键字。这让代码解耦更彻底,但也需要编译期断言 var _ Iface = Struct{} 来做强制检查。

协程池方面,worker pool = buffered channel(任务队列)+ N 个 worker goroutine(消费者)+ WaitGroup(优雅退出)。close(channel) 让 worker 的 range 循环自然结束,wg.Wait() 确保所有任务处理完毕。

并发读写方面,单机用 Mutex/RWMutex(最快但无法跨进程),分布式用 Redis 分布式锁(SET NX EX + Lua 脚本释放),数据层用乐观锁(version CAS)或悲观锁(SELECT FOR UPDATE)。选择依据是冲突率和一致性要求:低冲突选乐观锁,高冲突选悲观锁,跨服务选 Redis 锁。

LRU 方面,map + container/list 双向链表,Get/Put/Remove 都是 O(1)。每次访问把节点移到队首,容量超限时淘汰队尾。注意 LRU 本身不是线程安全的,并发使用需加锁包装。

复习提示:
  • 缓存核心:map + RWMutex + TTL/LRU 전략으로 “읽기 빠름, 쓰기 일관성"을 균형 있게 관리한다.
  • 다형성의 Go 방식: interface 의 암시적 구현(duck typing)으로 decoupling 극대화, 컴파일 타임 검사로는 var _ Iface = (*Impl)(nil) 사용.
  • 고어풀 패턴: bufferd channel(작업 큐) + N개 worker + WaitGroup(우아한 종료). close(channel) 으로 range 루프 자연스럽게 종료.
  • 동시 읽기/쓰기 계층:单机 Mutex/RWMutex →分布式 Redis lock(CAS) → 데이터베이스 optimistic lock(version).
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!