本章目标:能区分 KV、宽表、文档、搜索四类存储的适用场景,会设计缓存键和 HBase 行键,并说清缓存穿透、雪崩、热点的处理方式。 前置知识:数据采集与同步、OLAP 查询调优 预计用时:100 分钟
数仓擅长批量扫描和聚合,但有两类需求它接不住:一是毫秒级按主键取一条数据,二是按关键词模糊搜索。这两类查询交给 NoSQL 更合适。用错存储,系统会又慢又贵。
1. 为什么需要 NoSQL
关系型数据库加索引也能点查,但数据量到亿级、并发到几万时,单机写入和扩展会成为瓶颈。NoSQL 不是“比关系型更好”,而是针对某类访问模式做了取舍:
| 需求 | 合适存储 | 原因 |
|---|---|---|
| 按用户 ID 取会话、配置 | KV(Redis) | 内存读写,微秒级 |
| 按订单 ID 查详情、按前缀扫描 | 宽表(HBase) | 海量行、按行键有序 |
| 存结构不固定的商品属性 | 文档(MongoDB) | 一条记录就是一个 JSON |
| 按关键词、拼音、同义词搜索 | 搜索引擎(Elasticsearch) | 倒排索引 |
先明确查询长什么样,再选存储。先选技术再找场景,通常都会踩坑。
2. 核心概念
2.1 四类存储对照
| 类型 | 数据模型 | 强项 | 弱项 |
|---|---|---|---|
| KV / 缓存 | key → value | 极低延迟、高并发 | 不适合复杂查询 |
| 宽表 | 行键 + 列族 | 海量写入、按行键范围扫描 | 行键设计不好会热点 |
| 文档 | JSON 文档 | 灵活 schema、嵌套查询 | 跨文档事务弱 |
| 搜索 | 倒排索引 | 全文检索、聚合 | 存储成本高、近实时而非实时 |
2.2 缓存键设计
缓存键要满足三点:可预测、带版本、能过期。
order:1001:v1 单条订单
user:2001:profile:v2 用户资料,v2 表示结构变更
city:gmv:2024-01 按月的聚合结果
不要用自增 ID 之外的模糊条件做键,例如 orders:city=北京,一旦数据更新,整个键都要失效。聚合结果要带时间范围,避免新旧口径混用。
2.3 HBase 行键与热点
HBase 按行键字典序存储,相邻行键落在同一个 Region。如果行键是自增订单号 0001、0002,写入会全部压到最后一台 RegionServer。
常见解法是加盐或哈希前缀:
原始行键:1001|2024-01-01 10:00:00
加盐后: 3f|1001|2024-01-01 10:00:00
3f 是订单号哈希的前两位,写入被打散到 256 个区间。代价是按订单号范围扫描时要查 256 次再合并,所以只有写热点严重时才加盐,查询为主表可以不加。
2.4 缓存更新策略
- Cache Aside:先读缓存,未命中查数据库再回填;更新时先改库再删缓存。实现简单,最常用。
- Write Through:写请求同时写缓存和数据库,一致性更好,但写延迟高。
- Write Behind:先写缓存,异步刷库,吞吐高但有丢数据风险。
无论哪种,都要接受“缓存和数据库短时间不一致”。对一致性要求高的数据,不要只依赖缓存。
3. 动手示例
3.1 启动 Redis 并操作常用结构
本地有 Docker 时:
docker run -d --name redis-dev -p 6379:6379 redis:7
docker exec -it redis-dev redis-cli
在 redis-cli 里执行:
SET order:1001 '{"city":"北京","amount":120}' EX 3600
GET order:1001
TTL order:1001
HSET dim:product P01 category 家居 price 100
HGETALL dim:product
ZADD city_gmv 455 北京 405 上海
ZREVRANGE city_gmv 0 1 WITHSCORES
EX 设置过期时间,避免缓存永久占内存;ZSet 适合做排行榜和 TopN。生产环境不要用 KEYS *,它会阻塞 Redis,改用 SCAN 渐进遍历。
3.2 用 Python 模拟 LRU 缓存
没有 Redis 也能观察缓存淘汰逻辑:
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.data = OrderedDict()
def get(self, key):
if key not in self.data:
return None
self.data.move_to_end(key)
return self.data[key]
def put(self, key, value):
if key in self.data:
self.data.move_to_end(key)
self.data[key] = value
if len(self.data) > self.capacity:
self.data.popitem(last=False)
cache = LRUCache(2)
cache.put("1001", 120)
cache.put("1002", 80)
cache.get("1001")
cache.put("1003", 260)
print(list(cache.data.items()))
输出:
[('1001', 120), ('1003', 260)]
访问 1001 后它变成“最近使用”,写入 1003 时淘汰最久未使用的 1002。Redis 的 allkeys-lru 策略是同一思路。
3.3 HBase 表与行键
用 HBase Shell 建一张订单表,只保留一个列族:
create 'ods_orders', 'info'
put 'ods_orders', '3f|1001|2024-01-01 10:00:00', 'info:city', '北京'
put 'ods_orders', '3f|1001|2024-01-01 10:00:00', 'info:amount', '120'
get 'ods_orders', '3f|1001|2024-01-01 10:00:00'
scan 'ods_orders', {STARTROW => '3f|1001', STOPROW => '3f|1002'}
列族要少,通常 1–3 个;列族越多,写入和刷盘开销越大。列名可以动态增加,不用预先建所有列。
3.4 MongoDB 聚合与 Elasticsearch 检索
MongoDB 存订单文档,按城市统计:
db.orders.aggregate([
{ $match: { status: "paid" } },
{ $group: { _id: "$city", gmv: { $sum: "$amount" } } },
{ $sort: { gmv: -1 } }
])
Elasticsearch 建索引后按关键词搜索:
PUT orders/_doc/1001
{"order_id": "1001", "city": "北京", "status": "paid", "amount": 120}
GET orders/_search
{
"query": { "match": { "city": "北京" } },
"aggs": { "gmv": { "sum": { "field": "amount" } } }
}
ES 默认近实时刷新(约 1 秒),不是强一致数据库。源数据仍要以业务库或数仓为准,ES 只做检索副本。
4. 常见坑
4.1 缓存穿透
查询一个根本不存在的键,每次都打到数据库。处理方式:缓存空值并设置较短过期时间,或在入口做参数校验和布隆过滤器。
4.2 缓存雪崩
大量键在同一时刻过期,请求同时压到数据库。给过期时间加随机抖动,例如 30 分钟 ± 5 分钟;关键数据做多级缓存。
4.3 热点键
某个商品或活动的键被高频访问,单节点 CPU 打满。把热点键拆成多个副本键,读时随机选一个;或者把热点数据放本地缓存。
4.4 大键
一个键存了几十万个元素的 Set 或 Hash,删除和迁移都会阻塞。控制单键大小,大集合按业务维度拆开。
4.5 把搜索库当主库
ES 索引重建、映射变更都要时间,也不适合事务。主数据写入业务库或数仓,通过同步任务更新 ES,出问题可以从源端重建。
4.6 行键设计成自增
自增行键让写入集中在一个 Region,出现写热点。先分析查询模式,再决定是否需要哈希前缀、反转或组合键。
5. 在线练习
| 题目 | 考点 |
|---|---|
| 1193. 每月交易 I | 状态统计与按月聚合 |
| 1321. 餐馆营业额变化增长 | 时间窗口与累计值 |
| 1158. 市场分析 I | 多表关联与空值处理 |
自测:
- 能说出 KV、宽表、文档、搜索四类存储各自最适合的查询。
- 能解释 HBase 行键为什么会热点,以及加盐的代价。
6. 面试延伸
面试题:Redis 和 MySQL 怎么配合?
是什么:MySQL 存权威数据,Redis 做缓存;读走 Cache Aside,写先更新数据库再删除缓存。
解决什么问题:把高频读挡在数据库之外,降低响应时间和数据库压力。
我的实操边界:本章在本地 Redis 里练过常用命令,未做过线上缓存容量规划。
面试题:缓存穿透、击穿、雪崩有什么区别?
是什么:穿透是查不存在的键;击穿是单个热点键过期;雪崩是大量键同时过期。
解决什么问题:三者都会把流量瞬间打回数据库,但处理手段不同:空值缓存/布隆过滤器、互斥重建、过期时间打散。
我的实操边界:本章说明思路,未在高并发生产环境验证。
面试题:HBase 行键怎么设计?
是什么:按查询模式组合字段,保证常用查询能按前缀扫描;写热点严重时加盐或哈希打散。
解决什么问题:行键决定数据分布和扫描效率,设计错了会热点或全表扫。
我的实操边界:本章在 HBase Shell 里建过表,未维护过生产集群。
面试题:为什么 Elasticsearch 不能当主库?
是什么:ES 是近实时索引,刷新有延迟,映射变更和重建成本高,事务能力弱。
解决什么问题:把 ES 定位为检索副本,主数据放业务库或数仓,才能重建和纠错。
我的实操边界:了解索引和查询语法,未做过 ES 集群运维。
7. 本章验收
- 能列出四类 NoSQL 存储的强项和弱项,并各举一个业务场景。
- 能写出带过期时间的 Redis 缓存键,并说明为什么带版本号。
- 能运行 Python LRU 示例,解释
1002为什么被淘汰。 - 能设计一个避免写热点的 HBase 行键,并说明查询代价。
- 能区分缓存穿透、击穿、雪崩,各说出一种处理方式。
- 能说明为什么 Elasticsearch 只做检索副本,不做权威数据源。