Skip to content

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:

  1. Bring the stack up per the quick start (.env from .env.docker.example).
  2. Add or change in the root .env:
ini
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
  1. Restart the API container:
bash
docker compose up -d app
# or
docker compose restart app
  1. Bootstrap platform + sample tenant (binary path inside the container is /www/main):
bash
docker compose exec app /www/main artisan platform:install
docker compose exec app /www/main artisan tenant:create acme "Acme" --migrate
  1. Enable tenant hints for local frontends (html/.env or html-react/.env):
ini
VITE_TENANCY_ENABLED=true
VITE_TENANCY_DRIVER=database
VITE_TENANCY_HEADER=X-Tenant-ID
  1. Open:
EntryNotes
/platform/loginPlatform console (PLATFORM_ADMIN_*)
/login + header X-Tenant-ID: acme, or /login?tenant_code=acmeTenant admin (seed credentials in the tenant DB)

Notes:

  • For public deploy use TENANCY_RESOLVER=subdomain and keep TENANCY_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,不切租户库。

架构 ​

text
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
Tokentokenplatform_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 ​

go
// 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.

配置 ​

ini
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)。

生产要点 ​

  1. 平台连接钉死:PlatformOrmQuery 使用 tenancy.platform_connection,不跟随 migrate 时临时翻转的 database.default。

  2. 生产 Artisan:APP_ENV=production 白名单含 tenant:* / platform:*(开户/迁移可用)。

  3. 连接回收:Forget 会 Close + Fresh 动态连接池。

  4. 开户状态:HTTP/CLI 创建后为 pending;平台 UI 异步迁移或 CLI tenant:migrate → migrating → ready/failed;未 ready 禁止业务绑定。UI 入队后若 worker 未消费,约 30 分钟后允许重试。

  5. 账号隔离:远程库必须独立凭据;同机共用平台账号仅当 TENANCY_ALLOW_PLATFORM_DB_CREDENTIALS=true(公网默认 false)。

  6. Async ops: per-tenant migrate / seed / backup / restore via tenant_ops (long-running); production needs Redis + long-running worker. migrate-all / backup-all remain CLI: backup-all enqueues long-running (paged fan-out); scheduled backup-scheduled rotates TENANT_BACKUP_SCHEDULE_BATCH tenants/day.

  7. Fleet migrate/seed concurrency: TENANCY_MIGRATE_CONCURRENCY (default 2) limits CLI tenant:migrate-all / seed-all and platform UI batch migrate/seed (tenant_ops_fleet); CLI --concurrency=N overrides. Separate from QUEUE_LONG_RUNNING_CONCURRENT (per-tenant / backup) and QUEUE_SCHEDULE_CONCURRENT (collection); see Concurrency knobs.

  8. Daily full-fleet backup: with TENANT_BACKUP_SCHEDULE_MODE=full (default), tenant:backup-scheduled enqueues all ready tenants; dumps run via tenant_ops_fleet + TENANCY_BACKUP_CONCURRENCY (default 2, max 50). MODE=rotate keeps TENANT_BACKUP_SCHEDULE_BATCH paging. CLI backup-all / UI batch backup use the same path. Wall clock ≈ ceil(tenants/concurrency)×per-tenant dump seconds; keep QUEUE_LONG_RUNNING_CONCURRENT=1 (fleet uses one slot).

  9. 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().

公网部署(推荐) ​

  1. TENANCY_RESOLVER=subdomain:租户以 acme.example.com 访问;apex/www/platform 等保留域不接受 Header/Query 冒充(除非显式 TENANCY_ALLOW_HEADER_FALLBACK=true)。
  2. 子域与 Header/body 冲突 → tenant_hint_conflict(400)。
  3. 支付回调:POST /api/payment/notify/{type}/{tenant_code}(渠道不会带租户 Header);tenancy 开启时须带 {tenant_code}。
  4. 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.
  5. 平台控制台走 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.

CaseApproach
Most tenantsWildcard *.example.com + subdomain
Vanity host, no CDNssl_mode=edge: CNAME to TENANCY_DOMAIN_TARGET; edge on-demand TLS
Customer CDN + SSLssl_mode=customer_cdn: CDN origin to shared ingress; keep Host as vanity domain
Cloudflare Worker / Pagescustomer_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) ​

SettingEffect
Empty / no valid entries / *Allows any Origin (handy for demos; avoid as a long-term production lockdown)
Explicit allowlistAuto-appends http(s)://*.{TENANCY_BASE_DOMAIN}; do not list each tenant subdomain
Vanity host + split APIPrefer 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:

SourceAllowed
DOMAINS_ADMIN (incl. *. wildcards)yes
TENANCY_BASE_DOMAIN and its subdomainsyes
Active tenant_domains vanity hostsyes
Missing Origin (curl / native)yes
Anything elseno (403 origin_not_allowed)
ini
ADMIN_ORIGIN_GUARD=true
DOMAINS_ADMIN=admin.example.com,platform.example.com
TENANCY_BASE_DOMAIN=example.com

Default false; ignored when tenancy is off. Pairs with Host middleware DomainAllowTenantVanity: Host is where the request lands; Origin is where the SPA came from.

ini
TENANCY_BASE_DOMAIN=example.com
TENANCY_DOMAIN_TARGET=tenants.example.com
TENANCY_DOMAIN_VERIFY_PREFIX=_goravel-tenant
TENANCY_DOMAIN_CACHE_TTL=60

Ops: 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).

RoleWhat 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=
MerchantBind 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)
AppResolves tenant from tenant_domains (status=active); do not add each customer domain to env

Example DNS:

text
# 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.

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).

ScenarioGuidance
Stock pool + single hostComfortable for about < 50 active; stretch 50–200 (tighten OPEN first)
Tuned pools + Redis queue + split API/WorkerHundreds to ~1k active
Plus tenants.host DB sharding + queue-schedule workersA few thousand active (this product's target band)
Plus regional multi-stack (cell) deploymentsBeyond 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) ​

MechanismConfigSmall fleetLarge fleet
Idle registry evictionTENANCY_REGISTERED_IDLE_TTL (default 900s)Idle pools drop; reconnect on next requestPrevents registry growing to full fleet
Registry hard capTENANCY_REGISTERED_MAX (0=unlimited)OptionalPrefer 300–500
Fleet command pagingTENANCY_SCOPE_AUTO_BATCH_AT (200) / TENANCY_SCOPE_AUTO_BATCH (100)≤200 tenants: one passAbove threshold: page; minute jobs rotate a cursor
Forced page sizeTENANCY_SCOPE_BATCH0=autoForce N per page
Flexible schedulesnext_run_at + tick fan-out + schedule queue (QUEUE_SCHEDULE_CONCURRENT)Same minute match semanticsTick enqueues only; workers run handlers in parallel
Fleet backupstenant:backup-all / backup-scheduledSmall fleets may enqueue all at onceEnqueue long-running; daily rotate TENANT_BACKUP_SCHEDULE_BATCH
Fleet migrate/seedTENANCY_MIGRATE_CONCURRENCY / --concurrencyDefault 2; same host prefer 2–5With 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.

DeploySuggested concurrencyNotes
Single host / many DBs on one instance2–5Default 2; higher risks max_connections / disk IO
~100 tenants per DB host, many hosts20–50Spread via tenants.host at provision; still a global cap, not per-host
Platform UI batch migrate/seedTENANCY_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:

KnobWhat it limitsTypical use
TENANCY_MIGRATE_CONCURRENCYIn-process parallel tenants for tenant:migrate-all / tenant:seed-all (goroutines)CLI fleet DDL/seed in a maintenance window
QUEUE_LONG_RUNNING_CONCURRENTJobs per long-running WorkerPer-tenant tenant_ops; batch migrate/seed/backup only needs 1 (fleet uses one slot)
QUEUE_SCHEDULE_CONCURRENTJobs per schedule WorkerMinute collection / flexible schedules — competes for tenant-DB IO with migrate
QUEUE_CONCURRENTDefault queue parallelismExports, notifications, etc.

How to tune them together (same DB host):

  1. During migrate-all / seed-all: temporarily lower QUEUE_SCHEDULE_CONCURRENT to 1–2 (or pause the schedule tick), keep QUEUE_LONG_RUNNING_CONCURRENT=1, and do not also fire UI batch migrate — CLI + Worker concurrency would stack on the same MySQL/PG.
  2. Wall clock: ≈ ceil(tenants / TENANCY_MIGRATE_CONCURRENCY) × per-tenant migrate time; seed is usually much faster and can share the same concurrency value.
  3. Day-to-day: TENANCY_MIGRATE_CONCURRENCY only affects CLI and may stay high; steady load is QUEUE_SCHEDULE_CONCURRENT × Worker hosts plus TENANCY_POOL_MAX_OPEN_CONNS.
  4. Rule of thumb: same host prefer CLI concurrency 2–5; use 20–50 only with many tenants.host shards. Keep long-running at 1; scale schedule separately for collection SLA.
  5. Batch migrate/seed: both CLI and platform UI batch read TENANCY_MIGRATE_CONCURRENCY. Do not raise QUEUE_LONG_RUNNING_CONCURRENT just for batch migrate. Still avoid running CLI and UI fleets against the same DB hosts at once.

Platform UI batch migrate / seed / backup:

  1. Batch migrate/seed enqueues one tenant_ops_fleet job that uses TENANCY_MIGRATE_CONCURRENCY (same path as CLI migrate-all). Per-tenant ops still use tenant_ops. Keep QUEUE_LONG_RUNNING_CONCURRENT=1 for batch migrate (the fleet occupies a single queue slot).
  2. Worker hosts must run queue-long-running (do not set APP_DISABLED_RUNNERS=queue-*); API hosts usually Dispatch only.
  3. After changing .env, restart the Worker processes that consume long-running (concurrency is read at startup in bootstrap/runners.go). Env-only change without Worker restart keeps the old concurrency. Restarting API alone does not change Worker parallelism.
  4. Batch migrate speed comes from raising TENANCY_MIGRATE_CONCURRENCY (applies to newly enqueued fleets). Batch backup remains one job per tenant and still scales with QUEUE_LONG_RUNNING_CONCURRENT.
KnobRestart needed?Notes
QUEUE_LONG_RUNNING_CONCURRENT / QUEUE_SCHEDULE_CONCURRENT / QUEUE_CONCURRENTYes — restart matching queue WorkersConcurrent is fixed when the runner starts
TENANCY_MIGRATE_CONCURRENCYNext artisan tenant:migrate-all picks it up; no need to restart long-lived API/Workers for CLIOr use one-shot --concurrency=N without editing env
Clicking migrate in the UIEnqueue works without restartParallelism still follows already-running Worker concurrency

Suggested values (copy-paste, one tenant-DB host):

ScenarioTENANCY_MIGRATE_CONCURRENCY (CLI / UI batch migrate·seed)QUEUE_LONG_RUNNING_CONCURRENT (per-tenant / batch backup)QUEUE_SCHEDULE_CONCURRENT
Local / small fleet <502–3 (default 2)15–10 (often 10)
Same host, cutover migrate-all3–51 (do not raise during cutover)Temporarily 1–2 or pause tick
~100 tenants/DB host, sharded tenants.host20–401–25–10
Few thousand, many hosts40–50 (max 100, use sparingly)1–2; add Worker hosts for more speedStart 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 tenantsTenant poolQueue / processesRedis
< 50Defaults IDLE=2 / OPEN=20 OK; can use OPEN=10Worker may share API host; QUEUE_CONNECTION=redisSingle or small managed Redis
50–200IDLE=1–2, OPEN=3–5; shorter idle/lifetime (e.g. 120 / 600)Split API vs Worker (see Production); keep QUEUE_LONG_RUNNING_CONCURRENT lowManaged Redis; watch used_memory and backlog
200–1000OPEN=2–3; optional REGISTERED_MAX=300Dedicated Workers: default + long-running + scheduleLarger tier; watch export/import neighbors
Few thousandOPEN=2; REGISTERED_MAX=300–500; shard via tenants.host; migrate may use TENANCY_MIGRATE_CONCURRENCY=20–50Scale Workers horizontally; tune QUEUE_SCHEDULE_CONCURRENTHA 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 bandAPIQueue WorkerSchedulerPlatform DBTenant DBsRedis
< 501× 2vCPU 4GB (Worker may colocated)Optional mergeMerged or 1 host1× small/midSame host multi-DB OK1× small
50–2002× 2vCPU 4GB (HTTP only)1× 2vCPU 4GBExactly 1 schedule host1× (raise max_connections)Same host or start 1 tenant DB host1× managed
200–10002–4× API2+× Workers (include schedule)1 hostDedicated platform DBMultiple hosts via tenants.hostMid/large HA
Few thousand4+× API behind LB3+× Workers; tune QUEUE_SCHEDULE_CONCURRENT1 hostDedicated + backupsMany tenant DB hostsHA; split if noisy

Roles:

RoleAPP_DISABLED_RUNNERSQueue runners needed
APIqueue-*None (Dispatch only)
Workeremptyqueue-default, queue-long-running, queue-schedule, optional queue-search
Schedulermay 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:

ini
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:

ini
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-schedule

Watch: 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:

text
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
PointGuidance
One stack per regionSeparate platform DB, TENANCY_DRIVER=database, API / Worker / Scheduler / Redis, tenant DBs (optionally split further with tenants.host)
Signup trafficPoint registration/sales at the current write region platform/API; from a cutover date, new tenants go only to the new region
How tenants returnRegion-specific subdomains/apexes, or a thin gateway/DNS map tenant_code → region API; do not point old tenant hosts at the wrong region
Platform consoleEach region has its own /platform; this product has no cross-region control plane
MovesAvoid moving tenants; cross-region means backup/restore plus DNS/directory changes
Cold tenantsdisabled / 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 with tenants/{code}/. Tenant admin cannot change file_disk (save rejected); export format remains editable.
  • Optional BYOB (platform): set tenant storage_mode=custom with s3/oss/cos/minio credentials (secret sealed with APP_KEY). New uploads use disk tenant_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_database drops DB/schema only; object storage is not purged here.
  • Recycle / force delete: GET /tenants?trashed=only; POST .../undelete; POST .../purge optional retry; DELETE .../force defaults to async purge objects+backups then hard-delete (frees code). Retention TENANCY_DELETED_RETENTION_DAYS; schedule tenant:cleanup-deleted.
  • Storage limit storage_limit_bytes (0=unlimited): enforced from SUM(attachments.size) before upload.

运维增强 ​

  1. Landlord 迁移跳过:平台表迁移(tenants / platform_admins / jobs / provision/migrate meta)在 tenant_* 连接上 SkipOnTenantConnection 空跑,避免污染租户库。
  2. Migrate / ops visibility:last_migrate_error / migrated_at;platform UI also shows last_op* / last_backup_path. Seed failure does not demote ready → failed.
  3. Ping / async ops:POST .../ping;.../migrate|seed|backup enqueue tenant_ops.
  4. 登录限流:login limiter 键含 body/query/header/subdomain 租户提示,避免跨租户互相锁号。
  5. 日志:带 tenant_code / tenant_id 前缀(app/utils/logger)。
  6. PG sslmode:TENANCY_POSTGRES_SSLMODE 或 DB_SSLMODE。
  7. 备份/恢复: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。
  8. CLI scope: RunTenantScope walks active + ready only; tenant:migrate-all can still cover pending (separate query); concurrency via TENANCY_MIGRATE_CONCURRENCY / --concurrency.
  9. 未绑定隔离:tenancy 开启但 ctx 未绑定时,CacheKey → t_unbound:*,StoragePrefix → tenants/_unbound_/(不与共享根冲突)。

首启(推荐) ​

bash
# .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 → 租户后台

命令 ​

bash
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 空:在平台实例 CREATE
  • host 有值:连目标主机系统库再 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) ​

TypeStorageAnswersWhere to view
Operation logTenant DB / platform platform_operation_logsWho changed what (audit)Tenant admin / Platform operation logs
Tenant ops logPlatform tenant_op_logsDid migrate/seed/backup succeedPlatform tenant ops logs
System logEach connection's system_logsPanics, DB failures, infra errorsTenant 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 ​

MethodPath说明
POST/api/platform/login登录
GET/api/platform/info当前管理员
POST/api/platform/logout登出
GET/POST/api/platform/tenantsList / create (no sync migrate)
GET/PUT/api/platform/tenants/{id}Detail / update connection
PUT/api/platform/tenants/{id}/statusEnable/disable
POST/api/platform/tenants/{id}/pingPing tenant DB
POST/api/platform/tenants/{id}/migrateAsync migrate (with_seed optional)
POST/api/platform/tenants/{id}/seedAsync seed
POST/api/platform/tenants/{id}/backupAsync backup
GET/api/platform/tenants/{id}/backupsList backup files under storage/backups/tenants/{code}/
GET/api/platform/tenants/{id}/backups/download?name=Download a .sql backup
POST/api/platform/tenants/{id}/restoreAsync restore (backup_name)
POST/api/platform/tenants/{id}/backups/pruneKeep 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}/undeleteRestore soft-deleted metadata
POST/api/platform/tenants/{id}/purgeRetry async object/backup purge
DELETE/api/platform/tenants/{id}/forceHard-delete recycle-bin row (confirm_code; frees code)
GET/api/platform/tenants/{id}/overviewDB snapshot stats
GET/api/platform/tenant-op-logsPlatform-wide ops execution logs (filter code/op/status/batch_id/operator)
GET/api/platform/login-logsPlatform login logs (landlord)
GET/POST/api/platform/adminsPlatform admins list / create (owner write)
GET/PUT/DELETE/api/platform/admins/{id}Platform admin detail / update / soft-delete
POST/api/platform/admins/{id}/reset-passwordReset platform admin password
GET/api/platform/login-logs/{id}Platform login log detail
GET/api/platform/operation-logsPlatform operation logs (landlord)
GET/api/platform/operation-logs/{id}Platform operation log detail
GET/api/platform/system-logsRead-only system logs (platform DB; tenant_code switches to that tenant DB)
GET/api/platform/system-logs/module-optionsModule 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-summaryLast 24h error/warning counts + recent errors
GET/api/platform/tenants/{id}/op-logsOps timeline
GET/api/platform/tenants/{id}/login-linksHow to open tenant admin
GET/api/platform/tenants/settingsConsole settings (backup_keep, queue)
GET/api/platform/tenants/queue-statuslong-running queue depth
GET/api/platform/tenants/exportFiltered CSV export
POST/api/platform/tenants/ops-batchBatch 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):

MethodPath说明
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 / --tenantCLI 按租户执行
SchemaHasTable / WithSchemaContext请求路径 Schema(SchemaConnLock)

硬性规则 ​

  1. 业务用 OrmQuery(ctx);平台路由不挂 Tenant 中间件;平台元数据用 PlatformOrmQuery(钉死平台连接)。
  2. 隔离是切库/切 Schema,不是行级 tenant_id GlobalScope。
  3. 缓存/上传(含 chunks/、导出、导入与附件临时目录)走租户前缀。
  4. 搜索索引绑定后为 {code}_orders;未绑定 fail-closed,禁止回退共享 orders。
  5. 导出/搜索队列缺 tenant_id 时 fail-closed,禁止写到平台库。
  6. HTTP 手动跑定时任务自动带当前 --tenant,禁止扫全租户;cron 可遍历启用租户。
  7. 队列表 jobs / failed_jobs 读平台连接(QUEUE_DATABASE_CONNECTION)。
  8. Browser CORS: CORS_ALLOWED_HEADERS must include X-Tenant-ID; subdomains use TENANCY_BASE_DOMAIN wildcards; for vanity hosts prefer same-Host or demo empty/* (see CORS above); on split API set ADMIN_ORIGIN_GUARD=true (see Origin guard) — do not hand-edit the CORS allowlist per merchant.
  9. 订单搜索用 search:* / SyncOrderSearch(SEARCH_*),勿再接旧 ES outbox 链路。
  10. IP 黑名单:进程内短 TTL(约 30s)缓存启用名单;CRUD 后立即失效。查库失败时在约 5 分钟内回退最近成功缓存,超时仍 fail-closed(503)。
  11. Only provision_status=ready tenants may bind traffic; HTTP create forbids sync migrate — use platform UI async migrate or CLI tenant:migrate.
  12. 远程租户库禁止空账号回落平台 root;公网默认 TENANCY_ALLOW_PLATFORM_DB_CREDENTIALS=false。
  13. 平台表迁移不得落在租户库(SkipOnTenantConnection);migrate 失败须可在平台侧看到 last_migrate_error。
  14. PG schema 隔离的 backup/restore 必须限定 schema;登录对 tenant_not_ready 返回 403(非 500)。
  15. 公网优先 subdomain;支付回调必须带 {type}/{tenant_code} 路径。
  16. Vanity domains: edge Host rewrite (see above); do not add per-domain app env, CORS allowlist entries, or separate deploys.
  17. Business dirs (app/services / http / jobs / console) must not call facades.Orm().Query(); CI runs bash scripts/check-tenant-orm.sh (tenant-maintenance allowlist excepted).
  18. Tenant business cache/locks must use tenancy.CacheKey / TenantCacheKey; CI runs bash 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.