本文档从原理角度深入分析缓存设计中的四个核心问题,重点解释为什么会出现这些问题 以及解决方案的底层原理。
1. 如何实现线程安全的缓存?
问题本质分析
为什么会出现线程安全问题?
缓存的核心操作涉及读取-修改-写入 的复合操作,这在多线程环境下会产生竞态条件(Race Condition):
- 数据结构破坏:比如LRU缓存中的双向链表,如果两个线程同时修改节点的prev/next指针,会导致链表结构损坏,出现环形引用或断链
- 状态不一致:HashMap和双向链表的状态可能不同步,比如HashMap中存在某个key,但链表中对应节点已被另一个线程删除
- ABA问题:线程A读取了某个值,线程B修改后又改回原值,线程A以为没有变化继续操作,实际上中间状态已经改变
举个具体例子:
- 线程1执行
get(key1)
,找到节点准备移动到头部 - 线程2同时执行
put(key2, value2)
,缓存满了要删除尾部节点,恰好是key1 - 线程1继续执行移动操作,但节点已被删除,导致空指针异常
方案1:粗粒度锁 - 简单粗暴但有效
核心思想:用一把大锁保护整个缓存,确保任何时刻只有一个线程能操作缓存。
为什么能解决问题?
- 通过互斥访问消除了竞态条件
- 保证了原子性:复合操作要么全部完成,要么全部不执行
- 内存可见性:锁的获取和释放提供了内存屏障,确保修改对其他线程可见
缺点分析:
- 吞吐量低:所有操作串行化,无法利用多核优势
- 锁竞争激烈:大量线程争抢同一把锁,CPU上下文切换开销大
- 可扩展性差:线程数增加时,性能不升反降
typescript
class ThreadSafeLRUCache {
private cache: LRUCache;
private lock = new Mutex(); // 假设有互斥锁实现
async get(key: number): Promise<number> {
return await this.lock.acquire(async () => {
return this.cache.get(key);
});
}
async put(key: number, value: number): Promise<void> {
return await this.lock.acquire(async () => {
this.cache.put(key, value);
});
}
}
方案2:读写锁 - 利用读操作的并发性
核心洞察:缓存场景下,读操作远多于写操作,而多个读操作之间不会产生数据竞争。
为什么读写锁更高效?
- 读读并发:多个线程可以同时读取,提高了并发度
- 读写互斥:读写操作互斥,保证了数据一致性
- 写写互斥:写操作独占,避免了写冲突
注意LRU的特殊性: 传统意义上的"读"操作在LRU中其实是"写"操作,因为get操作会修改节点位置!
真正的优化 :可以考虑延迟更新策略,get操作先返回值,异步更新位置,这样真正的读操作就不需要写锁了。
typescript
class ReadWriteLockCache {
private cache: LRUCache;
private readWriteLock = new ReadWriteLock();
async get(key: number): Promise<number> {
// 读操作使用读锁,允许多个读操作并发
return await this.readWriteLock.readLock(async () => {
return this.cache.get(key);
});
}
async put(key: number, value: number): Promise<void> {
// 写操作使用写锁,独占访问
return await this.readWriteLock.writeLock(async () => {
this.cache.put(key, value);
});
}
}
方案3:分段锁 - 化整为零的智慧
核心思想:将大缓存分割成多个小段,每段独立加锁,降低锁粒度。
为什么分段锁能大幅提升性能?
- 锁竞争降低:原来N个线程争抢1把锁,现在是N个线程争抢M把锁(M通常是CPU核心数的倍数)
- 并发度提升:不同段的操作可以真正并行执行
- 缓存友好:每个段的数据结构更小,更容易放入CPU缓存
关键设计决策:
- 段数选择:通常选择2的幂次,便于位运算取模
- 哈希策略:需要保证数据分布均匀,避免热点段
- 扩容策略:段数固定后很难动态调整
Java ConcurrentHashMap的启发: JDK 7使用分段锁,JDK 8改用CAS+synchronized,这个演进过程体现了技术的发展。
注意事项:"分段锁体现了分治思想,通过空间换时间,将全局竞争转化为局部竞争。"
typescript
class SegmentedLRUCache {
private segments: LRUCache[];
private locks: Mutex[];
private segmentCount: number;
constructor(capacity: number, segmentCount: number = 16) {
this.segmentCount = segmentCount;
this.segments = Array(segmentCount).fill(null)
.map(() => new LRUCache(Math.ceil(capacity / segmentCount)));
this.locks = Array(segmentCount).fill(null)
.map(() => new Mutex());
}
private getSegmentIndex(key: number): number {
return Math.abs(this.hash(key)) % this.segmentCount;
}
async get(key: number): Promise<number> {
const segmentIndex = this.getSegmentIndex(key);
return await this.locks[segmentIndex].acquire(async () => {
return this.segments[segmentIndex].get(key);
});
}
async put(key: number, value: number): Promise<void> {
const segmentIndex = this.getSegmentIndex(key);
return await this.locks[segmentIndex].acquire(async () => {
this.segments[segmentIndex].put(key, value);
});
}
private hash(key: number): number {
// 简单的哈希函数
return key * 31;
}
}
方案4:无锁算法 - 终极性能追求
哲学思考 :锁的本质是什么?是协调机制,但协调本身就是开销。能否不协调?
CAS(Compare-And-Swap)的魔力:
- 原子性保证:硬件层面的原子操作,比软件锁更高效
- 乐观策略:假设冲突不常发生,冲突时重试
- ABA问题:需要版本号或指针标记解决
为什么无锁算法性能最高?
- 无上下文切换:线程不会被阻塞,避免了内核态切换
- 无死锁风险:没有锁就没有死锁
- 可扩展性好:性能随核心数线性增长
实现难点:
- 内存模型:需要深入理解CPU缓存一致性协议
- ABA问题:经典的并发编程陷阱
- 饥饿问题:某些线程可能一直重试失败
typescript
// 使用原子操作和CAS(Compare-And-Swap)
class LockFreeLRUCache {
private atomicOperations: AtomicOperations;
// 使用原子引用和版本号实现无锁更新
get(key: number): number {
// 实现基于CAS的无锁读取
// 这需要底层原子操作支持
}
put(key: number, value: number): void {
// 实现基于CAS的无锁写入
// 使用重试机制处理冲突
}
}
性能分析总结
方案 | 并发度 | 实现复杂度 | 适用场景 |
---|---|---|---|
粗粒度锁 | 低 | 简单 | 原型系统 |
读写锁 | 中 | 中等 | 读多写少 |
分段锁 | 高 | 中等 | 生产环境 |
无锁算法 | 极高 | 极复杂 | 极致性能 |
2. 如何支持TTL(Time To Live)?
问题本质分析
为什么需要TTL?
TTL不仅仅是内存管理工具,更是数据一致性的重要保障:
- 内存泄漏防护:没有TTL的缓存会无限增长,最终OOM
- 数据新鲜度:缓存的数据可能过时,TTL强制刷新
- 缓存雪崩预防:通过TTL分散过期时间,避免同时失效
- 业务语义:某些数据本身就有时效性(如验证码、会话)
TTL实现的核心挑战:
- 时间复杂度:如何在O(1)时间内判断过期?
- 空间开销:存储过期时间的额外成本
- 清理策略:何时清理过期数据?
- 精度权衡:毫秒级精度 vs 性能开销
方案1:被动过期(惰性删除)- 懒人哲学
核心思想:不主动清理,访问时才检查是否过期。
为什么这样设计?
- CPU友好:避免了定时任务的CPU开销
- 实现简单:只需在访问时加一行时间判断
- 精确性高:访问时刻判断,不会有时间窗口问题
潜在问题及解决:
- 内存泄漏风险 :过期但未访问的数据永远不会被清理
- 解决:结合定期清理或LRU淘汰
- 访问延迟 :每次访问都要检查时间
- 解决:时间检查是纳秒级操作,影响微乎其微
Redis的启发:Redis正是采用被动过期+主动清理的混合策略。
typescript
interface CacheNode {
key: number;
value: number;
expireTime: number; // 过期时间戳
prev: CacheNode | null;
next: CacheNode | null;
}
class TTLLRUCache {
private cache: Map<number, CacheNode>;
private head: CacheNode;
private tail: CacheNode;
private capacity: number;
private defaultTTL: number; // 默认TTL(毫秒)
constructor(capacity: number, defaultTTL: number = 300000) { // 默认5分钟
this.capacity = capacity;
this.defaultTTL = defaultTTL;
this.cache = new Map();
this.head = { key: 0, value: 0, expireTime: 0, prev: null, next: null };
this.tail = { key: 0, value: 0, expireTime: 0, prev: null, next: null };
this.head.next = this.tail;
this.tail.prev = this.head;
}
get(key: number): number {
if (!this.cache.has(key)) {
return -1;
}
const node = this.cache.get(key)!;
// 检查是否过期
if (this.isExpired(node)) {
this.removeNode(node);
this.cache.delete(key);
return -1;
}
this.moveToHead(node);
return node.value;
}
put(key: number, value: number, ttl?: number): void {
const expireTime = Date.now() + (ttl || this.defaultTTL);
if (this.cache.has(key)) {
const node = this.cache.get(key)!;
node.value = value;
node.expireTime = expireTime;
this.moveToHead(node);
} else {
if (this.cache.size >= this.capacity) {
// 移除过期节点或LRU节点
this.evictExpiredOrLRU();
}
const newNode: CacheNode = {
key,
value,
expireTime,
prev: null,
next: null
};
this.cache.set(key, newNode);
this.addToHead(newNode);
}
}
private isExpired(node: CacheNode): boolean {
return Date.now() > node.expireTime;
}
private evictExpiredOrLRU(): void {
// 首先尝试移除过期的节点
let current = this.tail.prev;
while (current && current !== this.head) {
if (this.isExpired(current)) {
const expired = current;
current = current.prev;
this.removeNode(expired);
this.cache.delete(expired.key);
return;
}
current = current.prev;
}
// 如果没有过期节点,移除LRU节点
const lru = this.tail.prev!;
this.removeNode(lru);
this.cache.delete(lru.key);
}
// ... 其他辅助方法(moveToHead, addToHead, removeNode等)
}
方案2:主动过期(定时清理)- 勤快管家
核心思想:定期扫描所有缓存项,清理过期数据。
设计考量:
- 清理频率:太频繁浪费CPU,太稀疏内存泄漏
- 扫描策略:全量扫描 vs 采样扫描
- 清理时机:固定间隔 vs 负载自适应
为什么需要主动清理?
- 内存可控:保证过期数据最终会被清理
- 性能稳定:避免访问时的清理开销集中爆发
- 资源释放:及时释放文件句柄、网络连接等资源
实现难点:
- 扫描开销:大缓存的全量扫描可能很耗时
- 并发安全:清理线程与业务线程的同步
- 清理粒度:批量清理 vs 逐个清理的权衡
优化技巧:
- 分批清理:每次只清理一定数量,避免长时间占用锁
- 优先级队列:按过期时间排序,优先清理最早过期的
- 负载感知:系统空闲时多清理,繁忙时少清理
typescript
class ActiveTTLCache extends TTLLRUCache {
private cleanupInterval: NodeJS.Timeout;
constructor(capacity: number, defaultTTL: number = 300000, cleanupIntervalMs: number = 60000) {
super(capacity, defaultTTL);
// 每分钟清理一次过期项
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, cleanupIntervalMs);
}
private cleanup(): void {
const now = Date.now();
const expiredKeys: number[] = [];
// 收集过期的键
for (const [key, node] of this.cache) {
if (now > node.expireTime) {
expiredKeys.push(key);
}
}
// 删除过期项
expiredKeys.forEach(key => {
const node = this.cache.get(key);
if (node) {
this.removeNode(node);
this.cache.delete(key);
}
});
}
destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
}
}
方案3:时间轮算法 - 时间管理的艺术
灵感来源:机械时钟的指针转动,这是一个绝妙的时间管理隐喻!
核心洞察 : 传统方案的问题是什么?时间是连续的,但我们的处理是离散的。时间轮巧妙地将时间离散化。
时间轮的天才设计:
- 固定刻度:将时间划分为固定的时间片(如1秒)
- 循环复用:时间轮是环形的,可以循环使用
- 分层结构:类似时钟的时分秒,可以有多层时间轮
为什么时间轮高效?
- O(1)插入:根据TTL直接计算应该放在哪个槽位
- O(1)删除:直接从对应槽位移除
- 批量过期:每个时间片到达时,整个槽位的数据一起过期
经典应用场景:
- Netty的定时器:HashedWheelTimer
- Kafka的延迟队列:基于时间轮实现
- Linux内核:定时器管理
其他:
- 分层时间轮:秒轮、分轮、时轮,类比时钟结构
- 时间精度与内存的权衡:精度越高,需要的槽位越多
- 对比优先级队列:时间轮是O(1),优先级队列是O(log n)
typescript
class TimeWheelCache {
private wheels: Set<number>[]; // 时间轮
private wheelSize: number;
private tickMs: number; // 每个刻度的时间
private currentTick: number;
private cache: Map<number, { value: number, wheelIndex: number, tick: number }>;
constructor(capacity: number, wheelSize: number = 3600, tickMs: number = 1000) {
this.wheelSize = wheelSize;
this.tickMs = tickMs;
this.currentTick = 0;
this.cache = new Map();
this.wheels = Array(wheelSize).fill(null).map(() => new Set());
// 启动时间轮
setInterval(() => {
this.tick();
}, tickMs);
}
put(key: number, value: number, ttlSeconds: number): void {
const targetTick = this.currentTick + ttlSeconds;
const wheelIndex = targetTick % this.wheelSize;
// 如果key已存在,先从旧位置移除
if (this.cache.has(key)) {
const old = this.cache.get(key)!;
this.wheels[old.wheelIndex].delete(key);
}
this.cache.set(key, { value, wheelIndex, tick: targetTick });
this.wheels[wheelIndex].add(key);
}
get(key: number): number {
const item = this.cache.get(key);
return item ? item.value : -1;
}
private tick(): void {
const currentWheel = this.wheels[this.currentTick % this.wheelSize];
// 处理当前刻度的过期项
for (const key of currentWheel) {
const item = this.cache.get(key);
if (item && item.tick <= this.currentTick) {
// 过期,删除
this.cache.delete(key);
currentWheel.delete(key);
}
}
this.currentTick++;
}
}
3. 如何实现分布式缓存?
问题本质分析
为什么需要分布式缓存?
单机缓存的三大瓶颈:
- 容量瓶颈:单机内存有限,无法存储海量数据
- 性能瓶颈:单机QPS有上限,无法应对高并发
- 可靠性瓶颈:单点故障导致所有缓存丢失
分布式带来的新挑战:
- 数据分布:如何决定数据放在哪个节点?
- 负载均衡:如何保证各节点负载相对均匀?
- 容错处理:节点宕机时如何保证服务可用?
- 数据一致性:多个副本之间如何保持一致?
- 网络分区:网络故障时如何处理?
CAP理论的体现:分布式缓存必须在一致性(C)、可用性(A)、分区容错性(P)之间做权衡。
方案1:一致性哈希分片 - 优雅的数据分布
传统哈希的致命缺陷 : 假设有3个节点,使用hash(key) % 3
分片。当增加到4个节点时,变成hash(key) % 4
,75%的数据需要迁移!
一致性哈希的天才设计:
- 哈希环:将哈希值空间想象成一个环(0到2^32-1)
- 节点映射:将节点哈希后放在环上
- 数据定位:数据沿环顺时针找到第一个节点
为什么叫"一致性"哈希?
- 单调性:新增节点只影响一个区间的数据
- 平衡性:理想情况下数据分布均匀
- 分散性:相同内容在不同终端的哈希值应该一致
虚拟节点的巧思:
- 问题:物理节点少时,数据分布可能不均匀
- 解决:每个物理节点对应多个虚拟节点,提高分布均匀性
- 数量选择:通常每个物理节点对应100-200个虚拟节点
经典应用:
- Amazon DynamoDB:P2P架构的基础
- Cassandra:数据分片策略
- Memcached客户端:libketama算法
注意:"一致性哈希解决了分布式系统中的数据分布和负载均衡问题,是分布式存储的基石。"
typescript
class ConsistentHashRing {
private ring: Map<number, string> = new Map(); // hash -> nodeId
private nodes: Set<string> = new Set();
private virtualNodes: number; // 虚拟节点数量
constructor(virtualNodes: number = 150) {
this.virtualNodes = virtualNodes;
}
addNode(nodeId: string): void {
this.nodes.add(nodeId);
// 为每个物理节点创建多个虚拟节点
for (let i = 0; i < this.virtualNodes; i++) {
const virtualNodeId = `${nodeId}:${i}`;
const hash = this.hash(virtualNodeId);
this.ring.set(hash, nodeId);
}
// 重新排序环
this.sortRing();
}
removeNode(nodeId: string): void {
this.nodes.delete(nodeId);
// 移除所有虚拟节点
const toRemove: number[] = [];
for (const [hash, node] of this.ring) {
if (node === nodeId) {
toRemove.push(hash);
}
}
toRemove.forEach(hash => this.ring.delete(hash));
}
getNode(key: string): string {
if (this.ring.size === 0) {
throw new Error('No nodes available');
}
const keyHash = this.hash(key);
const sortedHashes = Array.from(this.ring.keys()).sort((a, b) => a - b);
// 找到第一个大于key hash的节点
for (const hash of sortedHashes) {
if (hash >= keyHash) {
return this.ring.get(hash)!;
}
}
// 如果没找到,返回环上的第一个节点
return this.ring.get(sortedHashes[0])!;
}
private hash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // 转换为32位整数
}
return Math.abs(hash);
}
private sortRing(): void {
const sorted = new Map([...this.ring.entries()].sort((a, b) => a[0] - b[0]));
this.ring = sorted;
}
}
class DistributedLRUCache {
private localCache: LRUCache;
private hashRing: ConsistentHashRing;
private nodeId: string;
private nodes: Map<string, RemoteCacheClient>; // nodeId -> client
constructor(nodeId: string, capacity: number) {
this.nodeId = nodeId;
this.localCache = new LRUCache(capacity);
this.hashRing = new ConsistentHashRing();
this.nodes = new Map();
}
async get(key: number): Promise<number> {
const targetNode = this.hashRing.getNode(key.toString());
if (targetNode === this.nodeId) {
// 本地查询
return this.localCache.get(key);
} else {
// 远程查询
const client = this.nodes.get(targetNode);
if (!client) {
throw new Error(`Node ${targetNode} not available`);
}
return await client.get(key);
}
}
async put(key: number, value: number): Promise<void> {
const targetNode = this.hashRing.getNode(key.toString());
if (targetNode === this.nodeId) {
// 本地存储
this.localCache.put(key, value);
} else {
// 远程存储
const client = this.nodes.get(targetNode);
if (!client) {
throw new Error(`Node ${targetNode} not available`);
}
await client.put(key, value);
}
}
addNode(nodeId: string, client: RemoteCacheClient): void {
this.hashRing.addNode(nodeId);
this.nodes.set(nodeId, client);
// 数据迁移逻辑
this.rebalanceData();
}
private async rebalanceData(): Promise<void> {
// 遍历本地缓存,检查数据是否应该迁移到其他节点
const keysToMigrate: Array<{key: number, value: number, targetNode: string}> = [];
for (const [key, node] of this.localCache.getAll()) {
const targetNode = this.hashRing.getNode(key.toString());
if (targetNode !== this.nodeId) {
keysToMigrate.push({key, value: node.value, targetNode});
}
}
// 执行数据迁移
for (const item of keysToMigrate) {
const client = this.nodes.get(item.targetNode);
if (client) {
await client.put(item.key, item.value);
// 从本地删除
this.localCache.delete(item.key);
}
}
}
}
interface RemoteCacheClient {
get(key: number): Promise<number>;
put(key: number, value: number): Promise<void>;
}
方案2:主从复制 - 高可用的经典模式
核心思想:数据冗余存储,一个主节点负责写入,多个从节点负责读取。
为什么主从复制能解决可用性问题?
- 读写分离:主节点压力小,从节点分担读压力
- 故障转移:主节点宕机时,从节点可以升级为主节点
- 数据安全:多份副本降低数据丢失风险
复制策略的权衡:
- 同步复制:强一致性,但性能差,主节点等待所有从节点确认
- 异步复制:高性能,但可能丢失数据,主节点写入后立即返回
- 半同步复制:折中方案,等待部分从节点确认
一致性级别:
- 强一致性:所有副本数据完全一致,代价是可用性
- 最终一致性:允许短期不一致,但最终会收敛
- 会话一致性:同一个会话内保证一致性
脑裂问题: 当网络分区导致出现多个主节点时,数据会不一致。解决方案:
- Quorum机制:多数派原则
- 仲裁节点:第三方决策者
- STONITH:Shoot The Other Node In The Head
后续:可以深入调研Redis的主从复制、MySQL的读写分离等实际案例。
typescript
class MasterSlaveCache {
private master: LRUCache;
private slaves: RemoteCacheClient[];
private isMaster: boolean;
constructor(capacity: number, isMaster: boolean = true) {
this.master = new LRUCache(capacity);
this.slaves = [];
this.isMaster = isMaster;
}
async get(key: number): Promise<number> {
// 读操作可以从任何节点进行
return this.master.get(key);
}
async put(key: number, value: number): Promise<void> {
if (!this.isMaster) {
throw new Error('Write operations only allowed on master');
}
// 写入主节点
this.master.put(key, value);
// 异步复制到从节点
const replicationPromises = this.slaves.map(slave =>
slave.put(key, value).catch(error => {
console.error('Replication failed:', error);
})
);
// 可以选择等待所有复制完成,或者异步进行
await Promise.allSettled(replicationPromises);
}
addSlave(slave: RemoteCacheClient): void {
this.slaves.push(slave);
}
}
4. 如何处理缓存穿透和缓存雪崩?
问题本质分析
这两个问题是缓存系统的阿喀琉斯之踵,看似简单实则致命。
缓存穿透 - 恶意攻击的温床
问题本质:攻击者故意查询不存在的数据,每次都绕过缓存打到数据库。
为什么危险?
- 缓存失效:不存在的数据无法缓存,缓存命中率为0
- 数据库压力:每个请求都要查询数据库
- 恶意放大:攻击者可以构造大量不存在的key,放大攻击效果
- 资源耗尽:数据库连接池、CPU、内存都可能被耗尽
真实案例:某电商网站被攻击,攻击者查询不存在的商品ID,导致数据库宕机,整个网站不可用。
缓存雪崩 - 多米诺骨牌效应
问题本质:大量缓存同时失效,瞬间的数据库访问量激增。
为什么会雪崩?
- 集中过期:缓存设置了相同的TTL,同时失效
- 冷启动:系统重启后缓存全部丢失
- 热点失效:热点数据过期时,大量请求同时打到数据库
雪崩的恐怖之处:
- 级联失效:数据库压力大→响应慢→更多缓存过期→压力更大
- 服务降级:整个系统可能不可用
- 恢复困难:数据库恢复后,缓存重建需要时间
缓存穿透解决方案
方案1:布隆过滤器 - 数学之美的体现
核心洞察 :我们不需要知道某个key存在,只需要知道它一定不存在。
布隆过滤器的数学原理:
- 位数组:用m个bit位表示集合
- k个哈希函数:将元素映射到k个位置
- 存在性判断:如果k个位置都是1,可能存在;如果任意一个是0,一定不存在
为什么布隆过滤器有效?
- 空间效率极高:每个元素只需要几个bit,比存储实际数据省空间
- 时间复杂度O(k):k是常数,实际上是O(1)
- 误判可控:可以通过调整参数控制误判率
参数选择的艺术:
- m(位数组大小):太小误判率高,太大浪费空间
- k(哈希函数个数) :有最优值,通常是
k = (m/n) * ln(2)
- 误判率计算 :
p = (1 - e^(-kn/m))^k
实际应用技巧:
- 预热:系统启动时将所有存在的key加入布隆过滤器
- 定期重建:随着数据变化,定期重建过滤器
- 分层过滤:多级布隆过滤器,粗筛+精筛
typescript
class BloomFilter {
private bitArray: boolean[];
private size: number;
private hashFunctions: number;
constructor(expectedElements: number, falsePositiveRate: number = 0.01) {
this.size = Math.ceil((-expectedElements * Math.log(falsePositiveRate)) / Math.pow(Math.log(2), 2));
this.hashFunctions = Math.ceil((this.size / expectedElements) * Math.log(2));
this.bitArray = new Array(this.size).fill(false);
}
add(item: string): void {
for (let i = 0; i < this.hashFunctions; i++) {
const hash = this.hash(item, i) % this.size;
this.bitArray[hash] = true;
}
}
mightContain(item: string): boolean {
for (let i = 0; i < this.hashFunctions; i++) {
const hash = this.hash(item, i) % this.size;
if (!this.bitArray[hash]) {
return false; // 确定不存在
}
}
return true; // 可能存在
}
private hash(item: string, seed: number): number {
let hash = seed;
for (let i = 0; i < item.length; i++) {
hash = hash * 31 + item.charCodeAt(i);
}
return Math.abs(hash);
}
}
class AntiPenetrationCache {
private cache: LRUCache;
private bloomFilter: BloomFilter;
private nullCache: Set<number>; // 缓存不存在的key
private database: Database; // 假设的数据库接口
constructor(capacity: number, expectedElements: number) {
this.cache = new LRUCache(capacity);
this.bloomFilter = new BloomFilter(expectedElements);
this.nullCache = new Set();
}
async get(key: number): Promise<number | null> {
// 1. 先检查布隆过滤器
if (!this.bloomFilter.mightContain(key.toString())) {
return null; // 确定不存在
}
// 2. 检查缓存
const cached = this.cache.get(key);
if (cached !== -1) {
return cached;
}
// 3. 检查空值缓存
if (this.nullCache.has(key)) {
return null;
}
// 4. 查询数据库
const dbResult = await this.database.get(key);
if (dbResult !== null) {
// 存在,加入缓存和布隆过滤器
this.cache.put(key, dbResult);
this.bloomFilter.add(key.toString());
return dbResult;
} else {
// 不存在,加入空值缓存(设置较短的TTL)
this.nullCache.add(key);
setTimeout(() => {
this.nullCache.delete(key);
}, 60000); // 1分钟后移除空值缓存
return null;
}
}
}
方案2:缓存空对象 - 以毒攻毒
核心思想:既然攻击者查询不存在的数据,那我们就缓存"不存在"这个结果。
为什么有效?
- 第一次查询:数据库返回null,我们缓存null值
- 后续查询:直接从缓存返回null,不再访问数据库
- 攻击失效:攻击者无法绕过缓存
设计要点:
- TTL设置:空对象的TTL要比正常数据短,避免占用过多内存
- 标识区分:需要区分"缓存未命中"和"缓存了空值"
- 内存控制:大量空对象可能占用内存,需要LRU淘汰
潜在问题及解决:
- 内存占用:大量空对象占用内存 → 设置较短TTL + LRU淘汰
- 数据一致性:如果后来添加了数据怎么办? → 写入时主动删除对应的空缓存
- 误判风险:网络异常导致的查询失败被误缓存 → 区分不同的错误类型
注意 :这体现了缓存即数据的思想,连"不存在"也是一种有价值的信息。
typescript
class NullObjectCache {
private cache: Map<number, {value: number | null, expireTime: number}>;
private nullTTL: number = 60000; // 空对象缓存1分钟
private normalTTL: number = 300000; // 正常对象缓存5分钟
constructor() {
this.cache = new Map();
}
async get(key: number): Promise<number | null> {
const cached = this.cache.get(key);
if (cached && Date.now() < cached.expireTime) {
return cached.value;
}
// 查询数据库
const dbResult = await this.database.get(key);
const ttl = dbResult !== null ? this.normalTTL : this.nullTTL;
this.cache.set(key, {
value: dbResult,
expireTime: Date.now() + ttl
});
return dbResult;
}
}
缓存雪崩解决方案
方案1:随机TTL - 时间的艺术
核心思想:通过引入随机性,将集中的过期时间分散开。
数学原理 : 假设原TTL是T,加入随机因子后变成T + random(-R, R)
,过期时间就从一个点变成了一个区间。
为什么随机性有效?
- 分散过期:原本同时过期的缓存被分散到不同时间点
- 负载均衡:数据库的访问压力被时间维度上平摊
- 自然恢复:系统有时间逐步重建缓存
参数调优:
- 随机范围:通常是基础TTL的10%-20%
- 分布选择:均匀分布 vs 正态分布 vs 指数分布
- 业务考虑:不同业务对数据新鲜度的要求不同
进阶技巧:
- 分层TTL:热点数据TTL长+小随机,冷数据TTL短+大随机
- 负载感知:系统繁忙时增大随机范围,空闲时减小
- 预过期:在真正过期前的随机时间点异步刷新
注意:"随机TTL体现了分治和概率论的思想,将确定性问题转化为概率问题。"
typescript
class AntiAvalancheCache {
private cache: TTLLRUCache;
private baseTTL: number;
private jitterRange: number; // TTL抖动范围
constructor(capacity: number, baseTTL: number = 300000, jitterRange: number = 60000) {
this.cache = new TTLLRUCache(capacity, baseTTL);
this.baseTTL = baseTTL;
this.jitterRange = jitterRange;
}
put(key: number, value: number): void {
// 添加随机抖动,避免同时过期
const jitter = Math.random() * this.jitterRange - (this.jitterRange / 2);
const ttl = this.baseTTL + jitter;
this.cache.put(key, value, ttl);
}
async get(key: number): Promise<number | null> {
const cached = this.cache.get(key);
if (cached !== -1) {
return cached;
}
// 使用互斥锁防止缓存击穿
return await this.getWithMutex(key);
}
private mutexes: Map<number, Promise<number | null>> = new Map();
private async getWithMutex(key: number): Promise<number | null> {
// 如果已经有线程在查询这个key,等待结果
if (this.mutexes.has(key)) {
return await this.mutexes.get(key)!;
}
// 创建互斥查询
const promise = this.queryDatabase(key);
this.mutexes.set(key, promise);
try {
const result = await promise;
if (result !== null) {
this.put(key, result);
}
return result;
} finally {
this.mutexes.delete(key);
}
}
private async queryDatabase(key: number): Promise<number | null> {
// 模拟数据库查询
return await this.database.get(key);
}
}
方案2:多级缓存 - 纵深防御
军事思想的启发:单一防线容易被突破,多重防线可以层层缓冲。
缓存层次设计:
- L1缓存(本地):进程内缓存,速度最快,容量最小
- L2缓存(分布式):Redis等,速度较快,容量较大
- L3缓存(数据库缓存):MySQL查询缓存,最后防线
为什么多级缓存能防雪崩?
- 故障隔离:上层缓存失效时,下层缓存仍可提供服务
- 压力缓冲:每层缓存都能过滤一部分请求
- 恢复时间:不同层的恢复时间不同,避免同时失效
设计原则:
- 容量递增:L1 < L2 < L3,越底层容量越大
- TTL递增:L1的TTL最短,L3的TTL最长
- 一致性权衡:上层缓存可以接受较弱的一致性
实现策略:
- 写入策略:Write-Through vs Write-Back vs Write-Around
- 失效策略:超时失效 vs 主动失效 vs 版本控制
- 预热策略:冷启动时的缓存预热顺序
经典案例:
- CPU缓存:L1/L2/L3 Cache的硬件实现
- CDN:边缘节点→区域节点→源站的多级架构
- 浏览器缓存:内存缓存→磁盘缓存→HTTP缓存
typescript
class MultiLevelCache {
private l1Cache: LRUCache; // 内存缓存
private l2Cache: RedisCache; // Redis缓存
private database: Database;
constructor(l1Capacity: number) {
this.l1Cache = new LRUCache(l1Capacity);
}
async get(key: number): Promise<number | null> {
// L1缓存
let result = this.l1Cache.get(key);
if (result !== -1) {
return result;
}
// L2缓存
result = await this.l2Cache.get(key);
if (result !== null) {
this.l1Cache.put(key, result);
return result;
}
// 数据库
result = await this.database.get(key);
if (result !== null) {
this.l1Cache.put(key, result);
await this.l2Cache.put(key, result, 300000); // 5分钟TTL
}
return result;
}
}
方案3:熔断器模式 - 自我保护的智慧
电路熔断器的启发:当电流过大时,熔断器会断开电路保护设备,避免更大损失。
熔断器的三种状态:
- 关闭状态(CLOSED):正常工作,统计失败率
- 开启状态(OPEN):快速失败,直接返回降级结果
- 半开状态(HALF_OPEN):尝试恢复,允许少量请求通过
状态转换的智慧:
- CLOSED → OPEN:失败率超过阈值(如50%)
- OPEN → HALF_OPEN:超过恢复时间窗口(如30秒)
- HALF_OPEN → CLOSED:探测请求成功
- HALF_OPEN → OPEN:探测请求失败
为什么熔断器能防雪崩?
- 快速失败:避免请求在故障服务上堆积
- 资源保护:保护下游服务不被压垮
- 自动恢复:故障恢复后自动切换回正常状态
- 降级服务:提供备用方案,保证可用性
参数调优的艺术:
- 失败阈值:太低容易误触发,太高保护不及时
- 时间窗口:统计失败率的时间范围
- 恢复时间:开启状态持续多久后尝试恢复
- 探测比例:半开状态允许多少比例的请求通过
降级策略:
- 返回默认值:如商品详情返回基本信息
- 返回缓存数据:即使过期也比没有强
- 返回错误页面:友好的错误提示
- 异步补偿:记录失败请求,后续补偿处理
Netflix Hystrix的启发:
- 命令模式:将每个依赖调用封装成命令
- 线程池隔离:不同服务使用不同线程池
- 信号量隔离:控制并发访问数量
- 实时监控:提供丰富的监控指标
typescript
class CircuitBreakerCache {
private cache: LRUCache;
private failureCount: number = 0;
private failureThreshold: number = 5;
private recoveryTimeout: number = 30000; // 30秒
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
private lastFailureTime: number = 0;
constructor(capacity: number) {
this.cache = new LRUCache(capacity);
}
async get(key: number): Promise<number | null> {
// 检查缓存
const cached = this.cache.get(key);
if (cached !== -1) {
return cached;
}
// 检查熔断器状态
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime > this.recoveryTimeout) {
this.state = 'HALF_OPEN';
} else {
// 熔断器开启,返回降级数据
return this.getFallbackData(key);
}
}
try {
const result = await this.database.get(key);
// 成功,重置失败计数
if (this.state === 'HALF_OPEN') {
this.state = 'CLOSED';
this.failureCount = 0;
}
if (result !== null) {
this.cache.put(key, result);
}
return result;
} catch (error) {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.state = 'OPEN';
}
// 返回降级数据
return this.getFallbackData(key);
}
}
private getFallbackData(key: number): number | null {
// 返回默认值、旧缓存数据或null
return null;
}
}