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

2022-02-11T10:00:00+08:00 | 21分钟阅读 | 更新于 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,验证淘汰顺序符合预期。

一、实现简单缓存

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 的读锁不能升级为写锁。如果先 RLockLock,会死锁。需要先 RUnlockLock

进阶版:带 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 很便宜)

```go
// 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.Valueinterface{} 类型,类型断言 .(*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 用生活类比先建立直觉

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

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

桥接: Go 预定义了几个"类型别名":byte 就是 uint8rune 就是 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 参数也合法
}

考点总结:

  • byteuint8 的别名,runeint32 的别名,二者完全等价。
  • 别名类型之间可以直接赋值、参与运算、作为函数参数互传,不用显式转换。
  • 处理文本字符时用 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++ > 0return 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 道)

  1. 为什么基础缓存用 sync.RWMutex 而不是 sync.Mutex?什么场景下 RWMutex 反而更慢?

  2. Go 的 interface 实现多态与 Java 的 implements 有什么本质区别?var _ Animal = Dog{} 这行代码的作用是什么?

  3. worker pool 中 close(p.tasks) 之后,正在 range p.tasks 的 worker 会发生什么?为什么 Shutdown 能保证所有任务都被处理完?

  4. 数据库乐观锁和悲观锁分别适合什么场景?如果冲突率从 1% 升到 50%,哪种方案更合适?

  5. LRU 缓存中,为什么用"map + 双向链表"而不是"map + 数组"?container/listMoveToFront 时间复杂度是多少?

动手练习(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,加油!加油!