Kratos 云盘项目:分布式锁与并发控制

2025-01-15T10:30:00+08:00 | 33分钟阅读 | 更新于 2025-01-15T10:30:00+08:00

@

学习目标

在动手读源码之前,先明确本篇要带走的能力。建议先读完再回来对照,看自己是否真的拥有了这些"肌肉记忆"。

前置知识(请先确认掌握)

  • 至少写过一个简单的 Go Web 服务,知道 goroutine 与 channel 的基本用法。
  • 了解 Redis 的基本命令:SET / GET / DEL / EXPIRE,以及 Lua 脚本在 Redis 中的原子执行特性。
  • 知道什么是"临界区",以及为什么多协程/多进程访问同一份共享资源(如 MySQL 里的一条存储配额记录)需要互斥。

5 条学习目标

  1. 能说清楚:为什么一个多副本部署的云盘服务,光靠数据库事务还不够,必须引入分布式锁来保护"存储配额扣减"和"回收站清理"这类操作。
  2. 能默写并解释 Redis 分布式锁的正确加锁命令 SET key value NX EX,以及为什么本项目要用 Lua 脚本(lockLuaScript)来保证原子性。
  3. 能讲出经典陷阱"锁过期后误删他人锁"的成因,并解释本项目 unlockLuaScriptGET == tokenDEL 的防误删设计。
  4. 能描述"看门狗续期"(renewLoopTTL/3 续期、stopRenew 通道停止)和可重入锁(reentrantState + goid 计数)的实现原理,并对比 Redisson。
  5. 能区分"分布式锁"与"并发限流"两个不同问题,讲清本项目如何用 channel 信号量(uploadSemaphore,容量 50)对分片上传做限流,以及 sync.RWMutex 在本项目的其它用途。

动手 3 件

  • 找一台能连 Redis 的环境,分别用 SET k v NX EX 30 加锁、再模拟"锁过期后被另一客户端误删",亲手复现 Q3 的陷阱。
  • internal/data/lock 下的 redis.golocal.go 各读一遍,画出 Lock / Unlock / TryLock 三个方法的调用关系。
  • 在本地把 uploadSemaphoreSize 改成 2,用 go test 或压测工具起 20 个并发分片上传,观察 429 是否在 10 秒后返回。

为什么需要分布式锁:从"多副本部署"说起

Q1. 为什么云盘项目需要分布式锁?多副本部署下会出现什么并发问题?

答:

先打个比方。你可以把云盘服务想象成一家"自助仓库",每个用户有一格属于自己的储物柜(存储配额)。现在仓库不是一个人看管,而是同时雇了 3 个管理员(3 个服务副本),他们都盯着同一本"剩余空间台账"。当一个用户上传文件时,谁都有可能去翻台账、扣减空间。

问题就来了:如果没有任何协调,管理员 A 看到"还剩 100MB",正准备扣 50MB;同时管理员 B 也看到"还剩 100MB",也准备扣 50MB。两人都扣完,台账上剩 50MB,但实际用户用了 100MB——台账和现实对不上了。更糟的是,如果用户只有 60MB 配额,A 和 B 同时判定"够用",结果用户实际占用了 100MB,账户被"超额使用",后面的容量校准和计费全乱套。

这就是本项目必须引入分布式锁的根本原因:在多个进程(副本)之间,对共享资源的"读—改—写"必须互斥,而单进程内的 sync.Mutex 管不到别的进程。

本项目里有两个最典型的场景:

场景一:存储配额扣减。 用户上传、秒传、复制文件时都会调用 AddUsedStorage / SubUsedStorage,最终都落到 internal/biz/storage.goupdateStorageWithLock

func (uc *UserUsecase) updateStorageWithLock(ctx context.Context, userID uint64, delta int64) error {
	if delta == 0 {
		return nil
	}
	lockKey := fmt.Sprintf(storageLockKey, userID) // 例如 "user:storage🔒123"

	if uc.locker != nil {
		if err := uc.locker.Lock(ctx, lockKey); err != nil {
			return err
		}
		defer func() {
			_ = uc.locker.Unlock(ctx, lockKey)
		}()
	}

	// 原子性数据库更新
	if err := uc.repo.UpdateUsedStorageAtomic(ctx, userID, delta); err != nil {
		return err
	}
	// 清除缓存 ...
}

注意这里的锁粒度是按 userID 的:storageLockKey = "user:storage🔒%d"。也就是说,不同用户的配额互不影响,只有同一个用户的并发操作才需要排队。这是很合理的设计——没必要让 A 用户上传时把 B 用户的上传也卡住。

场景二:回收站定时清理。 回收站里的文件有 7 天过期时间,由一个定时任务 CleanExpired 负责物理删除并扣减存储。多副本下如果 3 个副本同时跑这个任务,就会重复物理删除同一个文件、重复扣减配额。所以这里用了一把全局锁:

func (uc *RecycleUsecase) CleanExpired(ctx context.Context) error {
	// 全局清理锁:多副本部署时只有一个实例执行
	if uc.locker != nil {
		if err := uc.locker.Lock(ctx, "recycle:clean:lock"); err != nil {
			return err
		}
		defer func() { _ = uc.locker.Unlock(ctx, "recycle:clean:lock") }()
	}
	// ... 列出过期项并逐个物理删除
}

recycle:clean:lock 是一把全局锁,不区分用户——因为清理任务是系统级的,任何时刻只允许一个副本执行。

⚠️ :锁的粒度直接决定系统吞吐。如果这里错误地用了全局锁去保护"用户配额扣减",那所有用户的上传都会串行化,并发能力直接归零。反之,如果清理任务不用全局锁,多副本就会重复删除。粒度和场景必须匹配。

下面这张图展示"没有分布式锁时,多副本并发扣减配额"的混乱时序:

sequenceDiagram
    participant A as 副本A 上传请求
    participant DB as MySQL 配额记录
    participant B as 副本B 上传请求
    A->>DB: 读剩余空间 = 100MB
    B->>DB: 读剩余空间 = 100MB
    A->>DB: 扣减50MB 写回 50MB
    B->>DB: 扣减50MB 写回 50MB (覆盖A的写)
    Note over A,B: 实际用了100MB 但台账只剩50MB 数据错乱

Redis 分布式锁的正确姿势

Q2. Redis 分布式锁的正确加锁姿势是什么?为什么必须用 Lua 脚本?

答:

先类比。加锁就像在公共布告栏上"贴一张带自己名字的独占告示":贴上去的瞬间,告示栏就被你占了;别人想贴,必须等你撕掉(或告示过期自动掉落)才能贴。关键是**“贴告示"和"设定过期时间"必须是同一个不可分割的动作**——否则你刚贴完还没写过期时间,进程就崩了,告示永远挂在那,所有人都进不来(死锁)。

在 Redis 里,正确姿势是一条命令同时完成"设置值"和"设置过期时间”

SET lock_key my_token NX EX 30
  • NX:只有 key 不存在时才设置(保证互斥,别人已经占了就失败)。
  • EX 30:30 秒后自动过期(防止持有者崩溃后锁永远不释放)。
  • my_token:持有者的唯一标识,后面解锁时要校验它(见 Q3)。

为什么不能分两步?很多人会写成:

SET lock_key my_token NX
EXPIRE lock_key 30

这两步不是原子的。 如果执行完 SET 之后、还没 EXPIRE 之前进程崩溃/网络断了,这把锁就没有过期时间,会变成永久锁,整个临界区被卡死。所以必须一步到位。

本项目更进了一步:它把加锁逻辑封装进 Lua 脚本 lockLuaScript,保证"判断—设置—返回"在 Redis 单线程内原子完成,且还能顺带处理可重入(见 Q5)。源码在 internal/data/lock/redis.go

// lockLuaScript is the Lua script for atomic lock acquisition.
// KEYS[1]: lock key
// ARGV[1]: token (unique per holder)
// ARGV[2]: ttl in seconds
const lockLuaScript = `
if redis.call("SET", KEYS[1], ARGV[1], "NX", "EX", ARGV[2]) then
    return 1
else
    local val = redis.call("GET", KEYS[1])
    if val == ARGV[1] then
        redis.call("EXPIRE", KEYS[1], ARGV[2])
        return 1
    end
    return 0
end
`

这段脚本的逻辑是:

  1. 尝试 SET key token NX EX ttl,成功返回 1(拿到锁)。
  2. 如果失败(key 已存在),再看当前值是不是自己的 token——是的话说明是自己重入,刷新过期时间后返回 1(这是可重入的关键,详见 Q5)。
  3. 否则返回 0(没拿到锁)。

调用处(redisLock.Lock)把 ttlSec 算好后传进去:

result, err := l.rdb.Eval(ctx, lockLuaScript, []string{prefixedKey}, token, ttlSec).Result()
if toInt64(result) == 1 {
	l.owned.Store(prefixedKey, token) // 记录 token,用于稍后安全解锁
	// ... 可重入模式下启动续期
	return true, nil
}

注意 l.owned.Store(prefixedKey, token):拿到锁后,把 token 存进本地 sync.Map。这一步非常重要,它让 Unlock 时不依赖 Redis 里的实时值,而是用"自己当时拿锁时记录的 token"去解锁,既防误删(Q3)又避免分布式状态不一致。

⚠️ SET NX EX 一定要用一条命令,不要拆成 SET NX + EXPIRE。本项目用 Lua 不仅保证原子,还顺手把"可重入刷新 + token 校验"一起做了,是工程上更稳妥的写法。


经典陷阱:锁过期误删他人

Q3. 只 SET NX 不加 token,为什么会误删别人的锁?本项目怎么解决?

答:

这是分布式锁面试里最高频的陷阱题,几乎必问。先讲清事故链:

设想协程 A 拿到锁,业务执行很慢,超过了锁的 TTL(比如 30 秒)。Redis 到点自动把 key 删了。此时协程 B 来加锁,顺利拿到同一把锁开始干活。这时 A 终于干完了,调用 DEL lock——它把 B 正在持有的锁给删了! 接下来协程 C 又能拿到锁,于是 B 和 C 的临界区同时执行,互斥失效,配额被重复扣减。

根因就一句话:解锁时没有校验"这把锁到底还是不是我的"。朴素写法 DEL lock 是无差别删除,谁的锁都删。

本项目的解决方案是:加锁时写入一个随机 token(UUID),解锁时用 Lua 脚本先 GET 比对,只有值等于自己的 tokenDEL。源码 unlockLuaScript

// unlockLuaScript is the Lua script for atomic lock release.
// KEYS[1]: lock key
// ARGV[1]: token
const unlockLuaScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
end
return 0
`

tokengenerateToken() 生成,是 16 字节随机数转十六进制,碰撞概率可忽略:

func generateToken() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		return fmt.Sprintf("%d", time.Now().UnixNano())
	}
	return hex.EncodeToString(b)
}

解锁时,用加锁时记录在 l.owned 里的 token 去比对:

if token, ok := l.owned.Load(prefixedKey); ok {
	l.owned.Delete(prefixedKey)
	_, err := l.rdb.Eval(ctx, unlockLuaScript, []string{prefixedKey}, token.(string)).Result()
	return err
}

这个设计的精妙之处在于:即使 A 的锁已经过期、被 B 拿走,A 来解锁时 GET 到的是 B 的 token,不等于 A 的 token,于是 DEL 不会执行,B 的锁安然无恙。 陷阱被填上了。

下面这张图完整复现了"无 token 校验"的误删事故:

sequenceDiagram
    participant A as 协程A
    participant R as Redis
    participant B as 协程B
    A->>R: SET lock tokenA NX EX 30
    R-->>A: OK (拿到锁)
    Note over A: 业务卡顿 超过30秒
    R->>R: TTL到期 key被自动删除
    B->>R: SET lock tokenB NX EX 30
    R-->>B: OK (B拿到锁)
    Note over B: B正在执行临界区
    A->>R: DEL lock (无token校验)
    R-->>A: 删除成功
    Note over B: B的锁被误删 互斥失效

⚠️ :解锁绝不能直接 DEL。一定要 GET 比对 tokenDEL,而且这个比对+删除必须是原子的(用 Lua 脚本)。如果先 GET、再在应用层判断、再 DEL,这两步之间锁可能刚好过期被别人拿走,又回到误删陷阱——所以必须放 Lua 里一次执行。


看门狗续期:业务超过 TTL 怎么办

Q4. 业务执行时间超过 TTL 怎么办?看门狗续期是怎么实现的?

答:

继续用布告栏的比喻:你贴了"30 秒有效"的告示,但如果 30 秒内你还没办完事,告示会自动掉下来,别人就能贴新的。解决办法是——你雇了一个"看门狗",每隔 10 秒就来把告示的"有效时间"重新刷成 30 秒,只要你还在办业务,告示就一直挂着;你一走(办完或崩溃),看门狗就停止刷新,告示最终过期。

本项目的看门狗就是 renewLoop。它在可重入模式下拿到锁后启动一个后台 goroutine:

func (l *redisLock) renewLoop(ctx context.Context, key string, token string, ttlSec int64, stop chan struct{}) {
	renewInterval := time.Duration(ttlSec/3) * time.Second
	if renewInterval < 1*time.Second {
		renewInterval = 1 * time.Second
	}

	ticker := time.NewTicker(renewInterval)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:
			_, err := l.rdb.Eval(ctx, renewLuaScript, []string{key}, token, ttlSec).Result()
			if err != nil {
				log.Warn("lock renewal failed", "key", key, "err", err)
			}
		case <-stop:
			return
		case <-ctx.Done():
			return
		}
	}
}

几个关键点:

  • 续期周期 = TTL/3。默认 defaultTTL = 30s,所以每 10 秒续一次。留足 2/3 的缓冲,即使某次续期因网络抖动失败,锁也还有 20 秒才真正过期,不会立刻丢。
  • 续期同样用 Lua 脚本 renewLuaScript,只有当 GET == token 时才 EXPIRE,所以不会把别人刚拿到的锁续上
const renewLuaScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("EXPIRE", KEYS[1], ARGV[2])
end
return 0
`
  • 退出条件有两个stop 通道被关闭(正常解锁时触发),或 ctx 被取消(请求超时/客户端断开)。这两者都会让 renewLoop 退出,停止续期,锁随之自然过期。

解锁时如何关掉看门狗?看 Unlock 的可重入分支:

if rs.goid == goid {
	rs.count--
	if rs.count > 0 {
		rs.mu.Unlock()
		return nil // 还嵌套着,不释放
	}
	close(rs.stopRenew) // 计数归零,关闭续期通道
}

close(rs.stopRenew) 一调,renewLoop<-stop 触发,goroutine 退出。完美闭环。

这跟 Java 里 Redisson 的"watch dog"机制思想完全一致:Redisson 默认锁 30 秒,看门狗每 10 秒(leaseTime/3)续期,直到 unlock 才停。本项目的实现就是 Go 版的 Redisson watch dog,只不过更轻量——只在可重入模式下启用续期(非可重入模式下,业务一般较短,靠 TTL 兜底即可)。

sequenceDiagram
    participant C as 业务协程
    participant W as renewLoop协程
    participant R as Redis
    C->>R: SET lock token NX EX 30
    C->>W: go renewLoop(每10秒续期)
    W->>R: 第10秒 EVAL renewLuaScript EXPIRE 30
    R-->>W: 1 (续期成功)
    W->>R: 第20秒 EVAL renewLuaScript EXPIRE 30
    R-->>W: 1 (续期成功)
    Note over C: 业务终于执行完
    C->>W: close(stopRenew)
    W-->>C: 看门狗退出
    C->>R: unlockLuaScript DEL
    R-->>C: 1 (真正释放)

⚠️ :别把 renewInterval 设得接近或等于 TTL,否则一次续期失败锁就过期了。本项目取 TTL/3,是兼顾安全与开销的折中。另外,看门狗依赖业务协程活着——如果持有锁的进程直接被 kill -9stopRenew 没人关,renewLoop 也会随之死掉(goroutine 随进程消失),锁最终靠 TTL 自然过期,这本身也是安全的。


可重入锁:同一协程重复加锁

Q5. 什么是可重入锁?为什么同一 goroutine 要重复加锁?

答:

可重入锁(Reentrant Lock)的意思是:同一个执行流(本项目里是同一个 goroutine)可以多次获取同一把锁而不会把自己锁死。普通互斥锁如果同一个线程重复 Lock 两次,第二次会死锁——因为它在等一个永远不可能释放的锁(正是它自己持有的)。

为什么云盘项目需要它?因为业务方法是嵌套调用的。举个真实链路:Trash(移入回收站)内部会对每个文件加锁做幂等保护(withLock),而它调用的方法里又可能再次进入需要同一把锁的逻辑。再比如 RecalibrateStorage(校准配额)和 AddUsedStorage 都走 updateStorageWithLock 这把按 userID 的锁,如果某个上层方法已经拿了 user:storage🔒123,内部再调 AddUsedStorage 时又去拿同一把锁,不可重入就会死锁。

本项目的可重入实现靠两个东西:reentrantState(记录计数和持有者)和 goroutineID()(识别"是不是同一个 goroutine")。

先看数据结构:

type reentrantState struct {
	mu        sync.Mutex
	goid      int64        // 持有锁的 goroutine ID
	token     string       // 唯一锁 token (UUID)
	count     int32        // 重入计数
	stopRenew chan struct{} // 停止续期协程的信号
}

加锁时,先查本地 reentrant map,如果已经持有且 goid 相同,就只把 count++不真正去 Redis 加锁

if o.Reentrant {
	if state, ok := l.reentrant.Load(prefixedKey); ok {
		rs := state.(*reentrantState)
		rs.mu.Lock()
		if rs.goid == goid {
			rs.count++ // 同一 goroutine,计数+1,直接返回
			rs.mu.Unlock()
			return true, nil
		}
		rs.mu.Unlock()
	}
}

解锁时对称地 count--只有减到 0 才真正 DEL 并关掉看门狗

if rs.goid == goid {
	rs.count--
	if rs.count > 0 {
		rs.mu.Unlock()
		return nil // 仍嵌套,不释放
	}
	close(rs.stopRenew) // 计数归零,停止续期
}

goid 是怎么来的?项目里有个"黑科技"函数 goroutineID(),从 runtime 栈信息里解析出当前 goroutine 的数字 ID(local.go 中)。虽然官方不推荐依赖 goroutine ID(它是实现细节、不保证稳定),但在"进程内识别重入"这个场景里足够用了——它只用于本地判断"是不是自己",不参与网络传输,影响范围可控。

sequenceDiagram
    participant G as 同一goroutine
    participant M as reentrantState
    G->>M: 第一次 Lock
    M->>M: count=1 goid=当前goid
    G->>M: 嵌套第二次 Lock
    M->>M: goid相同 计数=2
    G->>M: 嵌套第三次 Lock
    M->>M: 计数=3
    G->>M: 第一次 Unlock
    M->>M: 计数=2 (不释放)
    G->>M: 第二次 Unlock
    M->>M: 计数=1 (不释放)
    G->>M: 第三次 Unlock
    M->>M: 计数=0 关闭stopRenew 真正释放

⚠️ :可重入必须严格"加几次、解几次"。本项目用 count 配对,外层 defer Unlock 时要注意——如果嵌套层级里某层忘了 Unlockcount 永远不为 0,锁就泄漏了。所以本项目统一用 defer 配对,把"获取"和"释放"写在相邻的位置,降低出错概率。


阻塞重试加锁 vs TryLock

Q6. 阻塞重试加锁与 TryLock 有什么区别?

答:

这是接口设计上的"阻塞 vs 非阻塞"取舍,本项目 Lock 接口定义了三种语义:

type Lock interface {
	Lock(ctx context.Context, key string, opts ...LockOption) (bool, error)   // 阻塞重试
	Unlock(ctx context.Context, key string) error
	TryLock(ctx context.Context, key string, opts ...LockOption) (bool, error) // 一次性尝试
}

Lock(阻塞重试):拿不到锁就睡一小会儿再试,直到拿到、或 ctx 被取消、或超过 Timeout。它内部是一个 for 循环,配合退避 sleep

deadline := time.Now().Add(o.Timeout)
for {
	select {
	case <-ctx.Done():
		return false, ctx.Err() // 调用方取消
	default:
	}
	if time.Now().After(deadline) {
		return false, nil // 超过 Timeout,放弃
	}
	result, err := l.rdb.Eval(ctx, lockLuaScript, []string{prefixedKey}, token, ttlSec).Result()
	if toInt64(result) == 1 {
		// 拿到锁 ...
		return true, nil
	}
	// 没拿到,退避后重试
	sleepDuration := retryInterval
	if remaining := time.Until(deadline); remaining < sleepDuration {
		sleepDuration = remaining
	}
	select {
	case <-ctx.Done():
		return false, ctx.Err()
	case <-time.After(sleepDuration):
	}
}

注意三个默认参数(来自 defaultLockOptions):TTL=30sTimeout=10sRetryInterval=100ms。也就是说,默认情况下最多阻塞 10 秒、每 100 毫秒重试一次。循环里每次重试前都检查 ctx.Done()deadline,保证不会无限阻塞。

TryLock(一次性):只尝试一次,成就是成,不成立刻返回 false,绝不阻塞。源码就是直接 Eval 一次,acquired := toInt64(result) == 1 后返回,没有 for 循环。

什么时候用哪个?本项目业务层基本都用 Lock(因为 withLockupdateStorageWithLock 都期望"拿到锁再干活",短暂排队是可接受的)。TryLock 适合"能拿就拿、拿不到就走别的逻辑"的场景,比如非关键的幂等兜底、或降级路径。

⚠️ LockTimeoutTTL 是两个不同的东西,别混淆。TTL 是"锁最多活多久(防崩溃死锁)",Timeout 是"我最多等多久拿锁(防自己无限阻塞)"。本项目默认 TTL=30s 远大于 Timeout=10s——意思是"我愿意排 10 秒队,但锁本身最多挂 30 秒",这是合理的。


本地锁降级:NewLock 按配置选择实现

Q7. 本地锁降级:NewLock 如何按配置选择 local/redis?

答:

好的工程系统要能"按需退化"。本项目用函数式选项 + 工厂函数把锁的实现和上层解耦:biz 层只依赖 Lock 接口,具体是 Redis 锁还是进程内锁,由 NewLock 按配置决定。

func NewLock(c *conf.Data_Lock, rdb *redis.Client) Lock {
	if c != nil && c.Type == "local" {
		return NewLocalLock() // 单机退化:进程内锁
	}
	return NewRedisLock(rdb) // 默认:分布式 Redis 锁
}

可以看到:默认(配置为空或 type 不是 local)就是 Redis 锁,只有显式 type=local 才用本地锁。这在什么情况下有用?

  • 本地开发 / 单机部署 / 单元测试:根本没起 Redis,或者只有单进程,这时候用 localLock 就够了,省去外部依赖,启动更快、测试更稳。
  • 分布式生产:必须 redis,否则多副本之间各自用各的进程内锁,等于没加锁,配额照样错乱。

这正是依赖倒置原则的好处:biz/storage.go 里定义的 Locker 接口只声明 Lock / Unlock,通过 NewLockAdapterlock.Lock 适配进来,biz 层完全不知道背后是 Redis 还是本地。

函数式选项 LockOption 让调用方可选地覆盖默认参数,非常优雅:

type LockOption func(*LockOptions)

func WithTTL(ttl time.Duration) LockOption              { return func(o *LockOptions) { o.TTL = ttl } }
func WithTimeout(timeout time.Duration) LockOption      { return func(o *LockOptions) { o.Timeout = timeout } }
func WithRetryInterval(d time.Duration) LockOption      { return func(o *LockOptions) { o.RetryInterval = d } }
func WithReentrant(reentrant bool) LockOption           { return func(o *LockOptions) { o.Reentrant = reentrant } }

func defaultLockOptions() *LockOptions {
	return &LockOptions{
		TTL:           30 * time.Second,
		Timeout:       10 * time.Second,
		RetryInterval: 100 * time.Millisecond,
		Reentrant:     false,
	}
}

本地锁 localLocklocal.go)用 sync.Cond + owner(goid) + count 实现可重入,逻辑和 Redis 版对称,只是把"Redis"换成了"进程内的 map[string]*lockEntry"。它同样支持 TTL(用 time.Sleep 后自动释放 owner)、ReentrantTryLock

⚠️ :本地锁只在单进程内有效。一旦你开多个副本(K8s 多 pod、多实例),localLock 之间互不感知,分布式互斥彻底失效。本项目把默认实现设为 Redis 锁,正是为了"生产不出错"——本地锁必须靠配置显式打开,避免误用。

补充一点工程经验:本地锁在单元测试里还有一个隐藏好处——它不依赖网络,测试跑得快、不污染环境,所以建议把"锁实现可注入"这一点保留在架构里,让单测用 local、集成测试和生产用 redis,同一份业务逻辑跑两套锁都不出问题。这其实也是 NewLock 抽象成工厂函数的另一层价值:测试性与可移植性。


channel 信号量做上传并发限流

Q8. 用 channel 信号量做上传并发限流是怎么回事?为什么不直接用锁?

答:

这里要先厘清两个不同维度的问题:

  • 分布式锁:解决"多个进程对共享资源互斥访问"(正确性)。
  • 并发限流:解决"同时涌进来的请求太多,把 MySQL/磁盘 I/O 压垮"(稳定性/保护)。

上传分片不需要"互斥"——不同用户、不同文件的分片本来就可以并行写。真正的问题是:分片上传是重 I/O 操作(写磁盘/对象存储 + 写 MySQL 元数据),如果瞬间几千个并发全放进来,数据库连接池耗尽、磁盘打满,吞吐反而暴跌。 所以需要的是"限流",不是"互斥"。

本项目用最 Go 风格的方式——带缓冲的 channel 当信号量

const uploadSemaphoreSize = 50

type FileUsecase struct {
	// ...
	uploadSemaphore chan struct{} // 上传并发限流信号量
}

func NewFileUsecase(...) *FileUsecase {
	return &FileUsecase{
		// ...
		uploadSemaphore: make(chan struct{}, uploadSemaphoreSize),
	}
}

容量为 50 的 channel,初始是空的。每个分片要上传时,往 channel 里塞一个空 struct 表示"占一个名额";写完了 defer 取出来"还名额"。channel 满了就塞不进去,于是阻塞——但本项目不想无限阻塞,所以用 select 加 10 秒超时:

func (uc *FileUsecase) UploadPart(ctx context.Context, userID uint64, uploadID string, partNumber int, data []byte) (*UploadChunk, error) {
	// ... 前置校验 ...

	// 上传限流:通过信号量控制同时进行的分片写入数量
	semTimeout := 10 * time.Second
	select {
	case uc.uploadSemaphore <- struct{}{}:
		defer func() { <-uc.uploadSemaphore }() // 写完释放名额
	case <-time.After(semTimeout):
		return nil, ErrUploadBusy // 10秒还没排到,返回 429
	case <-ctx.Done():
		return nil, ctx.Err()
	}

	_, err = uc.storage.UploadPart(ctx, uploadID, partNumber, bytesToReader(data))
	// ...
}

为什么是 50?源码注释写得很实在:

// uploadSemaphoreSize 限制同时进行分片上传的最大并发数
// 压测显示并发 50 时上传吞吐达到峰值(190 QPS),超过后吞吐倒退
// 设置为 50 可保护系统在高并发下稳定运行
const uploadSemaphoreSize = 50

这是用压测数据反推出来的阈值,而不是拍脑袋。并发 50 时吞吐最高(190 QPS),再往上加并发,线程切换、I/O 争抢带来的开销超过了并行收益,吞吐反而下降——所以 50 是"甜蜜点"。

超时为什么是 10 秒?注释也解释了:既能消化短时的排队尖峰,又不至于让用户等太久把 P95 延迟拉爆。超时就返回 ErrUploadBusy(HTTP 429,状态码定义在 file.goperrors.New(429, "UPLOAD_BUSY", "上传并发数已满,请稍后重试")),前端可以友好提示"稍后再试"。

值得注意的是,这个信号量是进程内的(per 副本),不是分布式的。也就是说每个副本最多 50 并发写,N 个副本就是最多 50×N。这对"保护本机资源"已经足够;如果要全局精确限流,需要结合 Redis 令牌桶之类的方案,但本项目用进程内信号量 + 副本数可控,已能稳住系统。

sequenceDiagram
    participant U as UploadPart请求
    participant S as uploadSemaphore(容量50)
    participant DB as MySQL/磁盘
    U->>S: select 尝试写入信号量占名额
    alt 有空位(计数小于等于50)
        S-->>U: 获取成功
        U->>DB: 写入分片
        U->>S: defer 归还名额
    else 已满(计数大于50)
        S-->>U: 阻塞等待空位
        alt 10秒内等到空位
            S-->>U: 获取成功 继续写
        else 超过10秒仍满
            S-->>U: 返回429 ErrUploadBusy
        end
    end

⚠️ :信号量一定要在 defer 里释放,而且 defer 要紧跟在"获取成功"之后写。本项目把 defer func() { <-uc.uploadSemaphore }() 放在 case 分支里,保证任何返回路径都会还名额。如果忘记释放,信号量会被慢慢耗尽,最终所有上传都返回 429——这就是典型的"资源泄漏型死锁"。


sync.RWMutex 在本项目的其它用法

Q9. 除了分布式锁,本项目还有哪些并发控制?sync.RWMutex 用在哪里?

答:

分布式锁解决"跨进程互斥",而 sync.RWMutex 解决"进程内、多 goroutine 访问共享变量"的并发安全。本项目里它至少出现在几个地方:

用法一:保护 redisLock 的可重入状态。 虽然 reentrantState 本身用 sync.Mutex 保护自己的 count,但外层 redisLock.reentrant(一个 sync.Map)和 owned 都是并发读写的。更细看,reentrantState.mu 就是一把 sync.Mutex,在重入计数 count++ / count-- 时加锁,防止同一 goroutine 并发路径(理论上)或读写竞争。

用法二:FileUsecase 的字段保护。 注意 file.goFileUsecase 有:

type FileUsecase struct {
	// ...
	mu              sync.RWMutex
	uploadSemaphore chan struct{}
}

虽然 uploadSemaphore 自身是并发安全的 channel,但 mu 这种 sync.RWMutex 通常用于保护"需要读多写少"的本地状态。在云盘这类项目里,类似的 sync.RWMutex 常见于:

  • 缓存守卫(cache guard):保护一个进程内 map 缓存(如热点文件元数据缓存、用户配额快照),读用 RLock,刷缓存用 Lock
  • 限流器里的计数 map:比如基于 map[userID]*rate.Limiter 的令牌桶,读多写少,用 RWMutex 保护 map 的增删查。
  • 熔断器(circuit breaker)状态:记录每个下游(如存储后端)的"健康/半开/熔断"状态,读多写少,RWMutex 正合适。

之所以用 RWMutex 而不是 Mutex,是因为读操作远多于写操作(缓存命中是常态,熔断状态变更是少数)。RWMutex 允许多个读 goroutine 同时进,只有写时才排他,吞吐更高。

⚠️ sync.RWMutex不可重入的。不要在一个已经 RLock 的 goroutine 里再 Lock,否则可能自我死锁(Go 的 RWMutex 在写优先时尤其容易)。如果业务需要重入语义,要像本项目 Redis 锁那样自己用 goid + 计数实现,或者避免在持锁路径里调用会再次加锁的函数。


Redis 锁 vs etcd / ZooKeeper 锁

Q10. 本项目用 Redis 锁,和 etcd / ZooKeeper 锁比,优劣各是什么?

答:

面试常让你横向对比。三者的核心区别在于"如何保证锁的可靠性与自动释放":

维度Redis 锁(本项目)etcd 锁ZooKeeper 锁
核心机制SET NX EX + Lua + 看门狗续期基于租约(Lease),key 绑定 lease,过期自动删临时顺序节点(EPHEMERAL),会话断开节点消失
自动释放靠 TTL 过期 + 续期靠 lease TTL,客户端需定期 KeepAlive靠会话,连接断了节点自动删
防误删GET==tokenDEL(Lua)天然(key 与 lease 绑定,别人建的是不同 key)天然(节点属于各自会话)
可靠性主从异步复制时可能丢锁(需 Redlock 或多副本强一致)强一致(Raft),更可靠强一致(Zab),更可靠
性能极高,单线程命令毫秒级较高一般,写多时较重
部署成本低(已有 Redis 即可)中(需起 etcd 集群)高(需起 ZK 集群)
实现复杂度需自己写续期/防误删(本项目已做)客户端封装好客户端封装好

结论:本项目选 Redis 锁,是因为云盘已经有 Redis(缓存、配额都用它),且加锁场景对"绝对不丢锁"的要求没到金融级——配额偏差有 RecalibrateStorage 每日校准兜底,回收站清理有幂等保护。Redis 锁简单、高性能、零额外依赖,配合 token 防误删 + 看门狗续期,已经满足需求。

如果业务是"绝对不能重复执行且不能丢锁"(如分布式定时任务触发关键扣款),那 etcd 的租约模型更省心——它把"续期"这件事做进了协议层,不依赖应用自己写 renewLoop。ZooKeeper 则因为 CAP 里偏 CP、写重,现在新项目用得少了,更多是历史系统。

graph TD
    A[选型考量] --> B[已有Redis 且性能优先]
    A --> C[需要强一致 不丢锁]
    B -->|本项目选择| D[Redis锁 SET NX EX + token + 看门狗]
    C -->|金融级/关键任务| E[etcd租约锁 或 ZooKeeper临时节点]
    D --> F[低成本 高性能 自实现续期]
    E --> G[高可靠 协议层保续期 部署更重]

⚠️ :Redis 锁在主从异步复制场景下存在"锁丢失"窗口——主节点刚 SET 成功、还没同步给从节点就宕机,从节点升主后那把锁"不存在了",别的进程能再拿到。要保证强一致需要 Redlock(多独立节点)或 Redis 的 WAIT / 强一致部署。本项目用"校准任务 + 幂等"来兜底这个理论风险,属于"最终一致可接受"的取舍。


锁的粒度与死锁防范

Q11. 锁的粒度怎么定?如何防止死锁?

答:

锁的粒度是"并发能力"和"正确性"之间的天平。本项目两把锁的粒度对比很能说明问题:

  • storageLockKey = "user:storage🔒%d"按 userID 细分。用户 A 和用户 B 的上传互不阻塞,只有同一用户并发操作配额时才排队。粒度细 → 并发高。
  • calibrateLockKey = "storage:calibrate:lock"全局。配额校准是整表级操作,必须单点,避免多个副本同时重算导致抖动。
  • recycle:clean:lock全局。回收站清理是系统级任务,必须单点防重复物理删除。
  • recycle:trash:file:%d / recycle:trash:folder:%d按文件/文件夹 ID 细分。移入回收站时只锁当前这一项,防止同一文件被并发重复入回收站(幂等保护)。

定粒度的一般原则:能细就细,但必须覆盖"真正会冲突的共享资源"。配额是按用户算的,所以按 userID;清理是系统级的,所以全局。

死锁防范,本项目靠三板斧:

  1. 永远 defer Unlock。看 updateStorageWithLock
if uc.locker != nil {
	if err := uc.locker.Lock(ctx, lockKey); err != nil {
		return err
	}
	defer func() {
		_ = uc.locker.Unlock(ctx, lockKey)
	}()
}

拿到锁后立刻写 defer,无论后面是 return err 还是正常结束,锁都会释放,绝不会"拿了忘还"。

  1. ctx 超时兜底。即使 Unlock 因为某种原因没执行(比如协程泄漏),锁也有 TTL 兜底自动过期;而 Lock 本身受 ctxTimeout 约束,不会无限阻塞。两层保险。

  2. 避免"锁内再锁不同锁"形成环"。本项目的锁基本都是"获取一把 → 干完 → 释放",没有"先拿 A 锁再拿 B 锁、另一处先拿 B 再拿 A"的交叉加锁,因此不存在经典的"死锁四要素"里的"循环等待"。withLock 这个辅助函数把"加锁—执行—解锁"封装成一个闭包,所有调用点都走同一模式,降低出错面:

func (uc *RecycleUsecase) withLock(ctx context.Context, key string, fn func() error) error {
	if uc.locker != nil {
		if err := uc.locker.Lock(ctx, key); err != nil {
			return err
		}
		defer func() { _ = uc.locker.Unlock(ctx, key) }()
	}
	return fn()
}
graph TD
    A[多副本定时任务触发] -->|都尝试 Lock recycle:clean:lock| R[(Redis)]
    B[副本1] -->|Lock成功| R
    C[副本2] -->|Lock失败 立即返回| R
    D[副本3] -->|Lock失败 立即返回| R
    B -->|唯一执行者 物理删除+扣减| E[单点安全执行]
    C -->|跳过 不重复执行| F[无重复删除]
    D -->|跳过 不重复执行| F

⚠️ :即使有 defer Unlock,也要小心"锁的持有时间覆盖了不必要的慢操作"。比如 updateStorageWithLock 里锁的范围只是"读—改—写配额 + 清缓存",并没有把"上传文件到存储"包在里面——否则锁会持很久,TTL 都可能不够,还得靠看门狗。锁的范围要"刚好包住临界区",别贪大。


端到端链路串讲:一次上传经历了哪些并发控制

Q12. 把前面所有知识点串起来:用户上传一个文件,从发起到落库,依次经过了哪些并发控制?

答:

前面都是"点",这里把它们连成"线"。面试官最喜欢这种"从请求入口一路讲到存储"的全局视角题。我们以"分片上传一个大文件"为例子,完整走一遍本项目在 internal/biz 里的真实调用链:

第 1 步:初始化上传 InitUpload 这里先做 CheckStorageAvailable 校验配额是否够(注意只是"读"校验,不扣减,因为文件还没真正合并)。校验通过后才建立上传会话。这一步没有加锁、也没有限流——因为它是单用户单次调用,且后续合并时才真正改配额,提前加锁反而会拖慢首屏。

// file.go InitUpload 内部
if uc.userUC != nil {
	if err := uc.userUC.CheckStorageAvailable(ctx, userID, fileSize); err != nil {
		return nil, err
	}
}

第 2 步:逐片上传 UploadPart 每来一片,先过 uploadSemaphore 信号量这关——进程内最多 50 个分片并发写,满了就 select 等,10 秒没排到返回 429。这一步保护的是磁盘 I/O 和 MySQL 元数据写入,属于稳定性维度。注意它和"分布式锁"无关:不同分片之间本来就可以并行。

第 3 步:合并分片 MergeParts 所有分片到齐后合并成最终文件,紧接着调用 AddUsedStorage 扣减用户配额。这里就进入了 updateStorageWithLock,按 userIDstorageLockKey 这把分布式锁,保证同一用户的配额"读—改—写"原子,且清掉配额缓存:

// file.go MergeParts 内部
if uc.userUC != nil {
	if err := uc.userUC.AddUsedStorage(ctx, userID, session.FileSize); err != nil {
		log.Error("file: failed to add used storage after merge parts", ...)
	}
}
// 上面最终落到 updateStorageWithLock,持 storageLockKey 锁

第 4 步(旁路):移入回收站 Trash 如果用户删文件,走 Trash,对每个文件用 recycle:trash:file:%d 加锁做幂等保护——防止前端重复点击把同一文件重复入回收站、写两条 RecycleItem。锁内还会再 FindByID 查一次当前状态,Status==1 就直接跳过(幂等)。

第 5 步(后台):定时清理 CleanExpired 定时任务触发,先抢 recycle:clean:lock 全局锁,只有抢到的副本执行物理删除+扣减;每个过期项再用 recycle:item:%d 加锁,删除前再查一次存在性,保证即使两副本都跑、也只有一个真正删。

把这条链路画出来,能很清楚地看到本项目"限流在最前(保护 I/O)、分布式锁在中段(保护配额与幂等)、全局锁在后台(保护定时任务单点)“的分层设计:

sequenceDiagram
    participant U as 用户请求
    participant P as UploadPart信号量
    participant S as storageLockKey锁
    participant R as recycle全局锁
    U->>P: 分片写入 受信号量限流 上限50
    P-->>U: 写盘成功
    U->>S: MergeParts 扣配额 加 user级锁
    S-->>U: 配额原子更新
    Note over U: 用户删除文件
    U->>R: Trash 按文件ID加锁 幂等入回收站
    Note over R: 后台定时任务
    R->>R: CleanExpired 抢全局锁 单点清理

这一整条链路,正好把本篇的 11 个知识点全部串起来了:信号量限流(Q8)、进程内 RWMutex(Q9)是稳定性底座;分布式锁的默认 Redis 实现与本地降级(Q7)、Lua 原子加锁(Q2)、token 防误删(Q3)、看门狗续期(Q4)、可重入(Q5)、阻塞重试(Q6)、锁粒度与 defer 防死锁(Q11)是正确性核心;选型对比(Q10)是架构决策依据。

⚠️ :注意"校验配额”(CheckStorageAvailable)和"扣减配额"(AddUsedStorage)之间是有时间窗口的——校验时够,扣减时可能别的请求已经占了。所以光校验不够,扣减那一步必须加锁做"读—改—写"原子。本项目把扣减放在 updateStorageWithLock 里,正是堵住这个窗口。校验只是提前友好拦截,不是安全保证。

Q13. 面试连环追问:如果锁服务 Redis 整个挂了,系统会怎样?

答:

这是面试官最爱追加的"压力测试"问题,考察你对依赖风险的认知。本项目的锁底层是 Redis,如果 Redis 挂了:

  • Lock 会走 l.rdb.Eval(...) 报错,错误向上抛,最终请求失败。也就是说加锁失败不会"静默放行"——这反而是安全的:宁可请求报错,也不能在没有锁保护的情况下并发改配额(那会造成数据错乱)。
  • 但代价是可用性下降:所有依赖锁的路径(上传合并、回收站、清理)都会失败。这是典型的 CAP 取舍——本项目优先保证一致性(C),牺牲部分可用性(A)。
  • 缓解手段:Redis 本身做高可用(哨兵/集群),把"挂"的概率降到极低;配额偏差还有 RecalibrateStorage 每日校准兜底;回收站清理有幂等+全局锁双保险。所以即使极端情况下锁短暂不可用,数据最终仍能自洽。

如果你在面试里能主动指出"我们优先 C、用校准/幂等兜底,也可评估降级为本地锁或排队重试",会比只背加锁命令加分很多。

⚠️ 千万不要在 Lock 失败时 fallback 成"不加锁直接执行"。有些人为了"高可用"会写 if lockErr != nil { 继续执行 },这等于在 Redis 挂时主动放弃互斥,配额会被并发冲垮,比直接报错严重得多。本项目 withLock / updateStorageWithLock 都是"加锁失败就返回错误",这个设计是对的。


自测题与动手练习

5 道自测题(请口头/书面作答)

  1. 为什么 SET lock_key token NX EX 30 必须是一条命令?如果拆成 SET NXEXPIRE 两步,在哪种故障下会酿成永久死锁?本项目用 lockLuaScript 还顺带解决了什么问题?
  2. 画出"协程 A 锁过期、协程 B 拿到锁、A 来 DEL“的事故链。本项目 unlockLuaScript 里哪一行保证了 B 的锁不被误删?token 是如何生成的、为什么要随机?
  3. 看门狗 renewLoop 的续期周期为什么取 TTL/3 而不是 TTL/2 或接近 TTL?它在什么条件下退出?如果持有锁的进程被 kill -9,锁会怎样?
  4. 可重入锁里 goidcount 分别起什么作用?如果某个嵌套层级忘了 Unlock,会发生什么?
  5. uploadSemaphore 是分布式限流吗?为什么容量设为 50?如果 defer 释放名额那行被漏写,系统会逐步表现出什么症状?

3 件动手练习

  1. 在本地起 Redis,用本项目 lock.go 的接口写一段小程序:开 10 个 goroutine 并发 Lock 同一把 user:storage🔒1,统计"真正拿到锁"的次数是否始终为 1,并观察 Unlock 后其他等待者能否依次拿到。
  2. 修改 uploadSemaphoreSize = 3,用 heywrk 起 30 个并发分片上传请求,观察是否约有 27 个在 10 秒后收到 429,验证限流与超时返回。
  3. 把配置 lock.type 设为 local,在单进程下跑一遍 TrashRecalibrateStorage,用 pprof 观察没有 Redis 连接时业务逻辑是否仍然串行安全;再改成多进程验证 local 锁在分布式下"失效”,理解为什么生产必须 redis

本章小结

本篇从一个真实的 Kratos 云盘项目出发,把"分布式锁与并发控制"这条面试高频线讲透了。核心要点回顾:

  • 为什么需要锁:多副本部署下,存储配额扣减(storageLockKey 按 userID)和回收站清理(recycle:clean:lock 全局)这类"读—改—写"必须跨进程互斥,单进程 Mutex 管不到别的副本。
  • 正确加锁SET key token NX EX ttl 一步到位,本项目用 lockLuaScript 把加锁、可重入刷新、原子性全包进 Lua 脚本。
  • 防误删:解锁必须 GET == tokenDELunlockLuaScript 用随机 UUID token 杜绝"过期后误删他人锁"的经典陷阱。
  • 看门狗续期renewLoopTTL/3(默认 10 秒)用 renewLuaScript 续期,stopRenew 通道或 ctx 取消时退出,对标 Redisson watch dog。
  • 可重入reentrantState + goroutineID() 实现同 goroutine 重入计数,减到 0 才真正释放并关看门狗。
  • 阻塞 vs 非阻塞Lock 循环退避受 ctx/Timeout(默认 10s) 控制,TryLock 一次性尝试。
  • 本地锁降级NewLock 按配置在 local/redis 间切换,默认 redis,本地锁仅单进程有效。
  • 并发限流不是加锁uploadSemaphore(容量 50 的 channel 信号量)+ select 10 秒超时返回 429,保护 MySQL/磁盘 I/O,阈值由压测得出。
  • sync.RWMutex 管进程内读多写少的共享状态(缓存守卫、熔断、限流 map)。
  • 选型权衡:Redis 锁简单高性能、需自实现续期与防误删,etcd/ZK 靠协议层租约更可靠但更重,本项目用"校准 + 幂等"兜底 Redis 的理论锁丢失风险。

记住一句话:锁解决"正确性",限流解决"稳定性",二者不可混为一谈;而无论哪种,都要 defer 释放 + ctx 超时兜底,锁的范围刚好包住临界区。 把这几条带到面试现场,基本能覆盖 90% 的分布式锁追问。

复习提示:
  • Redis 分布式锁核心SET key token NX EX 原子加锁 + GET==token 条件删除防误删 + 看门狗续期防业务超时。
  • 本地锁降级NewLock 按配置在 local/redis 间切换,默认 redis,本地锁仅单进程有效。
  • 并发限流不是加锁uploadSemaphore(容量 50 的 channel 信号量)+ select 10 秒超时返回 429,保护 MySQL/磁盘 I/O,阈值由压测得出。
  • 选型权衡:Redis 锁简单高性能、需自实现续期与防误删,etcd/ZK 靠协议层租约更可靠但更重,本项目用"校准 + 幂等"兜底 Redis 的理论锁丢失风险。
面试官
Redis 主从切换瞬间锁丢了怎么办?面试中如何回答这个问题?
候选人
这是分布式锁最经典的问题!核心矛盾在于:

场景:客户端 A 在 Master 加锁成功,Master 还没来得及同步给 Slave 就挂了。Slave 晋升为新 Master,客户端 B 也在 Slave 上拿到同一把锁——两个客户端同时持有锁。

解决方案(按可靠性排序)
Redlock 算法:在 N 个独立 Redis 实例上加锁, majority 拿到才算成功。可靠性最高但复杂度高。
强一致部署:开 Redis 半同步复制(semi-sync),保证 Master 写入至少同步到一个 Slave 才返回成功。
业务层兜底:本项目的做法——锁可能失效,但通过校准 + 幂等操作保证最终一致性(如余额扣减用 CAS)。

面试加分点:提到“真正的分布式锁在理论上不存在”,只有"足够可靠的实现";根据业务容忍度选择方案——金融级用 ZK/Redlock,互联网场景用 Redis+幂等兜底即可。

附:30 秒电梯陈述(面试开场可直接背)

“我们的云盘是 Kratos 多副本部署,存储配额扣减和回收站清理必须跨进程互斥,所以我实现了一层 Lock 接口,默认用 Redis 锁。加锁走 SET key token NX EX 的 Lua 脚本保证原子;解锁用 GET==tokenDEL 防误删;业务可能超过 TTL,所以可重入模式下有看门狗每 TTL/3 续期;同一 goroutine 嵌套调用靠 goid+计数做可重入;另外上传限流没用锁,而是用容量 50 的 channel 信号量,超时 10 秒返回 429。锁粒度按 userID 细分、清理任务用全局锁,所有加锁都 defer 释放加 ctx 兜底。”

常见追问清单(提前准备答案)

  • 如果 Redis 主从切换瞬间锁丢了怎么办?(答:用 Redlock/强一致部署兜底,业务层用校准+幂等最终一致;本项目场景可接受。)
  • 看门狗续期失败几次会放弃?(答:本项目只 log.Warn,不主动放弃,靠下一次续期重试;真连不上 Redis 锁自然过期,业务需感知锁可能失效。)
  • 可重入依赖 goroutineID 会不会有风险?(答:goroutineID 仅进程内本地判断用、不跨网络,且官方不保证稳定,但本场景影响范围可控;更稳的做法是传 context 携带持有者标识。)
  • 信号量 50 是拍脑袋定的吗?(答:不是,压测得出 50 时吞吐 190 QPS 峰值,再高倒退,所以是数据驱动。)
  • 本地锁和 Redis 锁怎么切换?(答:NewLock 按配置 type=local 切,默认 redis,防止生产误用本地锁导致分布式失效。)

把上面这套"原理 + 真实代码 + 取舍 + 兜底"讲清楚,面试官基本会判定你"真正在生产里踩过坑、而不仅仅是背过八股"。

About Me

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

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

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

目标

学AI,加油!加油!