总结:
热点key拆分是什么?
简单理解
key拆分 = 把1个key的数据,拆分存储到多个key中
类比:
就像超市只有1个收银台,100人排队(热点key)
多副本的解决方式:
→ 开10个收银台,每个都能收款(复制数据)
key拆分的解决方式:
→ 把100人分成10组,每组去不同收银台
每个收银台负责自己那组人的账单
(数据本身就是分散的)
核心区别:读多副本 vs key拆分
区别:读多副本是幂等的,数据不会被改变,但是key拆分适用于写场景,最后合并统计。
最终目的是一样的,都是为了减少对一个key的访问压力。
对比图示
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
【多副本】- 数据完全相同,随机读取
原始key:
stock:123 = 100 (1个key,10万QPS访问)
拆分后:
stock:123:copy0 = 100 ← 33%流量
stock:123:copy1 = 100 ← 33%流量
stock:123:copy2 = 100 ← 34%流量
特点:数据相同,只是复制了多份
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
【key拆分】- 数据不同,需要汇总
原始key:
counter:product_view = 10000000 (1个key,10万次INCR)
拆分后:
counter:product_view:0 = 1000000 ← shard 0
counter:product_view:1 = 1050000 ← shard 1
counter:product_view:2 = 980000 ← shard 2
...
counter:product_view:9 = 1020000 ← shard 9
总计 = sum(所有分片) = 10000000
特点:数据分散,需要汇总
典型场景:计数器
问题场景
bash
# 场景:统计商品浏览量
# 问题:1个key被10万QPS的INCR操作打爆
# 原始方案(有瓶颈)
INCR product:123:view_count
# 10万用户同时访问
# 所有INCR操作都打到同1个key
# 成为性能瓶颈
解决方案:拆分为10个分片(统计总浏览量)
python
import random
import hashlib
class ShardedCounter:
"""分片计数器"""
def __init__(self, redis_client, shard_count=10):
self.redis = redis_client
self.shard_count = shard_count # 分片数量
def incr(self, counter_name, user_id=None):
"""
递增计数器
user_id: 用户ID,用于确定分片
"""
# 根据用户ID计算分片编号
if user_id:
# 方式1: 用户维度分片(推荐)
# 同一用户总是访问同一分片
shard_id = self._get_shard_by_user(user_id)
else:
# 方式2: 随机分片
shard_id = random.randint(0, self.shard_count - 1)
# 构建分片key
shard_key = f"{counter_name}:shard:{shard_id}"
# 只递增对应的分片
count = self.redis.incr(shard_key)
print(f"用户{user_id}访问 → 分片{shard_id} → {shard_key}")
return count
def get_total(self, counter_name):
"""
获取总计数(汇总所有分片)
"""
total = 0
# 读取所有分片并求和
for i in range(self.shard_count):
shard_key = f"{counter_name}:shard:{i}"
count = self.redis.get(shard_key)
if count:
total += int(count)
return total
def _get_shard_by_user(self, user_id):
"""根据用户ID计算分片"""
# 使用哈希确保同一用户总是映射到同一分片
hash_value = int(hashlib.md5(str(user_id).encode()).hexdigest(), 16)
return hash_value % self.shard_count
# ========================================
# 使用示例
# ========================================
counter = ShardedCounter(redis_client, shard_count=10)
# 模拟10万用户浏览商品
print("模拟10万用户浏览商品...")
for user_id in range(100000):
counter.incr("product:123:view_count", user_id)
# 输出示例:
# 用户0访问 → 分片6 → product:123:view_count:shard:6
# 用户1访问 → 分片3 → product:123:view_count:shard:3
# 用户2访问 → 分片8 → product:123:view_count:shard:8
# ...
# 查看各分片情况
print("\n各分片计数:")
for i in range(10):
shard_key = f"product:123:view_count:shard:{i}"
count = redis_client.get(shard_key)
print(f"分片{i}: {count}次")
# 输出:
# 分片0: 10023次
# 分片1: 9987次
# 分片2: 10105次
# 分片3: 9876次
# ...
# 获取总浏览量
total_views = counter.get_total("product:123:view_count")
print(f"\n总浏览量: {total_views}")
# 输出:总浏览量: 100000
# ========================================
# 效果分析
# ========================================
# 原方案:1个key承受10万QPS
# 新方案:10个key,每个承受1万QPS
# 压力分散:10倍
可视化说明
流程图
【原始方案 - 单key热点】
10万用户
↓
↓ (所有INCR打到1个key)
↓
┌─────────────────────┐
│ counter:total = X │ ← 10万QPS,性能瓶颈!
└─────────────────────┘
【拆分方案 - 分片】
10万用户
↓
├─→ 用户0 → hash → 分片6
├─→ 用户1 → hash → 分片3
├─→ 用户2 → hash → 分片8
├─→ 用户3 → hash → 分片1
└─→ ...
┌──────────────────────────────────────┐
│ counter:shard:0 = 10023 (1万QPS) │
│ counter:shard:1 = 9987 (1万QPS) │
│ counter:shard:2 = 10105 (1万QPS) │
│ counter:shard:3 = 9876 (1万QPS) │
│ ... │
│ counter:shard:9 = 10002 (1万QPS) │
└──────────────────────────────────────┘
↓
[需要时汇总]
↓
Total = 100000
实战案例:秒杀库存
场景:库存扣减
热点库存总量拆分为多个key,持有均匀分布的库存量,然后让用户的id和单个分片产生映射关系,如果这个分片对应的key-value消耗完了,就访问别的分片的value。真的是非常的聪明啊 ,但是仍然是采用空间换取时间性能,减轻单个key的访问压力。
python
class ShardedStock:
"""分片库存管理"""
def __init__(self, redis_client, shard_count=10):
self.redis = redis_client
self.shard_count = shard_count
def init_stock(self, product_id, total_stock):
"""
初始化库存:均匀分配到各分片
"""
stock_per_shard = total_stock // self.shard_count
remainder = total_stock % self.shard_count
for i in range(self.shard_count):
# 余数分配给前几个分片
stock = stock_per_shard + (1 if i < remainder else 0)
key = f"stock:{product_id}:shard:{i}"
self.redis.set(key, stock)
print(f"分片{i}初始化库存: {stock}")
def deduct_stock(self, product_id, user_id):
"""
扣减库存:从用户对应的分片扣除
"""
# 用户固定访问某个分片(避免来回切换)
shard_id = self._get_user_shard(user_id)
stock_key = f"stock:{product_id}:shard:{shard_id}"
# Lua脚本保证原子性
lua_script = """
local stock = redis.call('GET', KEYS[1])
if tonumber(stock) > 0 then
redis.call('DECR', KEYS[1])
return 1
else
return 0
end
"""
result = self.redis.eval(lua_script, 1, stock_key)
if result == 1:
return True
else:
# 当前分片库存不足,尝试其他分片
return self._try_other_shards(product_id, shard_id)
def _try_other_shards(self, product_id, exclude_shard):
"""尝试从其他分片扣减"""
lua_script = """
local stock = redis.call('GET', KEYS[1])
if tonumber(stock) > 0 then
redis.call('DECR', KEYS[1])
return 1
else
return 0
end
"""
# 遍历其他分片
for i in range(self.shard_count):
if i == exclude_shard:
continue
stock_key = f"stock:{product_id}:shard:{i}"
result = self.redis.eval(lua_script, 1, stock_key)
if result == 1:
return True
return False # 所有分片都没库存
def get_total_stock(self, product_id):
"""获取总库存"""
total = 0
for i in range(self.shard_count):
key = f"stock:{product_id}:shard:{i}"
stock = self.redis.get(key)
if stock:
total += int(stock)
return total
def _get_user_shard(self, user_id):
"""用户ID映射到分片"""
return int(hashlib.md5(str(user_id).encode()).hexdigest(), 16) % self.shard_count
# ========================================
# 使用示例:秒杀100件商品
# ========================================
stock_mgr = ShardedStock(redis_client, shard_count=10)
# 初始化库存
stock_mgr.init_stock(product_id=999, total_stock=100)
# 输出:
# 分片0初始化库存: 10
# 分片1初始化库存: 10
# 分片2初始化库存: 10
# ...
# 分片9初始化库存: 10
# 10万用户抢购
success_count = 0
for user_id in range(100000):
if stock_mgr.deduct_stock(999, user_id):
success_count += 1
print(f"用户{user_id}抢购成功!")
if success_count >= 100:
break
print(f"\n总共{success_count}人抢购成功")
print(f"剩余库存: {stock_mgr.get_total_stock(999)}")
# 效果:
# - 原方案:1个库存key被10万DECR打爆
# - 新方案:10个分片,每个承受1万QPS
# - 压力分散10倍
分片策略对比
策略1:随机分片
如果是固定的映射关系 就是一个用户有固定的分片去对应 那么系统本地是有缓存的 不需要重新计算分片的数字
python
# 完全随机选择分片
shard_id = random.randint(0, shard_count - 1)
优点:
✅ 实现简单
✅ 负载均衡好
缺点:
⚠️ 同一用户可能访问不同分片
⚠️ 缓存亲和性差
策略2:用户哈希分片(推荐)
意思是 万一 哈希倾斜了 有些分片对应的用户数量还是很多
python
# 根据用户ID哈希
shard_id = hash(user_id) % shard_count
优点:
✅ 同一用户总是访问同一分片
✅ 缓存亲和性好
✅ 便于追踪
缺点:
⚠️ 如果用户分布不均,可能负载不均
策略3:地域分片
python
# 根据地域
region_map = {
"北京": 0,
"上海": 1,
"广州": 2,
"深圳": 3,
# ...
}
shard_id = region_map.get(user_region, 0)
优点:
✅ 便于地域统计
✅ 可以针对地域优化
缺点:
⚠️ 负载可能严重不均(大城市访问量高)
适用场景对比
读多副本 vs key拆分
其实我感觉就是读多副本和写多副本的区别。。。。
| 场景 | 多副本 | key拆分 |
|---|---|---|
| 计数器 | ❌ 不适合 (多个副本值不同) | ✅ 适合 (分片累加) |
| 库存扣减 | ❌ 不适合 (会超卖) | ✅ 适合 (分片扣减) |
| 商品详情 | ✅ 适合 (内容相同) | ❌ 不适合 (没必要拆分) |
| 配置数据 | ✅ 适合 (只读数据) | ❌ 不适合 (整体数据) |
| 点赞数 | ❌ 不适合 (需要准确计数) | ✅ 适合 (累加统计) |
| 用户信息 | ✅ 适合 (读多写少) | ❌ 不适合 (单个对象) |
完整对比总结
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
【多副本】
原理:同一份数据,复制N份
读取:随机选择一个副本
写入:更新所有副本
示例:
product:123:copy0 = "iPhone数据"
product:123:copy1 = "iPhone数据" (内容相同)
product:123:copy2 = "iPhone数据"
适用:只读或写少读多的完整数据
场景:商品详情、文章内容、配置信息
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
【key拆分】
原理:逻辑上1个值,物理上拆成N份
读取:需要汇总所有分片
写入:只写对应分片
示例:
counter:view:shard:0 = 1000
counter:view:shard:1 = 1050 (内容不同)
counter:view:shard:2 = 980
总计 = 1000 + 1050 + 980 = 3030
适用:需要聚合计算的数据
场景:计数器、库存、统计数据
代码示例对比
python
# ========================================
# 场景1: 商品浏览 - 应该用"多副本"
# ========================================
class ProductCache:
"""商品缓存 - 使用多副本"""
def get_product(self, product_id):
# 随机选择副本(内容相同)
copy_id = random.randint(0, 2)
key = f"product:{product_id}:copy{copy_id}"
return redis.get(key)
def update_product(self, product_id, data):
# 更新所有副本
for i in range(3):
key = f"product:{product_id}:copy{i}"
redis.set(key, data)
# ========================================
# 场景2: 浏览计数 - 应该用"key拆分"
# ========================================
class ViewCounter:
"""浏览计数器 - 使用key拆分"""
def incr_view(self, product_id, user_id):
# 根据用户选择分片(数据分散)
shard_id = hash(user_id) % 10
key = f"view:{product_id}:shard:{shard_id}"
return redis.incr(key)
def get_total_views(self, product_id):
# 汇总所有分片
total = 0
for i in range(10):
key = f"view:{product_id}:shard:{i}"
count = redis.get(key) or 0
total += int(count)
return total
总结
我个人认为多副本适合读 key拆分适合统计
快速判断
问题:我的热点key应该用多副本还是拆分?
判断流程:
1. 这是一个完整的数据对象吗?
(如:商品信息、文章内容、用户资料)
✅ 是 → 用"多副本"
❌ 否 → 继续判断
2. 这是需要累加/聚合的数据吗?
(如:计数器、统计值、库存)
✅ 是 → 用"key拆分"
❌ 否 → 考虑其他方案
3. 主要操作是什么?
读多写少 → 多副本
频繁递增/递减 → key拆分
本质区别
多副本(Multi-Copy):
┌───────┐
│ 数据A │ → copy0
│ 数据A │ → copy1 (复制)
│ 数据A │ → copy2
└───────┘
特点:内容相同
key拆分(Sharding):
┌───────┐
│ 数据1 │ → shard0
│ 数据2 │ → shard1 (拆分)
│ 数据3 │ → shard2
└───────┘
特点:内容不同,需要汇总
希望这个详细的解释让您理解了"热点key拆分"的概念!简单说就是:把一个逻辑上的值(如总计数),拆分成多个物理key存储,用时再汇总。