系统设计知识(一)

缓存技术

Posted by Jingming on February 27, 2026

参考了九章的系统设计。

一、系统的访问量级

了解系统的访问量级后,就可以配置相应的技术来满足要求。

关注点在于,多少用户每天进行操作,操作的种类,操作的数量,以及操作之后,系统究竟进行了哪些的耗时操作;
另外,这些操作往往不是平摊在24小时的每一秒上,通常情况是白天比晚上忙,一天中有顶峰操作数,一般来说是平均操作
数的三倍。

具体来说,假设日活用户是1亿(100M),最频繁的操作种类是浏览查询,假设每天100次,那么 QPS 约为 100k(100M * 100 / 86400 ≈ 115,740)。

数据库和缓存的吞吐能力高度依赖于硬件、查询复杂度、索引策略与部署方式(单机、分片、读写分离等)。作为经验参考, 在单机场景和简单查询下,关系型数据库(如 MySQL、PostgreSQL)通常能达到10^3 QPS;分布式 NoSQL(如 MongoDB、Cassandra)在10^4 QPS 量级; 而内存缓存(如 Redis、Memcached)在10^5 QPS 或以上较为常见。但请注意:这些数值只是粗略经验值,生产环境应通过基准测试(benchmarking)和观测(监控)来确定实际容量, 并结合水平扩展(分片/复制)与缓存层设计来满足业务需求。

二、缓存技术

使用缓存的原因

查询操作过程中,瓶颈往往是读写数据库的部分速度比较慢,不满意。

缓存技术就是用于解决读数据库慢的技术(不解决写技术!)。它的原理是,使用读取速度更快但量没有数据库大的存储技术,记录数据库的复制值,

这些复制值是经常被用户访问的内容,通常遵循“80/20”规律:少量的热数据可能产生大部分的访问流量(例如约20% 的数据产生约80% 的请求)。 实际的缓存命中率受访问分布、缓存容量、淘汰策略(LRU/TTL 等)和数据模型影响,不能简单给出单一的百分比。

在工程实践中,应通过监控与基准测试来得到可信的命中率估算。常用的监控指标包括:命中率(hits / misses)、驱逐次数(evictions)、缓存利用率与不同时间粒度的请求分布(用于发现热点和峰值)。根据这些数据,可以调整缓存大小、TTL 和缓存策略以提高命中率和系统稳定性。

PS. 缓存不一定非要是内存或极低延迟的存储;只要该缓存层的读取性能优于直接回源的代价(例如磁盘缓存、CDN、或本地文件缓存在某些场景下也是有用的),就可以作为缓存使用。

另外,服务器为了防止Dos攻击,或者担心自己的服务器挤爆了,那么会对访问的频率做出限制,但是实际上,有些服务是不必每次都调用的,例如token服务,例如token 的有效期是一天,那么一天只需要调用一次就可以了,这样服务器干脆告诉客户端,这个是24小时有效的,因此客户端知道后,每次读取token后,就保存到本地缓存里面, 只有token过期了,那么才去再次访问服务器。

缓存产品

memcached 是一种专注于内存缓存的轻量级缓存服务器,设计目标是高速读写与简单的数据模型(通常是 key-value)。它适合作为分布式缓存层,提供快速的键值查询。

Redis 是一个内存数据结构存储(in-memory data structure store),常用于缓存,也可作为消息队列、会话存储或在某些场景下作为主存储。 Redis 支持多种持久化选项(RDB 快照、AOF 追加日志或二者结合),因此在发生重启时可以恢复数据。但需要注意:

  • 持久化并不等同于关系数据库的事务与完整性约束;Redis 更侧重于速度和灵活的数据结构(字符串、列表、集合、有序集合、哈希等)。
  • 持久化选项有性能与恢复速度的权衡(例如 AOF 的 fsync 策略会影响写性能与数据安全性)。

因此,Redis 可以既被用作高速缓存(cache),也可以在需要高性能且可接受其数据语义的场景下作为主要数据存储,但在设计时应明确其一致性、持久性与功能上的差别,不要把它简单等同为通用的关系型数据库。

缓存设计模式

所谓设计模式,也就是指编码中设计时候的一些固定套路,参考多种固定套路能指导我们制定自己的套路来正确合理使用缓存满足业务需求。

场景一:读多写少的数据。例如,用户的资料信息,访问的多,但是用户不会经常更新自己的资料信息;又例如,token服务器返回的信息告诉服务器token的时效,客户端 可以缓存结果,减少访问服务器。

解:这种场景适合经典策略:懒加载,也就是先读取缓存,如果缓存中没有要的数据,就去数据库中读,然后将最新值更新到缓存中。

懒加载的边界情况考虑:如何更新缓存中的数据?例如DB中的数据更新了,而此时进程却不知道数据更新了,还是在使用缓存中的旧数据,从而发生已知或未知的错误;又例如, 缓存系统不工作了,例如连不上网了,导致其中的数据全部是旧数据。

解:如果是发生已知错误,那么一种方法是可以在错误发生时候捕获,然后进行处理:从DB中读数据并更新到缓存中,然后继续尝试一次。这种策略的缺点是,多线程的情况下(现实情况),很多线程都会由于 发生错误了,然后去读DB,然后更新缓存,这样读DB的次数仍然不少,而且还会有竞争问题race condition。 如果发生未知错误,那无解,因为很难察觉。

最佳实践:引入 TTL 是基础,但工程上还要处理“缓存同时大量失效导致并发回源”(缓存击穿 / stampede)的问题。

竞争 TTL(race TTL)说明: 竞争 TTL 是一种折衷策略,常见做法是在缓存项上维护两类时间:主 TTL(logical expiry)和一个短的保护期/竞争窗(grace window)。 当主 TTL 到期时,系统允许在保护期内继续返回“旧”数据(stale),同时只由第一个检测到到期的请求负责回源并刷新缓存,其他请求直接返回 stale 或短时重试。 这样可以把大量并发回源的压力集中到一次刷新操作,避免雪崩。实现方式可以是:

  • 在缓存值中保存元信息(例如 JSON:{ value: …, expire_at: 123456, refresh_after: 123460 }),读取时根据时间判断要返回 fresh、stale 还是阻塞刷新;或
  • 使用原子操作(SETNX)配合短锁,拿锁者回源并刷新,未拿锁者返回 stale。

示例(简化 Ruby,基于 Redis 存 JSON):

data_json = redis.get(cache_key)
if data_json
    data = JSON.parse(data_json)
    now = Time.now.to_i
    if now <= data["expire_at"]
        return data["value"] # fresh
    elsif now <= data["refresh_after"]
        # within grace window:触发后台异步刷新,但先返回 stale
        RefreshWorker.perform_async(key) unless redis.get("#{cache_key}:refreshing")
        return data["value"]
    else
        # 已过保护期:同步回源并刷新(或做锁控制)
        val = db_get(key)
        redis.set(cache_key, { value: val, expire_at: now + TTL, refresh_after: now + TTL + GRACE }.to_json)
        return val
    end
else
    val = db_get(key)
    redis.set(cache_key, { value: val, expire_at: now + TTL, refresh_after: now + TTL + GRACE }.to_json)
    return val
end

优缺点小结:竞争 TTL 能兼顾可用性与减压,但会增加实现复杂度(需存储元信息并处理刷新并发),并带来短期数据陈旧窗口;适合能接受短期不一致且强调可用性的场景。

下面用 Ruby 举例,列出常见对策、优缺点与简明实现思路:

1) 分布式互斥(锁)

  • 思路:当发现缓存缺失或过期时,先尝试用 Redis 的原子操作(SET NX + expire)获取一把短期锁;拿到锁的进程/线程去回源并更新缓存,其他未拿到锁的请求等待或返回旧数据。
  • 优点:直接有效,能阻止大量并发回源。
  • 缺点:锁实现需小心(超时、死锁),会增加延迟,锁本身可能成为瓶颈。

Rails 风格示例(redlock + ActiveSupport::Cache + Sidekiq):

# 使用 redlock-rb 来做分布式锁
# gem 'redlock'
lock_manager = Redlock::Client.new([Redis.new(url: ENV['REDIS_URL'])])

begin
    lock_manager.lock!("#{cache_key}:lock", 5_000) do
        val = db_get(key)
        Rails.cache.write(cache_key, val, expires_in: ttl)
        return val
    end
rescue Redlock::LockError
    # 未拿到锁:快速返回缓存的 stale 值或短时重试
    cached = Rails.cache.read(cache_key)
    return cached if cached
    sleep(0.05)
    retry_count ||= 3
    retry_count -= 1
    retry if retry_count > 0
    # 降级或抛错
    raise "failed to get value"
end

建议:在 Rails 场景通常将实际回源放到后台任务(Sidekiq),或在未获锁时快速返回 stale 数据并异步触发刷新,以优化延迟体验。

2) 请求合并(singleflight,单进程合并)

  • 思路:在进程/线程内合并多个并发的回源请求,使得只有一个实际回源操作;适合同一进程下的并发场景。
  • 优点:避免重复回源,延迟更可控。
  • 缺点:仅限单进程;跨进程需要分布式协调。

Rails 单进程请求合并(使用 concurrent-ruby):

require 'concurrent'

INFLIGHT = Concurrent::Map.new

def fetch_with_singleflight(key)
    future = INFLIGHT.compute_if_absent(key) do
        Concurrent::Promises.future do
            val = db_get(key)
            Rails.cache.write(key, val, expires_in: ttl)
            val
        end
    end
    future.value!
ensure
    INFLIGHT.delete(key)
end

3) stale-while-revalidate(返回可用的 stale 数据并后台刷新)

  • 思路:当缓存过期但仍有旧值时,优先返回旧值给用户,同时异步触发后台刷新任务去回源并更新缓存。
  • 优点:响应快速、用户体验好;适用于可容忍短期不一致的场景。
  • 缺点:短期内有数据不一致风险,后台刷新需健壮的重试/降级策略。

Rails + Sidekiq 示例:

if Rails.cache.exist?(cache_key)
    entry = Rails.cache.read(cache_key)
    if entry[:expires_at] > Time.now
        return entry[:value]
    else
        # stale
        RefreshWorker.perform_async(key) unless Redis.new.get("#{cache_key}:refreshing")
        return entry[:value]
    end
else
    val = db_get(key)
    Rails.cache.write(cache_key, { value: val, expires_at: Time.now + TTL }, expires_in: TTL)
    return val
end

class RefreshWorker
    include Sidekiq::Worker
    def perform(key)
        val = db_get(key)
        Rails.cache.write("cache:#{key}", { value: val, expires_at: Time.now + TTL }, expires_in: TTL)
    end
end

4) 提前过期与抖动(probabilistic early expiration / jitter)

  • 思路:为每个缓存项在写入时加上随机抖动(例如 TTL - rand(0..jitter)),把多个 key 的失效时间均匀化,减少同一时刻大量回源的概率。

Ruby 计算示例:

def write_with_jitter(key, val, ttl, jitter)
    actual_ttl = ttl - rand(0..jitter)
    Rails.cache.write(key, val, expires_in: actual_ttl)
end

5) 热点预热 / 专门处理热 key

  • 对于明确的热点(hot key),可以主动延长 TTL、常驻缓存或使用本地进程缓存副本来降低对集中缓存/数据库的压力。

组合与降级:生产环境通常把上述策略组合使用(例如对一般 key 用 stale-while-revalidate,对热点 key 用锁或预热),并在数据库高负载时做降级或限流。

概率逻辑说明与“回源”解释

1) 为什么要讲概率逻辑

  • 当大量缓存项在同一时刻失效时,会产生瞬时回源(origin fetch)压力,导致数据库或上游服务瞬时拥堵。通过把失效时间打散(jitter)或使用概率触发刷新,可以把原本集中在瞬间的 N 次回源分散到更长的时间区间或按概率降低触发频率,从而显著降低峰值压力。

2) 简单的近似估算

  • 如果有 N 个缓存 key,且你把它们的过期时间均匀地分布在长度为 J 的时间窗口内(采用 uniform jitter [0, J]),那么在任意长度为 Δ 的短时间窗内(Δ « J),期望的回源次数约为:

    E[回源数] ≈ N * (Δ / J)

例如:N = 10,000,J = 3600 秒(1 小时),若我们关心每秒(Δ = 1s)的平均回源数:E ≈ 10000*(1/3600) ≈ 2.78 次/秒;而没有抖动时,可能在某一秒内承受 10000 次并发回源。

  • 另一种常见做法是概率刷新(read-triggered probabilistic refresh):在每次读取缓存时以概率 p 触发异步刷新。如果缓存的读取速率为 R 次/秒,则期望的刷新速率为 p * R。这在高读低写场景下能用较小的 p 值(例如 0.01)把回源负载控制在可接受范围。

3) Ruby 概率刷新示例

if cache.fresh?(key)
    # 以概率触发异步刷新
    RefreshWorker.perform_async(key) if rand < 0.01
    return cache.get(key)
end

4) 回源(origin fetch)定义

  • “回源”指的是当缓存无法满足请求(缓存未命中或需要刷新)时,系统向原始数据源请求数据的行为。原始数据源通常包括数据库(RDBMS/NoSQL)、外部 API 服务、文件存储或其它后端系统。回源代价通常较高(延迟/资源消耗),因此缓存策略的目标是最小化不必要的回源,并把必需的回源负载分散或异步化以保证系统稳定性。

以上概率逻辑和回源定义可以结合前面的 jitter、stale-while-revalidate 与锁策略使用,作为压力缓解的工程化手段。

参考与延伸阅读

  • Martin Kleppmann, “Designing Data-Intensive Applications” — 系统设计与一致性、可扩展性基础读物。
  • Redis 官方文档 — 关于持久化(RDB/AOF)、高可用与性能调优的权威资源。
  • Alexis Richardson 等, “Microservices Patterns” — 有关缓存、去中心化数据和消息驱动一致性的实践(可选章节)。
  • 文章/博客:Cloudflare 博客与 High Scalability 上关于缓存雪崩与抖动的实战文章(搜索 “cache stampede jitter”)。

场景二:读多写多的数据,用户多、可以预见用户经常来读。例如,排行榜、推荐内容,这些都是预料到用户会来读取的且经常变化的内容。

解:这种场景下,要做的是保证在写的时候,要立即把数据同步更新到缓存中,减少用户读缓存不命中的情况。也就是经典的直写策略。

PS. cache和db是两套系统,它们需要保持一致性:保证从两套系统中读出数据是一样的(也就是保证从cache里面读出来的和数据库是一样的、因为先读cache)。 多线程(之前的刚从缓存删除,另一个线程就把旧数据写回来)和down机情况都会带来不一致的问题。

实战考虑

写缓存的一致性策略(常见顺序与问题)

在工程中,写操作涉及到数据库(DB)和缓存(cache)两个系统,这里常见的几种顺序和它们的风险如下:

1) 先写数据库,再删除缓存(DB -> delete cache)

  • 做法:先把新数据写入 DB,随后删除/使缓存失效。下一次读请求会回源到 DB 并写回缓存(cache-aside)。
  • 优点:实现简单,普遍采用。
  • 风险(race window):如果在写 DB 之后、删除缓存之前,有一个并发的读发生,该读可能命中旧缓存并把旧值写回缓存(回写旧值),导致最终缓存仍为旧数据。

2) 先删除缓存,再写数据库(delete cache -> DB)

  • 做法:先删除缓存,再写 DB。
  • 优点:在某些场景能减少“读到旧值然后回写缓存”的概率。
  • 风险:如果先删除缓存但写 DB 失败(或延迟很久),会导致短时间内缓存和数据库都没有最新数据(可用性下降);并且其他并发读可能回源到 DB 返回旧值(若 DB 未更新)。

3) 先写缓存再写数据库(write-through / write-behind)

  • write-through:写操作先写入缓存,同时同步写入 DB(或由缓存层同步写入 DB);对外呈现一致性更简单,但会增加写路径延迟。
  • write-behind:先写缓存,异步批量写入 DB(高吞吐、低延迟),但会引入更复杂的一致性与持久化风险(消息丢失、延迟写入)。

工程实践建议(权衡与常用做法):

  • 常用并推荐的模式是“写 DB 后删除缓存”,配合其它手段减小 race 窗口:使用短期重试删除缓存、使用消息队列保证删除操作最终执行,或在关键业务上采用分布式锁/悲观锁/版本号(乐观锁)来控制并发。\
  • 对于强一致性要求极高的场景,考虑把缓存作为只读层,所有写操作只走 DB,然后用同步/事务化的方式更新缓存(或避免把这些数据放入缓存)。\
  • 为降低回写旧值问题,可以在缓存中保存版本号或时间戳(write-through 或带版本的 cache-aside):写 DB 时同时更新一个版本号,缓存写入时比对版本号,只有版本一致时才回写缓存。

示例(Ruby:常见两种策略)

DB -> delete cache(带短重试):

def update_user(user_id, new_attrs)
    DB.transaction do
        DB.update_user(user_id, new_attrs)
    end
    # 尝试删除缓存,若失败可重试或放入队列保证最终一致性
    begin
        redis.del("user:#{user_id}")
    rescue => e
        # 记录到队列/日志,或进行短期重试
        AsyncQueue.push(:cache_invalidate, key: "user:#{user_id}")
    end
end

写缓存带版本(防止回写旧值):

def update_user_with_version(user_id, new_attrs)
    new_version = Time.now.to_i
    DB.update_user(user_id, new_attrs.merge(version: new_version))
    # 删除缓存或写入带版本的数据
    redis.set("user:#{user_id}", { value: new_attrs, version: new_version }.to_json)
end

def read_user(user_id)
    data = JSON.parse(redis.get("user:#{user_id}")) rescue nil
    if data
        # 可返回或校验版本
        return data["value"]
    else
        val = DB.get_user(user_id)
        redis.set("user:#{user_id}", { value: val, version: val.version }.to_json)
        return val
    end
end

补充方案:

  • 使用消息队列(MQ)异步保证缓存删除或更新:写 DB 后写一条消息到 MQ,由消费者负责最终使缓存一致,能提高可靠性与可观测性。\
  • 对于高并发热点,可配合分布式锁 / singleflight 等策略,或者在写操作短时间内增加读写排队/降级策略。

总结:没有一刀切的答案。常见实践是以“写 DB 后删除缓存”为默认策略,结合重试/队列/版本控制或分布式锁等手段减少 race 情况,并在高一致性场景中放弃缓存或采用更保守的同步更新策略。