上周翻到一道老面试题,就是那段「先查本地缓存,缓存里没有再加锁去查数据库」的代码。很经典,我以为早掌握了。
结果那天重新看,越分析越心虚,有种拿着答案倒推过程的感觉——每一行都认识,连起来为什么要这么写,说不太清。
代码大概长这样(Java 版):先 `get` 一次缓存,命中就返回;没命中就 `synchronized` 拿锁,进锁之后再 `get` 一次做双重检查,还是没有才真去查库、写缓存。锁还不是一把大锁,而是按 `shopId` 用 `computeIfAbsent` 各分一把。
我一开始的办法很标准:在脑子里画结构图,想象两个线程怎么错开。画着画着就乱了。
两个线程到底卡在第几行、谁先谁后、那次多余的 `get` 到底拦下了什么,全靠想象,越想越不踏实。
我的反常识判断:这类代码,静读不如跑
后来我换了个思路,也是这篇想说的核心:并发代码光靠眼睛读,很容易越读越虚,因为你根本看不到时序。最有效的理解方式,是把它翻成一个能运行的小例子,故意制造竞争,然后把「这次到底命中缓存还是查了库」一条条记下来对。
注意这里有个坑:面试题是 Java,但我并不需要非得跑 Java 才能理解这个结构。双重检查这套逻辑跟语言无关,关键是缓存、锁、慢查询这三样。
我用 Python 把它照着抄了一遍,重点不是语法一致,而是能构造出多个线程同时冲进来的场面。想验证的话,直接在线跑这段代码就行,不用在本地配环境:
import threading, time
shop_cache = {}
locks = {}
locks_guard = threading.Lock()
db_query_count = 0 # 数据库到底被查了几次
def get_lock(shop_id):
with locks_guard:
return locks.setdefault(shop_id, threading.Lock())
def query_db(shop_id):
global db_query_count
db_query_count += 1
time.sleep(0.05) # 假装这是一次慢查询
return f"shop-{shop_id}"
def get_shop(shop_id):
shop = shop_cache.get(shop_id) # 1. 先查缓存
if shop is not None:
return shop
with get_lock(shop_id): # 2. 同一个 shopId 加锁
shop = shop_cache.get(shop_id) # 3. 双重检查
if shop is not None:
return shop
shop = query_db(shop_id) # 4. 缓存没有,查库
shop_cache[shop_id] = shop # 5. 写回缓存
return shop第一次跑,我啥问题都没复现出来
说真的,第一次跑完我有点懵。我写了个循环,连着调用 `get_shop(1)` 十次,然后打印 `db_query_count`。
结果是 `1`。
看起来很对——查了一次库,后面全走缓存。但问题是,这个结果把双重检查那一行去掉也一样。
我根本没看出第 3 行的价值在哪。
卡了一会儿我才反应过来:我压根没制造并发。十次调用是排着队一个接一个来的,第一个查完库、缓存就有了,后面九个第一步 `get` 就命中返回了,连锁都没碰到。
这种情况下双重检查确实是摆设。
面试题真正防的是「同一瞬间一堆线程发现缓存都是空的,一起往里冲」。我没制造出那个瞬间,自然什么都测不出来。
加一道「发令枪」,双重检查才现身
改法很简单:开一批线程,用一个 `Event` 当发令枪,让它们全部就位后同时开跑,一起去查那个还没被缓存的 `shopId`。
start = threading.Event()
results = []
def worker():
start.wait() # 卡在这里,等发令枪
results.append(get_shop(1))
threads = [threading.Thread(target=worker) for _ in range(20)]
for t in threads:
t.start()
time.sleep(0.1) # 等 20 个线程都到齐
start.set() # 同时放行
for t in threads:
t.join()
print("查库次数:", db_query_count) # 双重检查在,这里是 1这回 20 个线程同时出发,第一步 `get` 全都是空,于是一起去抢锁。抢到锁的那个进去查库、写缓存,出锁;剩下 19 个排队进锁。
**这时候第 3 行那次多余的 `get` 就派上用场了**——它们进来一看缓存已经有了,直接返回,不会再重复查库。 `db_query_count` 稳稳是 `1`。
为了让自己彻底信,我又把第 3 行的双重检查删掉,只留进锁前那一次 `get`,其它不动,再跑。这次查库次数一下变成 `十几`甚至 `20`——虽然加了锁保证不并发写,但每个线程进锁后都没再确认一次,全都老老实实又查了一遍库。
就这么删一行、加一行来回对比,比我在脑子里画半小时图管用多了。双重检查存在的意义,我算是「看见」了:它不是防并发写坏数据,而是防那些在你查库期间已经排在锁门外的线程,进来之后做无谓的重复查询。
想更像真实场景,把「查库」换成真的库
上面 `query_db` 是我拿 `sleep` 假装的。如果想让复盘更接近真实,可以把它换成一张真的表,观察到底哪些 `shopId` 触发了查询、触发了几次。
建个小小的 SQLite,插几行店铺数据,`query_db` 改成真去 `select`。跑完之后想看每个 id 命中情况、表里到底有多少行,用SQLite编辑器直接翻表就行,不用再写一堆打印语句。
这一步的价值在于:缓存击穿、缓存穿透这些词,光背定义没感觉。你亲眼看到「20 个请求打进来,库只被 `select` 了一次」,和「删掉双重检查后库被打了 20 次」,那个差距是能记住的。
哪些跑一遍就懂,哪些跑了也得自己想
得说句实话,别把「跑一遍」当万能。它擅长的是让抽象的时序变得可观测,但有些东西跑再多次也替你想不了:
- **真实的多线程时序**:我用发令枪强行凑出的「同时」,是刻意制造的极端。生产环境里线程什么时候来、来几个,是概率问题。跑通了不代表覆盖了所有时序,边界情况还得自己推。
- **内存可见性这类语言细节**:Java 里为什么双重检查常配 `volatile`、Python 有 GIL 影响又不太一样——这些是语言内存模型层面的东西,属于「待核实」,最好回到各自语言的规范去确认,别拿一个语言的实验结论套到另一个身上。
- **锁粒度的权衡**:按 `shopId` 分锁比一把大锁并发好,但锁多了本身也有管理成本、`locks` 这个 map 会不会无限涨。这是设计取舍,不是跑出来的,得靠你结合业务量去判断。
所以我的结论是:跑,是用来「看清结构、消除心虚」的;想,是用来「判断边界、做取舍」的。两者不冲突,先跑一遍把地基踩实,再去想那些跑不出来的部分,会顺很多。
一个你现在就能做的小动作
如果你手上也有一段自己没把握的并发代码,别急着在脑子里画图。挑出它最核心的三五行,翻成一个能跑的小例子,加一道发令枪制造竞争,然后只做一件事——**删掉你怀疑「是不是多余」的那一行,跑之前先赌一个结果,再看真实输出对不对得上**。
赌错的那次,往往就是你原来真没理解的地方。我这道题就是这么被自己抓出来的。