轻量栈,重负载
模块化单体 + PostgreSQL + 独立 Worker,不依赖 Redis / Kafka / Kubernetes。 不堆叠中间件,把硬件预算留给规则计算与数据库索引。
技术栈总览
每一层的选型都以"减少可故障部件"为目标。能用数据库做的事,不引入新的中间件。
| 层 | 选型 | 说明 |
|---|---|---|
| 前端 | React 19.3 + Vite 6.4 | 唯一运行时依赖 lucide-react;无 UI 组件库、无 TypeScript、无 Redux / Zustand,状态来自服务端响应。 |
| 后端 | Python 3.12 模块化单体 | 185 个 .py / 22,089 行。生产为 FastAPI + Uvicorn;单机模式使用标准库 HTTP。 |
| 存储(单机) | SQLite | 业务库 data/cpq.sqlite3 与控制库 data/control.sqlite3 分离。 |
| 存储(生产) | PostgreSQL 17 | 强制:未设置 CPQ_DATABASE_URL 时,CPQ_ENV=production 启动失败。 |
| 表规模 | 38 张表 / 23 个索引 | 性能索引见下方清单。 |
| 文件层 | blob_store | 本地共享卷或 S3 兼容;租户前缀 CPQ_S3_PREFIX/{tenant}/{key};内容寻址读缓存。 |
| 后台任务 | job_queue + 独立 Worker 子进程 | 数据库原子认领 SKIP LOCKED;租约 180 秒,每 10 秒续租,最多重试 3 次。 |
| 网关 | Nginx 1.30.5 | least_conn、keepalive 32、gzip(响应体 ≥1024 字节)、client_max_body_size 30m。 |
| 中间件依赖 | 无 | 不依赖 Redis / Kafka / Kubernetes。 |
package.json、vite.config.js、pyproject.toml、server/asgi.py、server/storage.py、server/storage_schema.py、server/blob_store.py、server/job_queue.py、server/worker.py、deploy/nginx.conf。性能索引(节选)
38 张表 / 23 个索引中,与列表、检索、授权直接相关的索引名如下。
quotes_owner_identity、quotes_updated、quotes_owner、quotes_scope、quotes_number、products_model、products_sku、lines_sku、grants_user、background_ready
文件层规则
- 租户前缀CPQ_S3_PREFIX/{tenant}/{key}
- 读缓存内容寻址
- prune 范围仅 jobs/ 与 previews/
- 绝不删除原始图片 / 资料 / 报价 / 审计
- 写入前置saas_quota.reserve_file
后台任务语义
- 认领为数据库原子操作(
SKIP LOCKED),同一任务不会被两个 Worker 取走。 - 租约 180 秒,每 10 秒续租;失去租约的旧进程不能覆盖新结果。
- 长任务在独立子进程中运行,默认超时 120 秒;超时先
terminate再kill,并明确失败。 - 幂等:相同 用户 +
requestId+ 参数去重,约束为UNIQUE(actor, request_key)。 - 重试上限 3 次。
网关热解析
upstream 使用 resolve 配合 resolver 127.0.0.11 valid=10s。
容器重建导致 IP 变化时,网关无需重启即可解析到新地址。
- 负载均衡least_conn
- 上游长连接keepalive 32
- gzip 阈值≥ 1024 字节
- 请求体上限30m
进程模型与端口
开发、单机与生产使用同一份镜像,差异只体现在配置与进程编排上。
| 端口 | 用途 |
|---|---|
| 8765 | 单机 / 原工作空间 HTTP:销售端 /sales、管理端 /admin、自助门户 /portal |
| 8766 | 多租户 SaaS:/workspaces(我的企业)、/platform(平台运营);企业入口 <企业地址>.localhost:8766 |
| 5173 | Vite dev server(开发时使用,/api 代理到 8765) |
| 8000 | 容器内 API(不对外暴露) |
| 443 | Nginx HTTPS 网关(生产唯一公开入口) |
生产拓扑
浏览器 → HTTPS 网关 → 无状态 API × 2 容器(每容器 2 个 Uvicorn 进程) → PostgreSQL 17。 独立 Worker 与报表调度器各自连库,不经过 API;API、Worker、调度器三者共享同一套文件存储。
API 无状态
会话与租户上下文不驻留进程内存;容器可随时替换。实测中容器 IP 变化后网关无需改动,登录会话持续有效。
Worker 独立
文档生成、报表导出等长任务在独立子进程中顺序执行,不占用 API 请求名额;默认超时 120 秒。
调度器独立
报表调度器单独连库,与 Worker 使用各自的连接池,互不挤占。
三种部署形态(同一镜像)
从一台笔记本到多租户平台,镜像不变,变的是配置、进程编排与数据库。
单机 / 开发模式
- SQLite,业务库与控制库分离
- 双击
启动CPQ.command启动 - 后台任务同步执行,无需 Worker
- 入口
127.0.0.1:8765
企业独立部署(生产)
- Docker Compose(
deploy/compose.yml) - API×2 + Worker + Scheduler + PostgreSQL 17 + Nginx
- PostgreSQL 17 强制,网关只开 443
- 连接预算 66 < 上限 100
多租户 SaaS
deploy/saas-compose.yml- 每 API 实例最多 10 个租户池 × 2 连接,平台池 4
- 角色分离,API 生产启动拒绝开通凭据
- 首期规模基线约 100 家企业
企业独立部署:服务与关键参数
| 服务 | 关键参数 |
|---|---|
| PostgreSQL 17 | postgres:17-alpine;max_connections=100、shared_buffers=256MB |
| API × 2 | uvicorn --workers 2 --timeout-keep-alive 30 --proxy-headers;CPQ_DB_POOL_SIZE=8、CPQ_CONTROL_POOL_SIZE=4、CPQ_REQUEST_CONCURRENCY=32 |
| Worker | 连接池 4;独立子进程执行长任务 |
| Scheduler | 连接池 2;报表调度 |
| Nginx 网关 | 只开放 443;least_conn、keepalive 32、gzip |
| 连接预算 | 48(API)+ 18(Worker / 调度器)= 66 < PostgreSQL 上限 100 |
多租户 SaaS:池与角色
- 每 API 实例租户池最多 10 × 2 连接
- 平台池4
- 单进程请求并发上限CPQ_REQUEST_CONCURRENCY=16
- 首期规模基线约 100 家企业
PostgreSQL 17 上已验证 100 家企业由持久开通任务创建。
角色分离(四种职责互不复用凭据):
- 集群管理员
- 迁移角色
- 平台 DML 角色
- 开通角色
API 在生产环境启动时拒绝开通凭据,开通能力与线上请求路径隔离。
| 套餐额度项 | 免费版 | 专业版 |
|---|---|---|
| 内部成员 | 3 | 20 |
| 门户账号 | 20 | 200 |
| 目录产品 | 200 | 5,000 |
| 月新报价 | 100 | 2,000 |
| 月文档生成 | 100 | 2,000 |
| 文件容量 | 1 GiB | 20 GiB |
| 并发后台任务 | 1 | 2 |
| 排队上限 | 20 | 100 |
| 每分钟 API 请求 | 600 | 3,000 |
saas_quota 校验,文件写入先经 reserve_file 预留容量。Compose 是单主机参考部署,不宣称主机级高可用。 RPO 24 小时 / RTO 4 小时仍是部署演练目标,不是已达成的 SLA。 未实现自助开通与 SaaS 计费;未测试跨地域数据库与中国办公网络环境。
容器与安全基线
以下为镜像构建、运行时约束、数据库权限与传输层的既定基线,逐项可核查。
镜像与构建
- 两阶段构建:
node:22-bookworm-slim构建前端 →python:3.12-slim-bookworm运行。 - 安装
fonts-wqy-microhei,用于中文 PDF 字体。 - 依赖安装:
pip install --require-hashes -r requirements.lock(85 KB 锁定)。 - 构建期执行
python -m compileall预编译,避免任务子进程重复编译。 - 运行身份
USER cpq(uid 10001,非 root)。
Compose 运行时约束
- no-new-privilegestrue
- cap_drop[ALL]
- inittrue
- mem_limit2g
- stop_grace_period30s
数据库权限初始化
- 应用角色
cpq:NOSUPERUSER NOCREATEDB NOCREATEROLE。 REVOKE ALL ON DATABASE ... FROM PUBLIC。REVOKE CREATE ON SCHEMA public。- 管理员密码与应用密码必须是不同的随机值。
- 不把管理员密码注入 API / Worker。
首次管理员
- 通过
POST /api/auth/setup创建,请求需带X-CPQ-Setup-Token头。 - 成功后从运行配置移除 Token 并重建 API 容器。
- 不提供默认生产账号 / 密码。
- SSO 未实现,当前仅本机账号体系。
| 项 | 基线 |
|---|---|
| 网关日志 | 只记录 $remote_addr "$method $uri" $status $request_time;不记录 Cookie、密码、请求体 |
| 上游重试 | proxy_next_upstream error timeout;写请求失败不重放 |
| 传输 | TLS 1.2 / 1.3 |
| Cookie | host-only + Secure + HttpOnly + __Host- 前缀 |
| 跨站与来源 | CSRF 校验与来源检查 |
| 主机头 | TrustedHostMiddleware 限定可信 Host |
| 监控端点 | /metrics 需 Bearer Token,未授权返回 404(不暴露端点存在) |
| 请求体上限 | 30 MiB |
| 过载保护 | 超载返回 503 + Retry-After,明确"服务繁忙" |
数据隔离与多租户
租户不由请求参数决定。隔离在请求上下文与连接借出两个环节同时生效。
租户识别
- 租户由可信 Host 映射(
CPQ_TENANT_HOSTS)确定。 - 请求参数不能选择租户。
- 每次借出连接都在事务范围内设置
search_path。 - 连接归还后不残留租户状态。
隔离维度
- 会话、账号、报价、后台任务、缓存、文件分别隔离。
- SaaS 每家企业拥有独立的
main/controlschema。 - 每家企业配备专属的 DML 运行角色。
以运行角色尝试跨 schema 读取、写入、创建 schema、创建角色,四项均被拒绝。
租户隔离由应用请求上下文实施,未使用 PostgreSQL RLS,也不向租户提供数据库账号。 合同要求数据库级隔离的客户,须使用独立数据库 + 独立应用实例。 当前未实现自助开通与 SaaS 计费。
报告导出容量边界
超出上限时系统给出明确提示,不静默截断结果。
- 普通报表 · 单次导出≤ 500,000 行
- 复杂内置报表 · 单次导出≤ 200,000 行
- 含动态商务字段的报表≤ 5,000 份报价
- 超限行为明确提示,不静默截断
相关实测:50,000 行 CSV 入队后 6.658 秒完成;50,000 行 XLSX(含入队 / 等待 / 下载)27.191 秒。 测量条件与完整数据见 性能与质量。