DD 数据开发学习站DATA ENGINEERING
模块 08 · 平台与数据服务

NoSQL 与缓存

KV、宽表、文档、搜索四类存储,缓存键、HBase 行键与穿透/雪崩/热点。

难度 · 进阶 约 100 分钟 选学 练习 8 项 计划内 刷题 0/3
建议先学:云数据仓库
本页目录 · 25 节

本章目标:能区分 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 只做检索副本,不做权威数据源。
本章任务0 / 8
本页目录