Skip to content

热点数据更新与缓存策略

热点数据更新的挑战

在高并发业务场景中,热点数据更新是一个常见且棘手的问题。当大量请求同时修改同一行或少数几行数据时,会引发严重的性能问题甚至系统崩溃。

典型业务场景

mermaid
graph TB
    Scene([热点更新场景])
    
    Scene --> Inventory([秒杀库存扣减])
    Scene --> Counter([社交点赞计数])
    Scene --> Balance([账户余额变更])
    Scene --> Points([积分增减])
    
    Inventory --> Example1([爆款商品1秒内<br/>数万次库存扣减])
    Counter --> Example2([热门视频<br/>每秒数千次点赞])
    
    classDef sceneStyle fill:#4A90E2,stroke:none,color:#fff
    classDef exampleStyle fill:#50C878,stroke:none,color:#fff
    
    class Scene,Inventory,Counter,Balance,Points sceneStyle
    class Example1,Example2 exampleStyle

以电商秒杀场景为例,假设某款限量商品开售瞬间有 10 万用户同时抢购:

sql
-- 扣减库存操作
UPDATE flash_sale_items 
SET stock_count = stock_count - 1 
WHERE item_id = 2024001 AND stock_count > 0;

这条简单的 SQL 会成为系统瓶颈,原因在于所有请求都在竞争同一行数据的锁。

热点更新带来的问题

锁竞争与阻塞

MySQL 的 UPDATE 语句需要获取行级排他锁(X锁),当多个事务同时更新同一行时,只有一个能执行,其他必须等待。

mermaid
graph TB
    subgraph "并发更新热点行"
        R1([请求1]) --> Lock1([获取X锁-成功])
        R2([请求2]) --> Wait2([等待锁...])
        R3([请求3]) --> Wait3([等待锁...])
        R4([请求N]) --> WaitN([等待锁...])
    end
    
    Lock1 --> Execute([执行UPDATE])
    Execute --> Release([释放锁])
    Release --> Next([下一个请求获取锁])
    
    classDef successStyle fill:#50C878,stroke:none,color:#fff
    classDef waitStyle fill:#E85D75,stroke:none,color:#fff
    classDef processStyle fill:#4A90E2,stroke:none,color:#fff
    
    class R1,Lock1 successStyle
    class R2,R3,R4,Wait2,Wait3,WaitN waitStyle
    class Execute,Release,Next processStyle

后果:系统吞吐量急剧下降,响应时间大幅增加。

数据库连接耗尽

等待锁的事务会持续占用数据库连接,而数据库连接是有限资源:

mermaid
graph TB
    Pool([连接池容量: 100])
    
    Pool --> Active([活跃连接])
    Pool --> Waiting([等待锁的连接])
    
    Active --> A1([正在执行SQL: 10个])
    Waiting --> W1([等待热点行锁: 90个])
    
    W1 --> Exhaust([连接池耗尽])
    Exhaust --> NewRequest([新请求无法获取连接])
    NewRequest --> Timeout([请求超时/失败])
    
    classDef poolStyle fill:#4A90E2,stroke:none,color:#fff
    classDef activeStyle fill:#50C878,stroke:none,color:#fff
    classDef waitStyle fill:#E67E22,stroke:none,color:#fff
    classDef failStyle fill:#E85D75,stroke:none,color:#fff
    
    class Pool poolStyle
    class Active,A1 activeStyle
    class Waiting,W1 waitStyle
    class Exhaust,NewRequest,Timeout failStyle

CPU 资源过载

大量锁等待会导致严重的 CPU 消耗:

  1. 锁自旋开销:等待锁的线程会不断尝试获取锁,消耗 CPU
  2. 死锁检测:MySQL 持续进行死锁检测,消耗 CPU
  3. 上下文切换:频繁的线程调度增加系统开销
sql
-- 查看当前锁等待情况
SELECT * FROM information_schema.INNODB_LOCK_WAITS;

-- 查看锁持有情况
SELECT * FROM performance_schema.data_locks;

死锁风险

高并发场景下,复杂的业务逻辑可能导致死锁:

sql
-- 事务A:先更新订单,再更新库存
UPDATE orders SET status = 2 WHERE order_id = 1001;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 5001;

-- 事务B:先更新库存,再更新订单
UPDATE inventory SET stock = stock - 1 WHERE product_id = 5001;
UPDATE orders SET status = 2 WHERE order_id = 1001;

-- 如果事务A和B交替执行,可能形成死锁

索引维护开销

频繁更新会导致相关索引的频繁维护,增加系统负担:

mermaid
graph LR
    Update([更新操作]) --> Data([修改数据页])
    Update --> Index([维护索引])
    
    Index --> Delete([删除旧索引条目])
    Index --> Insert([插入新索引条目])
    Index --> Split([可能触发页分裂])
    
    classDef updateStyle fill:#4A90E2,stroke:none,color:#fff
    classDef indexStyle fill:#E67E22,stroke:none,color:#fff
    
    class Update,Data updateStyle
    class Index,Delete,Insert,Split indexStyle

主从复制延迟

热点数据的高频更新会产生大量 binlog,加剧主从复制延迟:

  • 主库写入频繁,binlog 生成速度快
  • 从库重放 binlog 需要时间
  • 延迟期间读写分离可能读到旧数据

热点数据的缓存策略

当数据库存在海量数据(如 2000 万条),而缓存容量有限(如只能存放 20 万条)时,如何确保缓存中都是热点数据,是缓存设计的核心问题。

缓存预热

系统启动或缓存重建时,需要将热点数据提前加载到缓存:

mermaid
graph TB
    Start([系统启动]) --> Analyze([分析历史数据])
    Analyze --> Identify([识别热点数据])
    Identify --> Load([加载到Redis])
    Load --> Ready([缓存就绪])
    
    Identify --> Criteria([识别标准])
    Criteria --> Access([高访问频率])
    Criteria --> Recent([近期活跃])
    Criteria --> Business([业务重要性])
    
    classDef processStyle fill:#4A90E2,stroke:none,color:#fff
    classDef criteriaStyle fill:#50C878,stroke:none,color:#fff
    
    class Start,Analyze,Identify,Load,Ready processStyle
    class Criteria,Access,Recent,Business criteriaStyle

实现示例

java
@Component
public class CacheWarmer {
    
    @Autowired
    private ProductMapper productMapper;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @PostConstruct
    public void warmUpCache() {
        // 查询近7天访问量Top 1000的商品
        List<Product> hotProducts = productMapper.selectHotProducts(7, 1000);
        
        for (Product product : hotProducts) {
            String key = "product:" + product.getId();
            redisTemplate.opsForValue().set(key, product, 1, TimeUnit.HOURS);
        }
        
        log.info("缓存预热完成,加载商品数量: {}", hotProducts.size());
    }
}

实时热点检测

业务运行过程中,热点数据会动态变化,需要实时检测和更新:

mermaid
graph LR
    Request([业务请求]) --> Collect([收集访问记录])
    Collect --> Aggregate([聚合统计])
    Aggregate --> Detect([检测热点])
    Detect --> Update([更新缓存])
    
    classDef flowStyle fill:#4A90E2,stroke:none,color:#fff
    
    class Request,Collect,Aggregate,Detect,Update flowStyle

方案一:本地计数 + 定期上报

java
@Component
public class HotKeyDetector {
    
    // 本地访问计数器
    private final ConcurrentHashMap<String, AtomicInteger> accessCounter = 
        new ConcurrentHashMap<>();
    
    public void recordAccess(String key) {
        accessCounter.computeIfAbsent(key, k -> new AtomicInteger())
                     .incrementAndGet();
    }
    
    @Scheduled(fixedRate = 60000)  // 每分钟上报
    public void reportHotKeys() {
        List<Map.Entry<String, AtomicInteger>> sorted = accessCounter.entrySet()
            .stream()
            .sorted((a, b) -> b.getValue().get() - a.getValue().get())
            .limit(100)
            .collect(Collectors.toList());
        
        // 上报到中心节点或直接加载到缓存
        for (Map.Entry<String, AtomicInteger> entry : sorted) {
            if (entry.getValue().get() > 100) {  // 阈值判断
                loadToCache(entry.getKey());
            }
        }
        
        accessCounter.clear();  // 重置计数器
    }
}

方案二:使用专业热点框架

京东开源的 hotkey 框架专门用于实时热点检测:

mermaid
graph TB
    Client([业务客户端]) --> Report([上报访问Key])
    Report --> Worker([Hotkey Worker])
    Worker --> Compute([实时计算热度])
    Compute --> Push([推送热Key])
    Push --> LocalCache([本地缓存])
    
    classDef clientStyle fill:#4A90E2,stroke:none,color:#fff
    classDef workerStyle fill:#9B59B6,stroke:none,color:#fff
    classDef cacheStyle fill:#50C878,stroke:none,color:#fff
    
    class Client,Report clientStyle
    class Worker,Compute,Push workerStyle
    class LocalCache cacheStyle

选择合适的过期策略

Redis 提供了多种内存淘汰策略,需要根据业务特点选择:

LRU(最近最少使用)

mermaid
graph LR
    LRU([LRU策略]) --> Principle([淘汰最久未访问的数据])
    Principle --> Suitable([适用场景])
    Suitable --> S1([短期热点明显])
    Suitable --> S2([访问模式稳定])
    
    classDef strategyStyle fill:#4A90E2,stroke:none,color:#fff
    classDef suitStyle fill:#50C878,stroke:none,color:#fff
    
    class LRU,Principle strategyStyle
    class Suitable,S1,S2 suitStyle

LFU(最不经常使用)

mermaid
graph LR
    LFU([LFU策略]) --> Principle([淘汰访问频率最低的数据])
    Principle --> Suitable([适用场景])
    Suitable --> S1([长期热点稳定])
    Suitable --> S2([关注累计访问量])
    
    classDef strategyStyle fill:#9B59B6,stroke:none,color:#fff
    classDef suitStyle fill:#50C878,stroke:none,color:#fff
    
    class LFU,Principle strategyStyle
    class Suitable,S1,S2 suitStyle

配置示例

bash
# redis.conf 配置
maxmemory 10gb
maxmemory-policy volatile-lru  # 对设置了过期时间的key使用LRU淘汰
策略说明适用场景
volatile-lru对有过期时间的key使用LRU缓存数据有TTL
allkeys-lru对所有key使用LRU全部作为缓存使用
volatile-lfu对有过期时间的key使用LFU长期热点稳定
allkeys-lfu对所有key使用LFU关注长期访问频率
volatile-ttl优先淘汰TTL较短的key按过期时间管理
noeviction内存满时拒绝写入数据不允许丢失

主动过期与被动更新

结合主动过期和被动更新,确保缓存数据的时效性:

java
@Service
public class ProductCacheService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private ProductMapper productMapper;
    
    // 缓存读取(Cache Aside 模式)
    public Product getProduct(Long productId) {
        String key = "product:" + productId;
        
        // 1. 先查缓存
        Product product = (Product) redisTemplate.opsForValue().get(key);
        if (product != null) {
            return product;
        }
        
        // 2. 缓存未命中,查数据库
        product = productMapper.selectById(productId);
        if (product != null) {
            // 3. 写入缓存,设置随机过期时间避免雪崩
            int ttl = 3600 + new Random().nextInt(600);  // 1小时 + 随机10分钟
            redisTemplate.opsForValue().set(key, product, ttl, TimeUnit.SECONDS);
        }
        
        return product;
    }
    
    // 数据更新时删除缓存
    @Transactional
    public void updateProduct(Product product) {
        // 1. 更新数据库
        productMapper.updateById(product);
        
        // 2. 删除缓存
        String key = "product:" + product.getId();
        redisTemplate.delete(key);
    }
}

热点更新的优化方案

库存分桶

将单个热点记录拆分为多个分桶,分散锁竞争:

mermaid
graph TB
    Origin([原始方案<br/>单行库存: 10000])
    
    Bucket([分桶方案])
    Bucket --> B1([桶1: 2000])
    Bucket --> B2([桶2: 2000])
    Bucket --> B3([桶3: 2000])
    Bucket --> B4([桶4: 2000])
    Bucket --> B5([桶5: 2000])
    
    Request([扣减请求]) --> Hash([Hash取模])
    Hash --> Random([随机选择桶])
    Random --> Deduct([扣减该桶库存])
    
    classDef originStyle fill:#E85D75,stroke:none,color:#fff
    classDef bucketStyle fill:#50C878,stroke:none,color:#fff
    classDef processStyle fill:#4A90E2,stroke:none,color:#fff
    
    class Origin originStyle
    class Bucket,B1,B2,B3,B4,B5 bucketStyle
    class Request,Hash,Random,Deduct processStyle

实现示例

sql
-- 创建库存分桶表
CREATE TABLE stock_buckets (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    product_id BIGINT NOT NULL,
    bucket_no INT NOT NULL,
    stock_count INT NOT NULL DEFAULT 0,
    UNIQUE KEY uk_product_bucket (product_id, bucket_no)
);

-- 初始化10个分桶,每桶1000库存
INSERT INTO stock_buckets (product_id, bucket_no, stock_count)
SELECT 2024001, seq, 1000 
FROM (SELECT 0 seq UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4
      UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) t;
java
@Service
public class BucketStockService {
    
    private static final int BUCKET_COUNT = 10;
    
    @Transactional
    public boolean deductStock(Long productId, int quantity) {
        // 随机选择一个桶
        int bucketNo = ThreadLocalRandom.current().nextInt(BUCKET_COUNT);
        
        // 尝试扣减该桶库存
        int affected = stockBucketMapper.deductStock(productId, bucketNo, quantity);
        
        if (affected > 0) {
            return true;
        }
        
        // 该桶库存不足,尝试其他桶
        for (int i = 0; i < BUCKET_COUNT; i++) {
            if (i == bucketNo) continue;
            affected = stockBucketMapper.deductStock(productId, i, quantity);
            if (affected > 0) {
                return true;
            }
        }
        
        return false;  // 所有桶都没有足够库存
    }
}

合并更新(批量写入)

将多个更新请求合并为一次批量操作:

mermaid
graph TB
    R1([请求1: +5分]) --> Queue([合并队列])
    R2([请求2: +10分]) --> Queue
    R3([请求3: +3分]) --> Queue
    
    Queue --> Timer([定时/定量触发])
    Timer --> Merge([合并计算: +18分])
    Merge --> Update([一次UPDATE])
    
    classDef requestStyle fill:#4A90E2,stroke:none,color:#fff
    classDef mergeStyle fill:#50C878,stroke:none,color:#fff
    
    class R1,R2,R3 requestStyle
    class Queue,Timer,Merge,Update mergeStyle

实现示例

java
@Component
public class PointsMergeUpdater {
    
    // 用户积分变更缓冲区
    private final ConcurrentHashMap<Long, AtomicInteger> pointsBuffer = 
        new ConcurrentHashMap<>();
    
    public void addPoints(Long userId, int points) {
        pointsBuffer.computeIfAbsent(userId, k -> new AtomicInteger())
                    .addAndGet(points);
    }
    
    @Scheduled(fixedRate = 1000)  // 每秒批量写入
    public void flushPoints() {
        if (pointsBuffer.isEmpty()) {
            return;
        }
        
        // 交换缓冲区,避免并发问题
        Map<Long, AtomicInteger> toFlush = new HashMap<>(pointsBuffer);
        pointsBuffer.clear();
        
        // 批量更新数据库
        List<PointsUpdate> updates = toFlush.entrySet().stream()
            .map(e -> new PointsUpdate(e.getKey(), e.getValue().get()))
            .collect(Collectors.toList());
        
        pointsMapper.batchUpdatePoints(updates);
    }
}

异步化处理

将热点更新异步化,通过消息队列削峰填谷:

mermaid
graph LR
    Request([业务请求]) --> Valid([参数校验])
    Valid --> MQ([发送到消息队列])
    MQ --> Response([立即返回成功])
    
    MQ --> Consumer([消费者])
    Consumer --> Limit([限流控制])
    Limit --> Update([更新数据库])
    
    classDef syncStyle fill:#4A90E2,stroke:none,color:#fff
    classDef asyncStyle fill:#50C878,stroke:none,color:#fff
    
    class Request,Valid,MQ,Response syncStyle
    class Consumer,Limit,Update asyncStyle

注意事项

  • 需要接受最终一致性
  • 做好幂等性处理
  • 消息堆积时需要有降级方案

Redis 预扣减

使用 Redis 承接热点更新压力,异步同步到数据库:

java
@Service
public class RedisStockService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    // Redis 预扣减库存
    public boolean tryDeductStock(Long productId, int quantity) {
        String key = "stock:" + productId;
        
        // 使用 Lua 脚本保证原子性
        String script = 
            "local stock = redis.call('get', KEYS[1]) " +
            "if stock and tonumber(stock) >= tonumber(ARGV[1]) then " +
            "    redis.call('decrby', KEYS[1], ARGV[1]) " +
            "    return 1 " +
            "end " +
            "return 0";
        
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(script, Long.class),
            Collections.singletonList(key),
            String.valueOf(quantity)
        );
        
        return result != null && result == 1;
    }
}

综合解决方案

对于超高并发的热点更新场景,通常需要组合多种策略:

mermaid
graph TB
    Request([更新请求]) --> Cache([1. Redis预扣减])
    Cache -->|成功| MQ([2. 发送消息])
    Cache -->|失败| Fail([返回失败])
    
    MQ --> Consumer([3. 消费者消费])
    Consumer --> Merge([4. 合并更新])
    Merge --> Bucket([5. 分桶写入DB])
    
    Bucket --> Sync([6. 定期对账])
    Sync --> Fix([7. 数据修复])
    
    classDef hotStyle fill:#E85D75,stroke:none,color:#fff
    classDef cacheStyle fill:#50C878,stroke:none,color:#fff
    classDef dbStyle fill:#4A90E2,stroke:none,color:#fff
    
    class Request hotStyle
    class Cache,MQ cacheStyle
    class Consumer,Merge,Bucket,Sync,Fix dbStyle

总结

热点数据更新是高并发系统必须面对的挑战,主要影响包括:

  1. 锁竞争:并发更新导致大量锁等待
  2. 连接耗尽:等待的事务占用连接资源
  3. CPU过载:锁自旋和死锁检测消耗CPU
  4. 主从延迟:高频更新加剧复制延迟

解决方案包括:

策略适用场景复杂度
库存分桶库存、计数类场景
合并更新积分、统计类场景
异步化可接受最终一致性
Redis预扣减高并发扣减场景

缓存策略要点:

  1. 预热:系统启动时加载热点数据
  2. 检测:实时监控识别新热点
  3. 淘汰:选择合适的LRU/LFU策略
  4. 更新:数据变更时及时失效缓存

通过合理的架构设计和策略组合,可以有效应对热点数据带来的性能挑战。

更新: 2025-12-04 17:38:18
原文: https://www.yuque.com/u22210564/zoxfmt/doc-01-mysql-16-mysql-03

Java 后端面试知识库