热点数据更新与缓存策略
热点数据更新的挑战
在高并发业务场景中,热点数据更新是一个常见且棘手的问题。当大量请求同时修改同一行或少数几行数据时,会引发严重的性能问题甚至系统崩溃。
典型业务场景
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 万用户同时抢购:
-- 扣减库存操作
UPDATE flash_sale_items
SET stock_count = stock_count - 1
WHERE item_id = 2024001 AND stock_count > 0;这条简单的 SQL 会成为系统瓶颈,原因在于所有请求都在竞争同一行数据的锁。
热点更新带来的问题
锁竞争与阻塞
MySQL 的 UPDATE 语句需要获取行级排他锁(X锁),当多个事务同时更新同一行时,只有一个能执行,其他必须等待。
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后果:系统吞吐量急剧下降,响应时间大幅增加。
数据库连接耗尽
等待锁的事务会持续占用数据库连接,而数据库连接是有限资源:
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 failStyleCPU 资源过载
大量锁等待会导致严重的 CPU 消耗:
- 锁自旋开销:等待锁的线程会不断尝试获取锁,消耗 CPU
- 死锁检测:MySQL 持续进行死锁检测,消耗 CPU
- 上下文切换:频繁的线程调度增加系统开销
-- 查看当前锁等待情况
SELECT * FROM information_schema.INNODB_LOCK_WAITS;
-- 查看锁持有情况
SELECT * FROM performance_schema.data_locks;死锁风险
高并发场景下,复杂的业务逻辑可能导致死锁:
-- 事务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交替执行,可能形成死锁索引维护开销
频繁更新会导致相关索引的频繁维护,增加系统负担:
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 万条)时,如何确保缓存中都是热点数据,是缓存设计的核心问题。
缓存预热
系统启动或缓存重建时,需要将热点数据提前加载到缓存:
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实现示例:
@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());
}
}实时热点检测
业务运行过程中,热点数据会动态变化,需要实时检测和更新:
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方案一:本地计数 + 定期上报
@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 框架专门用于实时热点检测:
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(最近最少使用)
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 suitStyleLFU(最不经常使用)
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配置示例:
# 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 | 内存满时拒绝写入 | 数据不允许丢失 |
主动过期与被动更新
结合主动过期和被动更新,确保缓存数据的时效性:
@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);
}
}热点更新的优化方案
库存分桶
将单个热点记录拆分为多个分桶,分散锁竞争:
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实现示例:
-- 创建库存分桶表
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;@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; // 所有桶都没有足够库存
}
}合并更新(批量写入)
将多个更新请求合并为一次批量操作:
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实现示例:
@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);
}
}异步化处理
将热点更新异步化,通过消息队列削峰填谷:
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 承接热点更新压力,异步同步到数据库:
@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;
}
}综合解决方案
对于超高并发的热点更新场景,通常需要组合多种策略:
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总结
热点数据更新是高并发系统必须面对的挑战,主要影响包括:
- 锁竞争:并发更新导致大量锁等待
- 连接耗尽:等待的事务占用连接资源
- CPU过载:锁自旋和死锁检测消耗CPU
- 主从延迟:高频更新加剧复制延迟
解决方案包括:
| 策略 | 适用场景 | 复杂度 |
|---|---|---|
| 库存分桶 | 库存、计数类场景 | 中 |
| 合并更新 | 积分、统计类场景 | 中 |
| 异步化 | 可接受最终一致性 | 中 |
| Redis预扣减 | 高并发扣减场景 | 高 |
缓存策略要点:
- 预热:系统启动时加载热点数据
- 检测:实时监控识别新热点
- 淘汰:选择合适的LRU/LFU策略
- 更新:数据变更时及时失效缓存
通过合理的架构设计和策略组合,可以有效应对热点数据带来的性能挑战。
更新: 2025-12-04 17:38:18
原文: https://www.yuque.com/u22210564/zoxfmt/doc-01-mysql-16-mysql-03