20260612
有关扩容,假如在数据量到达阈值时直接开新的哈希表进行扩容,那不会导致那一次的 set 操作用时特别久吗?有什么方案能保证这次 set 不会超时吗?
一次性全量迁移确实会造成单次 set 卡顿、超时,业界分「基础扩容」「渐进式扩容」「分片扩容」三类方案解决,以 Java HashMap、Go map、Redis 为例逐一说明。
一、先讲原生一次性扩容(问题根源)
流程
- 元素数量达到负载因子阈值(容量 × 负载因子)
- 本次
put/set触发扩容:新建2倍容量数组 - 一次性遍历原数组所有节点,重新计算哈希、迁移到新数组
- 本次操作结束
缺点
- 数据量越大,迁移耗时越长,单次请求阻塞,高并发/接口场景极易超时、毛刺严重。
- 线程不安全场景下还会引发并发死循环(Java JDK1.7 HashMap 经典问题)。
二、主流解决方案(按落地场景划分)
方案1:渐进式扩容(增量迁移,最常用)
核心思想:不把所有数据迁移压在一次操作里,拆分迁移工作量,分摊到后续每一次读写请求。
代表实现:Java 8 HashMap、Go 内置 map、Redis 哈希结构。
执行流程
- 触发扩容:新建新数组,同时保留原数组,此时哈希表存在新旧两个数组。
- 标记迁移状态,后续所有
get/put/remove操作,顺便搬运原数组当前桶的整条链表/树。 - 每次请求只迁移一个桶的数据,单次操作耗时极小。
- 直到原数组所有桶全部迁移完成,丢弃原数组,扩容结束。
优势
- 单次
set仅做少量迁移,耗时平稳,无长时间阻塞。 - 读写请求均匀分摊迁移开销,消除性能毛刺。
细节补充(Java8 HashMap)
- 扩容期间
table同时指向新旧数组; - 访问旧桶时触发迁移,迁移完该桶就清空旧桶;
- 迁移完成后旧数组 GC 回收。
方案2:预扩容 + 分批异步迁移(业务层/中间件常用)
核心思想:提前预判容量,在流量低峰期异步迁移,避开业务高峰。
适用:Redis、分布式缓存、业务自研哈希表。
- 预扩容
监控元素数量,未到阈值就提前触发扩容,避免满载时紧急扩容。 - 异步后台迁移
扩容触发后,启动后台线程分批迁移数据,主线程直接响应客户端set请求,不阻塞。 - 读写路由规则
- 写:优先写入新数组;
- 读:先查新数组,找不到再查旧数组;
- 后台慢慢把旧数据迁完,最终下线旧数组。
优势
- 主线程完全不卡,
set几乎零额外耗时,彻底杜绝超时。 - 适合高并发在线服务。
劣势
- 同时维护双份数据,临时占用双倍内存;
- 需处理读写双数组的逻辑,代码复杂度上升。
方案3:分片哈希(分段锁 + 分片独立扩容)
核心思想:把一个大哈希表拆成多个独立小哈希表(分片),每个分片单独扩容。
代表:ConcurrentHashMap(Java)、分布式哈希表。
- 全局哈希表分为 N 个分片,每个分片有独立容量、独立阈值、独立锁。
- 单个分片达到阈值,仅对当前分片扩容,其余分片不受影响。
- 扩容只涉及一小部分数据,迁移量极小,单次
set耗时微乎其微。
优势
- 扩容粒度极细,不会出现全表大迁移;
- 配合分段锁,并发性能优秀。
延伸:分布式场景
分布式哈希(一致性哈希)更进一步:扩容只影响少量节点数据,全局无大规模迁移。
方案4:延迟扩容 / 动态负载因子(兜底优化)
- 临时调高负载因子,推迟扩容时机,减少扩容频次;
- 仅作为辅助手段,不能根治问题,一般搭配上面方案使用。
三、不同语言/组件实际选型总结
- Java 8 HashMap:渐进式扩容 → 解决单次 put 阻塞,单机普通场景首选。
- Java ConcurrentHashMap:分片 + 渐进式扩容 → 高并发本地哈希表。
- Go map:纯渐进式增量迁移 → 语言内置,无明显卡顿。
- Redis:预扩容 + 后台异步分批迁移 + 双数组读写 → 高并发缓存标准方案。
- 自研业务哈希表:优先「异步后台迁移 + 预扩容」,保证接口响应时间。
你说异步扩容,那假如正在扩容的时候旧的哈希表被填满了怎么办??
异步扩容会同时存在 旧表 (old_table) + 新表 (new_table) 两张哈希表,状态分为:
扩容中:新表已创建,后台线程分批迁移旧表数据;
迁移完成:旧表下线,只用新表。
核心写策略(通用规则):
所有新写入,一律直接写到新表,不再写入旧表
旧表只进不出,只会逐步变少,永远不会被再次填满。
为什么需要延迟双删
线程 A 查数据 → 拿到旧缓存 → 线程 B 更新 DB + 删除缓存 → 线程 A 把旧数据写回缓存 → 缓存永久脏数据
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 皖月清风的个人博客!
评论




