之前也参与过一个医疗行业的saas平台建设与维护工作,但说实话那个系统架构太low了,总监也是菜得很,租户层和业务层根本没分开,每个view中判断请求所属租户,然后操作对应数据库。
本人最近也是基于django框架实现saas多租户CMDB资产管理系统,鉴于之前经验教训,高度抽象了租户层,真正实现多租户共用一套业务代码的架构,业务逻辑不需要关心租户的概念,系统自动处理,程序员只需要安心写逻辑就行了。
多租户数据库架构可分为三种类型,即
- 独立数据库
- 共享数据库、独立 Schema
- 共享数据库、共享 Schema、共享数据表
独立数据库是一个租户独享一个数据库实例,它提供了最强的分离度,租户的数据彼此物理不可见,备份与恢复都很灵活;
共享数据库、独立 Schema 将每个租户关联到同一个数据库的不同 Schema,租户间数据彼此逻辑不可见,上层应用程序的实现和独立数据库一样简单,但备份恢复稍显复杂;
最后一种模式则是租户数据在数据表级别实现共享,它提供了最低的成本,但引入了额外的编程复杂性(程序的数据访问需要用 tenantId 来区分不同租户),备份与恢复也更复杂。
以 Django+第一或第二种数据库隔离架构 为例,实现多租户saas平台。
django项目创建过程请自行完成,以下只演示关键步骤。
一、编写db_router
不同租户连接不同数据库,django通过自定义数据库路由来实现:
from itom_cmdb.libs.utils.tenant import TenantUtil
class DBRouter:
def db_for_read(self, model, **hints):
tenant_id = TenantUtil.get_current_tenant()
if not tenant_id:
raise Exception('DBRouter get `tenant_id` failed')
return tenant_id
#django用这个返回值去匹配settings中DATABASES数据库,
#例如tenant_id=tenant_1,那么查询时会选择DATABASES中名为tenant_1的数据库
def db_for_write(self, model, **hints):
tenant_id = TenantUtil.get_current_tenant()
if not tenant_id:
raise Exception('DBRouter get `tenant_id` failed')
return tenant_id
def allow_relation(self, obj1, obj2, **hints):
return None
def allow_migrate(self, db, app_label, model_name=None, **hints):
return True
settings中声明DATABASE_ROUTERS:
....
DATABASES = {
'default': {# 必须有个名为default的默认数据库
'ENGINE': 'django.db.backends.mysql',
'NAME': os.getenv('DATABASE_NAME', 'default'),
'USER': os.getenv('DATABASE_USER', 'root'),
'PASSWORD': os.getenv('DATABASE_PASSWORD', 'pwd123456'),
'HOST': os.getenv('DATABASE_HOST', '127.0.0.1'),
'PORT': os.getenv('DATABASE_PORT', 3306),
'ATOMIC_REQUESTS': True,
'OPTIONS': {'charset': 'utf8mb4'},
},
'tenant_1': {
'ENGINE': 'django.db.backends.mysql',
'NAME': os.getenv('DATABASE_NAME', 'tenant_1'),
'USER': os.getenv('DATABASE_USER', 'root'),
'PASSWORD': os.getenv('DATABASE_PASSWORD', 'pwd123456'),
'HOST': os.getenv('DATABASE_HOST', '127.0.0.1'),
'PORT': os.getenv('DATABASE_PORT', 3306),
'ATOMIC_REQUESTS': True,
'OPTIONS': {'charset': 'utf8mb4'},
},
'tenant_2': {
'ENGINE': 'django.db.backends.mysql',
'NAME': os.getenv('DATABASE_NAME', 'tenant_2'),
'USER': os.getenv('DATABASE_USER', 'root'),
'PASSWORD': os.getenv('DATABASE_PASSWORD', 'pwd123456'),
'HOST': os.getenv('DATABASE_HOST', '127.0.0.1'),
'PORT': os.getenv('DATABASE_PORT', 3306),
'ATOMIC_REQUESTS': True,
'OPTIONS': {'charset': 'utf8mb4'},
},
}
DATABASE_ROUTERS = ['itom_cmdb.libs.utils.db_router.DBRouter']
TenantUtil工具主要实现对租户ID进行缓存和读取,下面来看 TenantUtil 工具的实现:
二、缓存tenant_id
try:
# Werkzeug.Local is a reliable cache(https://blog.youkuaiyun.com/bocai_xiaodaidai/article/details/118569734)
from werkzeug.local import Local as LocalContext
except ImportError:
from threading import local as LocalContext
thread_local = LocalContext()
TENANT_KEY = 'current_tenant_id'
class TenantUtil:
@classmethod
def set_current_tenant(cls, tenant_id):
setattr(thread_local, TENANT_KEY, tenant_id)
@classmethod
def get_current_tenant(cls):
return getattr(thread_local, TENANT_KEY, None)
@classmethod
def release_current_tenant(cls):
thread_local.__delattr__(TENANT_KEY)
三、添加租户ID缓存中间件
class CacheTenantMiddleWare(MiddlewareMixin):
def process_request(self, request):
tenant_id = request.header.get(HTTP_TENANT-ID)
if not tenant_id:
raise PermissionDenied(_('租户ID为设置'))
TenantUtil.set_current_tenant(tenant_id)
return None
def process_response(self, request, response):
TenantUtil.release_current_tenant()
return response
MIDDLEWARE = [
...
'itom_cmdb.libs.frameworks.middlewares.CacheTenantMiddleWare',
]
流程梳理:
- 前端发起请求时,在header中加入tenant_id参数
- 中间件通过获取header中的租户id并缓存到本地线程局部变量中(这一步变种很多,比如后端可以通过登录用户的邮箱后缀或者访问域名来区分租户)
- Django读写数据时,会调用我们自己配置的DATABASE_ROUTERS
- DATABASE_ROUTERS通过获取本地缓存的租户id去匹配对应的数据库
tips:
- 数据库migrate迁移命令需要重写,因为默认的migrate拿到不tenant_id
以下是重写之后的migrate命令,使用时指定要迁移的数据库即可。如:
python manage.py makemigrations
python manage.py migrate --database tenant_1 (仅迁移tenant_1数据库)
python manage.py migrate --database all (all代表迁移所有数据库)
# -*- coding:utf-8 -*-
from django.conf import settings
from django.core.management.commands.migrate import Command as DjangoCommand
from itom_cmdb.libs.utils.tenant_util import TenantUtil
class Command(DjangoCommand):
def add_arguments(self, parser):
parser.add_argument(
'--database',
required=True,
help='Nominates a database to synchronize, Defaults to all databases.',
)
parser.add_argument(
'--skip-checks', action='store_true',
help='Skip system checks.',
)
parser.add_argument(
'app_label', nargs='?',
help='App label of an application to synchronize the state.',
)
parser.add_argument(
'migration_name', nargs='?',
help='Database state will be brought to the state after that '
'migration. Use the name "zero" to unapply all migrations.',
)
parser.add_argument(
'--noinput', '--no-input', action='store_false', dest='interactive',
help='Tells Django to NOT prompt the user for input of any kind.',
)
parser.add_argument(
'--fake', action='store_true',
help='Mark migrations as run without actually running them.',
)
parser.add_argument(
'--fake-initial', action='store_true',
help='Detect if tables already exist and fake-apply initial migrations if so. Make sure '
'that the current database schema matches your initial migration before using this '
'flag. Django will only check for an existing table name.',
)
parser.add_argument(
'--plan', action='store_true',
help='Shows a list of the migration actions that will be performed.',
)
parser.add_argument(
'--run-syncdb', action='store_true',
help='Creates tables for apps without migrations.',
)
parser.add_argument(
'--check', action='store_true', dest='check_unapplied',
help='Exits with a non-zero status if unapplied migrations exist.',
)
def handle(self, *args, **options):
database = options['database']
if database == 'all':
self.stdout.write(self.style.SUCCESS("Nominates all database to synchronize:"))
for d in settings.DATABASES:
self.stdout.write(self.style.SUCCESS(f'Sync {d}:'))
TenantUtil.set_current_tenant(d)
options['database'] = d
super().handle(*args, **options)
TenantUtil.release_current_tenant()
else:
TenantUtil.set_current_tenant(database)
super(Command, self).handle(*args, **options)
TenantUtil.release_current_tenant()
- 需要用到django admin后台工具时,也要写个中间件做支持