Multi-tenancy
Chinese remains the full reference: 多租户. Key local Docker steps are below in English.
Docker local setup
Default docker compose / quick start is single-DB (TENANCY_DRIVER=off). To try database-per-tenant locally:
- Bring the stack up per the quick start (
.envfrom.env.docker.example). - Add or change in the root
.env:
TENANCY_DRIVER=database
TENANCY_RESOLVER=header
TENANCY_HEADER=X-Tenant-ID
# Same MySQL container may reuse platform DB credentials (local only)
TENANCY_ALLOW_PLATFORM_DB_CREDENTIALS=true
PLATFORM_ADMIN_USERNAME=admin
PLATFORM_ADMIN_PASSWORD=secret
PLATFORM_ADMIN_NAME=Platform Admin- Restart the API container:
docker compose up -d app
# or
docker compose restart app- Bootstrap platform + sample tenant (binary path inside the container is
/www/main):
docker compose exec app /www/main artisan platform:install
docker compose exec app /www/main artisan tenant:create acme "Acme" --migrate- Enable tenant hints for local frontends (
html/.envorhtml-react/.env):
VITE_TENANCY_ENABLED=true
VITE_TENANCY_DRIVER=database
VITE_TENANCY_HEADER=X-Tenant-ID- Open:
| Entry | Notes |
|---|---|
/platform/login | Platform console (PLATFORM_ADMIN_*) |
/login + header X-Tenant-ID: acme, or /login?tenant_code=acme | Tenant admin (seed credentials in the tenant DB) |
Notes:
- For public deploy use
TENANCY_RESOLVER=subdomainand keepTENANCY_ALLOW_PLATFORM_DB_CREDENTIALS=false. - Compose defaults stay single-DB.
- Full commands and production rules: Chinese tenancy page.
默认 TENANCY_DRIVER=off:整站单库。
设为 database 后:每租户独立 database(MySQL)或 database/schema(PostgreSQL);租户认证与业务都在该租户库。平台控制台使用独立账号与 /api/platform,不切租户库。
架构
TENANCY_DRIVER=database
├── 平台库 DB_*
│ ├── tenants
│ ├── platform_admins
│ └── personal_access_tokens(platform_admin)
└── 租户库
├── admins / RBAC / 业务
└── personal_access_tokens(admin)| 租户后台 | 平台控制台 | |
|---|---|---|
| 入口 | /login | /platform/login |
| API | /api/admin | /api/platform |
| Token | token | platform_token |
| 开户 migrate | — | Platform UI async queue / CLI;HTTP create still forbids sync migrate |
| 开户状态 | — | provision_status: pending → migrating → ready/failed |
Tenant entitlements (developer)
Plan features are separate from RBAC. Super-admin does not bypass entitlements.
Backend
// Whole module
router.Middleware(middleware.EntitlementModule("guestbook")).Group(...)
// In-module capability (platform UI: Register capability -> module.member.export)
router.Middleware(middleware.EntitlementFeature("module.member.export")).Get("members/export", ...)
// Optional row quota on Create
services.ConsumeModuleRowsFromHTTP(ctx, "guestbook", 1)Frontend
GET /api/admin/info includes entitlements.features / entitlements.limits when tenancy is on.
- React:
useUserStore((s) => s.hasEntitlement('module.member.export')) - Vue:
useUserStore().hasEntitlement('module.member.export')
When tenancy is off, hasEntitlement returns true. Capability keys have no menu — hide buttons in the UI yourself.
Tenant-side: My Plan
Avatar menu in the tenant admin -> My Plan (/subscription, shown only when TENANCY_DRIVER=database). Read-only: current plan, start/expiry (warning within 7 days), quota usage (quota.*), and the feature list (enabled / "upgrade to unlock").
API: GET /api/admin/entitlements/me (any logged-in admin; no platform-only override reasons).
Blocked features return 403 tenant_feature_disabled; quota overruns return 422 entitlement_limit_exceeded. Both show a localized "contact your administrator to upgrade" message.
配置
TENANCY_DRIVER=database
TENANCY_RESOLVER=subdomain # 公网推荐;本地可用 header
TENANCY_HEADER=X-Tenant-ID
TENANCY_ALLOW_HEADER_FALLBACK= # 空=subdomain 禁止客户端回落
TENANCY_SUBDOMAIN_RESERVED=www,api,admin,platform,static,assets
TENANCY_DATABASE_PREFIX=tenant_
TENANCY_SCHEMA_PREFIX=tenant_
TENANCY_PLATFORM_CONNECTION= # 可选;钉死平台连接名,默认取 database.default
TENANCY_ALLOW_PLATFORM_DB_CREDENTIALS=false # 公网默认 false;同机开发可 true
# 远程 host 始终要求独立 username/password
TENANCY_POSTGRES_SSLMODE= # 空则回落 DB_SSLMODE / disable
TENANCY_BACKUP_KEEP=10 # tenant:backup keep count; 0=never prune
TENANCY_MIGRATE_CONCURRENCY=2 # tenant:migrate-all / seed-all parallelism; --concurrency overrides; max 100
TENANCY_POOL_MAX_IDLE_CONNS=10
TENANCY_POOL_MAX_OPEN_CONNS=20
PLATFORM_ADMIN_USERNAME=admin
PLATFORM_ADMIN_PASSWORD=secret
PLATFORM_ADMIN_NAME=平台管理员
# header 解析租户时,浏览器跨域需放行(默认已含):
# CORS_ALLOWED_HEADERS=...,X-Tenant-ID前端:VITE_TENANCY_ENABLED=true(或 VITE_TENANCY_DRIVER=database)。
生产要点
平台连接钉死:
PlatformOrmQuery使用tenancy.platform_connection,不跟随 migrate 时临时翻转的database.default。生产 Artisan:
APP_ENV=production白名单含tenant:*/platform:*(开户/迁移可用)。连接回收:
Forget会Close+Fresh动态连接池。开户状态:HTTP/CLI 创建后为
pending;平台 UI 异步迁移或 CLItenant:migrate→migrating→ready/failed;未 ready 禁止业务绑定。UI 入队后若 worker 未消费,约 30 分钟后允许重试。账号隔离:远程库必须独立凭据;同机共用平台账号仅当
TENANCY_ALLOW_PLATFORM_DB_CREDENTIALS=true(公网默认 false)。Async ops: per-tenant migrate / seed / backup / restore via
tenant_ops(long-running); production needs Redis + long-running worker.migrate-all/backup-allremain CLI:backup-allenqueues long-running (paged fan-out); scheduledbackup-scheduledrotatesTENANT_BACKUP_SCHEDULE_BATCHtenants/day.Fleet migrate/seed concurrency:
TENANCY_MIGRATE_CONCURRENCY(default 2) limits CLItenant:migrate-all/seed-alland platform UI batch migrate/seed (tenant_ops_fleet); CLI--concurrency=Noverrides. Separate fromQUEUE_LONG_RUNNING_CONCURRENT(per-tenant / backup) andQUEUE_SCHEDULE_CONCURRENT(collection); see Concurrency knobs.Daily full-fleet backup: with
TENANT_BACKUP_SCHEDULE_MODE=full(default),tenant:backup-scheduledenqueues all ready tenants; dumps run viatenant_ops_fleet+TENANCY_BACKUP_CONCURRENCY(default 2, max 50).MODE=rotatekeepsTENANT_BACKUP_SCHEDULE_BATCHpaging. CLIbackup-all/ UI batch backup use the same path. Wall clock ≈ ceil(tenants/concurrency)×per-tenant dump seconds; keepQUEUE_LONG_RUNNING_CONCURRENT=1(fleet uses one slot).CLI op logs: enant:migrate / migrate-all / seed / seed-all write enant_op_logs (operator=cli, shared atch_id for fleets). Platform can filter last_op / last_op_status and retry failed seeds. Session DB checks use MySQL DATABASE() and PostgreSQL current_database().
公网部署(推荐)
TENANCY_RESOLVER=subdomain:租户以acme.example.com访问;apex/www/platform等保留域不接受 Header/Query 冒充(除非显式TENANCY_ALLOW_HEADER_FALLBACK=true)。- 子域与 Header/body 冲突 →
tenant_hint_conflict(400)。 - 支付回调:
POST /api/payment/notify/{type}/{tenant_code}(渠道不会带租户 Header);tenancy 开启时须带{tenant_code}。 - Connection pool: per-tenant
TENANCY_POOL_MAX_*(default idle 10 / open 20); at higher tenant counts, tighten using Scale and recommended settings so MySQL is not exhausted. - 平台控制台走
platform.或独立域名;勿与租户子域混用。
Custom domains (tenant_domains)
Subdomain {code}.${TENANCY_BASE_DOMAIN} works by default. Bind vanity hosts in the platform console — no per-tenant Nginx edits, API restarts, or CORS_ALLOWED_ORIGINS entries.
| Case | Approach |
|---|---|
| Most tenants | Wildcard *.example.com + subdomain |
| Vanity host, no CDN | ssl_mode=edge: CNAME to TENANCY_DOMAIN_TARGET; edge on-demand TLS |
| Customer CDN + SSL | ssl_mode=customer_cdn: CDN origin to shared ingress; keep Host as vanity domain |
| Cloudflare Worker / Pages | customer_cdn: add Custom Domain on the same app; DNS needs TXT only for ownership. Do not orange-cloud CNAME to TENANCY_DOMAIN_TARGET (often 522) |
Resolve order: active vanity Host → Origin host (split SPA/API) → subdomain → Header/Query.
Set TENANCY_BASE_DOMAIN when using vanity hosts so subdomain resolution only matches {code}.that-apex (avoids treating crm.customer.com as tenant code crm). C-end /api/user and /api/public/* share the same Host binding. Frontend builds should set VITE_TENANCY_BASE_DOMAIN to the same apex so subdomain login can hide the tenant code field.
CORS (CORS_ALLOWED_ORIGINS)
| Setting | Effect |
|---|---|
Empty / no valid entries / * | Allows any Origin (handy for demos; avoid as a long-term production lockdown) |
| Explicit allowlist | Auto-appends http(s)://*.{TENANCY_BASE_DOMAIN}; do not list each tenant subdomain |
| Vanity host + split API | Prefer same-Host reverse proxy for /api (no CORS); demos may use empty/*; or temporarily add that Origin |
App CORS middleware also tries to allow Origins whose host is an active row in tenant_domains. If preflight still fails (e.g. framework rs/cors only sees the static list), use empty/* or same-Host — do not edit env for every new merchant domain. Include X-Tenant-ID in CORS_ALLOWED_HEADERS when resolving tenants via header.
Origin guard (ADMIN_ORIGIN_GUARD)
Only when multi-tenancy is enabled (TENANCY_DRIVER not off). On split deploys (e.g. Pages SPA + api.*), CORS_ALLOWED_ORIGINS is often empty/* for preflight. Set ADMIN_ORIGIN_GUARD=true so /api/admin checks the browser Origin host:
| Source | Allowed |
|---|---|
DOMAINS_ADMIN (incl. *. wildcards) | yes |
TENANCY_BASE_DOMAIN and its subdomains | yes |
Active tenant_domains vanity hosts | yes |
Missing Origin (curl / native) | yes |
| Anything else | no (403 origin_not_allowed) |
ADMIN_ORIGIN_GUARD=true
DOMAINS_ADMIN=admin.example.com,platform.example.com
TENANCY_BASE_DOMAIN=example.comDefault false; ignored when tenancy is off. Pairs with Host middleware DomainAllowTenantVanity: Host is where the request lands; Origin is where the SPA came from.
TENANCY_BASE_DOMAIN=example.com
TENANCY_DOMAIN_TARGET=tenants.example.com
TENANCY_DOMAIN_VERIFY_PREFIX=_goravel-tenant
TENANCY_DOMAIN_CACHE_TTL=60Ops: TENANCY_DOMAIN_TARGET (one static ingress)
This is a single fixed hostname, not a list of customer domains, and it is unrelated to your platform console host (e.g. admin.example.com).
| Role | What to do |
|---|---|
| You (operator) | Point tenants.example.com with A/AAAA (or your cloud's record) at the edge / reverse proxy / API ingress that forwards by request Host (vanity domain); for on-demand TLS call GET /api/platform/public/tls-allow?host= |
| Merchant | Bind the vanity host in the platform console and pass TXT verify; with ssl_mode=edge, CNAME the customer host to TENANCY_DOMAIN_TARGET (every merchant shares this same target) |
| App | Resolves tenant from tenant_domains (status=active); do not add each customer domain to env |
Example DNS:
# Your static ingress (configure once)
tenants.example.com. A 203.0.113.10
# Each merchant (many records, same target)
crm.customer-a.com. CNAME tenants.example.com.
shop.customer-b.com. CNAME tenants.example.com.If you only use subdomains, or all vanity hosts use customer_cdn, you may leave TENANCY_DOMAIN_TARGET empty. Once you enable edge vanity hosts, that hostname must resolve and accept traffic.
Platform API: GET/POST /api/platform/tenants/{id}/domains, POST .../verify, PUT .../primary|disable, DELETE; edge ask GET /api/platform/public/tls-allow?host= (HTTP 200 only when edge+active).
Legacy Nginx Host rewrite to {code}.apex still works; prefer tenant_domains for new setups.
Scale and recommended settings
These are starting points, not hard quotas — tune from monitoring. With many tenants the first bottleneck is usually the per-tenant DB pool, not Redis. Redis is shared cluster-wide (cache keys use tenancy.CacheKey → t{id}: prefix); size the instance up first. Split cache vs queue only if noisy-neighbor becomes real.
What defaults can hold
"Active" means recently trafficked tenants with a registered pool in-process, not COUNT(tenants).
| Scenario | Guidance |
|---|---|
| Stock pool + single host | Comfortable for about < 50 active; stretch 50–200 (tighten OPEN first) |
| Tuned pools + Redis queue + split API/Worker | Hundreds to ~1k active |
Plus tenants.host DB sharding + queue-schedule workers | A few thousand active (this product's target band) |
| Plus regional multi-stack (cell) deployments | Beyond one fleet — copy the stack (see below) |
Defaults: TENANCY_POOL_MAX_OPEN_CONNS=20, TENANCY_REGISTERED_MAX=0 (unlimited). Do not ship stock defaults unchanged for thousands of merchants.
Capacity formula (tenant DBs):
registered pools in process × TENANCY_POOL_MAX_OPEN_CONNS × API instances ≪ DB max_connections (leave headroom for platform DB, backups, ops).
Multiple tenant_* databases on one MySQL/PG host still share that host's connection limit.
Runtime dual-mode (small / large fleets)
| Mechanism | Config | Small fleet | Large fleet |
|---|---|---|---|
| Idle registry eviction | TENANCY_REGISTERED_IDLE_TTL (default 900s) | Idle pools drop; reconnect on next request | Prevents registry growing to full fleet |
| Registry hard cap | TENANCY_REGISTERED_MAX (0=unlimited) | Optional | Prefer 300–500 |
| Fleet command paging | TENANCY_SCOPE_AUTO_BATCH_AT (200) / TENANCY_SCOPE_AUTO_BATCH (100) | ≤200 tenants: one pass | Above threshold: page; minute jobs rotate a cursor |
| Forced page size | TENANCY_SCOPE_BATCH | 0=auto | Force N per page |
| Flexible schedules | next_run_at + tick fan-out + schedule queue (QUEUE_SCHEDULE_CONCURRENT) | Same minute match semantics | Tick enqueues only; workers run handlers in parallel |
| Fleet backups | tenant:backup-all / backup-scheduled | Small fleets may enqueue all at once | Enqueue long-running; daily rotate TENANT_BACKUP_SCHEDULE_BATCH |
| Fleet migrate/seed | TENANCY_MIGRATE_CONCURRENCY / --concurrency | Default 2; same host prefer 2–5 | With many tenants.host shards, 20–50 OK (true parallel DDL/seed; do not stampede the DB) |
Fleet schema migrate (tenant:migrate-all)
Database-per-tenant means one DDL/seed pass per tenant. Wall clock is roughly tenants × per-tenant time / concurrency. MigrateTenant / SeedTenant bind per-goroutine Schema+Orm (no global database.default flip), so TENANCY_MIGRATE_CONCURRENCY is true parallelism for both migrate-all and seed-all. Load still hits each MySQL/PG host that holds tenant DBs.
| Deploy | Suggested concurrency | Notes |
|---|---|---|
| Single host / many DBs on one instance | 2–5 | Default 2; higher risks max_connections / disk IO |
| ~100 tenants per DB host, many hosts | 20–50 | Spread via tenants.host at provision; still a global cap, not per-host |
| Platform UI batch migrate/seed | TENANCY_MIGRATE_CONCURRENCY (tenant_ops_fleet) | Same path as CLI; QUEUE_LONG_RUNNING_CONCURRENT=1 is enough |
Failed tenants get provision_status=failed + last_migrate_error; filter and retry without re-running the whole fleet. Cutover: new image platform migrate, then tenant:migrate-all (optional --concurrency), then switch traffic.
Concurrency knobs (tune with queue workers)
These are different controls — raising one does not raise the others:
| Knob | What it limits | Typical use |
|---|---|---|
TENANCY_MIGRATE_CONCURRENCY | In-process parallel tenants for tenant:migrate-all / tenant:seed-all (goroutines) | CLI fleet DDL/seed in a maintenance window |
QUEUE_LONG_RUNNING_CONCURRENT | Jobs per long-running Worker | Per-tenant tenant_ops; batch migrate/seed/backup only needs 1 (fleet uses one slot) |
QUEUE_SCHEDULE_CONCURRENT | Jobs per schedule Worker | Minute collection / flexible schedules — competes for tenant-DB IO with migrate |
QUEUE_CONCURRENT | Default queue parallelism | Exports, notifications, etc. |
How to tune them together (same DB host):
- During
migrate-all/seed-all: temporarily lowerQUEUE_SCHEDULE_CONCURRENTto1–2(or pause the schedule tick), keepQUEUE_LONG_RUNNING_CONCURRENT=1, and do not also fire UI batch migrate — CLI + Worker concurrency would stack on the same MySQL/PG. - Wall clock:
≈ ceil(tenants / TENANCY_MIGRATE_CONCURRENCY) × per-tenant migrate time; seed is usually much faster and can share the same concurrency value. - Day-to-day:
TENANCY_MIGRATE_CONCURRENCYonly affects CLI and may stay high; steady load isQUEUE_SCHEDULE_CONCURRENT × Worker hostsplusTENANCY_POOL_MAX_OPEN_CONNS. - Rule of thumb: same host prefer CLI concurrency
2–5; use20–50only with manytenants.hostshards. Keep long-running at1; scale schedule separately for collection SLA. - Batch migrate/seed: both CLI and platform UI batch read
TENANCY_MIGRATE_CONCURRENCY. Do not raiseQUEUE_LONG_RUNNING_CONCURRENTjust for batch migrate. Still avoid running CLI and UI fleets against the same DB hosts at once.
Platform UI batch migrate / seed / backup:
- Batch migrate/seed enqueues one
tenant_ops_fleetjob that usesTENANCY_MIGRATE_CONCURRENCY(same path as CLImigrate-all). Per-tenant ops still usetenant_ops. KeepQUEUE_LONG_RUNNING_CONCURRENT=1for batch migrate (the fleet occupies a single queue slot). - Worker hosts must run
queue-long-running(do not setAPP_DISABLED_RUNNERS=queue-*); API hosts usually Dispatch only. - After changing
.env, restart the Worker processes that consume long-running (concurrency is read at startup inbootstrap/runners.go). Env-only change without Worker restart keeps the old concurrency. Restarting API alone does not change Worker parallelism. - Batch migrate speed comes from raising
TENANCY_MIGRATE_CONCURRENCY(applies to newly enqueued fleets). Batch backup remains one job per tenant and still scales withQUEUE_LONG_RUNNING_CONCURRENT.
| Knob | Restart needed? | Notes |
|---|---|---|
QUEUE_LONG_RUNNING_CONCURRENT / QUEUE_SCHEDULE_CONCURRENT / QUEUE_CONCURRENT | Yes — restart matching queue Workers | Concurrent is fixed when the runner starts |
TENANCY_MIGRATE_CONCURRENCY | Next artisan tenant:migrate-all picks it up; no need to restart long-lived API/Workers for CLI | Or use one-shot --concurrency=N without editing env |
| Clicking migrate in the UI | Enqueue works without restart | Parallelism still follows already-running Worker concurrency |
Suggested values (copy-paste, one tenant-DB host):
| Scenario | TENANCY_MIGRATE_CONCURRENCY (CLI / UI batch migrate·seed) | QUEUE_LONG_RUNNING_CONCURRENT (per-tenant / batch backup) | QUEUE_SCHEDULE_CONCURRENT |
|---|---|---|---|
| Local / small fleet <50 | 2–3 (default 2) | 1 | 5–10 (often 10) |
Same host, cutover migrate-all | 3–5 | 1 (do not raise during cutover) | Temporarily 1–2 or pause tick |
~100 tenants/DB host, sharded tenants.host | 20–40 | 1–2 | 5–10 |
| Few thousand, many hosts | 40–50 (max 100, use sparingly) | 1–2; add Worker hosts for more speed | Start around 10, tune to SLA |
Rule of thumb: UI batch migrate tunes TENANCY_MIGRATE_CONCURRENCY (same as CLI); keep QUEUE_LONG_RUNNING_CONCURRENT=1. Wall clock ≈ ceil(tenants / concurrency) × per-tenant migrate seconds (this repo: often ~10–20s DDL per tenant on a typical laptop/dev MySQL).
Active-tenant bands
| Active tenants | Tenant pool | Queue / processes | Redis |
|---|---|---|---|
| < 50 | Defaults IDLE=2 / OPEN=20 OK; can use OPEN=10 | Worker may share API host; QUEUE_CONNECTION=redis | Single or small managed Redis |
| 50–200 | IDLE=1–2, OPEN=3–5; shorter idle/lifetime (e.g. 120 / 600) | Split API vs Worker (see Production); keep QUEUE_LONG_RUNNING_CONCURRENT low | Managed Redis; watch used_memory and backlog |
| 200–1000 | OPEN=2–3; optional REGISTERED_MAX=300 | Dedicated Workers: default + long-running + schedule | Larger tier; watch export/import neighbors |
| Few thousand | OPEN=2; REGISTERED_MAX=300–500; shard via tenants.host; migrate may use TENANCY_MIGRATE_CONCURRENCY=20–50 | Scale Workers horizontally; tune QUEUE_SCHEDULE_CONCURRENT | HA Redis; split cache/queue if needed |
Machine sizing and role layout (rule of thumb)
Same binary; split roles with APP_DISABLED_RUNNERS (see Production). vCPU/RAM are cloud-host ballparks — adjust for QPS, collection duration, and export load.
| Active band | API | Queue Worker | Scheduler | Platform DB | Tenant DBs | Redis |
|---|---|---|---|---|---|---|
| < 50 | 1× 2vCPU 4GB (Worker may colocated) | Optional merge | Merged or 1 host | 1× small/mid | Same host multi-DB OK | 1× small |
| 50–200 | 2× 2vCPU 4GB (HTTP only) | 1× 2vCPU 4GB | Exactly 1 schedule host | 1× (raise max_connections) | Same host or start 1 tenant DB host | 1× managed |
| 200–1000 | 2–4× API | 2+× Workers (include schedule) | 1 host | Dedicated platform DB | Multiple hosts via tenants.host | Mid/large HA |
| Few thousand | 4+× API behind LB | 3+× Workers; tune QUEUE_SCHEDULE_CONCURRENT | 1 host | Dedicated + backups | Many tenant DB hosts | HA; split if noisy |
Roles:
| Role | APP_DISABLED_RUNNERS | Queue runners needed |
|---|---|---|
| API | queue-* | None (Dispatch only) |
| Worker | empty | queue-default, queue-long-running, queue-schedule, optional queue-search |
| Scheduler | may disable queue-* | Only goravel:schedule; one host cluster-wide |
Minute-collection throughput ≈ QUEUE_SCHEDULE_CONCURRENT × Worker hosts. Example: 3000 tenants/min at 5s each needs ~250 concurrent slots (or lower frequency).
Example configs
~100 active tenants, 2 API instances:
TENANCY_POOL_MAX_IDLE_CONNS=1
TENANCY_POOL_MAX_OPEN_CONNS=5
TENANCY_POOL_CONN_MAX_IDLETIME=120
TENANCY_POOL_CONN_MAX_LIFETIME=600
TENANCY_REGISTERED_IDLE_TTL=900
CACHE_STORE=redis
QUEUE_CONNECTION=redis
QUEUE_CONCURRENT=2
QUEUE_LONG_RUNNING_CONCURRENT=1
QUEUE_SCHEDULE_CONCURRENT=5
# API hosts: APP_DISABLED_RUNNERS=queue-*Few-thousand active starting point:
TENANCY_POOL_MAX_IDLE_CONNS=1
TENANCY_POOL_MAX_OPEN_CONNS=2
TENANCY_REGISTERED_IDLE_TTL=900
TENANCY_REGISTERED_MAX=400
TENANCY_SCOPE_AUTO_BATCH_AT=200
TENANCY_SCOPE_AUTO_BATCH=100
TENANCY_FLEX_SCHEDULE_TICK_LIMIT=2000
TENANCY_FLEX_SCHEDULE_QUEUE=schedule
TENANCY_MIGRATE_CONCURRENCY=40
QUEUE_SCHEDULE_CONCURRENT=10
TENANT_BACKUP_SCHEDULE_ENABLED=true
TENANT_BACKUP_SCHEDULE_BATCH=100
QUEUE_LONG_RUNNING_CONCURRENT=1
# Set tenants.host when provisioning; API disables queue-*; Workers run queue-scheduleWatch: MySQL/PG Threads_connected, queue pending / queue:alert-backlog, schedule / long-running depth, Redis memory and connections. Near limits, lower TENANCY_POOL_* or scale DB/Workers before blindly adding API replicas (replicas multiply connection usage).
Beyond one fleet: regional (cell) deployments
A single fleet targets about a few thousand active tenants (see table above). When counts keep growing, prefer standing up another full stack (region / cell) instead of endlessly adding API replicas or tenant_* databases on one platform DB.
Recommended pattern:
Region A (existing) Region B (new)
Platform DB A + API/Worker… → Platform DB B + API/Worker…
Existing tenants stay on A New signups write only to B| Point | Guidance |
|---|---|
| One stack per region | Separate platform DB, TENANCY_DRIVER=database, API / Worker / Scheduler / Redis, tenant DBs (optionally split further with tenants.host) |
| Signup traffic | Point registration/sales at the current write region platform/API; from a cutover date, new tenants go only to the new region |
| How tenants return | Region-specific subdomains/apexes, or a thin gateway/DNS map tenant_code → region API; do not point old tenant hosts at the wrong region |
| Platform console | Each region has its own /platform; this product has no cross-region control plane |
| Moves | Avoid moving tenants; cross-region means backup/restore plus DNS/directory changes |
| Cold tenants | disabled / archive or DROP the DB while keeping the platform row; do not leave huge numbers of idle-but-ready DBs to drag down regional backup / migrate-all |
There is no built-in CELL_ID, global tenant router, or one-click cross-region migrate. Regional scale-out is ops-level copy of the stack; tune each cell with the settings in this section. About ~1k tenants per cell is the comfortable band; grow total capacity as cells × per-cell capacity.
Object storage and quotas
- Default shared disk: one platform
FILESYSTEM_DISK; paths isolated withtenants/{code}/. Tenant admin cannot changefile_disk(save rejected); export format remains editable. - Optional BYOB (platform): set tenant
storage_mode=customwith s3/oss/cos/minio credentials (secret sealed withAPP_KEY). New uploads use disktenant_byob_{id}; attachments store that disk name. - Switching: shared ↔ custom only changes new writes. Old objects remain on the disk recorded in
attachments.disk. Keep BYOB credentials after switching back to shared so historical custom files stay readable. Purge cleans platform prefix and, if credentials exist, the BYOB prefix too. - Delete tenant: soft-delete into recycle bin;
drop_databasedrops DB/schema only; object storage is not purged here. - Recycle / force delete:
GET /tenants?trashed=only;POST .../undelete;POST .../purgeoptional retry;DELETE .../forcedefaults to async purge objects+backups then hard-delete (freescode). RetentionTENANCY_DELETED_RETENTION_DAYS; scheduletenant:cleanup-deleted. - Storage limit
storage_limit_bytes(0=unlimited): enforced fromSUM(attachments.size)before upload.
运维增强
- Landlord 迁移跳过:平台表迁移(
tenants/platform_admins/jobs/ provision/migrate meta)在tenant_*连接上SkipOnTenantConnection空跑,避免污染租户库。 - Migrate / ops visibility:
last_migrate_error/migrated_at;platform UI also showslast_op*/last_backup_path. Seed failure does not demoteready→failed. - Ping / async ops:
POST .../ping;.../migrate|seed|backupenqueuetenant_ops. - 登录限流:
loginlimiter 键含 body/query/header/subdomain租户提示,避免跨租户互相锁号。 - 日志:带
tenant_code/tenant_id前缀(app/utils/logger)。 - PG sslmode:
TENANCY_POSTGRES_SSLMODE或DB_SSLMODE。 - 备份/恢复:
tenant:backup [--keep=N],tenant:backup-all(enqueue, not sync dump-all),tenant:backup-scheduled(daily rotate);PG schema 隔离备份用pg_dump -n,恢复用PGOPTIONS=--search_path。 - CLI scope:
RunTenantScopewalks active + ready only;tenant:migrate-allcan still cover pending (separate query); concurrency viaTENANCY_MIGRATE_CONCURRENCY/--concurrency. - 未绑定隔离:tenancy 开启但 ctx 未绑定时,
CacheKey→t_unbound:*,StoragePrefix→tenants/_unbound_/(不与共享根冲突)。
首启(推荐)
# .env 中 TENANCY_DRIVER=database,并配置 APP_KEY、DB_*
go run . artisan platform:install -u admin -p 'your-password'
# 或依赖 .env 的 PLATFORM_ADMIN_*:
# go run . artisan platform:install
# 同机开户 + migrate + seed
go run . artisan tenant:create acme "Acme" --migrate
# 远程库已存在
go run . artisan tenant:create remote "Remote" \
--host=10.0.0.8 --port=3306 --username=u --password=secret \
--database=tenant_remote --skip-create --migrate
# 前端
# /platform/login → 平台管理租户
# /login?tenant_code=acme → 租户后台命令
go run . artisan platform:install [-u] [-p] [--name=]
go run . artisan platform:admin {username} {password} [--name=] [--role=owner|viewer]
# role=owner default (full write); viewer = read-only (list/detail/logs/backup download/change own password)
go run . artisan tenant:create {code} {name} \
[--driver=mysql|postgres] [--isolation=database|schema] \
[--host=] [--port=] [--username=] [--password=] \
[--database=] [--schema=] [--skip-create] [--migrate]
go run . artisan tenant:migrate {id|code}
go run . artisan tenant:migrate-all [--concurrency=N]
go run . artisan tenant:seed {id|code} [--class=...]
go run . artisan tenant:seed-all
go run . artisan tenant:list
go run . artisan tenant:enable|disable {id|code}
go run . artisan tenant:backup {id|code} [--keep=N]
go run . artisan tenant:backup-all [--keep=N] [--limit=N] [--rotate]
go run . artisan tenant:backup-scheduled # needs TENANT_BACKUP_SCHEDULE_ENABLED
go run . artisan tenant:restore {id|code} {sql路径}
# tenancy 开启时,以下命令默认遍历启用租户;可用 --tenant={code|id} 限定
go run . artisan order:create-sharding-tables [--tenant=]
go run . artisan payment:create-sharding-tables [--tenant=]
go run . artisan search:init-orders-index [--tenant=]
go run . artisan search:sync-orders [--tenant=]
go run . artisan search:retry-outbox [--tenant=]
go run . artisan app:clear-logs [--tenant=]
go run . artisan app:clear-chunks [--tenant=]
go run . artisan db:analyze-stats [--tenant=]
go run . artisan db:optimize-tables {tables...} [--tenant=]
# 写多数据命令:tenancy 开启时必须指定 --tenant
go run . artisan order:generate-test-data --tenant={code} --count=1000
go run . artisan payment:generate-test-data --tenant={code} --count=1000建库与密码
host空:在平台实例CREATEhost有值:连目标主机系统库再CREATE--skip-create/skip_create:库已存在,只登记tenants.password:仅enc:v1:+APP_KEY密文;无明文兼容- HTTP
POST /api/platform/tenants:禁止migrate=true(返回tenant_migrate_via_cli)
Log types (do not mix)
| Type | Storage | Answers | Where to view |
|---|---|---|---|
| Operation log | Tenant DB / platform platform_operation_logs | Who changed what (audit) | Tenant admin / Platform operation logs |
| Tenant ops log | Platform tenant_op_logs | Did migrate/seed/backup succeed | Platform tenant ops logs |
| System log | Each connection's system_logs | Panics, DB failures, infra errors | Tenant admin; Platform system logs (read-only, optional tenant_code) |
Platform process errors (no tenant context) write to the landlord system_logs via SystemLogOrmQuery. Tenant-bound errors stay in the tenant DB -- no dual-write to the platform DB.
平台 API
| Method | Path | 说明 |
|---|---|---|
| POST | /api/platform/login | 登录 |
| GET | /api/platform/info | 当前管理员 |
| POST | /api/platform/logout | 登出 |
| GET/POST | /api/platform/tenants | List / create (no sync migrate) |
| GET/PUT | /api/platform/tenants/{id} | Detail / update connection |
| PUT | /api/platform/tenants/{id}/status | Enable/disable |
| POST | /api/platform/tenants/{id}/ping | Ping tenant DB |
| POST | /api/platform/tenants/{id}/migrate | Async migrate (with_seed optional) |
| POST | /api/platform/tenants/{id}/seed | Async seed |
| POST | /api/platform/tenants/{id}/backup | Async backup |
| GET | /api/platform/tenants/{id}/backups | List backup files under storage/backups/tenants/{code}/ |
| GET | /api/platform/tenants/{id}/backups/download?name= | Download a .sql backup |
| POST | /api/platform/tenants/{id}/restore | Async restore (backup_name) |
| POST | /api/platform/tenants/{id}/backups/prune | Keep newest N backups (keep) |
| DELETE | /api/platform/tenants/{id} | Soft-delete (confirm_code; optional drop_database; async purge_objects / purge_backups; legacy purge_files = both) |
| POST | /api/platform/tenants/{id}/undelete | Restore soft-deleted metadata |
| POST | /api/platform/tenants/{id}/purge | Retry async object/backup purge |
| DELETE | /api/platform/tenants/{id}/force | Hard-delete recycle-bin row (confirm_code; frees code) |
| GET | /api/platform/tenants/{id}/overview | DB snapshot stats |
| GET | /api/platform/tenant-op-logs | Platform-wide ops execution logs (filter code/op/status/batch_id/operator) |
| GET | /api/platform/login-logs | Platform login logs (landlord) |
| GET/POST | /api/platform/admins | Platform admins list / create (owner write) |
| GET/PUT/DELETE | /api/platform/admins/{id} | Platform admin detail / update / soft-delete |
| POST | /api/platform/admins/{id}/reset-password | Reset platform admin password |
| GET | /api/platform/login-logs/{id} | Platform login log detail |
| GET | /api/platform/operation-logs | Platform operation logs (landlord) |
| GET | /api/platform/operation-logs/{id} | Platform operation log detail |
| GET | /api/platform/system-logs | Read-only system logs (platform DB; tenant_code switches to that tenant DB) |
| GET | /api/platform/system-logs/module-options | Module filter options for current scope |
| GET | /api/platform/system-logs/{id} | System log detail (same scope as list) |
| GET | /api/platform/tenants/{id}/system-log-summary | Last 24h error/warning counts + recent errors |
| GET | /api/platform/tenants/{id}/op-logs | Ops timeline |
| GET | /api/platform/tenants/{id}/login-links | How to open tenant admin |
| GET | /api/platform/tenants/settings | Console settings (backup_keep, queue) |
| GET | /api/platform/tenants/queue-status | long-running queue depth |
| GET | /api/platform/tenants/export | Filtered CSV export |
| POST | /api/platform/tenants/ops-batch | Batch migrate/seed/backup |
Platform console supports per-tenant ping/migrate/seed/backup/restore/delete, batch seed/backup, and CSV export. Heavy migrate-all remains CLI-friendly.
公开支付回调(非 platform):
| Method | Path | 说明 |
|---|---|---|
| POST | /api/payment/notify/{type}/{tenant} | wechat|alipay;路径绑定租户 |
| POST | /api/payment/notify/{type} | tenancy 关闭时可用;开启时 tenant_required |
代码约定
| API | 用途 |
|---|---|
tenancy.Enabled() | 是否一户一库 |
OrmQuery(ctx) | 租户业务默认入口 |
PlatformOrmQuery(ctx) | 平台元数据 / 平台 token |
tenancyctx.Detach(ctx) | 异步 goroutine 保留租户连接 |
tenancy.CacheKey / StoragePrefix | 缓存键 / 对象存储路径前缀 |
RunTenantScope / --tenant | CLI 按租户执行 |
SchemaHasTable / WithSchemaContext | 请求路径 Schema(SchemaConnLock) |
硬性规则
- 业务用
OrmQuery(ctx);平台路由不挂Tenant中间件;平台元数据用PlatformOrmQuery(钉死平台连接)。 - 隔离是切库/切 Schema,不是行级
tenant_idGlobalScope。 - 缓存/上传(含
chunks/、导出、导入与附件临时目录)走租户前缀。 - 搜索索引绑定后为
{code}_orders;未绑定 fail-closed,禁止回退共享orders。 - 导出/搜索队列缺
tenant_id时 fail-closed,禁止写到平台库。 - HTTP 手动跑定时任务自动带当前
--tenant,禁止扫全租户;cron 可遍历启用租户。 - 队列表
jobs/failed_jobs读平台连接(QUEUE_DATABASE_CONNECTION)。 - Browser CORS:
CORS_ALLOWED_HEADERSmust includeX-Tenant-ID; subdomains useTENANCY_BASE_DOMAINwildcards; for vanity hosts prefer same-Host or demo empty/*(see CORS above); on split API setADMIN_ORIGIN_GUARD=true(see Origin guard) — do not hand-edit the CORS allowlist per merchant. - 订单搜索用
search:*/SyncOrderSearch(SEARCH_*),勿再接旧 ES outbox 链路。 - IP 黑名单:进程内短 TTL(约 30s)缓存启用名单;CRUD 后立即失效。查库失败时在约 5 分钟内回退最近成功缓存,超时仍 fail-closed(503)。
- Only
provision_status=readytenants may bind traffic; HTTP create forbids sync migrate — use platform UI async migrate or CLItenant:migrate. - 远程租户库禁止空账号回落平台 root;公网默认
TENANCY_ALLOW_PLATFORM_DB_CREDENTIALS=false。 - 平台表迁移不得落在租户库(
SkipOnTenantConnection);migrate 失败须可在平台侧看到last_migrate_error。 - PG schema 隔离的 backup/restore 必须限定 schema;登录对
tenant_not_ready返回 403(非 500)。 - 公网优先 subdomain;支付回调必须带
{type}/{tenant_code}路径。 - Vanity domains: edge Host rewrite (see above); do not add per-domain app env, CORS allowlist entries, or separate deploys.
- Business dirs (
app/services/http/jobs/console) must not callfacades.Orm().Query(); CI runsbash scripts/check-tenant-orm.sh(tenant-maintenance allowlist excepted). - Tenant business cache/locks must use
tenancy.CacheKey/TenantCacheKey; CI runsbash scripts/check-tenant-cache.sh(global/platform allowlist in script ALLOW).
Platform ops: domain board / onboard / health
- Tenant list: domain_status + domain_host search; health_status.
- Onboard: POST /api/platform/tenants/onboard.
- Health cron: enant:health-inspect hourly; webhook + optional TENANT_HEALTH_ALERT_MAIL.
- Manual: POST /api/platform/tenants/health-inspect.
