文档修改
This commit is contained in:
@@ -762,15 +762,64 @@ class PropertyManager(ActiveManager):
|
||||
## 八、数据量与性能预测
|
||||
|
||||
| 表名 | 预估行数 | 增长速度 | 分区策略 |
|
||||
|------|---------|---------|---------|
|
||||
|------|---------|---------|---------|
|
||||
| `properties` | 89,000+ | 中速 | 暂不分区,建议 500k 后按 `created_at` RANGE 分区 |
|
||||
| `follow_logs` | 200万+ | 高速(最高频写入) | 按 `created_at` 月度 RANGE 分区 |
|
||||
| `property_photos` | 500万+ | 高速 | 按 `property_id` HASH 分区(16分区) |
|
||||
| `follow_logs` | 200万+ | 高速(最高频写入) | ✅ `PARTITION BY RANGE (created_at)` 月度分区 |
|
||||
| `property_photos` | 500万+ | 高速 | ✅ `PARTITION BY RANGE (created_at)` 月度分区 |
|
||||
| `permission_change_logs` | 100万+ | 中高速 | ✅ `PARTITION BY RANGE (operated_at)` 月度分区 |
|
||||
| `login_attempts` | 500万+ | 高速(每次登录一条) | ✅ `PARTITION BY RANGE (attempted_at)` 月度分区 |
|
||||
| `platform_audit_logs` | 10万+ | 低中速 | ✅ `PARTITION BY RANGE (created_at)` 月度分区 |
|
||||
| `price_changes` | 50万 | 中速 | 无需分区 |
|
||||
| `listing_histories` | 20万 | 低速 | 无需分区 |
|
||||
| `clients` | 10万+ | 中速 | 暂不分区 |
|
||||
| `viewings` | 100万 | 中速 | 无需分区 |
|
||||
|
||||
### 8.1 分区维护策略(partition_maintenance_task)
|
||||
|
||||
所有月度分区表统一由 **Celery Beat 定时任务** `partition_maintenance_task` 维护,每月 1 日凌晨 01:00(UTC+8)自动执行:
|
||||
|
||||
```python
|
||||
# apps/property/tasks.py(及 permission/login/shared 各 App 对应任务)
|
||||
@app.task(name="partition_maintenance_task")
|
||||
def partition_maintenance_task():
|
||||
"""
|
||||
为下一个月预建所有分区表的分区。
|
||||
- 检查是否已存在目标分区,幂等执行
|
||||
- 失败时发送 Sentry 告警
|
||||
"""
|
||||
tables = [
|
||||
("follow_logs", "created_at"),
|
||||
("property_photos", "created_at"),
|
||||
("permission_change_logs", "operated_at"),
|
||||
("login_attempts", "attempted_at"),
|
||||
("public.platform_audit_logs", "created_at"),
|
||||
]
|
||||
next_month = date.today().replace(day=1) + relativedelta(months=1)
|
||||
month_start = next_month
|
||||
month_end = next_month + relativedelta(months=1)
|
||||
|
||||
for table, _key in tables:
|
||||
suffix = month_start.strftime("%Y_%m")
|
||||
part_name = f"{table.replace('.', '_')}_{suffix}"
|
||||
sql = f"""
|
||||
CREATE TABLE IF NOT EXISTS {part_name}
|
||||
PARTITION OF {table}
|
||||
FOR VALUES FROM ('{month_start}') TO ('{month_end}');
|
||||
"""
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(sql)
|
||||
```
|
||||
|
||||
**Celery Beat 配置**(`celery.py`):
|
||||
```python
|
||||
app.conf.beat_schedule["partition_maintenance_task"] = {
|
||||
"task": "partition_maintenance_task",
|
||||
"schedule": crontab(day_of_month=1, hour=1, minute=0), # 每月1日 01:00 UTC+8
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ **注意**:每张分区表均保留一个 `_default` 默认分区作为兜底,防止任务失败时写入报错。`_default` 分区数据应在运维 SOP 中周期性检查(有数据则说明提前建分区失败)。
|
||||
|
||||
---
|
||||
|
||||
## 九、必须在开发启动前明确的数据架构决策
|
||||
|
||||
@@ -107,6 +107,7 @@ Staff (员工)
|
||||
| deleted_at | TIMESTAMPTZ | | 软删除 |
|
||||
| created_by | UUID | FK→staff, SET NULL | |
|
||||
| updated_by | UUID | FK→staff, SET NULL | |
|
||||
| version | INTEGER | NOT NULL DEFAULT 1 | 乐观锁版本号;每次 UPDATE +1;应用层检测 0 行受影响时抛 ConflictError |
|
||||
|
||||
**关键索引**:
|
||||
```sql
|
||||
|
||||
@@ -204,6 +204,7 @@ CREATE INDEX idx_schools_name_trgm ON schools USING gin(name gin_trgm_ops);
|
||||
| deleted_at | TIMESTAMPTZ | | 软删除 |
|
||||
| created_by | UUID | FK→staff, SET NULL | |
|
||||
| updated_by | UUID | FK→staff, SET NULL | |
|
||||
| version | INTEGER | NOT NULL DEFAULT 1 | 乐观锁版本号;每次 UPDATE +1;应用层检测 0 行受影响时抛 ConflictError |
|
||||
|
||||
**关键索引**:
|
||||
```sql
|
||||
|
||||
@@ -190,13 +190,39 @@ class UserAccount(AbstractBaseUser):
|
||||
|
||||
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
|
||||
| `id` | `BIGSERIAL` | `NOT NULL` | — | 自增主键(与 attempted_at 组成复合 PK) |
|
||||
| `attempted_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 尝试时间(分区键,必须在复合主键中) |
|
||||
| `username` | `VARCHAR(30)` | `NOT NULL` | — | 尝试登录的用户名(冗余存储,即使账号不存在也记录) |
|
||||
| `ip_address` | `INET` | `NOT NULL` | — | 来源 IP 地址(支持 IPv4/IPv6) |
|
||||
| `user_agent` | `TEXT` | `NULL` | `NULL` | 客户端 User-Agent(Electron 版本信息) |
|
||||
| `success` | `BOOLEAN` | `NOT NULL` | — | 是否登录成功 |
|
||||
| `failure_reason` | `VARCHAR(30)` | `NULL` | `NULL` | 失败原因;可选值见下方枚举 |
|
||||
| `attempted_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 尝试时间 |
|
||||
|
||||
> ⚠️ **分区说明**:`login_attempts` 为高写入审计表,采用 `PARTITION BY RANGE (attempted_at)` 按月分区。主键为 `(id, attempted_at)` 复合主键(分区表规范:主键必须包含分区键)。
|
||||
|
||||
**DDL**:
|
||||
```sql
|
||||
CREATE TABLE login_attempts (
|
||||
id BIGSERIAL NOT NULL,
|
||||
attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 分区键
|
||||
username VARCHAR(30) NOT NULL,
|
||||
ip_address INET NOT NULL,
|
||||
user_agent TEXT,
|
||||
success BOOLEAN NOT NULL,
|
||||
failure_reason VARCHAR(30)
|
||||
CHECK (failure_reason IS NULL OR failure_reason IN (
|
||||
'wrong_password','wrong_captcha','account_locked',
|
||||
'account_disabled','tenant_not_found'
|
||||
)),
|
||||
PRIMARY KEY (id, attempted_at) -- 分区表主键必须包含分区键
|
||||
) PARTITION BY RANGE (attempted_at);
|
||||
|
||||
CREATE TABLE login_attempts_2026_04 PARTITION OF login_attempts
|
||||
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||
CREATE TABLE login_attempts_2026_05 PARTITION OF login_attempts
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE login_attempts_default PARTITION OF login_attempts DEFAULT;
|
||||
```
|
||||
|
||||
**`failure_reason` 枚举值**:
|
||||
|
||||
|
||||
@@ -988,7 +988,8 @@ CREATE INDEX idx_data_scopes_expires ON staff_data_scopes(expires_at) WHERE expi
|
||||
|
||||
-- permission_change_logs (append-only, no deleted_at)
|
||||
CREATE TABLE permission_change_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
operated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 分区键(原 operated_at 前置)
|
||||
target_type VARCHAR(30) NOT NULL
|
||||
CHECK (target_type IN ('role','role_permission','staff_role','staff_override','staff_scope')),
|
||||
target_id UUID NOT NULL,
|
||||
@@ -1003,8 +1004,15 @@ CREATE TABLE permission_change_logs (
|
||||
operator_ip INET,
|
||||
user_agent TEXT,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
operated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
PRIMARY KEY (id, operated_at) -- 分区表主键必须包含分区键
|
||||
) PARTITION BY RANGE (operated_at);
|
||||
|
||||
CREATE TABLE permission_change_logs_2026_04 PARTITION OF permission_change_logs
|
||||
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||
CREATE TABLE permission_change_logs_2026_05 PARTITION OF permission_change_logs
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE permission_change_logs_default PARTITION OF permission_change_logs DEFAULT;
|
||||
CREATE INDEX idx_perm_log_staff ON permission_change_logs(staff_id, operated_at DESC) WHERE staff_id IS NOT NULL;
|
||||
CREATE INDEX idx_perm_log_role ON permission_change_logs(role_id, operated_at DESC) WHERE role_id IS NOT NULL;
|
||||
CREATE INDEX idx_perm_log_target ON permission_change_logs(target_type, target_id, operated_at DESC);
|
||||
|
||||
@@ -310,7 +310,10 @@ CREATE TABLE properties (
|
||||
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL,
|
||||
|
||||
-- ── 全文检索向量 ──
|
||||
search_vector TSVECTOR
|
||||
search_vector TSVECTOR,
|
||||
|
||||
-- ── 乐观锁 ──
|
||||
version INTEGER NOT NULL DEFAULT 1 -- 每次 UPDATE 必须 +1;应用层检测 0 行受影响时抛 ConflictError
|
||||
);
|
||||
|
||||
-- ── 索引策略 ──
|
||||
@@ -520,7 +523,8 @@ CREATE INDEX idx_price_changes_time ON price_changes(property_id, changed_at DES
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE follow_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 分区键,必须在最前声明
|
||||
property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
|
||||
|
||||
log_type VARCHAR(30) NOT NULL
|
||||
@@ -554,9 +558,19 @@ CREATE TABLE follow_logs (
|
||||
-- 是否可删除(sensitive_view = FALSE,合规强制)
|
||||
is_deletable BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ -- 仅 is_deletable=TRUE 时可软删
|
||||
);
|
||||
deleted_at TIMESTAMPTZ, -- 仅 is_deletable=TRUE 时可软删
|
||||
|
||||
PRIMARY KEY (id, created_at) -- 分区表主键必须包含分区键
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
-- ── 按月自动建分区(由 partition_maintenance_task Celery 任务维护)──
|
||||
-- 示例:初始建立当前月 + 下一个月的分区
|
||||
CREATE TABLE follow_logs_2026_04 PARTITION OF follow_logs
|
||||
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||
CREATE TABLE follow_logs_2026_05 PARTITION OF follow_logs
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
-- 默认分区:兜底,防止超出已建分区范围导致写入失败
|
||||
CREATE TABLE follow_logs_default PARTITION OF follow_logs DEFAULT;
|
||||
|
||||
-- 时间线展示(核心)
|
||||
CREATE INDEX idx_follow_logs_property_time ON follow_logs(property_id, created_at DESC)
|
||||
@@ -798,7 +812,8 @@ CREATE INDEX idx_survey_photos_category ON survey_photos(survey_id, category);
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE property_photos (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 分区键
|
||||
property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
|
||||
|
||||
category VARCHAR(20) NOT NULL
|
||||
@@ -817,10 +832,17 @@ CREATE TABLE property_photos (
|
||||
is_cover BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES staff(id) ON DELETE SET NULL
|
||||
);
|
||||
created_by UUID REFERENCES staff(id) ON DELETE SET NULL,
|
||||
|
||||
PRIMARY KEY (id, created_at) -- 分区表主键必须包含分区键
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE property_photos_2026_04 PARTITION OF property_photos
|
||||
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||
CREATE TABLE property_photos_2026_05 PARTITION OF property_photos
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE property_photos_default PARTITION OF property_photos DEFAULT;
|
||||
|
||||
CREATE INDEX idx_property_photos_property ON property_photos(property_id);
|
||||
CREATE INDEX idx_property_photos_cover ON property_photos(property_id)
|
||||
|
||||
@@ -241,7 +241,8 @@ CREATE INDEX idx_ip_whitelist_active ON public.ip_whitelist(cidr) WHERE is_activ
|
||||
|
||||
-- 平台操作审计日志(所有写操作 + 高危操作,无 deleted_at,无 UPDATE)
|
||||
CREATE TABLE public.platform_audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 分区键
|
||||
operator_id UUID, -- 管理员 ID;NULL 表示系统自动操作
|
||||
operator_name VARCHAR(100), -- 快照(防止账号删除后失去溯源)
|
||||
action_type VARCHAR(50) NOT NULL,
|
||||
@@ -257,9 +258,16 @@ CREATE TABLE public.platform_audit_logs (
|
||||
CHECK (result IN ('SUCCESS','FAILED')),
|
||||
error_message TEXT,
|
||||
ip_address INET,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
-- 无 deleted_at,无 UPDATE;建议按月 RANGE 分区
|
||||
);
|
||||
-- 无 deleted_at,无 UPDATE;按月 RANGE 分区
|
||||
|
||||
PRIMARY KEY (id, created_at) -- 分区表主键必须包含分区键
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE public.platform_audit_logs_2026_04 PARTITION OF public.platform_audit_logs
|
||||
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||
CREATE TABLE public.platform_audit_logs_2026_05 PARTITION OF public.platform_audit_logs
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE public.platform_audit_logs_default PARTITION OF public.platform_audit_logs DEFAULT;
|
||||
|
||||
CREATE INDEX idx_audit_logs_operator ON public.platform_audit_logs(operator_id, created_at DESC);
|
||||
CREATE INDEX idx_audit_logs_action ON public.platform_audit_logs(action_type, created_at DESC);
|
||||
|
||||
Reference in New Issue
Block a user