店群系统从“自己用”发展到“给多个团队用”时,会遇到一道坎:
A团队的店铺和脚本,不能被B团队看到。
我们最初的服务是单租户的:一套数据库、一套Redis、所有店铺混在一起。运营A登录后台,能看到运营B的店铺数据。虽然都是自己人,但跨组操作容易误伤,而且没法对外输出能力。
后来我们要把自动化能力开放给外部客户(SAAS化),必须做多租户隔离。
这篇文章不讲浏览器自动化,也不讲调度算法。
专门聊聊店群自动化系统的多租户架构设计:租户隔离、权限模型、数据安全、配额管理。
适用场景:SaaS化店群平台、多团队协作、数据安全要求高的场景。
技术栈:Python + RBAC + PostgreSQL行级安全 + Redis分区 + Kubernetes命名空间。
一、多租户的核心挑战
多租户不是简单地在表里加一个tenant_id字段。
主要挑战:
挑战一:数据隔离
租户A绝对不能看到租户B的店铺、任务、日志、配置。即使是数据库管理员,也不应该轻易跨租户查询。
挑战二:性能隔离
租户A突然提交大量任务,不能拖垮租户B的任务执行。资源要按配额分配。
挑战三:脚本隔离
租户A自定义的影刀脚本或编排流程,不能影响租户B的执行环境。每个租户的脚本版本独立。
挑战四:成本分摊
多个租户共享同一套基础设施(节点、代理IP、浏览器池),需要按使用量计费。
挑战五:权限模型
一个租户内部可能有多个角色(管理员、运营、观察员),权限需要精细化控制。
我们选择的是共享数据库、独立Schema的模式,平衡了隔离性和运维复杂度。
二、数据隔离方案:Schema级隔离
每个租户拥有独立的PostgreSQL Schema,表结构相同,但数据完全隔离。
database
├── schema_tenant_001
│ ├── shops
│ ├── tasks
│ ├── scripts
│ └── logs
├── schema_tenant_002
│ ├── shops
│ ├── tasks
│ ├── scripts
│ └── logs
└── schema_public
├── tenants # 租户信息
├── users # 全局用户
└── plans # 套餐定义
优势:备份恢复粒度到租户、迁移方便、天然隔离。劣势:跨租户统计需要聚合多个Schema。
应用层通过连接池动态切换Schema:
# tenant_router.py
class TenantRouter:
def __init__(self):
self.connections = {}
def get_connection(self, tenant_id):
if tenant_id not in self.connections:
# 每个租户独立的数据库连接(或连接池)
conn = psycopg2.connect(
database="automation",
user="postgres",
password="xxx",
options=f"-c search_path=tenant_{tenant_id},public"
)
self.connections[tenant_id] = conn
return self.connections[tenant_id]
def execute(self, tenant_id, sql, params=None):
conn = self.get_connection(tenant_id)
cursor = conn.cursor()
cursor.execute(sql, params)
return cursor
对于Redis,我们使用key前缀隔离:tenant:{tenant_id}:shop:{shop_id}。这样可以避免不同租户的key冲突,也方便批量清理。
对象存储(S3/OSS)使用租户目录:/{tenant_id}/scripts/、/{tenant_id}/screenshots/。
三、RBAC权限模型
每个租户内部有独立的权限体系,基于RBAC(角色-权限-用户)。
# rbac_models.py
permissions = {
"shop:read": "查看店铺信息",
"shop:write": "创建/修改店铺",
"shop:delete": "删除店铺",
"task:create": "创建任务",
"task:cancel": "取消任务",
"task:view_all": "查看所有任务(不限于自己创建的)",
"script:edit": "编辑自动化脚本",
"script:publish": "发布脚本到生产",
"node:manage": "管理执行节点",
"billing:view": "查看账单",
"tenant:settings": "修改租户设置"
}
roles = {
"owner": ["*"], # 所有权限
"admin": ["shop:*", "task:*", "script:edit", "script:publish", "node:manage", "billing:view", "tenant:settings"],
"operator": ["shop:read", "task:create", "task:cancel", "task:view_all"],
"viewer": ["shop:read", "task:view_all"],
"script_developer": ["shop:read", "task:create", "script:edit"] # 不能发布
}
用户登录后,JWT token中包含tenant_id、user_id、role。每次API请求,中间件校验:
- token中的
tenant_id与请求路径中的租户标识是否一致 - 当前角色是否有该操作所需的权限
我们还在数据访问层加入了行级安全:对于支持行级安全的表(如tasks),查询时自动加上user_id = current_user or role='admin',确保普通用户只能看到自己的任务。
四、脚本与编排的租户隔离
每个租户可以上传自己的影刀脚本和编排DSL。脚本存储在租户专属的S3路径下,且运行时加载到隔离的Python虚拟环境。
# tenant_script_manager.py
class TenantScriptManager:
def __init__(self, tenant_id):
self.tenant_id = tenant_id
self.script_root = f"/data/scripts/{tenant_id}"
self.venv_root = f"/data/venvs/{tenant_id}"
def get_script_path(self, script_name):
return f"{self.script_root}/{script_name}.py"
def execute_in_isolated_env(self, script_name, args):
# 为租户创建或复用虚拟环境
venv_path = f"{self.venv_root}/venv"
if not os.path.exists(venv_path):
self._create_venv(venv_path)
# 使用虚拟环境的Python解释器执行脚本
cmd = [f"{venv_path}/bin/python", self.get_script_path(script_name)] + args
return subprocess.run(cmd, capture_output=True)
不同租户的Python依赖可以不同,互不干扰。比如租户A需要requests==2.28.1,租户B需要requests==2.31.0,各自在自己的虚拟环境中解决。
编排引擎同样按租户隔离:每个租户的DSL定义、原子操作注册表、变量上下文都是独立的。
五、资源配额与限流
为了防止某个租户占用过多资源,我们为每个租户设定了配额:
# quota_manager.py
class QuotaManager:
def __init__(self, tenant_id):
self.tenant_id = tenant_id
self.quotas = self._load_quotas() # 从套餐表读取
def check_and_consume(self, resource, amount=1):
"""检查并消耗配额"""
current = self._get_usage(resource)
if current + amount > self.quotas.get(resource, 0):
return False
self._increment_usage(resource, amount)
return True
def _get_usage(self, resource):
key = f"quota:{self.tenant_id}:{resource}"
return int(redis_client.get(key) or 0)
配额类型示例:
max_shops: 最大店铺数(如50)daily_tasks: 每日任务总量(如10000)concurrent_tasks: 同时执行的任务数(如20)storage_gb: 日志/截图存储空间(如10GB)monthly_api_calls: 月度API调用次数(如10万)
当租户超过配额时,API返回429(Too Many Requests),并提示升级套餐或等待下个周期。
对于并发任务数,调度器在分配任务前会检查租户当前正在运行的任务数是否超过配额。超过则任务入队等待,不分配执行节点。
六、审计日志与可追溯性
多租户环境下,审计日志尤为重要。租户管理员需要知道“谁在什么时间做了什么操作”,平台运营需要能追溯所有租户的行为(合规要求)。
每条审计日志包含:
{
"log_id": "uuid",
"tenant_id": "tenant_001",
"user_id": "user_123",
"user_ip": "1.2.3.4",
"action": "shop.delete",
"target_type": "shop",
"target_id": "shop_456",
"result": "success",
"request_body": "{\"shop_id\":\"456\"}",
"timestamp": "2025-01-15T10:30:00Z"
}
日志写入到每个租户独立的Schema中的audit_logs表,同时异步复制到平台运营的集中日志系统(用于全局分析,但脱敏处理)。
租户管理员可以在后台查看自己租户的审计日志,支持按用户、操作类型、时间范围筛选。
我们曾通过审计日志发现,一个租户的员工凌晨3点批量删除了50个店铺。调查后发现是该员工的账号被盗。之后我们增加了MFA和异常登录告警。
七、计费与成本分摊
多租户模式下,需要将共享资源(服务器、代理IP、带宽)的成本分摊到各个租户。
我们按使用量计费:
- 任务执行量:每完成一个任务计费一次(区分任务类型,上架任务比查询任务贵)
- 浏览器实例时长:店铺的浏览器实例运行时间(分钟)
- 代理IP流量:出网流量(GB)
- 存储空间:日志、截图占用的空间(GB/月)
采集方式:每个任务执行完成后,将消耗的资源写入计费表。
# billing_collector.py
def collect_billing(tenant_id, task):
billing_item = {
"tenant_id": tenant_id,
"task_type": task.type,
"browser_minutes": task.browser_duration_seconds / 60,
"proxy_bytes": task.proxy_bytes_sent + task.proxy_bytes_recv,
"storage_delta": task.new_log_bytes + task.new_screenshot_bytes,
"timestamp": datetime.now()
}
# 写入计费队列,异步聚合
kafka_producer.send("billing", value=billing_item)
每天凌晨聚合前一天的用量,生成每个租户的账单。租户可以在后台查看实时用量(近7天曲线)和历史账单。
我们提供两种计费模式:预付费套餐(按月订阅,包含一定额度,超额按量付费)和纯后付费(按量付费)。
八、多租户环境下的执行节点调度
执行节点是共享的,但不同租户的任务不能互相干扰。我们做了两层隔离:
节点级隔离:将节点分为多个节点池,不同套餐的租户分配到不同的节点池。例如:
- 免费/试用套餐:共享节点池(每个节点跑多个租户的任务)
- 专业套餐:独立节点池(租户独占几个节点)
- 企业套餐:独享物理机或专用K8s集群
进程级隔离:共享节点池中,使用容器(Docker)为每个租户的任务创建隔离环境。
# docker_runner.py
def run_task_in_container(tenant_id, task):
container_name = f"task_{tenant_id}_{task.id}"
cmd = [
"docker", "run",
"--name", container_name,
"--rm",
"--memory", "2g",
"--cpus", "1.0",
"-e", f"TENANT_ID={tenant_id}",
"-v", f"/data/scripts/{tenant_id}:/scripts",
"rpa-runner:latest",
"python", f"/scripts/{task.script_name}.py", json.dumps(task.data)
]
result = subprocess.run(cmd, capture_output=True, timeout=300)
return result
容器化保证了CPU和内存的硬隔离,一个租户的任务不会因为内存泄漏而影响到其他租户。
对于浏览器实例,每个容器内独立启动Chromium,不会跨租户串号。
九、真实踩坑与教训
坑1:共享Redis key前缀冲突,租户A读到了租户B的缓存
我们早期只用tenant_id作为前缀,但缓存key的生成逻辑分散在各处,有开发忘记加前缀。导致租户A登录后,从Redis读取的店铺列表包含了租户B的数据。
解决:封装统一的缓存客户端,自动注入租户前缀,代码审查强制检查。
坑2:数据库连接数被租户数撑爆
每个租户一个独立的数据库连接池,100个租户就100个连接,PostgreSQL默认最多100个连接,超限。
解决:改用连接池共享,但通过search_path切换Schema。每个物理连接可以服务多个租户,只需在每次查询前SET search_path。或使用PgBouncer的事务级连接池。
坑3:租户删除后,数据残留导致合规风险
租户到期后,我们只标记为deleted,没有真正清理数据。后来有客户要求GDPR合规,必须彻底删除。
解决:实现租户销毁流程:先软删除,保留30天用于恢复;30天后异步清理所有Schema、S3目录、Redis keys、监控数据。完成后发送销毁证明给客户。
坑4:一个租户的恶意脚本导致其他租户任务变慢
某个租户上传了死循环脚本,不断创建浏览器进程,吃光了节点资源。
解决:增加租户级沙箱:限制每个租户的脚本CPU时间、内存限制、子进程数量。脚本超过阈值自动Kill,并封禁该租户的发布权限。
十、总结:多租户是SaaS化的必经之路
店群自动化从内部工具走向商业化SaaS,多租户架构是绕不开的课题。
我们花了两个月重构,核心收获是:
- 隔离要尽早设计:后期加租户字段改造量大,且容易漏
- 容器化是隔离的好帮手:Docker/K8s让资源隔离简单可靠
- 配额和计费需要精细采集:成本分摊不清晰,运营就会亏钱
- 审计日志是信任基础:客户需要知道自己的数据安全
如果你计划将自己的店群能力对外输出,或者内部需要严格隔离不同业务线,建议参考本文的设计思路。
多租户不是负担,而是产品成熟度的标志。
作者:林焱
