Featured image of post 用 DuckDB 搭建多租户营收分析 SaaS,一份代码卖 10 个客户

用 DuckDB 搭建多租户营收分析 SaaS,一份代码卖 10 个客户

用 DuckDB 零配置 OLAP 引擎搭建多租户自动日报系统,每个店铺独立数据隔离,单文件即数据库,代码完全复用,一份产品卖 10 个电商客户,月收过万。

用 DuckDB 搭建多租户营收分析 SaaS,一份代码卖 10 个客户

很多自由数据分析师接到一个反复出现的需求:帮电商店主每天自动出营收分析报告。市面上常见的做法是写 Python 脚本 + 手动跑 SQL,效率低还容易出错。今天用 DuckDB 搭一个轻量级、可复用的多租户自动报告系统,你可以直接拿去改造成自己的 SaaS 产品——一份代码卖给 10 个客户,边际成本几乎为零。

DuckDB 多租户营收分析 SaaS 架构图

为什么选 DuckDB 做 SaaS 后端

传统方案用 Pandas + CSV 处理电商订单数据,遇到千万级记录就卡住了:内存爆炸、IO 瓶颈、执行慢。DuckDB 的核心优势在于:

  • 单文件即数据库:一个 .duckdb 文件就是完整数据库,无需启动任何服务
  • 列式存储 + 向量化执行:同样的聚合查询比 Pandas 快 10-50 倍
  • 多租户天然隔离:每个店铺一张表,同一文件内数据隔离,也可以拆分为独立文件
  • 零运维:不需要 PostgreSQL、MySQL 等服务端数据库

项目结构

duck_revenue_saaS/
├── config.py
├── engine.py
├── report_generator.py
├── queries/
│   ├── daily_summary.sql
│   ├── category_breakdown.sql
│   └── refund_analysis.sql
├── templates/
│   └── report.html
└── main.py

第一步:引擎层——多租户数据隔离

DuckDB 的 CREATE INDEX 支持压缩索引,比普通 B-Tree 更省空间且自动维护。

# engine.py
import duckdb
from pathlib import Path
from contextlib import contextmanager

class DuckRevenueEngine:
    """DuckDB 多租户引擎,一个文件管理所有店铺"""
    
    def __init__(self, db_path: str = "revenue.duckdb"):
        self.db_path = db_path
    
    @contextmanager
    def connection(self):
        conn = duckdb.connect(self.db_path, read_only=False)
        try:
            yield conn
            conn.commit()
        except Exception:
            conn.rollback()
            raise
        finally:
            conn.close()
    
    def init_schema(self, shop_id: str):
        """为每个店铺初始化表结构"""
        with self.connection() as conn:
            conn.execute(f"""
                CREATE TABLE IF NOT EXISTS {shop_id}_orders (
                    order_id VARCHAR PRIMARY KEY,
                    shop_id VARCHAR,
                    order_time TIMESTAMP,
                    customer_id VARCHAR,
                    amount DECIMAL(12, 2),
                    refund_amount DECIMAL(12, 2) DEFAULT 0,
                    status VARCHAR,
                    category VARCHAR,
                    channel VARCHAR
                )
            """)
            conn.execute(f"""
                CREATE INDEX IF NOT EXISTS idx_{shop_id}_time 
                ON {shop_id}_orders(order_time)
            """)
            conn.execute(f"""
                CREATE INDEX IF NOT EXISTS idx_{shop_id}_cust 
                ON {shop_id}_orders(customer_id)
            """)
            print(f"✅ 店铺 {shop_id} 表结构初始化完成")

关键设计:所有店铺共享同一个 DuckDB 文件,通过表名前缀隔离。好处是:

  1. 部署极其简单——只有一个文件
  2. 跨店铺分析时直接 JOIN,无需分布式架构
  3. 备份只需拷贝一个文件

第二步:SQL 模板化查询

把查询语句拆成独立 .sql 文件,用 {{placeholder}} 做占位符,在 Python 层做替换。这样 SQL 和代码分离,方便维护和审计。

-- queries/daily_summary.sql
SELECT 
    shop_id,
    DATE(order_time) AS order_date,
    COUNT(*) AS total_orders,
    SUM(amount - refund_amount) AS net_gmv,
    AVG(amount - refund_amount) AS avg_order_value,
    ROUND(
        SUM(refund_amount) * 100.0 / NULLIF(SUM(amount), 0),
        2
    ) AS refund_rate_pct
FROM {{shop_table}}
WHERE DATE(order_time) = DATE('{{date}}')
GROUP BY shop_id, DATE(order_time)
ORDER BY order_date DESC
-- queries/category_breakdown.sql
SELECT 
    category,
    COUNT(*) AS order_count,
    SUM(amount - refund_amount) AS category_gmv,
    ROUND(
        SUM(amount - refund_amount) * 100.0 / 
        NULLIF(SUM(SUM(amount - refund_amount)) OVER(), 0),
        2
    ) AS gmv_share_pct
FROM {{shop_table}}
WHERE DATE(order_time) = DATE('{{date}}')
GROUP BY category
ORDER BY category_gmv DESC
LIMIT 10
-- queries/refund_analysis.sql
SELECT 
    DATE(order_time) AS refund_date,
    order_id,
    amount,
    refund_amount,
    customer_id,
    category
FROM {{shop_table}}
WHERE DATE(order_time) = DATE('{{date}}')
  AND refund_amount > 0
ORDER BY refund_amount DESC
LIMIT 20

注意 {{shop_table}}{{date}} 是运行时替换的占位符,不是 SQL 参数绑定。这种模板化方式更适合批量生成报告,每个店铺独立替换。

第三步:报告生成器

# report_generator.py
import duckdb
from pathlib import Path
from datetime import date, datetime, timedelta
import jinja2

class ReportGenerator:
    def __init__(self, engine, template_dir: str = "templates"):
        self.engine = engine
        self.env = jinja2.Environment(
            loader=jinja2.FileSystemLoader(template_dir),
            autoescape=True
        )
    
    def generate_report(self, shop_id: str, report_date: date) -> str:
        table_name = f"{shop_id}_orders"
        date_str = report_date.strftime("%Y-%m-%d")
        
        with self.engine.connection() as conn:
            # 汇总数据
            summary = self._run_query(
                conn, "queries/daily_summary.sql",
                shop_table=table_name, date=date_str
            )
            # 品类分布
            categories = self._run_query(
                conn, "queries/category_breakdown.sql",
                shop_table=table_name, date=date_str
            )
            # 退款明细
            refunds = self._run_query(
                conn, "queries/refund_analysis.sql",
                shop_table=table_name, date=date_str
            )
            # 对比昨日
            yesterday = self._run_query(
                conn, "queries/daily_summary.sql",
                shop_table=table_name,
                date=(report_date - timedelta(days=1)).strftime("%Y-%m-%d")
            )
        
        template = self.env.get_template("report.html")
        return template.render(
            shop_id=shop_id,
            report_date=report_date,
            summary=summar.fetchone() if summary else None,
            categories=categories.fetchall() if categories else [],
            refunds=refunds.fetchall() if refunds else [],
            yesterday=yesterday.fetchone() if yesterday else None,
            now=datetime.now().strftime("%Y-%m-%d %H:%M")
        )
    
    def _run_query(self, conn, query_file: str, **params):
        sql = Path(__file__).parent / query_file
        sql_text = sql.read_text()
        for key, value in params.items():
            sql_text = sql_text.replace(f"{{{{{key}}}}}", str(value))
        return conn.execute(sql_text)

第四步:主程序与调度

# main.py
import argparse
from datetime import date, timedelta
from engine import DuckRevenueEngine
from report_generator import ReportGenerator

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--date", type=str, default=date.today().isoformat())
    parser.add_argument("--shops", nargs="+", required=True)
    parser.add_argument("--output", default="reports/")
    args = parser.parse_args()
    
    engine = DuckRevenueEngine("revenue.duckdb")
    generator = ReportGenerator(engine)
    
    for shop_id in args.shops:
        report_date = date.fromisoformat(args.date)
        html = generator.generate_report(shop_id, report_date)
        
        out_path = f"{args.output}/{shop_id}_{args.date}.html"
        Path(out_path).parent.mkdir(parents=True, exist_ok=True)
        Path(out_path).write_text(html)
        print(f"📊 {shop_id} 报告已生成: {out_path}")

if __name__ == "__main__":
    main()

用 cron 定时执行:

# 每天 22:00 生成所有店铺报告
0 22 * * * cd /home/user/duck_revenue_saaS && python main.py --shops shop001 shop002 shop003

与传统方案的性能对比

维度Pandas + CSVDuckDB
数据量上限受内存限制,~500 万行后明显变慢列式压缩,10 亿行轻松处理
查询速度逐行迭代,聚合慢向量化执行,聚合快 10-50 倍
部署复杂度需要文件读写 + 内存管理单文件,零配置
多租户隔离需要手动拆分文件表级隔离,天然支持
跨店铺分析需合并多个 CSV直接 JOIN 同库表
内存占用高(全量加载)低(列式 + 压缩)

变现路径

这个系统的核心价值在于可复用性

  1. 单次开发,多次售卖:一套代码适配所有店铺,只需导入不同客户的数据
  2. 按店铺收费:每月 200-500 元/店,10 个客户就是 2000-5000 元/月
  3. 增值服务:趋势分析、异常检测、 competitor 对比,每个功能都可以单独加价
  4. SaaS 化:把 HTML 报告升级为 Web 仪表盘,用 FastAPI 做 API 层,月收入可达 10000+

部署建议

  • 轻量版:单机 Docker + cron,适合 10 个以内客户
  • 标准版:FastAPI + DuckDB + Redis 缓存,支持 Web 访问
  • 高级版:DuckDB Cloud 或云原生部署,支持多区域
# 快速启动
pip install duckdb jinja2
python main.py --shops shop001 shop002 --date 2026-08-18

学习更多 DuckDB 实战经验 → duckdblab.org

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials

使用 Hugo 构建
主题 StackJimmy 设计

⚠️ 本站为独立社区项目,与 DuckDB 基金会及 DuckDB 官方项目无任何从属、背书或赞助关系。

"DuckDB" 是 DuckDB 基金会的注册商标,本站仅以事实描述方式使用该名称。

本站内容仅供教育与社区推广用途,不构成任何商业服务。