Showing Posts From
SQL
数据库设计:E-R 图、3NF 范式与 SQL 约束建表
E-R 图基础 以供应商-零件为例:实体1:供应商(Sno, Sname, City) 实体2:零件(Pno, Pname, Color, Weight) 联系:供应(M:N,含属性 Price)一个供应商可供应多种零件,一种零件可由多个供应商供应,联系属于 多对多(M:N)。 Supplier(供应商) Part(零件) ┌─────────────────┐ ┌─────────────────┐ │ Sno (PK) │ M N │ Pno (PK) │ │ Sname ├────────┤ Pname │ │ City │ 供应 │ Color │ └─────────────────┘ Price │ Weight │ └─────────────────┘3NF 关系模式设计 M:N 联系必须单独拆出一张联系表: Supplier(供应商表) Supplier(Sno PK, Sname, City)Part(零件表) Part(Pno PK, Pname, Color, Weight)Supply(供应联系表) Supply(Sno PK FK→Supplier, Pno PK FK→Part, Price)联合主键 (Sno, Pno) 保证每个供应商对每种零件只有一条记录。三个表均满足 3NF(无传递依赖)。 SQL 建表(含约束) CREATE TABLE Supplier ( Sno CHAR(10) PRIMARY KEY, Sname VARCHAR(50) NOT NULL, City VARCHAR(50) );CREATE TABLE Part ( Pno CHAR(10) PRIMARY KEY, Pname VARCHAR(50) NOT NULL, Color VARCHAR(20), Weight DECIMAL(8,2) );CREATE TABLE Supply ( Sno CHAR(10), Pno CHAR(10), Price DECIMAL(10,2), PRIMARY KEY (Sno, Pno), FOREIGN KEY (Sno) REFERENCES Supplier(Sno), FOREIGN KEY (Pno) REFERENCES Part(Pno), CHECK (Price > 0) );PRIMARY KEY:联合主键,值组合唯一且不为 NULL FOREIGN KEY:引用父表主键,保证参照完整性 CHECK:价格必须大于 0,在插入/更新时自动校验JOIN 查询 查询"供应红色零件的供应商名称": SELECT DISTINCT S.Sname FROM Supplier S JOIN Supply SP ON S.Sno = SP.Sno JOIN Part P ON SP.Pno = P.Pno WHERE P.Color = '红色';等价子查询写法: SELECT DISTINCT Sname FROM Supplier WHERE Sno IN ( SELECT Sno FROM Supply WHERE Pno IN ( SELECT Pno FROM Part WHERE Color = '红色' ) );JOIN 写法更清晰,子查询写法在没有 JOIN 语法支持时可用。 常见范式对比范式 要求 典型违反1NF 每个字段原子值,不含多值 用逗号分隔多个标签存一列2NF 非主属性完全依赖主键(无部分依赖) 联系表里存冗余属性3NF 非主属性不传递依赖主键 城市→邮编→省份放同一表实践中通常达到 3NF 即可,OLAP 场景允许适度冗余换查询性能。
SQL 建表约束:PRIMARY KEY / FOREIGN KEY / CHECK 写法
建表时声明约束是数据库设计的基础,常见的有主键约束、外键约束和 CHECK 约束。 场景:供应商-零件多对多关系 经典的供应商(Supplier)-零件(Part)-供应(Supply)三表结构:供应商可以供应多种零件(M:N 关系) 每对供应商+零件组合有一个供应价格关系模式(3NF) Supplier(Sno PK, Sname, City) Part(Pno PK, Pname, Color, Weight) Supply(Sno PK FK, Pno PK FK, Price)Supply 的主键是复合主键 (Sno, Pno),同时 Sno 和 Pno 各自是外键。 SQL 建表 CREATE TABLE Supplier ( Sno CHAR(10) NOT NULL, Sname VARCHAR(50) NOT NULL, City VARCHAR(50), PRIMARY KEY (Sno) );CREATE TABLE Part ( Pno CHAR(10) NOT NULL, Pname VARCHAR(50) NOT NULL, Color VARCHAR(20), Weight DECIMAL(8,2), PRIMARY KEY (Pno) );CREATE TABLE Supply ( Sno CHAR(10) NOT NULL, Pno CHAR(10) NOT NULL, Price DECIMAL(10,2) NOT NULL, PRIMARY KEY (Sno, Pno), FOREIGN KEY (Sno) REFERENCES Supplier(Sno) ON DELETE CASCADE ON UPDATE CASCADE, FOREIGN KEY (Pno) REFERENCES Part(Pno) ON DELETE CASCADE ON UPDATE CASCADE, CHECK (Price > 0) );各约束说明 PRIMARY KEY:不能为空,不能重复。复合主键用 PRIMARY KEY (col1, col2) 表格级声明,不能用列级。 FOREIGN KEY:引用另一张表的主键或唯一键,保证参照完整性。ON DELETE CASCADE 表示父表删除时子表自动级联删除。 CHECK:定义列值的范围或条件,CHECK (Price > 0) 要求价格必须大于零。 NOT NULL:禁止 NULL 值,通常和主键一起用。 常用查询 查询"供应红色零件的供应商名称": SELECT DISTINCT s.Sname FROM Supplier s JOIN Supply sp ON s.Sno = sp.Sno JOIN Part p ON sp.Pno = p.Pno WHERE p.Color = '红色';或用子查询: SELECT Sname FROM Supplier WHERE Sno IN ( SELECT sp.Sno FROM Supply sp JOIN Part p ON sp.Pno = p.Pno WHERE p.Color = '红色' );约束的列级 vs 表级写法 列级(约束跟在列后面,只作用于该列): CREATE TABLE t ( id INT PRIMARY KEY, val INT CHECK (val > 0), fk INT REFERENCES other(id) );表级(单独一行,支持多列主键和复合外键): CREATE TABLE t ( id1 INT, id2 INT, val INT, PRIMARY KEY (id1, id2), -- 复合主键必须用表级 FOREIGN KEY (id1) REFERENCES a(id), CHECK (val > 0) );复合主键(多列联合唯一)只能用表级声明;单列约束两种写法均可。
供应关系 E-R 建模 + 3NF + SQL:一道数据库题的标准答案
数据库课程有一道经典题目:供应商-零件-供应关系建模。是标准的 M:N 联系带属性,按 E-R → 3NF → SQL 完整走一遍。 题目 某企业采购管理系统:供应商 (Supplier):编号 Sno、名称 Sname、城市 City 零件 (Part):编号 Pno、名称 Pname、颜色 Color、重量 Weight 供应商可以供应多种零件,每种零件可由多个供应商供应 每个供应商对每种零件有一个供应价格 Price要求:画 E-R 图 设计满足 3NF 的关系模式 SQL 建"供应"表,带主键、外键、价格 > 0 约束 写"供应红色零件的供应商名称"查询第一步:E-R 图 两个实体、一个联系: ┌──────────────────┐ │ Supplier │ │──────────────────│ │ Sno (PK) │ │ Sname │ │ City │ └────────┬─────────┘ │ M │ ┌──────┴──────┐ │ Supply │◄── Price(联系属性) └──────┬──────┘ │ N │ ┌────────┴─────────┐ │ Part │ │──────────────────│ │ Pno (PK) │ │ Pname │ │ Color │ │ Weight │ └──────────────────┘关系类型:Supplier M——(Supply)——N Part,Supply 联系有属性 Price。 第二步:3NF 关系模式 M:N 联系必须独立成一张表,不能合并到任何一端。 Supplier 表: Supplier ( Sno ← 主键 (PK) Sname City )Part 表: Part ( Pno ← 主键 (PK) Pname Color Weight )Supply 表(M:N 联系变实体): Supply ( Sno ← 主键的一部分, 外键 → Supplier(Sno) Pno ← 主键的一部分, 外键 → Part(Pno) Price )Supply 的主键是 (Sno, Pno) 联合主键——同一个供应商同一个零件只能有一条供应记录,不同的价格意味着不同的关系需要建模成"报价历史"。 为什么满足 3NF:每个表所有非主属性都完全依赖于主键(无部分依赖 → 2NF) 每个表非主属性只依赖主键,不依赖其它非主属性(无传递依赖 → 3NF)第三步:SQL 建"供应"表 CREATE TABLE Supply ( Sno CHAR(10) NOT NULL, Pno CHAR(10) NOT NULL, Price DECIMAL(10,2) NOT NULL, PRIMARY KEY (Sno, Pno), FOREIGN KEY (Sno) REFERENCES Supplier(Sno), FOREIGN KEY (Pno) REFERENCES Part(Pno), CHECK (Price > 0) );要点:联合主键:PRIMARY KEY (Sno, Pno) 一起才唯一 外键:Sno 必须在 Supplier 里存在、Pno 必须在 Part 里存在 CHECK 约束:Price > 0 由数据库强制,即使应用忘了校验也保证不了负价 DECIMAL(10,2) 而不是 FLOAT——金额永远用定点数,浮点会有精度问题第四步:查询"供应红色零件的供应商名称" 需要连 Supplier / Supply / Part 三张表: SELECT DISTINCT S.Sname FROM Supplier S JOIN Supply SP ON S.Sno = SP.Sno JOIN Part P ON SP.Pno = P.Pno WHERE P.Color = '红色';关键点:DISTINCT — 一个供应商可能供应多种红色零件,去重 JOIN — 内连接,只要有匹配就出结果 WHERE P.Color = '红色' — 过滤条件不用 JOIN 也能写(子查询版本): SELECT DISTINCT Sname FROM Supplier WHERE Sno IN ( SELECT Sno FROM Supply WHERE Pno IN ( SELECT Pno FROM Part WHERE Color = '红色' ) );两种写法结果一样。现代 SQL 优化器基本能把子查询版本优化成 JOIN,但读起来 JOIN 更直观。 一些延伸 要报价历史怎么办? 加一个 EffectiveDate: CREATE TABLE SupplyHistory ( Sno CHAR(10), Pno CHAR(10), EffectiveDate DATE, Price DECIMAL(10, 2), PRIMARY KEY (Sno, Pno, EffectiveDate), FOREIGN KEY (Sno) REFERENCES Supplier(Sno), FOREIGN KEY (Pno) REFERENCES Part(Pno), CHECK (Price > 0) );查最新价格: SELECT Sno, Pno, Price FROM SupplyHistory sh WHERE EffectiveDate = ( SELECT MAX(EffectiveDate) FROM SupplyHistory WHERE Sno = sh.Sno AND Pno = sh.Pno );现代 SQL 也可以用窗口函数: SELECT Sno, Pno, Price FROM ( SELECT Sno, Pno, Price, ROW_NUMBER() OVER (PARTITION BY Sno, Pno ORDER BY EffectiveDate DESC) rn FROM SupplyHistory ) t WHERE rn = 1;一句话总结 M:N 联系带属性必须独立成表,联合主键 + 两个外键。3NF 的关系模式设计基本是"每个 M:N 拆一张表 + 每张表消除传递依赖"。查询用 JOIN 比多层子查询好读。
API 分页设计:Offset vs Cursor(Keyset)怎么选
后端接口拉列表几乎都要分页。最常见的是 current + pageSize: { "current": 1, "pageSize": 20 }简单直观,但数据量大 + 频繁翻页 + 数据在变化的场景下有致命问题。生产上大厂 API(GitHub、Slack、Stripe)都在推 Cursor 分页。 Offset 分页的两个坑 1. 深度分页越来越慢 SQL 长这样: SELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 1000000;数据库要扫过前 100 万行才能扔掉、再取 20 行。1000 万条数据翻到第 500 页,实测能到秒级。 原因:MySQL 不知道"跳过 100 万行"能不能走索引——即使能,也要真的扫过去。 2. 数据漂移 分页过程中数据在增删——用户看到重复或者漏掉。 例子:翻第 1 页看到 20 条 → 有人删了第 5 条 → 翻第 2 页时,原本第 21 条变成了第 20 条,被跳过。 对于列表实时更新的场景(订单、动态、评论流),Offset 分页几乎必然会有这种漏项。 Cursor 分页(Keyset) 思路:用"上一页最后一条的位置"作为下一页起点,不用 offset。 -- 第一页 SELECT * FROM products ORDER BY created_at DESC, id DESC LIMIT 20;-- 第二页(用上一页最后一条的 created_at + id 作为起点) SELECT * FROM products WHERE (created_at, id) < ('2026-06-04 13:00:00', 998765) ORDER BY created_at DESC, id DESC LIMIT 20;关键点:有索引的排序字段(created_at、id) 元组比较处理并列时间 走索引 range scan,速度不随深度衰减接口设计 Offset 版本: // 请求 { "current": 3, "pageSize": 20 }// 响应 { "list": [...], "total": 12345, "current": 3, "pageSize": 20 }Cursor 版本: // 请求 { "cursor": "eyJ0IjoxNzE3NDg...", "pageSize": 20 }// 响应 { "list": [...], "nextCursor": "eyJ0IjoxNzE3NDg2...", "hasMore": true }cursor 通常是 Base64 编码的 {time, id} 之类的组合,客户端不需要理解内容,直接透传。 Cursor 具体怎么生成 import base64, jsondef encode_cursor(last_row): payload = {"t": last_row.created_at.isoformat(), "id": last_row.id} return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()def decode_cursor(cursor): return json.loads(base64.urlsafe_b64decode(cursor))# API 侧 if cursor: c = decode_cursor(cursor) rows = db.execute(""" SELECT * FROM products WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT ? """, [c["t"], c["id"], page_size]) else: rows = db.execute(""" SELECT * FROM products ORDER BY created_at DESC, id DESC LIMIT ? """, [page_size])next_cursor = encode_cursor(rows[-1]) if len(rows) == page_size else None两者对比维度 Offset Cursor简单度 ✅ 直观 需要理解元组比较深度分页性能 ❌ 越来越慢 ✅ 常数时间支持随机跳页 ✅ 可以直接第 N 页 ❌ 只能顺序翻数据变化时稳定 ❌ 漏项 / 重复 ✅ 稳定需要 total 计数 ✅ 好算 ❌ 通常不返回支持排序 任意 排序字段必须唯一 + 有索引什么时候用哪个 用 Offset:后台管理系统的表格(数据不常变、用户会跳页) 数据总量不大(几千到几万条) 客户端要显示"第 5 / 100 页"这种明确导航用 Cursor:时间流列表(订单、聊天、动态、日志) 数据量大(十万+) 客户端做"上拉加载更多"(不需要跳页) 数据实时增删两个都做:GitHub API 大多支持 ?page=N(Offset)和 ?before=cursor(Cursor),场景多的时候都提供。 顺带:total 慢查询 Offset 分页返回 total 时,SELECT COUNT(*) 在千万级表上也会慢。优化:缓存 total(Redis,几分钟一次异步刷新) 估算 total(PostgreSQL pg_class.reltuples、MySQL INFORMATION_SCHEMA.TABLES) 只算前几页的精确 total,之后返回 total: null 或 hasMore: true 就够一句话总结 大数据 + 时间流用 Cursor(速度不衰减、结果稳定)、后台管理小表格用 Offset(简单、支持跳页)。别把 Offset 用在千万级实时数据的 API 上。
数据库存储版本范围:SemVer 比较与漏洞版本匹配方案
为什么不能用字符串比较版本号 SELECT * FROM assets WHERE version < '2.15.0'字符串比较结果: '2.10.0' < '2.9.0' -- 错误!字典序 '1' < '9'SemVer(语义化版本)中 2.10.0 > 2.9.0,必须按数字分段比较。 方案一:程序端比较(推荐) 数据库只存原始版本字符串,程序端做 SemVer 比较: Java(semver4j): <dependency> <groupId>org.semver4j</groupId> <artifactId>semver4j</artifactId> <version>5.3.0</version> </dependency>import org.semver4j.Semver;public class VersionMatcher { public static boolean isVulnerable(String version, String range) { try { Semver v = Semver.parse(version); return v.satisfies(range); } catch (Exception e) { return false; } } }// 使用 isVulnerable("2.14.1", ">=2.0.0 <2.15.0") // true isVulnerable("2.15.0", ">=2.0.0 <2.15.0") // falsePython(packaging 库): from packaging.version import Version from packaging.specifiers import SpecifierSetdef is_vulnerable(version: str, spec: str) -> bool: try: return Version(version) in SpecifierSet(spec) except Exception: return Falseis_vulnerable("2.14.1", ">=2.0.0,<2.15.0") # True方案二:拆成上下界存数据库 将范围条件拆为四个字段,程序比较时不需要解析字符串: CREATE TABLE vuln_rule ( id BIGINT PRIMARY KEY, component VARCHAR(100), min_version VARCHAR(50), max_version VARCHAR(50), min_include TINYINT DEFAULT 1, -- 1=包含 >=,0=不包含 > max_include TINYINT DEFAULT 0 -- 1=包含 <=,0=不包含 < );表示 >= 2.0.0 < 2.15.0:min_version max_version min_include max_include2.0.0 2.15.0 1 0程序判断: boolean matches(String version, VulnRule rule) { Semver v = Semver.parse(version); Semver min = Semver.parse(rule.getMinVersion()); Semver max = Semver.parse(rule.getMaxVersion()); boolean lowerOk = rule.isMinInclude() ? v.isGreaterThanOrEqualTo(min) : v.isGreaterThan(min); boolean upperOk = rule.isMaxInclude() ? v.isLowerThanOrEqualTo(max) : v.isLowerThan(max); return lowerOk && upperOk; }方案三:版本号数字化后数据库直接查 将 2.15.0 转换为定宽整数 002015000,再存数据库: public static long versionToLong(String version) { String[] parts = version.split("\\."); long major = parts.length > 0 ? Long.parseLong(parts[0]) : 0; long minor = parts.length > 1 ? Long.parseLong(parts[1]) : 0; long patch = parts.length > 2 ? Long.parseLong(parts[2]) : 0; return major * 1_000_000L + minor * 1_000L + patch; }// "2.15.0" -> 2015000 // "2.9.0" -> 2009000 // "2.10.0" -> 2010000 (正确:2010000 > 2009000)数据库存 version_num BIGINT,直接用 SQL 范围查询: SELECT * FROM vuln_rule WHERE version_num >= 2000000 AND version_num < 2015000;适合版本号分段均不超过 999 的情况。 方案对比方案 优点 缺点程序端比较 支持复杂范围(pre-release) 无法在 SQL 直接过滤上下界字段 数据结构清晰,易扩展 多字段 JOIN 稍复杂数字化版本 SQL 直接查询,性能最好 分段超过 999 会溢出推荐:中小规模用方案一(程序端比较),大规模漏洞库(百万级 asset)用方案三配合索引加速。 漏洞管理平台实践 批量扫描 asset 时,避免 N+1 查询: // 一次查出所有漏洞规则 List<VulnRule> rules = vulnRuleMapper.selectAll();// 按 component 分组 Map<String, List<VulnRule>> ruleMap = rules.stream() .collect(Collectors.groupingBy(VulnRule::getComponent));// 批量匹配 for (Asset asset : assets) { List<VulnRule> candidates = ruleMap.getOrDefault(asset.getComponent(), List.of()); for (VulnRule rule : candidates) { if (VersionMatcher.isVulnerable(asset.getVersion(), rule.getRange())) { record(asset, rule); } } }
版本号存数据库 + 范围匹配:SemVer 的正确姿势
漏洞库 / 依赖资产表里经常要存"受影响版本范围",然后拿具体版本号去匹配——比如 Log4j >= 2.0-beta9 < 2.15.0、Spring 4Shell >= 5.3.0 < 5.3.18 || >= 5.2.0 < 5.2.20。直接 SQL 字符串比较必翻车: SELECT * WHERE version >= '2.9.0' AND version < '2.15.0'; -- 会把 2.10.0 判成 < 2.9.0(字符串按字典序)字典序里 '2.10' < '2.9'(因为 '1' < '9')。要按语义化版本比,得单独设计。 方案 1:存字符串,程序判断(灵活但慢) 数据库里就存原始范围: CREATE TABLE vuln_rule ( id BIGINT PRIMARY KEY, product VARCHAR(64), affected_range VARCHAR(255), -- '>= 2.0 < 2.15.0' fixed_version VARCHAR(50) );匹配时程序里跑 SemVer 库: Java: import com.vdurmont.semver4j.Requirement; import com.vdurmont.semver4j.Semver;Requirement req = Requirement.buildNPM(">=2.0 <2.15.0"); Semver ver = new Semver("2.14.1", Semver.SemverType.NPM); boolean vulnerable = req.isSatisfiedBy(ver);Node.js: import semver from "semver"; semver.satisfies("2.14.1", ">=2.0 <2.15.0"); // truePython: from packaging.specifiers import SpecifierSet from packaging.version import VersionVersion("2.14.1") in SpecifierSet(">=2.0,<2.15.0") # True优点:范围表达最灵活,各种 >=、<、!=、~、^ 都支持。 缺点:数据库查不了范围——每条规则拿出来在程序里跑一遍,几万条规则会慢。 方案 2:拆上下界(企业最常用) 把 >=2.0 <2.15.0 拆成四列: CREATE TABLE vuln_rule ( id BIGINT PRIMARY KEY, product VARCHAR(64), min_version VARCHAR(50), max_version VARCHAR(50), min_include TINYINT DEFAULT 1, -- 1: >= 0: > max_include TINYINT DEFAULT 0, -- 1: <= 0: < INDEX idx_product (product) );数据:product min_version max_version min_include max_includelog4j 2.0 2.15.0 1 0spring 5.3.0 5.3.18 1 0spring 5.2.0 5.2.20 1 0多个范围(OR 关系)拆成多行。 匹配:数据库先粗筛(按 product),程序做精确 SemVer 比: List<VulnRule> rules = mapper.findByProduct("spring"); for (VulnRule r : rules) { if (semver.gteq(target, r.minVersion) && semver.lt (target, r.maxVersion)) { // 命中 } }优点:结构清晰、粗筛快、精确匹配用程序保证正确。大多数漏洞平台走这条路。 方案 3:版本数字化后 SQL 直接查 把版本号编码成一个数字,SQL 就能直接范围查: 2.15.0 → 002 015 000 → 2015000 2.14.10 → 002 014 010 → 2014010 2.10.0 → 002 010 000 → 2010000每段用固定位数(3 位)保留: long encode(String v) { String[] p = v.split("\\."); long n = 0; for (int i = 0; i < 3; i++) { int part = i < p.length ? Integer.parseInt(p[i].split("[^0-9]")[0]) : 0; n = n * 1000 + part; } return n; }数据库: CREATE TABLE vuln_rule ( id BIGINT PRIMARY KEY, product VARCHAR(64), min_version_int BIGINT, max_version_int BIGINT, min_include TINYINT, max_include TINYINT, INDEX idx_product_range (product, min_version_int, max_version_int) );查询: SELECT * FROM vuln_rule WHERE product = 'log4j' AND 2014001 >= min_version_int -- target 版本 encode 后 AND 2014001 < max_version_int;优点:纯 SQL 就能查,走索引,几百万数据也很快。 缺点:每段有位数上限(3 位 = 999,遇到 10.1000.0 就崩) 预发布版本、build metadata(2.0-beta9、1.0+20130313144700)编码复杂 需要维护 encode / decode 函数大厂漏洞平台一般是"方案 3 粗筛 + 方案 1 精确"组合——SQL 层快速缩小范围,程序层用 SemVer 库保证语义正确。 处理预发布版本 2.0-beta9、2.0-rc1 这种版本比较:SemVer 规定 1.0.0-alpha < 1.0.0-beta < 1.0.0-rc < 1.0.0 字符串比又反过来:'1.0.0' < '1.0.0-alpha'(长度)别自己写比较,用 SemVer 库:Java:semver4j Node:semver Python:packaging 或 python-semver Go:semver三个方案对比方案 存储 查询速度 语义正确性 复杂度 推荐场景存字符串 一列 慢 完美 低 规则量少(< 1 万条)上下界拆列 4 列 中 靠程序 中 推荐,通用最优版本数字化 长整型 极快 简单版本 OK 高 亿级数据 + 版本规范一句话总结 版本号别当字符串比——SQL 存上下界列 + 程序里跑 SemVer 库判定是最稳的组合。追求极致查询速度就把版本 encode 成整数走索引,代价是预发布版本处理复杂。
下载量统计:滑动窗口 vs 整点分桶与 Redis 分钟桶方案
两种统计方式 滑动窗口(实时系统首选) 统计"当前往前推 N 小时"的下载数: -- 1 小时下载量 SELECT COUNT(*) FROM downloads WHERE download_time >= NOW() - INTERVAL 1 HOUR;-- 3 小时下载量 SELECT COUNT(*) FROM downloads WHERE download_time >= NOW() - INTERVAL 3 HOUR;特点:平滑、不会整点突变,适合实时热榜、推荐系统、CDN 统计。 自然时间段(BI 报表常用) 按整点分桶,例如现在 15:37,统计范围是 15:00~15:59: SELECT COUNT(*) FROM downloads WHERE HOUR(download_time) = HOUR(NOW()) AND DATE(download_time) = CURDATE();适合财务报表、数据仓库场景,不适合实时排行——整点会出现排名抽搐。 Redis 分钟桶(高并发推荐) 生产环境不会每次扫大表,而是用 Redis 分钟桶: Key 格式:download:item:{id}:{yyyyMMddHHmm} 例如:download:item:123:202605281537每次下载: INCR download:item:123:202605281537 EXPIRE download:item:123:202605281537 86400查询 1 小时:累加最近 60 个分钟 Key 查询 3 小时:累加最近 180 个分钟 Key 逻辑上是滑动窗口,底层是时间桶——抖音、B站、Steam 热榜的标准做法。 热度分值 单纯看 1 小时容易被刷量,实际排行榜通常组合多个时间窗口: score = download_1h * 0.5 + download_3h * 0.3 + download_24h * 0.2再叠加时间衰减因子防止老内容永远霸榜。 方案选型场景 推荐方案普通业务后台 MySQL NOW() - INTERVAL高并发下载统计 Redis 分钟桶 + 定时汇总热门排序 1h + 3h + 24h 三个窗口组合历史分析 / BI ClickHouse 聚合
