Featured image of post 用 DuckDB 搭建自助式数据分析 SaaS:客户上传文件,秒出报表

用 DuckDB 搭建自助式数据分析 SaaS:客户上传文件,秒出报表

手把手教你用 DuckDB + FastAPI 构建自助式数据分析 SaaS 产品。用户上传 CSV/Excel,自定义查询条件,即时获得分析结果。附完整可运行代码和变现方案。

用 DuckDB 搭建自助式数据分析 SaaS:客户上传文件,秒出报表

引言:把分析能力变成可售卖的产品

很多数据分析师的困境是:帮客户做一份报表要几个小时,改个字段又要重新来一遍。时间全耗在重复劳动上,收入却按项目计费。

但如果换个思路呢?

做一个自助式数据分析平台——客户自己上传 CSV、Excel 或 Parquet 文件,选择分析维度,点击生成,几秒钟拿到结果。你卖的不是"做报表的时间",而是"平台的使用权"。

这正是 DuckDB 擅长的场景。嵌入式 OLAP 数据库,零配置,列式存储,直接读取各种文件格式,查询性能远超传统方案。

架构图


为什么 DuckDB 适合做自助分析引擎?

维度PostgreSQL/MySQLSQLiteDuckDB
部署方式需要独立数据库服务嵌入式,但行式存储嵌入式,列式存储
启动速度秒级毫秒级毫秒级
分析查询性能一般(OLTP 优化)慢(无向量化执行)极快(向量化 + 列式)
文件格式支持需 ETL 导入仅自身格式原生支持 CSV/Parquet/JSON/Excel/HTTP
运维成本高(备份、连接池、监控)
适合场景事务型应用小型本地应用分析型/自助查询

核心优势就一句话:DuckDB 让分析引擎像 SQLite 一样轻量,却拥有比肩云数仓的分析性能。


完整实现:自助式数据分析 SaaS

第一步:安装依赖

pip install duckdb fastapi uvicorn pandas pydantic python-multipart

第二步:创建主服务 (main.py)

import duckdb
import pandas as pd
from fastapi import FastAPI, UploadFile, File, HTTPException, Query
from pydantic import BaseModel
import tempfile
import os
from typing import Optional, List

app = FastAPI(title="自助数据分析 API", version="1.0")

class AnalysisRequest(BaseModel):
    """分析请求参数"""
    query_type: str = "auto"          # auto, top_n, trend, distribution, custom_sql
    columns: Optional[List[str]] = None  # 指定分析的列
    group_by: Optional[str] = None     # 分组字段
    measure: Optional[str] = None      # 度量字段
    top_k: int = 10                    # Top N 数量
    date_column: Optional[str] = None  # 日期列
    filter_sql: Optional[str] = None   # 自定义过滤条件

@app.post("/analyze")
async def analyze(
    file: UploadFile = File(...),
    req: AnalysisRequest = AnalysisRequest()
):
    """核心分析接口:上传文件 + 分析参数 = 即时结果"""
    
    # 1. 保存上传文件到临时目录
    suffix = os.path.splitext(file.filename)[1].lower() if file.filename else '.csv'
    with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
        content = await file.read()
        tmp.write(content)
        tmp_path = tmp.name
    
    try:
        conn = duckdb.connect(database=':memory:')
        
        # 2. 根据扩展名选择读取函数
        if suffix == '.csv':
            conn.execute(f"CREATE TABLE data AS SELECT * FROM read_csv_auto('{tmp_path}', header=true)")
        elif suffix in ['.xlsx', '.xls']:
            conn.execute(f"CREATE TABLE data AS SELECT * FROM read_xlsx('{tmp_path}')")
        elif suffix == '.parquet':
            conn.execute(f"CREATE TABLE data AS SELECT * FROM read_parquet('{tmp_path}')")
        elif suffix == '.json':
            conn.execute(f"CREATE TABLE data AS SELECT * FROM read_json_auto('{tmp_path}')")
        else:
            raise HTTPException(status_code=400, detail=f"不支持的文件格式: {suffix}")
        
        # 3. 获取列信息供前端展示
        columns = conn.execute("DESCRIBE data").fetchall()
        column_names = [c[0] for c in columns]
        
        # 4. 根据查询类型执行分析
        result = execute_analysis(conn, req, column_names)
        
        conn.close()
        
        return {
            "status": "success",
            "columns": column_names,
            "total_rows": conn.execute("SELECT COUNT(*) FROM data").fetchone()[0],
            "result": result.to_dict('records') if hasattr(result, 'to_dict') else result,
            "preview_sql": result._sql if hasattr(result, '_sql') else ""
        }
    
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))
    
    finally:
        if os.path.exists(tmp_path):
            os.unlink(tmp_path)

def execute_analysis(conn: duckdb.DuckDBPyConnection, req: AnalysisRequest, columns: List[str]):
    """执行不同类型的分析查询"""
    
    if req.query_type == 'custom_sql' and req.filter_sql:
        sql = f"SELECT * FROM data WHERE {req.filter_sql} LIMIT 1000"
        return conn.execute(sql).fetchdf()
    
    elif req.query_type == 'top_n':
        group_col = req.group_by or columns[0]
        measure_col = req.measure or "COUNT(*)"
        sql = f"""
            SELECT 
                {group_col},
                COUNT(*) as record_count,
                AVG(CAST({req.measure} AS DOUBLE)) as avg_value
            FROM data 
            GROUP BY {group_col}
            ORDER BY record_count DESC
            LIMIT {req.top_k}
        """
        return conn.execute(sql).fetchdf()
    
    elif req.query_type == 'trend':
        date_col = req.date_column or find_date_column(columns)
        measure_col = req.measure or "SUM(amount)"
        sql = f"""
            SELECT 
                DATE_TRUNC('month', CAST({date_col} AS DATE)) as month,
                COUNT(*) as order_count,
                SUM(CAST({measure_col} AS DOUBLE)) as total_amount
            FROM data
            GROUP BY month
            ORDER BY month
        """
        return conn.execute(sql).fetchdf()
    
    elif req.query_type == 'distribution':
        col = req.columns[0] if req.columns else columns[0]
        sql = f"""
            SELECT 
                {col},
                COUNT(*) as count,
                ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM data), 2) as percentage
            FROM data
            GROUP BY {col}
            ORDER BY count DESC
            LIMIT {req.top_k}
        """
        return conn.execute(sql).fetchdf()
    
    else:  # auto: 自动检测并给出摘要
        return get_auto_summary(conn, columns)

def find_date_column(columns: List[str]):
    """智能识别日期列"""
    date_keywords = ['date', 'time', 'created', 'updated', 'order_date']
    for col in columns:
        if any(kw in col.lower() for kw in date_keywords):
            return col
    return columns[0]

def get_auto_summary(conn: duckdb.DuckDBPyConnection, columns: List[str]):
    """自动生成数据摘要"""
    summary_queries = [
        ("总行数", "SELECT COUNT(*) FROM data"),
        ("数值列统计", "SELECT " + ", ".join([f"ROUND(AVG(CAST({c} AS DOUBLE)), 2)" for c in columns if c.isdigit()][:5]) + " FROM data"),
    ]
    
    results = {}
    for name, sql in summary_queries:
        try:
            results[name] = conn.execute(sql).fetchdf().to_dict('records')
        except:
            results[name] = []
    
    return results

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

第三步:测试 API

# 启动服务
python main.py

# 使用 curl 测试
curl -X POST "http://localhost:8000/analyze" \
  -F "file=@sales_data.csv" \
  -F "req={'query_type':'top_n','group_by':'category','top_k':5}"

# 趋势分析
curl -X POST "http://localhost:8000/analyze" \
  -F "file=@sales_data.csv" \
  -F "req={'query_type':'trend','date_column':'order_date','measure':'amount'}"

返回示例:

{
  "status": "success",
  "columns": ["order_id", "category", "amount", "order_date"],
  "total_rows": 15000,
  "result": [
    {"category": "电子产品", "record_count": 3200, "avg_value": 256.8},
    {"category": "服装", "record_count": 2800, "avg_value": 189.5}
  ]
}

进阶:添加用户认证和配额管理

生产环境需要限制滥用:

from fastapi import Depends, Header
import jwt

SECRET_KEY = "your-secret-key"

def verify_api_key(x_api_key: str = Header(...)):
    """简单的 API Key 验证"""
    # 实际项目中应查数据库验证
    allowed_keys = {"demo-key-123", "pro-key-456"}
    if x_api_key not in allowed_keys:
        raise HTTPException(status_code=401, detail="Invalid API Key")
    return x_api_key

@app.post("/analyze")
async def analyze_protected(
    file: UploadFile = File(...),
    req: AnalysisRequest = AnalysisRequest(),
    api_key: str = Depends(verify_api_key)
):
    # ... 原有逻辑 ...
    pass

💰 变现路径

这个架构可以直接转化为多种商业模式:

1. 按调用次数收费(Pay-per-use)

  • 免费版:每月 10 次免费查询
  • 基础版:¥99/月,100 次查询
  • 专业版:¥299/月,无限查询 + 导出 Excel

2. 行业模板 SaaS

针对不同行业预置分析模板:

  • 电商版:自动识别订单、商品、用户表,生成销售看板
  • 财务版:自动识别凭证、科目、余额,生成财务报表
  • HR 版:自动识别员工、部门、考勤,生成人力分析

每个行业模板可以单独收费 ¥500-2000/年。

3. 私有化部署

帮中大型企业部署到内网,一次性收取 ¥5000-20000 的项目费用,外加年度维护费。

4. 数据咨询 + 平台绑定

先免费帮客户做数据分析,然后推荐他们的数据接入你的自助平台,按月收费。


⚡ 性能优化建议

  1. 文件缓存:对相同文件计算 hash,避免重复解析
  2. 连接池:使用 duckdb.connect() 创建持久连接,而非每次新建
  3. 流式处理:大文件使用 read_csv_auto(..., hive_partitioning=true) 分批读取
  4. 结果缓存:热门查询结果缓存 5-30 分钟
  5. 异步队列:复杂查询放入 Celery 队列,返回异步任务 ID

本文的完整版已发布在 duckdblab.org,包含更详细的步骤和更多案例,以及可复用的生产环境模板。想系统学习 DuckDB 在企业级场景的应用?duckdblab.org 上有完整教程系列。

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials

使用 Hugo 构建
主题 StackJimmy 设计

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

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

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