热数据识别最先要解决的是统计口径问题。访问次数多不等于热,一次大查询可能扫描几百万行,却不一定代表这些行本身被频繁访问。真正需要记录的是对某个数据键的读、写、范围扫描分别发生多少次,以及每次访问之间的时间分布。如果只用一个累加计数器,当业务进入低峰期后,历史计数会一直占据权重,导致冷数据迟迟无法降级。因此需要引入带时间窗口的统计模型,让旧的访问记录随时间衰减。

一、热数据统计表的设计与写入策略
要识别热数据,首先得明确统计对象。数据库里的热度通常不会精确到每一行,而是落在主键、二级索引键或者分区键上。以订单表为例,可以统计某个买家近期的查询次数,也可以统计某个商品详情被读取的次数。统计口径越细,识别越精准,但统计表本身的写入和存储开销也越大。常见的做法是以业务主键作为 data_key,同时冗余 db_name 和 table_name,便于跨表汇总。
统计表不能只存一个累计访问次数。累计值无法反映时间分布,也无法区分最近五分钟和上周的访问。推荐在表中增加窗口起始时间和访问类型,按固定时间窗口聚合。这样既能控制行数增长,又能用SQL直接计算滑动评分。下面的建表语句给出了一个可参考的结构。
CREATE TABLE hot_data_stats (
stat_id BIGINT PRIMARY KEY AUTO_INCREMENT,
db_name VARCHAR(64) NOT NULL,
table_name VARCHAR(64) NOT NULL,
data_key VARCHAR(255) NOT NULL,
access_type ENUM('read','write','scan') NOT NULL,
window_start DATETIME NOT NULL,
access_count INT NOT NULL DEFAULT 0,
last_access_time DATETIME NOT NULL,
weighted_score DECIMAL(12,4) NOT NULL DEFAULT 0,
UNIQUE KEY uk_window (db_name, table_name, data_key, access_type, window_start)
);
写入统计表时要避免在业务事务里同步执行 INSERT ... ON DUPLICATE KEY UPDATE,因为这会增加事务延迟。更好的方式是先把访问事件写入本地消息队列或内存缓冲区,再由后台任务每5到10秒批量合并。合并时按窗口和类型做 GROUP BY,将次数累加后写入统计表。这样对统计表的压力会从业务请求量级降低到窗口数量级。
二、滑动窗口与半衰期衰减评分模型
固定窗口统计虽然简单,但存在明显的边界问题。比如窗口定义为一分钟,某条数据在12:00:59被访问了100次,在12:01:01又被访问了100次,两个相邻窗口各算100次,评分没有问题;但如果窗口切换瞬间出现请求集中,可能会被拆成两个低分窗口,或者冷启动时窗口内数据不完整。滑动窗口可以缓解这种边界效应,它不按自然时间切分,而是以当前时间为基准往前看一段时间。
实现滑动窗口不一定需要存储每次访问时间戳,但为了计算带有时间衰减的评分,保留时间戳列表会更灵活。下面这段Python代码演示了基于半衰期衰减的打分逻辑。每一条访问记录根据距当前时间的差值计算权重,距离越远权重越小。半衰期设置为3600秒,意味着一小时前的访问权重只有当前的一半。
import time
import math
class HotDataScorer:
def __init__(self, half_life=3600):
self.half_life = half_life
self.access_records = {} # key -> list of timestamps
def record_access(self, key, timestamp=None):
if timestamp is None:
timestamp = time.time()
self.access_records.setdefault(key, []).append(timestamp)
def score(self, key, now=None):
if now is None:
now = time.time()
total = 0.0
for ts in self.access_records.get(key, []):
delta = now - ts
if delta < 0:
continue
weight = math.exp(-math.log(2) * delta / self.half_life)
total += weight
# 清理过期记录,避免内存膨胀
self.access_records[key] = [
ts for ts in self.access_records.get(key, [])
if now - ts < self.half_life * 6
]
return total
使用半衰期衰减的好处是无需维护固定的窗口边界,评分会平滑变化。例如某数据在早上有大量访问,中午业务低谷,下午又出现访问。固定窗口可能需要等到早上窗口过期才能降级,而衰减模型会随着时间自然降低分数。实际落地时,如果访问量极大,可以只保留最近6个半衰期内的记录,超出部分直接删除,避免内存或Redis存储膨胀。也可以将时间戳分桶为10秒粒度,用Redis的Sorted Set以时间戳为score进行 ZADD,再用 ZREMRANGEBYSCORE 清理过期数据。
三、不侵入业务代码的审计日志统计方案
如果业务系统已经上线,不太方便在每个查询入口埋点记录访问统计,可以通过数据库审计日志或内置统计视图来被动采集。PostgreSQL的 pg_stat_statements 扩展会记录每类SQL的调用次数、总执行时间、扫描行数等信息。虽然它不能直接识别具体的数据键,但可以快速找出哪些查询模板最频繁、哪些表被扫描最多,为进一步设计行级热数据统计指明方向。
下面的SQL可以从 pg_stat_statements 中提取访问频率和平均执行时间,帮助定位热点查询。calls_per_second字段用累计调用次数除以统计数据重置以来的秒数,得到一个长期平均频率。这个值适合发现稳定的热查询,但对瞬时热点不敏感。
SELECT
queryid,
calls,
total_exec_time / calls AS avg_exec_time,
rows / calls AS avg_rows,
calls / (EXTRACT(EPOCH FROM (now() - stats_reset))) AS calls_per_second
FROM pg_stat_statements
ORDER BY calls_per_second DESC
LIMIT 100;
对于MySQL,可以开启慢查询日志配合 pt-query-digest 做离线分析,但要拿到行级或键级热度,还是需要结合业务埋点。审计日志方案的优势是不修改业务代码,缺点是统计粒度受限于SQL文本,而且开启general log会有明显的性能损耗。通常建议先通过 pg_stat_statements 这类轻量级视图圈定热点表,再针对这些表做行级统计,避免全局采集带来的额外开销。
四、评分结果的落地与冷热转换策略
识别出热数据只是第一步,关键是把结果用起来。最常见的用途是缓存预热。可以在缓存层启动时或定时任务中,从统计表里取评分最高的前N个数据键,批量加载到Redis或本地缓存中。也可以把热数据清单写入一张单独的热度元数据表,应用代码在查缓存未命中时,先判断该数据是否处于热区,再决定是否回源数据库并写回缓存。
热度和冷热状态不是一成不变的。如果每次评分变化都立刻切换缓存策略,可能在阈值附近造成频繁抖动。建议加入滞后区间,例如评分超过100标记为hot,低于80才降为warm,避免在高频访问数据周围反复切换。下面的SQL示例将实时评分聚合后更新到热数据元数据表,并按评分区间设置heat_level。
UPDATE hot_data_meta h
JOIN (
SELECT data_key,
MAX(weighted_score) AS score
FROM hot_data_stats
WHERE window_start >= NOW() - INTERVAL 10 MINUTE
GROUP BY data_key
) t ON h.data_key = t.data_key
SET h.current_score = t.score,
h.heat_level = CASE
WHEN t.score > 100 THEN 'hot'
WHEN t.score > 10 THEN 'warm'
ELSE 'cold'
END;
除了缓存,热数据识别结果还可以用于分区裁剪和索引优化。比如时间分区表可以只对热分区做更激进的压缩或物化视图构建;对高频访问的二级索引,可以考虑覆盖索引减少回表。冷热转换策略应该和业务容忍度绑定,而不是单纯依赖技术指标。对于金融交易类系统,冷热阈值可以保守一些;对于内容推荐类系统,可以更激进地依赖统计评分自动调整。