redis热点key拆分和读多副本

总结:

热点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存储,用时再汇总

相关推荐
雪球不会消失了2 小时前
MySQL(开发篇)
数据库·mysql·oracle
小雨下雨的雨2 小时前
第8篇:Redis缓存设计与缓存问题
java·redis·缓存
LeeZhao@2 小时前
【狂飙全模态】狂飙AGI-智能视频生成助手
人工智能·redis·语言模型·音视频·agi
qq_348231852 小时前
Redis 事务(MULTI/EXEC)与 Lua 脚本的核心区别
数据库·redis·lua
whn19772 小时前
寻找listener.log
数据库
代码游侠2 小时前
学习笔记——文件I/O
linux·数据库·笔记·学习·算法
Tanjia_kiki2 小时前
无法打开新数据库 ‘test‘。CREATE DATABASE 中止。 (Microsoft SQL Server,错误: 9004)
数据库
铭keny2 小时前
MySQL 误删数据恢复操作手册
数据库·mysql
2的n次方_2 小时前
Catlass 模板库调试调优经验与踩坑记录
服务器·数据库