工业边缘系统的数据量常常增长得比预期快:设备测点从几百个变成几万个,采样周期从分钟级变成秒级,告警、报文、诊断日志又会额外放大写入量。单库或单节点达到容量、写入或查询瓶颈后,Sharding 会自然进入方案讨论。
但 Sharding 不是“把数据拆开放”这么简单。它改变的是数据放置、路由、查询、事务、扩容和运维的完整链路。选错 shard key 或低估迁移成本,比单库慢往往更难修复。
本文按工业边缘场景拆解 Hash、一致性哈希、范围分片和业务分片的适用边界,并给出跨分片查询、事务、扩容迁移和监控的工程做法。
一、先确认是否真的需要 Sharding
在很多项目里,真正的问题不是“库不够大”,而是以下几类:
- 数据没有生命周期管理,历史数据永不删除;
- 查询缺少时间和设备维度过滤,导致大范围扫描;
- 索引设计不合理,写入放大严重;
- schema 频繁变更,老数据没有归档;
- 边缘与云端职责不清,把所有原始报文都长期存在本地。
这些问题优先用保留策略、分区、降采样、冷热分层和索引优化解决。Sharding 带来的复杂度是全局性的,应该在单实例纵向扩容、表分区、读写分离和归档策略仍然无法满足目标时再引入。
Sharding 与 Partitioning 的区别
| 方式 | 数据如何拆 | 常见目的 | 典型问题 |
|---|---|---|---|
| 表分区 / Partitioning | 单数据库实例内按时间、范围或列表拆表 | 便于删除旧分区、减少扫描范围 | 不解决单实例容量和写入瓶颈 |
| 应用层 Sharding | 应用或网关按规则把数据写到多个库 / 节点 | 水平扩容、隔离故障域 | 路由、跨片查询、迁移要自己做 |
| 分布式数据库 Sharding | 数据库自动切分和调度数据 | 自动化扩缩容、多副本高可用 | 运维复杂,仍要关注热点和跨片事务 |
| 时间序列分区 | 按天 / 月组织时序数据 | 高效 TTL、时间窗口查询 | 单调写入可能集中在最新分区 |
工业遥测数据常见组合是:边缘本地按时间分区,云端或集团平台再按租户、站点、设备做 Sharding。不要把“按月建表”直接等同于完整的分布式 Sharding。
二、先定义查询与增长模型
选分片策略前,先统计真实访问模式:
| 问题 | 需要的结论 |
|---|---|
| 最常见的查询条件是什么? | 设备 ID、租户、站点、时间范围,还是告警类型 |
| 写入 key 如何分布? | 是否有超大租户、超大站点或高频设备 |
| 数据增长率多少? | 每天 / 每月新增多少 GB |
| 保留多久? | 热数据、温数据、冷数据分别多久 |
| 哪些查询允许异步? | 跨片聚合是否可以走分析链路 |
| 何时扩容? | 预留的容量水位和触发条件 |
一个典型工业遥测场景的查询形态是:
租户 / 站点 → 设备 / 测点 → 时间窗口
这类数据通常适合让“租户 / 站点 / 设备”参与路由,再让时间参与分区内索引或二级分区。这样单设备曲线、单站点报表都能落在少数分片上,避免每次查询都广播全集群。
三、Hash 分片:均匀,但扩容代价高
Hash 分片把 key 映射为稳定散列值,再对分片数取模:
import hashlib
NUM_SHARDS = 16
def stable_hash(key: str) -> int:
digest = hashlib.blake2b(
key.encode("utf-8"),
digest_size=8,
).digest()
return int.from_bytes(digest, "big")
def get_shard(device_id: str) -> int:
return stable_hash(device_id) % NUM_SHARDS
优点
- key 分布通常比较均匀;
- 路由计算简单,只需要 key 和分片数;
- 适合按设备 ID、会话 ID 等高基数字段随机写入。
代价
- 基本不支持高效范围查询;
- 分片数从 16 变成 32 时,绝大多数 key 会被重新映射;
- 无法单独把某个热点设备迁移出去;
- 一旦 shard key 选错,后期的路由变更和数据迁移成本很高。
一个必须纠正的问题
不要用 Python 内置 hash() 做持久化路由:
# 错误示例
shard = hash(device_id) % NUM_SHARDS
Python 字符串的 hash() 默认带随机化,不同进程或不同主机可能得到不同结果。重启后同一台设备可能被路由到另一个分片。持久路由必须使用 SHA-256、BLAKE2、xxHash 等稳定哈希,并固定编码、分隔符和版本号。
Hash 分片适合的场景
| 适合 | 不适合 |
|---|---|
| 设备 ID 点查 | 时间范围扫描 |
| 高基数字段写入 | 单租户 / 单站点聚合报表 |
| 无明显热点 key | 超大客户集中写入 |
| 分片数较稳定 | 频繁扩缩容 |
四、一致性哈希:降低扩容迁移量
一致性哈希把节点和 key 都映射到环上,key 顺时针找到最近的节点。相比简单取模,它减少了节点增减时的 key 迁移量。
基础实现
import bisect
import hashlib
class ConsistentHashRing:
def __init__(self, nodes=None, virtual_nodes=150):
self.virtual_nodes = virtual_nodes
self.ring = {}
self.sorted_tokens = []
for node in nodes or []:
self.add_node(node)
def _hash(self, key: str) -> int:
digest = hashlib.blake2b(
key.encode("utf-8"),
digest_size=8,
).digest()
return int.from_bytes(digest, "big")
def add_node(self, node: str):
for i in range(self.virtual_nodes):
token = self._hash(f"{node}#vnode-{i}")
if token in self.ring:
continue
self.ring[token] = node
bisect.insort(self.sorted_tokens, token)
def remove_node(self, node: str):
for i in range(self.virtual_nodes):
token = self._hash(f"{node}#vnode-{i}")
if self.ring.get(token) != node:
continue
del self.ring[token]
idx = bisect.bisect_left(self.sorted_tokens, token)
if idx < len(self.sorted_tokens):
self.sorted_tokens.pop(idx)
def get_node(self, key: str):
if not self.ring:
return None
token = self._hash(key)
idx = bisect.bisect_right(self.sorted_tokens, token)
if idx == len(self.sorted_tokens):
idx = 0
return self.ring[self.sorted_tokens[idx]]
生产实现通常还会加入节点权重、故障探测、路由版本、并发控制和 token 元数据。上面示例用于解释机制,不是完整的分布式组件。
虚拟节点解决什么
如果每个物理节点只在环上占一个位置,数据分布可能很不均匀。为每个节点建立大量虚拟节点后,key 的归属概率会更接近节点容量比例。
虚拟节点还有两个作用:
- 负载均衡:让容量更大的节点承载更多 token;
- 迁移平滑:节点下线时,它的数据分散迁移给多个节点,而不是全部压给下一个节点。
一致性哈希不解决的问题
这是工程中最容易误解的部分:
- 它不消除迁移,只是减少大部分 key 的重映射;
- 它不解决天然热点,超大设备或超大租户仍会集中写入;
- 它不自动解决范围查询,时间范围仍可能扫所有分片;
- 它不保证数据高可用,仍需要副本、仲裁和故障切换;
- 节点权重变化仍要评估迁移量,不能随意调整。
适用场景
一致性哈希适合节点数量会变化、key 基数高、以点查为主的缓存层、设备状态层或消息分区层。对于强时间窗口查询,仍应叠加时间分区或范围分片。
五、范围分片:利于时间窗口,但要防热点
范围分片按 key 的连续区间拆分,例如时间、自增 ID、站点编号区间:
shard_01: 2026-01 ~ 2026-03
shard_02: 2026-04 ~ 2026-06
shard_03: 2026-07 ~ 2026-09
按时间组织遥测数据
from datetime import datetime
def metric_table_name(timestamp: datetime) -> str:
month = timestamp.strftime("%Y%m")
return f"metrics_{month}"
时间分区的优势:
- 天然支持时间窗口查询;
- 过期数据可以整分区删除或归档;
- 便于冷热分层,把老数据迁到对象存储;
- 采样任务可以只打开当前分区。
范围分片的风险
| 风险 | 原因 | 缓解方式 |
|---|---|---|
| 写入热点 | 时间和自增 ID 单调递增,新写入集中在一个分片 | 时间再做哈希子分片,或按站点 / 设备混合 |
| 边界管理复杂 | 分裂、合并、路由元数据要严格维护 | 路由表版本化,变更走审核 |
| 跨边界查询 | 查询刚好落在两个时间段 | 明确边界闭开规则,合并结果 |
| 冷热不均 | 历史范围很少访问 | 自动归档和降采样 |
边界必须显式定义
不要只把区间写在代码里。至少保存:
{
"routing_version": 12,
"entity": "tenant:plant-a",
"ranges": [
{"start": "2026-01-01T00:00:00Z", "end": "2026-04-01T00:00:00Z", "shard": "ts-01", "end_inclusive": false},
{"start": "2026-04-01T00:00:00Z", "end": null, "shard": "ts-02", "end_inclusive": false}
]
}
边界规则统一使用左闭右开,可以避免 4 月 1 日 00:00:00 同时命中两个分片。
六、业务分片:按租户、站点和设备族拆
工业系统里常见业务维度包括:
- 运营主体或租户;
- 电站 / 工厂 / 园区;
- 设备类型;
- 项目编号;
- 区域;
- 大客户独立集群。
业务分片的优点是数据局部性好:单租户、单站点、单设备族的查询通常只访问一个分片,也便于隔离、备份和按客户迁移。
def get_shard_by_tenant(tenant_id: str) -> str:
if tenant_id in dedicated_tenants:
return f"shard-{tenant_id}"
bucket = stable_hash(f"tenant:{tenant_id}") % 8
return f"shard-common-{bucket}"
注意,这里也不能使用 Python 内置 hash()。此外,超大租户不能永久挤在公共桶里,应设计二级拆分:
tenant:plant-a → shard-tenant-a
tenant:plant-a / devices → device buckets
tenant:plant-a / telemetry → time partitions
业务分片的缺点是容量预测依赖客户规模。如果所有大客户都在同一区域,或某个站点设备数远超均值,需要支持局部再拆分,而不是全量重洗。
七、Shard Key 的选择原则
一个可用的 shard key 通常满足:
- 高基数:可能取值足够多,数据才能分散;
- 分布均匀:避免少数 key 占绝大多数写入;
- 稳定不变:key 更新会导致数据迁移;
- 出现在主要查询里:避免常见查询全分片广播;
- 有业务语义:便于隔离、授权、备份和跨机房调度;
- 可控热点:超大租户、超大设备可以单独处理;
- 可组合:支持“业务 key + 时间”的混合策略。
常见字段对比:
| 字段 | 优点 | 问题 |
|---|---|---|
| 设备 ID | 高基数,适合点查和写入路由 | 单设备故障可能高频写入,站点级报表会跨片 |
| 租户 / 站点 ID | 查询局部性好,便于隔离 | 大客户可能成为热点 |
| 时间 | 适合窗口查询和 TTL | 单调写入会集中在最新分片 |
| 告警级别 | 查询频率高 | 基数太低,分布极不均 |
| 自增 ID | 范围扫描方便 | 写入热点和数据倾斜明显 |
| 随机 UUID | 分布均匀 | 范围查询和人工排查不友好 |
工业遥测更常见的组合 key 是:
(tenant_id, site_id, device_id, timestamp)
其中 tenant_id / site_id / device_id 用于路由,timestamp 用于分区内排序、索引和生命周期管理。
八、工业遥测的混合策略
单一策略很难同时满足写入、点查、时间报表和归档。一个常见架构是:
设备数据
↓
边缘本地缓冲
↓ 按时间分区 + 断网补传
云端 / 集团平台
↓ 按租户 / 站点 / 设备哈希路由
↓ 再按月或按天分区
↓ 热数据在库,冷数据对象存储
分层职责:
| 层级 | 分配策略 | 主要目标 |
|---|---|---|
| 边缘本地 | 按时间分区,按设备组织文件 | 断网可写、断电可恢复 |
| 上传队列 | 按设备 / 批次哈希 | 均衡并发,避免重复上传 |
| 云端热库 | 租户 / 站点 + 时间混合 | 支撑实时查询和告警 |
| 分析层 | 按时间批量扫描 | 聚合、降采样、训练 |
| 冷存储 | 按日期对象归档 | 降低成本,保留审计 |
边缘侧还要考虑磁盘空间、CPU、内存和上传带宽。不要让所有分片决策都依赖云端服务:本地缓存的路由应尽量简单,网络恢复后再做汇聚。
九、跨分片查询不能简单拼接
只要查询条件不包含 shard key,就可能演变成跨分片 fan-out。正确处理至少包含:
- 并发控制;
- 每分片超时;
- 部分失败标记;
- 去重;
- 排序;
- 聚合;
- 分页;
- 重试预算;
- 查询审计。
一个简化示例:
async def cross_shard_query(query, shards):
tasks = {
shard.name: asyncio.create_task(
query_shard(shard, query, timeout=3)
)
for shard in shards
}
results = await asyncio.gather(
*tasks.values(),
return_exceptions=True,
)
rows = []
failed_shards = []
for shard_name, result in zip(tasks, results):
if isinstance(result, Exception):
failed_shards.append(shard_name)
else:
rows.extend(result.rows)
return {
"rows": sorted(rows, key=lambda row: row.timestamp, reverse=True),
"failed_shards": failed_shards,
"partial": bool(failed_shards),
}
这个示例返回了部分失败信息。对告警、计费或控制相关查询,不能把 partial=true 的结果当作完整结果使用。
分页的坑
跨分片分页不是每页 LIMIT 20 再合并。常见做法:
| 方式 | 做法 | 适合 |
| — | — |
| 查询扇出 | 各分片取 N 条,内存排序后截断 | 小页码和低并发查询 |
| 游标分页 | 各分片按同一游标推进,返回下一游标 | 稳定排序的实时列表 |
| 全局索引 | 先查索引拿 shard key,再回源 | 高频管理查询 |
| 异步物化 | 后台预聚合成宽表 | 报表和大盘 |
深分页、模糊搜索和多字段排序应尽量走专用索引或分析链路,不应压在在线路由层。
十、跨分片事务:优先避免,而不是硬实现
分片后,跨 shard key 的事务会变成分布式事务。工业边缘场景通常不建议依赖 XA / 2PC 作为默认方案,因为它会带来锁持有时间长、协调器单点、网络分区恢复复杂等问题。
更好的设计顺序是:
- 单分片亲和:把同一租户、站点或工单相关数据放在同一分片;
- 拆小事务:每次只改一个聚合根;
- 幂等:请求 ID、事件 ID、版本号;
- Outbox:业务状态与事件同库事务写入;
- Saga / 状态机:跨分片流程显式定义补偿;
- 对账:定时检查状态差异并修复。
适合最终一致的场景
- 告警通知;
- 数据同步;
- 索引更新;
- 缓存失效;
- 报表生成。
必须谨慎的场景
- 控制指令状态变更;
- 计量和结算;
- 安全联锁;
- 权限和审计状态;
- 跨库存量扣减。
这些场景要么保证相关数据落在同一事务边界,要么明确使用支持分布式事务的存储,并提供完整的失败恢复与人工审核路径。
十一、扩容与在线迁移
Sharding 的最终考验是扩容。无论是从 8 片到 16 片,还是把大租户拆出去,都必须有可回滚流程。
迁移阶段
1. 固定当前 schema 与路由版本
2. 创建新分片和表结构
3. 复制存量数据或按 CDC 同步增量
4. 启动受控双写或写新读旧
5. 校验行数、checksum、时间范围和业务抽样
6. 小流量切读
7. 全量切写
8. 保留旧分片作为回滚窗口
9. 归档并清理
迁移控制点
| 控制点 | 建议 |
|---|---|
| 路由版本 | 每次变更递增,新旧版本可共存 |
| 写入路径 | 双写必须幂等,失败要可重试 |
| 数据校验 | 行数、聚合值、checksum、抽样对比 |
| 切换 | 按租户 / 站点灰度,不做一次性全量切换 |
| 回滚 | 保留旧路由和数据,明确反向写入策略 |
| 冻结窗口 | 迁移期间避免修改分片边界和 key 规则 |
一致性哈希只告诉 key 最终归属,不负责安全搬运数据。扩容期间要处理旧请求、重试请求和乱序请求,仍然需要路由快照、序列号和幂等写入。
十二、监控与容量水位
Sharding 上线后,监控要从“数据库是否存活”扩展到“数据是否放对、是否均衡、跨片是否过多”。
| 指标 | 观察什么 | 告警示例 |
|---|---|---|
| 分片磁盘使用率 | 容量是否倾斜 | 任一分片超过 75% |
| 写入 QPS / 字节 | 是否存在热点 | 单片写入超过均值 3 倍 |
| 查询延迟 P95 / P99 | 单片性能 | P99 持续升高 |
| fan-out 查询率 | 路由设计是否失效 | 广播查询占比持续上升 |
| 分片错误率 | 节点或 schema 问题 | 单片错误率突增 |
| 复制 / 迁移 lag | 数据是否可用 | lag 超过恢复目标 |
| key 分布 | hash 是否均衡 | 最大最小分片差超过阈值 |
| TTL 执行情况 | 旧数据是否清理 | 分区删除滞后超过 1 天 |
同时保存路由版本分布:
routing_version=v12: 85%
routing_version=v13: 15%
tenant=plant-a: v13
tenant=plant-b: v12
如果版本长期无法收敛,说明迁移或客户端配置存在问题。
十三、常见坑与修正
| 常见做法 | 问题 | 修正 |
|---|---|---|
用 Python hash() 路由 | 进程间结果可能不同 | 使用稳定哈希 |
| 只按时间分片 | 最新分片写入热点 | 时间 + 站点 / 设备混合 |
| 只按告警级别分片 | 基数太低,分布极不均 | 改用租户、设备等高基数 key |
| 以为一致性哈希不用迁移 | 扩容时仍有数据搬迁 | 做快照、回填、校验和灰度切换 |
| 跨片查询直接拼接 | 排序、分页、失败状态不正确 | 统一合并并标记 partial |
| 频繁修改 shard key | 数据大量迁移 | key 不可变,路由版本化 |
| 只监控平均延迟 | 少数慢分片被掩盖 | 按分片看 P95 / P99 |
| 没有容量水位 | 扩容变成救火 | 明确触发条件和预备流程 |
十四、落地检查清单
上线前逐项确认:
- 已明确热数据保留周期和冷数据归档策略;
- 已统计主要查询条件和写入分布;
- shard key 高基数、稳定、分布均匀;
- 没有使用语言内置随机化 hash;
- 路由函数有版本号和回归测试;
- 大租户和大站点有独立拆分预案;
- 时间分区边界规则统一;
- 跨分片查询支持超时、部分失败、排序和分页;
- 跨分片事务有幂等、补偿或对账方案;
- 扩容步骤包含回滚窗口;
- 迁移期间路由版本可观测;
- 分片容量、热点、延迟、错误率都有告警;
- 边缘断网时本地写入和补传不会破坏路由一致性;
- 已做过故障演练和数据抽样校验。
TL;DR
Sharding 的目标不是让架构看起来更分布式,而是让数据放置方式匹配查询和增长模型。
核心结论:
- Hash 分片分布均匀,但范围查询弱,扩容重映射多;
- 一致性哈希通过虚拟节点降低迁移量,但不消除迁移,也不解决热点;
- 范围分片适合时间窗口和 TTL,但要防最新分片热点;
- 业务分片适合租户、站点和设备族,需处理大客户局部拆分;
- 工业遥测常用“业务 key 路由 + 时间分区 + 冷热分层”的组合;
- 跨分片查询要处理部分失败、排序、分页和聚合;
- 跨分片事务优先通过单分片亲和、幂等、Outbox 和对账规避;
- 扩容必须有路由版本、数据回填、校验、灰度和回滚。
真正决定 Sharding 成败的,往往不是哈希算法,而是 shard key、迁移流程和可观测性。

459

被折叠的 条评论
为什么被折叠?



