Skip to main content

tdm_server_rust/db/
shard.rs

1//! 分库分表路由 (Sharding)
2//!
3//! 编辑器标记单元 `editor_unit` 以 `episode_id` 作为分片键:
4//!
5//! - **默认**:单库内按 `episode_id` 做 HASH 分区(见 schema,8 个分区),等价分表,
6//!   由 PostgreSQL 自动按分区键路由,应用层无需感知。
7//! - **扩展**:未来可将分区迁到独立库;届时由 [`ShardRouter`] 按 [`ShardKey`] 把请求
8//!   路由到对应库的连接池。当前实现只持有单一连接池。
9//!
10//! 约束:所有 `editor_unit` / `editor_page` 查询 **必须** 携带 `episode_id` 过滤,
11//! 禁止全分区/全表扫描,以保证单分片命中与未来跨库不跨片。
12
13use crate::db::DbConn;
14
15/// 默认 HASH 分区数(与 schema 中 `MODULUS 8` 保持一致)
16pub const EDITOR_UNIT_PARTITIONS: u32 = 8;
17
18/// 分片键
19///
20/// 编辑器域以话数为分片单元;漫画 ID 仅作辅助归类。
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct ShardKey {
23    /// 漫画 ID(可选,用于跨话数归类与缓存对齐)
24    pub manga_id: Option<i32>,
25    /// 话数 ID(主分片键)
26    pub episode_id: i32,
27}
28
29impl ShardKey {
30    /// 由话数 ID 构造分片键
31    pub fn from_episode(episode_id: i32) -> Self {
32        Self {
33            manga_id: None,
34            episode_id,
35        }
36    }
37
38    /// 计算该分片键落在哪个 HASH 分区(0..partitions)。
39    ///
40    /// 仅用于诊断/未来多库路由;单库部署下 PostgreSQL 自动路由,无需调用。
41    pub fn partition(&self, partitions: u32) -> u32 {
42        // 采用非负取模,保证与 PostgreSQL HASH 分区落点同一桶序无强耦合,
43        // 仅用于应用层一致性诊断与多库映射。
44        let v = self.episode_id as i64;
45        (v.rem_euclid(partitions as i64)) as u32
46    }
47}
48
49/// 分片路由器
50///
51/// 当前为单库实现:所有分片键都返回同一连接。多库分片时在此按 [`ShardKey::partition`]
52/// 选择目标库连接池。
53#[derive(Clone)]
54pub struct ShardRouter {
55    /// 单库连接(默认)
56    primary: DbConn,
57}
58
59impl ShardRouter {
60    /// 用单库连接构造路由器
61    pub fn single(primary: DbConn) -> Self {
62        Self { primary }
63    }
64
65    /// 按分片键取连接(当前恒返回单库连接)
66    pub fn conn_for(&self, _key: ShardKey) -> &DbConn {
67        &self.primary
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    /// 同一 episode_id 必须固定落在同一分片(路由稳定)
76    #[test]
77    fn same_episode_routes_to_same_partition() {
78        let first = ShardKey::from_episode(123_456).partition(EDITOR_UNIT_PARTITIONS);
79        let second = ShardKey::from_episode(123_456).partition(EDITOR_UNIT_PARTITIONS);
80        assert_eq!(first, second);
81        assert!(first < EDITOR_UNIT_PARTITIONS);
82    }
83
84    /// 负数/边界话数 ID 也必须落在合法分区范围内
85    #[test]
86    fn partition_always_in_range() {
87        for id in [-7, -1, 0, 1, 7, 8, i32::MAX] {
88            let p = ShardKey::from_episode(id).partition(EDITOR_UNIT_PARTITIONS);
89            assert!(
90                p < EDITOR_UNIT_PARTITIONS,
91                "episode_id={id} 越界 partition={p}"
92            );
93        }
94    }
95}