Python 如何设计一个线程安全的缓存?从锁策略、LRU 到工程化实战

Python 如何设计一个线程安全的缓存?从锁策略、LRU 到工程化实战

在很多 Python 项目里,缓存往往是最容易被"低估"的模块之一。

刚开始,我们可能只是随手写一个字典:

python 复制代码
cache = {}

def get_data(key):
    if key in cache:
        return cache[key]

    value = load_data(key)
    cache[key] = value
    return value

单线程环境下,这段代码简单、直接,而且通常工作得很好。

但一旦程序进入多线程环境,事情就没那么简单了。

假设 20 个线程几乎同时请求同一个尚未缓存的数据:

text 复制代码
Thread-1  ──检查缓存──> miss ──查询数据库──┐
Thread-2  ──检查缓存──> miss ──查询数据库──┤
Thread-3  ──检查缓存──> miss ──查询数据库──┤
...                                        ├──> 同时写入缓存
Thread-20 ──检查缓存──> miss ──查询数据库──┘

最终数据也许没有错,但数据库却被重复查询了 20 次。

如果与此同时还有缓存过期、删除、LRU 淘汰、统计命中率等逻辑,那么很快就会出现更难排查的问题:

  • 重复计算;
  • 缓存击穿;
  • 数据竞争;
  • 状态不一致;
  • KeyError
  • LRU 链表结构被破坏;
  • 锁竞争严重;
  • 甚至出现死锁。

所以,"线程安全缓存"绝不等于"给字典外面加一把锁"。

真正值得讨论的是:

我们到底要保护什么?锁应该锁多大范围?怎样同时兼顾正确性、吞吐量、缓存过期和工程可维护性?

本文就从一个最简单的 dict 开始,一步一步实现一个能够真正用于项目的 Python 线程安全缓存。


一、先理解:什么叫线程安全?

所谓线程安全,可以简单理解为:

多个线程同时操作一个对象时,不会因为执行时序不同而破坏程序状态,也不会产生违反业务语义的结果。

例如有如下代码:

python 复制代码
cache = {}

def get_or_create(key):
    if key not in cache:
        cache[key] = expensive_query(key)

    return cache[key]

很多刚接触 Python 并发的开发者会想到一个问题:

Python 有 GIL,这段代码是不是天然线程安全?

答案是:

不能这么理解。

即使底层某些字典操作本身不会把 Python 对象"写坏",下面这一整个流程也并不是一个不可分割的原子操作:

python 复制代码
if key not in cache:
    cache[key] = expensive_query(key)

它实际上包含:

text 复制代码
1. 检查 key 是否存在
2. 根据检查结果进行判断
3. 调用 expensive_query()
4. 将结果写入 cache

线程可能在这些步骤之间发生切换。

于是可能出现:

text 复制代码
Thread A:检查 user:1001,不存在
Thread B:检查 user:1001,不存在

Thread A:查询数据库
Thread B:查询数据库

Thread A:写缓存
Thread B:再次写缓存

虽然最终缓存里可能只有一份结果,但昂贵操作已经执行了两次。

因此判断线程安全时,一个非常重要的思想是:

不要只考虑某一条 Python 语句是否安全,而要考虑完整业务操作是否具有原子性。


二、第一版:使用 Lock 实现最简单的线程安全缓存

Python 的 threading 模块提供了最基本的互斥锁:

python 复制代码
threading.Lock

我们可以先实现一个简单版本:

python 复制代码
import threading


class ThreadSafeCache:
    def __init__(self):
        self._cache = {}
        self._lock = threading.Lock()

    def get(self, key):
        with self._lock:
            return self._cache.get(key)

    def set(self, key, value):
        with self._lock:
            self._cache[key] = value

    def delete(self, key):
        with self._lock:
            self._cache.pop(key, None)

    def clear(self):
        with self._lock:
            self._cache.clear()

使用方式:

python 复制代码
cache = ThreadSafeCache()

cache.set("name", "Python")

print(cache.get("name"))

cache.delete("name")

这里的关键是:

python 复制代码
with self._lock:

它本质上等价于:

python 复制代码
self._lock.acquire()

try:
    # 临界区
    ...
finally:
    self._lock.release()

推荐使用 with,因为即使临界区代码抛出异常,锁也可以被正常释放。

这也是 Python 最佳实践之一:

资源获取与释放尽量交给上下文管理器。


三、为什么 get() 和 set() 都安全,get_or_create() 却仍然可能有问题?

假设我们基于刚才的缓存写:

python 复制代码
def get_user(user_id):
    user = cache.get(user_id)

    if user is None:
        user = query_database(user_id)
        cache.set(user_id, user)

    return user

看起来:

  • get() 加锁了;
  • set() 也加锁了。

是不是就安全了?

并不是。

假设两个线程同时运行:

text 复制代码
线程 A:get(1001) → None
线程 B:get(1001) → None

线程 A:query_database(1001)
线程 B:query_database(1001)

线程 A:set(...)
线程 B:set(...)

问题仍然存在。

原因就在于:

python 复制代码
get()

和:

python 复制代码
set()

虽然分别线程安全,但是:

python 复制代码
get -> miss -> compute -> set

这一系列组合操作并不是原子的。

这是并发开发中非常重要的一个概念:

线程安全的方法,不一定能够组成线程安全的业务操作。


四、第二版:实现原子的 get_or_create()

最直接的方法,是让完整过程处于同一个临界区:

python 复制代码
import threading


class ThreadSafeCache:
    def __init__(self):
        self._cache = {}
        self._lock = threading.Lock()

    def get_or_create(self, key, factory):
        with self._lock:
            if key in self._cache:
                return self._cache[key]

            value = factory()
            self._cache[key] = value

            return value

使用:

python 复制代码
cache = ThreadSafeCache()


def load_user():
    print("query database...")
    return {
        "id": 1001,
        "name": "Alice"
    }


user = cache.get_or_create("user:1001", load_user)

如果 100 个线程同时调用它,那么只有第一个线程会执行:

python 复制代码
load_user()

其他线程必须等待。

从正确性上说,这种方案很好。

但是性能问题马上出现了。


五、不要在全局锁中执行耗时操作

看看刚才的代码:

python 复制代码
with self._lock:
    if key in self._cache:
        return self._cache[key]

    value = factory()
    self._cache[key] = value

如果:

python 复制代码
factory()

需要 2 秒,那么这 2 秒期间其他线程连访问完全不同的 key 都做不到。

例如:

text 复制代码
Thread A:加载 user:1,耗时 2 秒

与此同时:

Thread B:读取 product:100    等待
Thread C:读取 config         等待
Thread D:加载 order:900      等待
Thread E:读取 token          等待

这样虽然保证了正确性,却把整个缓存变成了串行系统。

可以把它想象成超市:

text 复制代码
              ┌───────────────┐
Thread A ────>│               │
Thread B ────>│   Global Lock │
Thread C ────>│               │
Thread D ────>│               │
              └───────────────┘

无论买什么,都只能排一条队。

这种锁通常称为:

粗粒度锁(Coarse-Grained Locking)

优点:

  • 实现简单;
  • 容易保证正确性;
  • 不容易出现复杂竞态。

缺点也很明显:

  • 并发度低;
  • 锁竞争严重;
  • 慢操作会阻塞整个缓存。

六、第三版:Double-Check,缩小锁范围

一个常见改进思路是"双重检查":

python 复制代码
def get_or_create(self, key, factory):
    with self._lock:
        if key in self._cache:
            return self._cache[key]

    value = factory()

    with self._lock:
        if key in self._cache:
            return self._cache[key]

        self._cache[key] = value
        return value

结构变成:

text 复制代码
第一次加锁
    ↓
检查缓存
    ↓
未命中
    ↓
释放锁
    ↓
执行耗时计算
    ↓
再次加锁
    ↓
再次检查
    ↓
写入

好处是:

python 复制代码
factory()

不会长时间占用全局锁。

但它仍然存在一个问题。

假设 50 个线程同时请求同一个 key:

text 复制代码
Thread 1  → miss → factory()
Thread 2  → miss → factory()
Thread 3  → miss → factory()
...
Thread 50 → miss → factory()

第二次检查虽然保证只有一个结果最终进入缓存,却无法避免昂贵计算被重复执行。

如果 factory() 是:

python 复制代码
SELECT * FROM huge_table ...

或者:

python 复制代码
requests.get(...)

那么问题依旧严重。

这就是经典的:

Cache Stampede / 缓存击穿

当一个热点数据不存在或刚刚过期时,大量并发请求同时穿透缓存,访问后端服务。


七、更好的方案:每个 Key 一把锁

真正实用的优化是:

不同 key 之间应该能够并行,相同 key 才需要互斥。

例如:

text 复制代码
user:1001   → Lock A
user:1002   → Lock B
order:900   → Lock C
config      → Lock D

于是:

text 复制代码
Thread A ─── user:1 ───┐
Thread B ─── user:1 ───┴─竞争同一把锁

Thread C ─── user:2 ─────独立执行
Thread D ─── order:1 ────独立执行

代码如下:

python 复制代码
import threading


class KeyLockCache:
    def __init__(self):
        self._cache = {}

        self._global_lock = threading.Lock()

        self._key_locks = {}

    def _get_key_lock(self, key):
        with self._global_lock:
            if key not in self._key_locks:
                self._key_locks[key] = threading.Lock()

            return self._key_locks[key]

    def get_or_create(self, key, factory):
        with self._global_lock:
            if key in self._cache:
                return self._cache[key]

        key_lock = self._get_key_lock(key)

        with key_lock:

            # 一定要再次检查
            with self._global_lock:
                if key in self._cache:
                    return self._cache[key]

            value = factory()

            with self._global_lock:
                self._cache[key] = value

            return value

这里为什么需要再次检查?

因为可能出现:

text 复制代码
Thread A:缓存 miss
Thread B:缓存 miss

Thread A:获得 key lock
Thread B:等待 key lock

Thread A:factory()
Thread A:写缓存
Thread A:释放锁

Thread B:获得锁

此时 Thread B 必须重新检查缓存。

否则:

python 复制代码
Thread B

仍然会再次执行 factory()

正确流程应该是:

text 复制代码
Thread B:获得锁
        ↓
再次检查缓存
        ↓
发现 Thread A 已写入
        ↓
直接返回

这种模式非常值得掌握。


八、加入 TTL:缓存通常不能永久有效

真实项目里的缓存几乎都需要过期机制。

例如:

text 复制代码
商品价格:缓存 10 秒
用户资料:缓存 5 分钟
系统配置:缓存 1 分钟
统计报表:缓存 30 分钟

所以我们可以保存:

python 复制代码
{
    key: (
        value,
        expire_time
    )
}

推荐使用:

python 复制代码
time.monotonic()

而不是:

python 复制代码
time.time()

做"经过时间"判断。

原因是 monotonic() 不受系统时间被人工修改、NTP 调整等影响。

实现:

python 复制代码
import time
import threading


class TTLCache:
    def __init__(self, ttl=60):
        self._cache = {}
        self._ttl = ttl
        self._lock = threading.RLock()

    def set(self, key, value):
        expire_at = time.monotonic() + self._ttl

        with self._lock:
            self._cache[key] = (
                value,
                expire_at
            )

    def get(self, key):
        with self._lock:
            item = self._cache.get(key)

            if item is None:
                return None

            value, expire_at = item

            if time.monotonic() >= expire_at:
                del self._cache[key]
                return None

            return value

测试:

python 复制代码
cache = TTLCache(ttl=2)

cache.set("name", "Python")

print(cache.get("name"))

time.sleep(3)

print(cache.get("name"))

输出类似:

text 复制代码
Python
None

九、为什么这里可以考虑 RLock?

Python 中还有一种锁:

python 复制代码
threading.RLock

它叫做:

可重入锁(Reentrant Lock)

假设:

python 复制代码
def get(self, key):
    with self._lock:
        return self._get_internal(key)

而:

python 复制代码
def _get_internal(self, key):
    with self._lock:
        ...

如果使用普通 Lock,同一个线程再次获取自己已经持有的锁,就可能发生死锁。

RLock 允许同一个线程重复获得同一把锁。

例如:

python 复制代码
lock = threading.RLock()

with lock:
    print("first")

    with lock:
        print("second")

这是允许的。

不过需要注意:

不要因为 RLock 更"高级",就到处使用它。

如果不存在锁重入需求,普通:

python 复制代码
threading.Lock

通常更加直观。


十、增加容量限制:无限缓存其实就是内存泄漏

下面这种缓存:

python 复制代码
cache[key] = value

如果 key 数量不断增加:

text 复制代码
10
100
1000
100000
10000000

内存迟早会失控。

因此生产级缓存通常还需要:

text 复制代码
max_size

当缓存满了以后,需要决定:

删除谁?

最经典策略之一就是:

LRU

即:

Least Recently Used,最近最少使用。

思路:

text 复制代码
最近访问                             最久未访问
   ↓                                   ↓

user:1 → user:8 → config → order:5 → product:10

缓存满:
删除 product:10

Python 中可以使用:

python 复制代码
collections.OrderedDict

实现一个简单 LRU。


十一、线程安全 LRU Cache 实现

来看完整版本:

python 复制代码
import threading
from collections import OrderedDict


class LRUCache:
    def __init__(self, capacity=100):
        if capacity <= 0:
            raise ValueError("capacity must be positive")

        self._capacity = capacity
        self._cache = OrderedDict()
        self._lock = threading.RLock()

    def get(self, key, default=None):
        with self._lock:
            if key not in self._cache:
                return default

            value = self._cache.pop(key)

            # 移动到最后,表示最近访问
            self._cache[key] = value

            return value

    def set(self, key, value):
        with self._lock:
            if key in self._cache:
                self._cache.pop(key)

            self._cache[key] = value

            if len(self._cache) > self._capacity:
                self._cache.popitem(last=False)

    def delete(self, key):
        with self._lock:
            self._cache.pop(key, None)

    def clear(self):
        with self._lock:
            self._cache.clear()

    def __len__(self):
        with self._lock:
            return len(self._cache)

使用:

python 复制代码
cache = LRUCache(capacity=3)

cache.set("A", 1)
cache.set("B", 2)
cache.set("C", 3)

cache.get("A")

cache.set("D", 4)

print(cache.get("B"))

因为访问过:

python 复制代码
A

所以 A 成为了最近使用的数据。

插入 D 后,较久未访问的 B 会被淘汰。


十二、为什么 LRU 的 get() 也是写操作?

这是一个很容易忽略的细节。

很多开发者会认为:

python 复制代码
cache.get(key)

只是"读取",应该不需要写锁。

但 LRU 不一样。

一次:

python 复制代码
get("A")

不仅需要读取 A,还需要把 A 调整为:

text 复制代码
Most Recently Used

也就是说:

python 复制代码
get()

实际上修改了内部数据结构。

所以:

python 复制代码
get()

依然必须被同步保护。

这是设计并发数据结构时一个非常重要的经验:

判断读写不能只看 API 名字,要看它是否修改内部状态。

命中次数统计也是如此:

python 复制代码
self.hits += 1

一旦加入统计信息,看似纯读取的方法就可能产生写操作。


十三、工程版:TTL + LRU + 线程安全

把前面的设计组合起来,可以实现一个实用版本:

python 复制代码
import time
import threading
from collections import OrderedDict


class ThreadSafeTTLCache:
    def __init__(self, capacity=1000, ttl=60):
        if capacity <= 0:
            raise ValueError("capacity must be positive")

        if ttl <= 0:
            raise ValueError("ttl must be positive")

        self._capacity = capacity
        self._ttl = ttl

        self._cache = OrderedDict()

        self._lock = threading.RLock()

        self._hits = 0
        self._misses = 0

    def get(self, key, default=None):
        now = time.monotonic()

        with self._lock:

            item = self._cache.get(key)

            if item is None:
                self._misses += 1
                return default

            value, expire_at = item

            if now >= expire_at:
                del self._cache[key]

                self._misses += 1

                return default

            # 更新 LRU 顺序
            self._cache.move_to_end(key)

            self._hits += 1

            return value

    def set(self, key, value):
        expire_at = time.monotonic() + self._ttl

        with self._lock:

            if key in self._cache:
                del self._cache[key]

            self._cache[key] = (
                value,
                expire_at
            )

            self._cache.move_to_end(key)

            while len(self._cache) > self._capacity:
                self._cache.popitem(last=False)

    def delete(self, key):
        with self._lock:
            return self._cache.pop(key, None)

    def clear(self):
        with self._lock:
            self._cache.clear()

    def stats(self):
        with self._lock:

            total = self._hits + self._misses

            hit_rate = (
                self._hits / total
                if total
                else 0
            )

            return {
                "size": len(self._cache),
                "hits": self._hits,
                "misses": self._misses,
                "hit_rate": hit_rate,
            }

现在我们的缓存已经具有:

text 复制代码
线程安全
   +
TTL
   +
LRU
   +
容量限制
   +
命中率统计

这已经比单纯:

python 复制代码
cache = {}

强大得多。


十四、实战:缓存数据库查询

假设 Web 服务经常读取用户信息。

原来的代码:

python 复制代码
def get_user(user_id):
    return database.query_user(user_id)

如果一个热门用户资料被访问 10000 次,就可能产生大量重复查询。

改成:

python 复制代码
user_cache = ThreadSafeTTLCache(
    capacity=10000,
    ttl=60
)


def get_user(user_id):

    key = f"user:{user_id}"

    user = user_cache.get(key)

    if user is not None:
        return user

    user = database.query_user(user_id)

    user_cache.set(key, user)

    return user

流程变成:

text 复制代码
HTTP Request
      │
      ▼
检查缓存
      │
  ┌───┴────┐
命中      未命中
 │          │
 ▼          ▼
返回      查询 DB
            │
            ▼
          写缓存
            │
            ▼
           返回

如果查询数据库一次需要:

text 复制代码
50 ms

而访问内存缓存只需要极短时间,那么对于高频重复请求,性能差距可能非常明显。

更重要的是:

缓存降低的不只是响应时间,还有数据库压力。


十五、进一步解决缓存击穿:Single Flight

前面的业务代码依然有一个缺陷:

python 复制代码
user = user_cache.get(key)

if user is None:
    user = database.query_user(user_id)

热点缓存一旦过期:

text 复制代码
100 个线程
    ↓
同时 miss
    ↓
100 次数据库查询

比较成熟的设计,是对同一个 key 的加载操作进行合并。

也就是:

同一时刻同一个 key 只允许一个线程加载,其余线程等待结果。

这种模式有时也被称为:

Single Flight。

简化实现:

python 复制代码
import threading


class SingleFlightCache:
    def __init__(self):
        self._cache = {}
        self._locks = {}

        self._lock = threading.Lock()

    def _get_lock(self, key):
        with self._lock:
            lock = self._locks.get(key)

            if lock is None:
                lock = threading.Lock()
                self._locks[key] = lock

            return lock

    def get_or_load(self, key, loader):

        with self._lock:
            if key in self._cache:
                return self._cache[key]

        key_lock = self._get_lock(key)

        with key_lock:

            with self._lock:
                if key in self._cache:
                    return self._cache[key]

            value = loader()

            with self._lock:
                self._cache[key] = value

            return value

使用:

python 复制代码
user = cache.get_or_load(
    "user:1001",
    lambda: database.query_user(1001)
)

于是 100 个线程同时访问:

text 复制代码
                user:1001

Thread A ─────────┐
Thread B ─────────┤
Thread C ─────────┤
...               ├── Key Lock
Thread Z ─────────┘
                    │
                    ▼
             Thread A 加载
                    │
                    ▼
                 写缓存
                    │
                    ▼
          其他线程直接读取结果

后端数据库从:

text 复制代码
100 次查询

降低到:

text 复制代码
1 次查询

在高并发系统中,这种优化非常有价值。


十六、别忘了缓存穿透

除了缓存击穿,还有另一个常见问题:

缓存穿透。

比如攻击者不断请求不存在的用户:

text 复制代码
user:-1
user:-2
user:-3
...

数据库返回:

python 复制代码
None

但我们的代码可能这么写:

python 复制代码
value = cache.get(key)

if value is None:
    value = database.query(key)

问题来了:

缓存中的:

python 复制代码
None

和:

text 复制代码
缓存不存在

无法区分。

于是数据库会被重复查询。

一个常见方案是设置特殊哨兵对象:

python 复制代码
MISSING = object()

例如:

python 复制代码
value = cache.get(key, MISSING)

if value is not MISSING:
    return value

这样:

python 复制代码
None

本身也可以作为合法缓存值。

这个技巧在缓存 API 设计中非常实用。


十七、过期数据什么时候删除?

TTL 缓存还有一个工程问题:

数据过期之后,什么时候从字典中真正删除?

常见有三种方案。

方案一:惰性删除

访问时发现过期:

python 复制代码
if now >= expire_at:
    del cache[key]

优点:

  • 实现简单;
  • 没有后台线程;
  • 额外成本较低。

缺点:

长期不访问的过期 key 可能一直占内存。


方案二:后台定期清理

例如启动一个线程:

python 复制代码
def cleanup():
    while True:
        remove_expired_items()
        time.sleep(10)

优点:

缓存空间能够定期回收。

缺点:

需要处理:

  • 后台线程生命周期;
  • 程序关闭;
  • 锁竞争;
  • 清理频率;
  • 大缓存扫描成本。

方案三:惰性删除 + 定期清理

实际工程中往往更平衡:

text 复制代码
访问时顺手清理
        +
后台低频清理

这样既避免每次执行全量扫描,又不会让大量死数据长期占据内存。


十八、锁并不是越多越好

谈到性能优化,一些开发者容易进入另一个误区:

text 复制代码
一把锁慢?

那就拆成 100 把!

但锁本身也是复杂度。

锁越多,需要考虑的问题越多:

  • 锁之间的获取顺序;
  • 死锁;
  • key-lock 生命周期;
  • 锁对象泄漏;
  • 调试难度;
  • 清理成本。

例如:

python 复制代码
self._locks[key] = threading.Lock()

如果 key 永远增长:

text 复制代码
1 万
100 万
1000 万

即使缓存数据已经被淘汰:

python 复制代码
self._locks

仍可能持续变大。

因此成熟实现还应该考虑:

text 复制代码
key lock 回收

这也是为什么真实生产环境中,如果需求复杂,我通常不建议团队"为了少一个依赖"无限扩展自制缓存组件。

自己实现缓存非常适合:

  • 学习并发原理;
  • 小型工具;
  • 特定业务优化。

但对于复杂生产系统,应优先评估成熟库和 Redis 等专业缓存系统。


十九、如何测试线程安全?

并发 bug 最麻烦的地方就是:

它可能运行一千次都正常,第一千零一次突然出错。

可以使用:

python 复制代码
concurrent.futures.ThreadPoolExecutor

制造并发测试。

例如:

python 复制代码
from concurrent.futures import ThreadPoolExecutor


cache = ThreadSafeTTLCache(
    capacity=100,
    ttl=60
)


def worker(i):
    key = f"key-{i % 10}"

    cache.set(key, i)

    return cache.get(key)


with ThreadPoolExecutor(max_workers=20) as executor:

    results = list(
        executor.map(worker, range(10000))
    )


print(cache.stats())

还可以验证 Single Flight:

python 复制代码
import time
import threading
from concurrent.futures import ThreadPoolExecutor


counter = 0
counter_lock = threading.Lock()


def loader():
    global counter

    with counter_lock:
        counter += 1

    time.sleep(0.2)

    return "Python"


with ThreadPoolExecutor(max_workers=50) as executor:
    results = list(
        executor.map(
            lambda _: cache.get_or_load(
                "language",
                loader
            ),
            range(50)
        )
    )

print(counter)

理想情况下:

text 复制代码
1

说明虽然有 50 个并发请求,昂贵的 loader 只执行了一次。


二十、缓存真正应该关注哪些指标?

生产系统不要只看:

python 复制代码
len(cache)

至少应该关注:

text 复制代码
cache_hit_total
cache_miss_total
cache_eviction_total
cache_expired_total
cache_load_total
cache_load_error_total
cache_load_duration
cache_size

其中最直观的是命中率:

python 复制代码
hit_rate = hits / (hits + misses)

例如:

text 复制代码
Hits   = 85000
Misses = 15000

命中率:

text 复制代码
85%

但需要注意:

命中率越高不一定意味着缓存设计越好。

如果为了提高命中率把 TTL 从:

text 复制代码
30 秒

改成:

text 复制代码
24 小时

虽然命中率可能非常漂亮,但用户看到的可能全是陈旧数据。

所以缓存始终是在多个目标之间做权衡:

text 复制代码
性能
 │
 ├── 命中率
 │
 ├── 数据实时性
 │
 ├── 内存占用
 │
 ├── 后端压力
 │
 └── 系统复杂度

优秀的缓存设计不是追求某一个数字最大,而是让这些目标达到合适的平衡。


二十一、线程缓存和进程缓存不是一回事

这是 Python Web 开发里特别容易踩的坑。

假设:

python 复制代码
cache = ThreadSafeTTLCache()

在一个 Gunicorn 服务里启动:

text 复制代码
4 个 Worker Process

那么实际上可能是:

text 复制代码
Process 1 → Cache A
Process 2 → Cache B
Process 3 → Cache C
Process 4 → Cache D

线程锁只能协调:

同一个进程中的线程。

不能协调不同进程。

所以:

python 复制代码
threading.Lock()

解决的是线程竞争,不等于解决:

text 复制代码
多进程共享缓存

如果业务要求多个:

  • Web Worker;
  • 容器;
  • Kubernetes Pod;
  • 不同服务器;

共享同一份缓存,就应该考虑 Redis 这类外部缓存。

可以简单记住:

text 复制代码
单进程多线程
      ↓
内存缓存 + Lock 可以解决很多问题

多进程 / 多服务器
      ↓
Redis 等分布式缓存

二十二、什么时候用 functools.lru_cache?

Python 自带一个非常方便的工具:

python 复制代码
functools.lru_cache

例如:

python 复制代码
from functools import lru_cache


@lru_cache(maxsize=1024)
def fibonacci(n):

    if n < 2:
        return n

    return (
        fibonacci(n - 1)
        + fibonacci(n - 2)
    )

特别适合:

  • 纯函数;
  • 参数可哈希;
  • 返回结果稳定;
  • 不需要复杂 TTL;
  • 不需要跨进程共享缓存。

例如:

python 复制代码
@lru_cache(maxsize=1024)
def calculate_tax(country, amount):
    ...

但是如果你需要:

text 复制代码
TTL
主动失效
复杂统计
缓存预热
异步刷新
分布式一致性
跨服务器缓存

就需要更完整的方案。

所以不要把:

python 复制代码
@lru_cache

当成所有缓存问题的万能答案。


二十三、一个更加合理的缓存架构

在真实项目里,我通常建议把缓存分成多个职责。

text 复制代码
                  Application
                       │
                       ▼
              Cache Service Layer
                       │
          ┌────────────┼─────────────┐
          │            │             │
          ▼            ▼             ▼
       Memory        Redis        Database
       Cache         Cache

业务代码只调用:

python 复制代码
user_service.get_user(user_id)

而不是到处写:

python 复制代码
cache.get(...)
redis.get(...)
database.query(...)

缓存逻辑应该集中管理,例如:

python 复制代码
class UserRepository:

    def __init__(self, cache, database):
        self.cache = cache
        self.database = database

    def get_user(self, user_id):

        key = f"user:{user_id}"

        user = self.cache.get(key)

        if user is not None:
            return user

        user = self.database.get_user(user_id)

        self.cache.set(key, user)

        return user

这样将来从:

text 复制代码
本地缓存

迁移到:

text 复制代码
Redis

或者增加:

text 复制代码
L1 Memory Cache
        +
L2 Redis Cache

业务层代码都不需要发生大规模变化。

这就是模块化设计真正带来的价值。


二十四、缓存最佳实践清单

做 Python 缓存时,可以重点检查下面这些问题。

1. 不要默认 dict 就能满足完整线程安全语义

重点检查:

text 复制代码
check → compute → set

这一类复合操作。

2. 临界区越短越好

尤其不要轻易在全局锁里执行:

python 复制代码
requests.get(...)

或者:

python 复制代码
database.query(...)

这类耗时 I/O。

3. 热点 Key 要考虑 Single Flight

避免:

text 复制代码
缓存刚失效
    ↓
1000 个请求
    ↓
1000 次数据库查询

4. 缓存必须考虑容量

没有:

python 复制代码
max_size

的缓存很容易变成隐蔽的内存泄漏。

5. 设计合理 TTL

TTL 应该由业务决定,而不是所有数据统一写:

python 复制代码
ttl = 3600

6. 明确 None 的语义

区分:

text 复制代码
缓存未命中

和:

text 复制代码
缓存值本身就是 None

可以使用哨兵对象:

python 复制代码
MISSING = object()

7. 要提供监控指标

至少观察:

text 复制代码
命中率
缓存大小
淘汰数量
加载耗时
加载失败

8. 不要把本地缓存误认为分布式缓存

多进程、多实例环境需要重新评估一致性问题。

9. 缓存数据要允许被重新构建

理论上缓存应该是:

丢了不会影响数据正确性,只会影响性能。

如果缓存一旦丢失就无法恢复,那么它实际上已经承担了数据库职责。


二十五、从"加一把锁"到真正理解并发设计

如果只想实现一个线程安全字典,我们可能十几行代码就结束了。

但真正深入缓存设计以后,会发现它连接着大量重要的工程概念:

text 复制代码
Thread Safety
      │
      ├── Atomicity
      │
      ├── Lock
      │
      ├── RLock
      │
      ├── Lock Granularity
      │
      ├── Race Condition
      │
      ├── Cache Stampede
      │
      ├── TTL
      │
      ├── LRU
      │
      ├── Single Flight
      │
      ├── Observability
      │
      └── Distributed Cache

这也是学习 Python 编程非常有意思的一点。

最初你可能只是在学习:

python 复制代码
dict

然后遇到了:

python 复制代码
threading.Lock

再往前一步,又会接触:

text 复制代码
并发控制
性能优化
数据一致性
系统架构
可观测性
分布式系统

很多真正重要的工程能力,就是这样从一个看似普通的小问题延伸出来的。


二十六、总结:好的缓存不是"快",而是在正确性和性能之间找到平衡

回到最开始的问题:

如何设计一个线程安全的缓存?

最基础的答案确实是:

python 复制代码
threading.Lock

但一个更加完整的答案应该包含:

text 复制代码
1. 明确需要保护的共享状态
2. 保证复合操作的原子性
3. 尽可能缩短锁的临界区
4. 避免在全局锁中执行昂贵 I/O
5. 使用 Key-Level Lock 提高不同 Key 的并发能力
6. 使用 Single Flight 防止缓存击穿
7. 使用 TTL 控制数据新鲜度
8. 使用 LRU 或容量上限控制内存
9. 区分缓存 miss 与合法的 None
10. 使用指标监控缓存行为
11. 对多进程和分布式部署采用外部缓存

如果只是写一个学习 Demo:

python 复制代码
dict + Lock

已经足够。

如果是一个长期运行的后台服务:

text 复制代码
Lock + TTL + Capacity

就值得认真考虑。

如果面对高并发热点数据:

text 复制代码
TTL
+
LRU
+
Per-Key Lock
+
Single Flight
+
Metrics

会更加合理。

而当系统已经发展到:

text 复制代码
多进程
多容器
多服务器

时,则应该认真评估:

text 复制代码
Redis

等成熟的分布式缓存系统,而不是继续把一个本地字典无限复杂化。

缓存最大的魅力,也许正是在这里。

它表面上只是:

python 复制代码
key -> value

背后却包含了并发、算法、内存管理、架构和数据一致性。

一个优秀的 Python 开发者,不仅要知道:

"怎么让代码跑起来?"

还应该不断追问:

"如果 1000 个请求同时进来会怎么样?"
"如果缓存过期会怎么样?"
"如果程序运行一个月会怎么样?"
"如果服务从一台机器扩展到十台机器又会怎么样?"

当你开始主动提出这些问题,Python 编程也就不再只是语法学习,而真正进入了工程设计的世界。


延伸思考

如果让你继续完善本文的 ThreadSafeTTLCache,你会优先增加什么功能?

是:

text 复制代码
后台过期清理?
异步刷新?
随机 TTL?
缓存预热?
按 Key 设置不同 TTL?
Weak Reference?
LFU?
Redis 二级缓存?

另一个非常值得讨论的问题是:

在你的 Python 项目中,缓存曾经解决过最大的性能问题是什么?又曾经制造过哪些难以排查的数据一致性 Bug?

欢迎把实际案例记录下来。很多并发问题,仅仅看书很难真正理解,而一次真实的线上故障,往往能让我们彻底理解"线程安全"这四个字意味着什么。


附录:推荐继续学习的方向

想进一步深入 Python 并发与缓存,可以继续研究:

  • Python threadingLockRLockConditionSemaphore
  • concurrent.futures.ThreadPoolExecutor
  • functools.lru_cache
  • collections.OrderedDict
  • asyncio
  • Redis 缓存设计
  • Cache Aside Pattern
  • Write Through / Write Back
  • LRU、LFU、FIFO 等淘汰算法
  • Cache Stampede、Cache Penetration、Cache Avalanche
  • 分布式锁与一致性问题
  • pytest 并发测试与压力测试
  • Prometheus 缓存指标监控

如果把这些内容逐步掌握,你会发现,"设计一个线程安全缓存"其实是一条非常漂亮的学习路径------它从 Python 基础语法出发,最终能够一路通向并发编程、高性能系统和分布式架构。

Python 编程真正迷人的地方,从来不只是写得少,而是我们能够用相对清晰的代码,把越来越复杂的工程思想表达出来。

相关推荐
qq_22589174661 小时前
基于Flask的城市地铁客流量数据预测系统设计与实现
后端·python·flask
W_326001 小时前
Python 常用标准 / 第三方库:random、tqdm、turtle、jieba 用法
开发语言·python
OptimizationMaster1 小时前
Python对视频文件分类,“横屏”和“竖屏”
python·视频
XLYcmy1 小时前
PDF论文处理器 - 功能总结
数据库·python·pdf·csv·pymupdf·dify·文本分割
Uncommon.1 小时前
线性代数与向量
pytorch·python·线性代数·机器学习
ZC跨境爬虫1 小时前
LeetCode 88. 合并两个有序数组(双指针详解 + Java Python 实现)
java·python·算法·leetcode
API快乐传递者2 小时前
电商竞品分析接口实战指南:从数据采集到决策洞察的全链路方案
java·python
2401_868534782 小时前
集中式存储和分布式存储
python·pygame