Files
AIclinicalresearch/docs/07-运维文档/08-Postgres-Only 全能架构解决方案.md
HaHafeng 66255368b7 feat(admin): Add user management and upgrade to module permission system
Features - User Management (Phase 4.1):
- Database: Add user_modules table for fine-grained module permissions
- Database: Add 4 user permissions (view/create/edit/delete) to role_permissions
- Backend: UserService (780 lines) - CRUD with tenant isolation
- Backend: UserController + UserRoutes (648 lines) - 13 API endpoints
- Backend: Batch import users from Excel
- Frontend: UserListPage (412 lines) - list/filter/search/pagination
- Frontend: UserFormPage (341 lines) - create/edit with module config
- Frontend: UserDetailPage (393 lines) - details/tenant/module management
- Frontend: 3 modal components (592 lines) - import/assign/configure
- API: GET/POST/PUT/DELETE /api/admin/users/* endpoints

Architecture Upgrade - Module Permission System:
- Backend: Add getUserModules() method in auth.service
- Backend: Login API returns modules array in user object
- Frontend: AuthContext adds hasModule() method
- Frontend: Navigation filters modules based on user.modules
- Frontend: RouteGuard checks requiredModule instead of requiredVersion
- Frontend: Remove deprecated version-based permission system
- UX: Only show accessible modules in navigation (clean UI)
- UX: Smart redirect after login (avoid 403 for regular users)

Fixes:
- Fix UTF-8 encoding corruption in ~100 docs files
- Fix pageSize type conversion in userService (String to Number)
- Fix authUser undefined error in TopNavigation
- Fix login redirect logic with role-based access check
- Update Git commit guidelines v1.2 with UTF-8 safety rules

Database Changes:
- CREATE TABLE user_modules (user_id, tenant_id, module_code, is_enabled)
- ADD UNIQUE CONSTRAINT (user_id, tenant_id, module_code)
- INSERT 4 permissions + role assignments
- UPDATE PUBLIC tenant with 8 module subscriptions

Technical:
- Backend: 5 new files (~2400 lines)
- Frontend: 10 new files (~2500 lines)
- Docs: 1 development record + 2 status updates + 1 guideline update
- Total: ~4900 lines of code

Status: User management 100% complete, module permission system operational
2026-01-16 13:42:10 +08:00

22 KiB
Raw Blame History

Postgres-Only 全能架构解决方案

—— 面向微型 AI 团队的高可靠、低成本技术战略

版本v1.0
适用场景1-2人初创团队、Node.js/Fastify 技术栈、阿里云 SAE 部署环境
核心目标:在不引入 Redis 的前提下,实现企业级的任务队列、缓存与会话管理,保障 2小时+ 长任务的绝对可靠性。

1. 执行摘要 (Executive Summary)

针对我方当前MAU < 5000的业务规模与“稳定性优先”的战略诉求本方案主张采用 "Postgres-Only" (全能数据库) 架构。

通过利用 PostgreSQL 的高级特性(如 SKIP LOCKED 锁机制、JSONB 存储、Unlogged Tables我们可以完全替代 Redis 在任务队列缓存会话存储中的作用。

战略收益:

  1. 架构极简:移除 Redis 中间件,系统复杂度降低 50%。
  2. 数据强一致:业务数据与任务状态在同一事务中提交,彻底根除“分布式事务”风险。
  3. 零额外成本:复用现有 RDS 资源,每年节省数千元中间件费用。
  4. 企业级可靠:依托 RDS 的自动备份与 PITR时间点恢复能力保障任务队列数据“永不丢失”。

2. 问题背景与挑战

2.1 当前痛点:长任务的脆弱性

我们的业务涉及“文献全库解析”和“双模型交叉验证”,单次任务耗时可能长达 2小时

  • 现状使用内存队列MemoryQueue
  • 风险:在 Serverless (SAE) 环境下,实例可能因无流量缩容、发布更新或内存溢出而随时销毁。一旦销毁,内存中的任务进度即刻丢失,导致用户任务失败。

2.2 常见误区:只有 Redis 能救命?

业界常见的观点认为:“必须引入 Redis (BullMQ) 才能实现任务持久化。”

  • 反驳:这是惯性思维。任务持久化的核心是**“持久化存储”**,而非 Redis 本身。PostgreSQL 同样具备持久化能力,且在事务安全性上优于 Redis。

3. 核心解决方案Postgres-Only 架构

本方案将 Redis 的三大核心功能(队列、缓存、会话)全部收敛至 PostgreSQL。

3.1 替代 Redis 队列:使用 pg-boss

我们引入 Node.js 库 pg-boss,它利用 PostgreSQL 的 FOR UPDATE SKIP LOCKED 特性,实现了高性能的抢占式队列。

架构逻辑

  1. 入队API 接收请求将任务元数据JSON写入 job 表。此操作在毫秒级完成,数据立即安全落盘。
  2. 处理Worker 进程从数据库捞取任务,并锁定该行记录。
  3. 容灾:如果 SAE 实例在处理过程中崩溃(如 OOM数据库锁会在超时后自动释放其他存活的 Worker 实例会立即接管该任务重试。

代码实现范式

import PgBoss from 'pg-boss';

const boss = new PgBoss({
connectionString: process.env.DATABASE_URL,
schema: 'job_queue', // 独立Schema不污染业务表
max: 5 // 并发控制,保护 DeepSeek API
});

await boss.start();

// 消费者定义 (Worker)
await boss.work('screening-task', {
// 关键配置:设置锁的有效期为 4小时
// 即使任务跑 3.9小时,只要 Worker 活着,就不会被抢走
// 如果 Worker 死了,锁过期,任务自动重试
expireInSeconds: 14400,
retryLimit: 3
}, async (job) => {
// 业务逻辑...
});

3.2 替代 Redis 缓存:基于 Table 的 KV 存储

对于 AI 结果缓存(避免重复调用 LLMPostgres 的查询速度1-3ms对于用户体验秒级等待来说完全可以接受。

性能论证

实际并发分析:
- 当前规模: 500 MAU
- 峰值并发: < 50 QPS极端情况
- Postgres能力: 5万+ QPS简单查询
- 性能余量: 1000倍

响应时间对比:
- Redis: 0.15ms(网络+读取)
- Postgres: 1.5ms(网络+查询)
- 差异: 1.35ms
- 用户感知: 无总耗时200ms中占比 < 1%

结论在日活10万以下Postgres性能完全够用

数据库设计

model AppCache {
  id         Int      @id @default(autoincrement())
  key        String   @unique
  value      Json     // 对应 Redis 的 Value
  expiresAt  DateTime // 对应 Redis 的 TTL
  createdAt  DateTime @default(now())

  @@index([expiresAt]) // 索引用于快速清理过期数据
  @@index([key, expiresAt]) // 复合索引优化查询
  @@map("app_cache")
}

封装 Service完整版

// 文件backend/src/common/cache/PostgresCacheAdapter.ts

import { prisma } from '../../lib/prisma';
import type { CacheAdapter } from './types';
import { logger } from '../logging/index';

export class PostgresCacheAdapter implements CacheAdapter {
  
  /**
   * 获取缓存(带懒惰删除)
   */
  async get<T = any>(key: string): Promise<T | null> {
    try {
      const record = await prisma.appCache.findUnique({
        where: { key }
      });
      
      if (!record) return null;
      
      // 检查是否过期
      if (record.expiresAt < new Date()) {
        // 懒惰删除:顺手清理(异步,不阻塞)
        this.deleteAsync(key);
        return null;
      }
      
      return record.value as T;
      
    } catch (error) {
      logger.error('[PostgresCache] 读取失败', { key, error });
      return null;
    }
  }

  /**
   * 设置缓存
   */
  async set(key: string, value: any, ttlSeconds: number = 3600): Promise<void> {
    try {
      const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
      
      await prisma.appCache.upsert({
        where: { key },
        create: { key, value, expiresAt },
        update: { value, expiresAt }
      });
      
      logger.debug('[PostgresCache] 写入成功', { key, ttl: ttlSeconds });
      
    } catch (error) {
      logger.error('[PostgresCache] 写入失败', { key, error });
      throw error;
    }
  }

  /**
   * 删除缓存
   */
  async delete(key: string): Promise<boolean> {
    try {
      await prisma.appCache.delete({ where: { key } });
      return true;
    } catch (error) {
      return false;
    }
  }

  /**
   * 异步删除(不阻塞主流程)
   */
  private deleteAsync(key: string): void {
    prisma.appCache.delete({ where: { key } })
      .catch(err => logger.debug('[PostgresCache] 懒惰删除失败', { key, err }));
  }

  /**
   * 批量删除
   */
  async deleteMany(pattern: string): Promise<number> {
    try {
      const result = await prisma.appCache.deleteMany({
        where: { key: { contains: pattern } }
      });
      return result.count;
    } catch (error) {
      logger.error('[PostgresCache] 批量删除失败', { pattern, error });
      return 0;
    }
  }

  /**
   * 清空所有缓存
   */
  async flush(): Promise<void> {
    try {
      await prisma.appCache.deleteMany({});
      logger.info('[PostgresCache] 缓存已清空');
    } catch (error) {
      logger.error('[PostgresCache] 清空失败', { error });
    }
  }
}

/**
 * 启动定时清理任务(分批清理,防止阻塞)
 */
export function startCacheCleanupTask() {
  setInterval(async () => {
    try {
      // 每次只删除1000条过期数据
      const result = await prisma.$executeRaw`
        DELETE FROM app_cache 
        WHERE id IN (
          SELECT id FROM app_cache 
          WHERE expires_at < NOW() 
          LIMIT 1000
        )
      `;
      
      if (result > 0) {
        logger.info('[PostgresCache] 清理过期数据', { count: result });
      }
      
    } catch (error) {
      logger.error('[PostgresCache] 定时清理失败', { error });
    }
  }, 60000);  // 每分钟执行一次
  
  logger.info('[PostgresCache] 定时清理任务已启动每分钟1000条');
}

性能优化技巧

  1. 索引优化@@index([key, expiresAt]) 覆盖查询,无需回表
  2. 懒惰删除:读取时顺便清理,分散负载
  3. 分批清理每次LIMIT 1000毫秒级完成
  4. 连接池复用Prisma自动管理无额外开销

3.3 替代 Redis 会话connect-pg-simple

使用成熟的社区方案,将 Session 存储在 Postgres 的 session 表中。SAE 多实例重启后,用户无需重新登录。

4. 深度对比:为什么 Postgres 胜出?

维度 方案 A: 传统 Redis (BullMQ) 方案 B: Postgres (pg-boss) 获胜原因
数据一致性 (双写一致性难题) 任务在 Redis业务在 DB。若 DB 事务回滚Redis 任务可能无法回滚。 (事务级原子性) 任务入队与业务数据写入在同一个 DB 事务中。要么全成,要么全败。 Postgres
运维复杂度 需维护 Redis 实例、VPC 白名单、监控内存碎片率、持久化策略。 复用现有 RDS。备份、监控、扩容全由阿里云 RDS 托管。 Postgres
备份与恢复 困难 Redis RDB/AOF 恢复可能会丢失最后几秒的数据。 完美 RDS 支持 PITR (按时间点恢复)。误删任务可精确回滚到 1秒前。 Postgres
成本 ¥1000+/年 (Tair 基础版) ¥0 (资源复用) Postgres
性能 (TPS) 极高 (10w+) (5000+) 对于日均几万次 AI 调用的场景Postgres 性能绰绰有余。 Postgres
锁竞争问题 误解 读写都需要网络往返高并发下Redis也会有竞争 真相 SELECT是快照读不加锁。Node.js单线程会先成为瓶颈。 误解澄清
缓存清理风险 存在 内存溢出需要配置eviction策略不当配置会导致数据丢失 可控 分批删除LIMIT 1000+ 懒惰删除,永远不会阻塞。 Postgres
学习曲线 陡峭 需要学习Redis、BullMQ、ioredis、持久化策略、内存管理 平缓 只需学习pg-bossAPI类似BullMQ其余都是熟悉的Postgres Postgres

常见误解澄清

误解1Postgres并发性能差

事实:
- Postgres可处理5万+ QPS简单查询
- 您的实际并发: < 50 QPS
- SELECT是快照读MVCC无锁竞争
- Node.js单线程1-2万QPS上限会先成为瓶颈

结论Postgres不是瓶颈

误解2DELETE会锁表阻塞

事实:
- DELETE是行级锁不是表锁
- LIMIT 1000毫秒级完成~5ms
- 配合懒惰删除,大部分过期数据在读取时已删除
- 即使有积压每分钟1000条1小时可清理6万条

结论:不会阻塞

误解3Redis内存操作一定快

事实:
- Redis: 网络延迟0.1ms + 读取0.05ms = 0.15ms
- Postgres: 网络延迟0.5ms + 查询1ms = 1.5ms
- 差异: 1.35ms
- 但是总响应时间(含业务逻辑): 200ms
- 用户感知差异: 0%1.35/200 < 1%

结论:性能差异在用户体验中无感知

5. 针对“2小时长任务”的可靠性证明

质疑Postgres 真的能保证 2 小时的任务不中断吗?
证明:

  1. 持久化保障任务一旦提交API返回 200 OK即写入硬盘。即使 SAE 集群下一秒全灭,任务记录依然在数据库中。
  2. 崩溃恢复机制
    • 正常情况Worker 锁定任务 -> 执行 2 小时 -> 提交结果 -> 标记完成。
    • 异常情况 (SAE 缩容)Worker 执行到 1 小时被销毁 -> 数据库锁在 4 小时后过期 -> pg-boss 守护进程检测到过期 -> 将任务重新标记为 Pending -> 新 Worker 领取重试。
  3. 断点续传Worker 可定期(如每 10 分钟)更新数据库中的 progress 字段。重试时读取 progress从断点继续执行。

结论:配合 pg-boss 的死信队列Dead Letter和重试策略可靠性等同甚至高于 Redis因为 Redis 内存溢出风险更大)。

6. 实施路线图

阶段1任务队列改造Week 1

Step 1.1:安装依赖

cd backend
npm install pg-boss --save

Step 1.2实现PgBossQueue适配器

// 文件backend/src/common/jobs/PgBossQueue.ts

import PgBoss from 'pg-boss';
import type { Job, JobQueue, JobHandler } from './types';
import { logger } from '../logging/index';
import { config } from '../../config/env';

export class PgBossQueue implements JobQueue {
  private boss: PgBoss;
  private started = false;

  constructor() {
    this.boss = new PgBoss({
      connectionString: config.databaseUrl,
      schema: 'job_queue',  // 独立schema不污染业务表
      max: 5,               // 连接池大小
      // 关键配置设置锁的有效期为4小时
      // 保证2小时任务不被抢走但实例崩溃后能自动恢复
      expireInHours: 4,
    });

    // 监听错误
    this.boss.on('error', error => {
      logger.error('[PgBoss] 错误', { error });
    });
  }

  async start(): Promise<void> {
    if (this.started) return;
    
    await this.boss.start();
    this.started = true;
    logger.info('[PgBoss] 队列已启动');
  }

  async push<T = any>(type: string, data: T, options?: any): Promise<Job> {
    await this.start();
    
    const jobId = await this.boss.send(type, data, {
      retryLimit: 3,
      retryDelay: 60,      // 失败后60秒重试
      expireInHours: 4,    // 4小时后过期
      ...options
    });

    logger.info('[PgBoss] 任务入队', { type, jobId });

    return {
      id: jobId,
      type,
      data,
      status: 'pending',
      createdAt: new Date(),
    };
  }

  process<T = any>(type: string, handler: JobHandler<T>): void {
    this.boss.work(type, async (job: any) => {
      logger.info('[PgBoss] 开始处理任务', { 
        type, 
        jobId: job.id,
        attemptsMade: job.data.__retryCount || 0
      });

      const startTime = Date.now();

      try {
        const result = await handler({
          id: job.id,
          type,
          data: job.data as T,
          status: 'processing',
          createdAt: new Date(job.createdon),
        });

        logger.info('[PgBoss] 任务完成', { 
          type, 
          jobId: job.id,
          duration: `${Date.now() - startTime}ms`
        });

        return result;

      } catch (error) {
        logger.error('[PgBoss] 任务失败', { 
          type, 
          jobId: job.id,
          error: error instanceof Error ? error.message : 'Unknown'
        });
        throw error;  // 抛出错误触发重试
      }
    });

    logger.info('[PgBoss] Worker已注册', { type });
  }

  async getJob(id: string): Promise<Job | null> {
    const job = await this.boss.getJobById(id);
    if (!job) return null;

    return {
      id: job.id,
      type: job.name,
      data: job.data,
      status: this.mapState(job.state),
      createdAt: new Date(job.createdon),
    };
  }

  async updateProgress(id: string, progress: number, message?: string): Promise<void> {
    // pg-boss暂不支持进度更新可通过更新业务表实现
    logger.debug('[PgBoss] 进度更新', { id, progress, message });
  }

  async cancelJob(id: string): Promise<boolean> {
    await this.boss.cancel(id);
    return true;
  }

  async retryJob(id: string): Promise<boolean> {
    await this.boss.resume(id);
    return true;
  }

  async cleanup(olderThan: number = 86400000): Promise<number> {
    // pg-boss有自动清理机制
    return 0;
  }

  private mapState(state: string): string {
    switch (state) {
      case 'completed': return 'completed';
      case 'failed': return 'failed';
      case 'active': return 'processing';
      default: return 'pending';
    }
  }

  async close(): Promise<void> {
    await this.boss.stop();
    logger.info('[PgBoss] 队列已关闭');
  }
}

Step 1.3更新JobFactory

// 文件backend/src/common/jobs/JobFactory.ts

import { JobQueue } from './types';
import { MemoryQueue } from './MemoryQueue';
import { PgBossQueue } from './PgBossQueue';
import { logger } from '../logging/index';
import { config } from '../../config/env';

export class JobFactory {
  private static instance: JobQueue | null = null;

  static getInstance(): JobQueue {
    if (!this.instance) {
      this.instance = this.createQueue();
    }
    return this.instance;
  }

  private static createQueue(): JobQueue {
    const queueType = config.queueType || 'pgboss';  // 默认使用pgboss

    switch (queueType) {
      case 'pgboss':
        return new PgBossQueue();
      
      case 'memory':
        logger.warn('[JobFactory] 使用内存队列(开发环境)');
        return new MemoryQueue();
      
      default:
        logger.warn(`[JobFactory] 未知队列类型: ${queueType}使用pgboss`);
        return new PgBossQueue();
    }
  }

  static reset(): void {
    this.instance = null;
  }
}

Step 1.4:更新环境变量

# backend/.env
QUEUE_TYPE=pgboss
DATABASE_URL=postgresql://user:password@host:5432/dbname

Step 1.5测试2小时长任务

# 提交1000篇文献筛选任务
curl -X POST http://localhost:3001/api/v1/asl/projects/:id/screening

# 等待处理到50% → 手动停止服务Ctrl+C
# 重启服务
npm run dev

# 查看任务状态 → 应该自动恢复并继续处理

阶段2缓存改造Week 2

Step 2.1添加Prisma Schema

// backend/prisma/schema.prisma

model AppCache {
  id         Int      @id @default(autoincrement())
  key        String   @unique
  value      Json
  expiresAt  DateTime
  createdAt  DateTime @default(now())

  @@index([expiresAt])
  @@index([key, expiresAt])
  @@map("app_cache")
}
# 生成迁移
npx prisma migrate dev --name add_app_cache

Step 2.2实现PostgresCacheAdapter

(见上文"3.2 替代 Redis 缓存"部分)

Step 2.3更新CacheFactory

// 文件backend/src/common/cache/CacheFactory.ts

export class CacheFactory {
  static getInstance(): CacheAdapter {
    const cacheType = config.cacheType || 'postgres';
    
    switch (cacheType) {
      case 'postgres':
        return new PostgresCacheAdapter();
      case 'memory':
        return new MemoryCacheAdapter();
      default:
        return new PostgresCacheAdapter();
    }
  }
}

Step 2.4:启动定时清理

// 文件backend/src/index.ts

import { startCacheCleanupTask } from './common/cache/PostgresCacheAdapter';

// 在应用启动时
await app.listen({ port: 3001, host: '0.0.0.0' });
startCacheCleanupTask();  // 启动缓存清理

阶段3SAE部署Week 3

  1. 本地测试通过ASL 1000篇文献 + DC 100份病历
  2. 数据库连接配置SAE环境变量设置DATABASE_URL
  3. 灰度发布先1个实例观察24小时
  4. 全量上线扩容到2-3个实例

7. 性能边界与扩展路径

7.1 适用规模

指标 Postgres-Only方案上限 您的当前值 安全余量
日活用户 10万 500 200倍
并发QPS 5000 < 50 100倍
缓存容量 10GB < 100MB 100倍
队列吞吐 1000任务/小时 < 50任务/小时 20倍

结论在可预见的未来2-3年您不会超出这个上限。

7.2 何时需要Redis

只有在以下情况发生时才需要考虑引入Redis

触发条件ANY
✅ 日活 > 5万
✅ 并发QPS > 1000
✅ Postgres CPU使用率持续 > 70%
✅ 缓存查询延迟 > 50msP99
✅ LLM API月成本 > ¥5000缓存命中率低

迁移策略:
1. 先迁移LLM缓存到Redis高频读
2. 保持任务队列在Postgres强一致性
3. 业务缓存按需迁移

成本:
- 迁移工作量: 2-3天
- 运维增加: 可接受(已有经验)

7.3 扩展路径

阶段1当前-5000用户: Postgres-Only
  ├─ 队列: pg-boss
  ├─ 缓存: Postgres表
  └─ 成本: ¥0

阶段25000-5万用户: 混合架构
  ├─ 队列: pg-boss保持
  ├─ LLM缓存: Redis迁移
  ├─ 业务缓存: Postgres保持
  └─ 成本: +¥1000/年

阶段35万-50万用户: 全Redis
  ├─ 队列: BullMQ + Redis
  ├─ 缓存: Redis
  └─ 成本: +¥5000/年

8. FAQ常见疑问

Q1: pg-boss会不会拖慢Postgres

A: 不会。pg-boss的查询都有索引优化单次查询 < 5ms。即使100个Worker同时抢任务也只是500ms的额外负载对于5万QPS的Postgres来说可忽略。

Q2: 缓存表会不会无限增长?

A: 不会。懒惰删除 + 分批清理过期数据会被自动清理。即使有积压每分钟1000条的清理速度足以应对。

Q3: 如果Postgres挂了怎么办

A:

  • 阿里云RDS:高可用版自动主从切换,故障恢复 < 30秒
  • 备份恢复PITR可恢复到任意秒数据不丢失
  • 降级策略队列和缓存都在DB一起恢复无不一致风险

相比之下Redis挂了还需要担心数据不一致问题。

Q4: 为什么不用Redis却说自己是云原生

A: 云原生的核心是状态外置,不是必须用Redis

云原生的本质:
✅ 无状态应用(不依赖本地内存)
✅ 状态持久化(数据不丢失)
✅ 水平扩展(多实例协调)

Postgres-Only完全满足
✅ 状态存储在RDS外置
✅ 任务持久化(不丢失)
✅ pg-boss支持多实例SKIP LOCKED

Redis只是实现方式之一不是唯一方式。

9. 结语

对于 1-2 人规模的精益创业团队,技术栈的坍缩Stack Collapse是降低熵增的最佳手段

选择 Postgres-Only 不是因为我们技术落后,而是因为我们对技术有着更深刻的理解:

  • 我们理解并发的真实规模不是臆想的百万QPS
  • 我们理解Postgres的能力边界(不是印象中的"慢"
  • 我们理解架构的核心目标(稳定性 > 炫技)
  • 我们理解团队的真实能力(运维能力 = 稳定性)

我们选择用架构的简洁性来换取运维的稳定性,用务实的判断来换取业务的快速迭代

这不是妥协,这是智慧。