总结:
热点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 特点:数据分散,需要汇总典型场景:计数器
问题场景
# 场景:统计商品浏览量# 问题:1个key被10万QPS的INCR操作打爆# 原始方案(有瓶颈)INCR product:123:view_count# 10万用户同时访问# 所有INCR操作都打到同1个key# 成为性能瓶颈解决方案:拆分为10个分片(统计总浏览量)
importrandomimporthashlibclassShardedCounter:"""分片计数器"""def__init__(self,redis_client,shard_count=10):self.redis=redis_client self.shard_count=shard_count# 分片数量defincr(self,counter_name,user_id=None):""" 递增计数器 user_id: 用户ID,用于确定分片 """# 根据用户ID计算分片编号ifuser_id:# 方式1: 用户维度分片(推荐)# 同一用户总是访问同一分片shard_id=self._get_shard_by_user(user_id)else:# 方式2: 随机分片shard_id=random.randint(0,self.shard_count-1)# 构建分片keyshard_key=f"{counter_name}:shard:{shard_id}"# 只递增对应的分片count=self.redis.incr(shard_key)print(f"用户{user_id}访问 → 分片{shard_id}→{shard_key}")returncountdefget_total(self,counter_name):""" 获取总计数(汇总所有分片) """total=0# 读取所有分片并求和foriinrange(self.shard_count):shard_key=f"{counter_name}:shard:{i}"count=self.redis.get(shard_key)ifcount:total+=int(count)returntotaldef_get_shard_by_user(self,user_id):"""根据用户ID计算分片"""# 使用哈希确保同一用户总是映射到同一分片hash_value=int(hashlib.md5(str(user_id).encode()).hexdigest(),16)returnhash_value%self.shard_count# ========================================# 使用示例# ========================================counter=ShardedCounter(redis_client,shard_count=10)# 模拟10万用户浏览商品print("模拟10万用户浏览商品...")foruser_idinrange(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各分片计数:")foriinrange(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的访问压力。
classShardedStock:"""分片库存管理"""def__init__(self,redis_client,shard_count=10):self.redis=redis_client self.shard_count=shard_countdefinit_stock(self,product_id,total_stock):""" 初始化库存:均匀分配到各分片 """stock_per_shard=total_stock//self.shard_count remainder=total_stock%self.shard_countforiinrange(self.shard_count):# 余数分配给前几个分片stock=stock_per_shard+(1ifi<remainderelse0)key=f"stock:{product_id}:shard:{i}"self.redis.set(key,stock)print(f"分片{i}初始化库存:{stock}")defdeduct_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)ifresult==1:returnTrueelse:# 当前分片库存不足,尝试其他分片returnself._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 """# 遍历其他分片foriinrange(self.shard_count):ifi==exclude_shard:continuestock_key=f"stock:{product_id}:shard:{i}"result=self.redis.eval(lua_script,1,stock_key)ifresult==1:returnTruereturnFalse# 所有分片都没库存defget_total_stock(self,product_id):"""获取总库存"""total=0foriinrange(self.shard_count):key=f"stock:{product_id}:shard:{i}"stock=self.redis.get(key)ifstock:total+=int(stock)returntotaldef_get_user_shard(self,user_id):"""用户ID映射到分片"""returnint(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=0foruser_idinrange(100000):ifstock_mgr.deduct_stock(999,user_id):success_count+=1print(f"用户{user_id}抢购成功!")ifsuccess_count>=100:breakprint(f"\n总共{success_count}人抢购成功")print(f"剩余库存:{stock_mgr.get_total_stock(999)}")# 效果:# - 原方案:1个库存key被10万DECR打爆# - 新方案:10个分片,每个承受1万QPS# - 压力分散10倍分片策略对比
策略1:随机分片
如果是固定的映射关系 就是一个用户有固定的分片去对应 那么系统本地是有缓存的 不需要重新计算分片的数字
# 完全随机选择分片shard_id=random.randint(0,shard_count-1)优点: ✅ 实现简单 ✅ 负载均衡好 缺点: ⚠️ 同一用户可能访问不同分片 ⚠️ 缓存亲和性差策略2:用户哈希分片(推荐)
意思是 万一 哈希倾斜了 有些分片对应的用户数量还是很多
# 根据用户ID哈希shard_id=hash(user_id)%shard_count 优点: ✅ 同一用户总是访问同一分片 ✅ 缓存亲和性好 ✅ 便于追踪 缺点: ⚠️ 如果用户分布不均,可能负载不均策略3:地域分片
# 根据地域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 适用:需要聚合计算的数据 场景:计数器、库存、统计数据代码示例对比
# ========================================# 场景1: 商品浏览 - 应该用"多副本"# ========================================classProductCache:"""商品缓存 - 使用多副本"""defget_product(self,product_id):# 随机选择副本(内容相同)copy_id=random.randint(0,2)key=f"product:{product_id}:copy{copy_id}"returnredis.get(key)defupdate_product(self,product_id,data):# 更新所有副本foriinrange(3):key=f"product:{product_id}:copy{i}"redis.set(key,data)# ========================================# 场景2: 浏览计数 - 应该用"key拆分"# ========================================classViewCounter:"""浏览计数器 - 使用key拆分"""defincr_view(self,product_id,user_id):# 根据用户选择分片(数据分散)shard_id=hash(user_id)%10key=f"view:{product_id}:shard:{shard_id}"returnredis.incr(key)defget_total_views(self,product_id):# 汇总所有分片total=0foriinrange(10):key=f"view:{product_id}:shard:{i}"count=redis.get(key)or0total+=int(count)returntotal总结
我个人认为多副本适合读 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存储,用时再汇总。