多租户数据库。文档ID和授权策略
Multi-tenanted DB. Strategy for Document ID and authorization
我正在权衡拥有单独的数据库(每个公司一个)还是一个多租户数据库(所有公司)。条件:
- 一个用户只能属于一个公司,不能访问其他公司的文档。
- 系统管理员需要维护所有公司的数据库。
- 数量companies/tenants——从几百到几万
- 所有 companies/tenants 都有一个带身份验证的入口点(它将解析租户并将其寻址到正确的数据库)。
问题 #1。在 RavenDB 中是否有任何用于设计多租户数据库的"good practices"?
有一个类似的post for MongoDB。 RavenDB 也会一样吗?
更多记录会影响 indexes,但它是否会潜在地使一些租户遭受其他租户积极使用索引的困扰?
如果我要为 RavenDB 设计一个多租户数据库,那么我将实现视为
- 每个 Company/Tenant 都有一个标签,因此一家公司的所有用户都有权使用公司标签,并且所有顶级文档都有标签(参见 KB on Auth Bundle)
- 有一个租户 ID 标签作为每个文档 ID 的前缀(由于 official recommendation 使用顺序标识符,我很高兴在服务器上生成 ID)
问题 #2.1。标记是利用 Authorization Bundle 解析用户权限并防止访问其他租户文档的最佳方式吗?
问题 #2.2。在顶级文档的 ID 前缀中包含租户 ID 有多重要?
我想,这里的主要考虑因素是通过标签解决权限问题后的性能,或者我遗漏了什么?
如果您要拥有数百家公司,那么每个公司一个数据库就可以了。
如果你打算拥有数万个,那么你想把它们全部放在一个数据库中。
一个数据库会消耗大量资源,拥有大量资源可能比单个较大的数据库要昂贵得多。
我建议不要使用授权包,它需要我们进行 O(N)
过滤。最好直接在查询中添加 TenantId = XYZ
,也许通过查询侦听器。
不要太担心顺序标识符。它们有影响,但它们并不那么重要,除非您每秒产生数万个。
查看处理多租户的侦听器示例。
将当前租户 ID 添加到所有查询的查询侦听器(过滤掉来自其他租户的条目):
public class TenantedEntityQueryListener : IDocumentQueryListener
{
private readonly ICurrentTenantIdResolver _resolver;
public TenantedEntityQueryListener(ICurrentTenantIdResolver resolver) : base(resolver)
{
_resolver = resolver;
}
public void BeforeQueryExecuted(IDocumentQueryCustomization customization)
{
var type = customization.GetType();
var entityType = type.GetInterfaces()
.SingleOrDefault(i => i.IsClosedTypeOf(typeof(IDocumentQuery<>))
|| i.IsClosedTypeOf(typeof(IAsyncDocumentQuery<>)))
?.GetGenericArguments()
.Single();
if (entityType != null && entityType.IsAssignableTo<ITenantedEntity>())
{
// Add the "AND" to the the WHERE clause
// (the method has a check under the hood to prevent adding "AND" if the "WHERE" is empty)
type.GetMethod("AndAlso").Invoke(customization, null);
// Add "TenantId = 'Bla'" into the WHERE clause
type.GetMethod( "WhereEquals",
new[] { typeof(string), typeof(object) }
)
.Invoke(customization,
new object[]
{
nameof(ITenantedEntity.TenantId),
_resolver.GetCurrentTenantId()
}
);
}
}
}
将当前租户 ID 设置为所有租户实体的商店侦听器:
public class TenantedEntityStoreListener : IDocumentStoreListener
{
private readonly ICurrentTenantIdResolver _resolver;
public TenantedEntityStoreListener(ICurrentTenantIdResolver resolver) : base(resolver)
{
_resolver = resolver;
}
public bool BeforeStore(string key, object entityInstance, RavenJObject metadata, RavenJObject original)
{
var tenantedEntity = entityInstance as ITenantedEntity;
if (tenantedEntity != null)
{
tenantedEntity.TenantId = _resolver.GetCurrentTenantId();
return true;
}
return false;
}
public void AfterStore(string key, object entityInstance, RavenJObject metadata) {}
}
由支持多租户的顶级实体实现的接口:
public interface ITenantedEntity
{
string TenantId { get; set; }
}
更新(2021 年 9 月):4 年后我制作了:
- 一篇博客 post“Multi-tenant applications with NoSQL”,深入探讨了 RavenDB 实施多租户的所有细节。
- YABT project 在 GitHub 上实现了多租户。
原回答:
我试图通过编辑他的 post 来让 @AyendeRahien 参与技术实施的讨论,但没有成功 :),所以下面我将解决我对上述问题的担忧:
1.多租户数据库与多个数据库
这里有一些Ayende's thoughts关于多租户的一般情况。
在我看来,问题归结为
- 预计租户数量
- 每个租户的数据库大小。
简而言之,在有大量记录的几个租户的情况下,将租户信息添加到索引中将不必要地增加索引大小,并且处理租户 ID 会带来一些您宁愿避免的开销,所以那就去买两个数据库吧。
2。多租户DB设计
第 1 步。将 TenantId
属性 添加到您要支持多租户的所有持久性文档。
/// <summary>
/// Interface for top-level entities, which belong to a tenant
/// </summary>
public interface ITenantedEntity
{
/// <summary>
/// ID of a tenant
/// </summary>
string TenantId { get; set; }
}
/// <summary>
/// Contact information [Tenanted document]
/// </summary>
public class Contact : ITenantedEntity
{
public string Id { get; set; }
public string TenantId { get; set; }
public string Name { get; set; }
}
第 2 步。实施 facade for the Raven's session(IDocumentSession
或 IAsyncDocumentSession
)来处理多租户实体。
示例代码如下:
/// <summary>
/// Facade for the Raven's IAsyncDocumentSession interface to take care of multi-tenanted entities
/// </summary>
public class RavenTenantedSession : IAsyncDocumentSession
{
private readonly IAsyncDocumentSession _dbSession;
private readonly string _currentTenantId;
public IAsyncAdvancedSessionOperations Advanced => _dbSession.Advanced;
public RavenTenantedSession(IAsyncDocumentSession dbSession, ICurrentTenantIdResolver tenantResolver)
{
_dbSession = dbSession;
_currentTenantId = tenantResolver.GetCurrentTenantId();
}
public void Delete<T>(T entity)
{
if (entity is ITenantedEntity tenantedEntity && tenantedEntity.TenantId != _currentTenantId)
throw new ArgumentException("Attempt to delete a record for another tenant");
_dbSession.Delete(entity);
}
public void Delete(string id)
{
throw new NotImplementedException("Deleting by ID hasn't been implemented");
}
#region SaveChanges & StoreAsync---------------------------------------
public Task SaveChangesAsync(CancellationToken token = new CancellationToken()) => _dbSession.SaveChangesAsync(token);
public Task StoreAsync(object entity, CancellationToken token = new CancellationToken())
{
SetTenantIdOnEntity(entity);
return _dbSession.StoreAsync(entity, token);
}
public Task StoreAsync(object entity, string changeVector, string id, CancellationToken token = new CancellationToken())
{
SetTenantIdOnEntity(entity);
return _dbSession.StoreAsync(entity, changeVector, id, token);
}
public Task StoreAsync(object entity, string id, CancellationToken token = new CancellationToken())
{
SetTenantIdOnEntity(entity);
return _dbSession.StoreAsync(entity, id, token);
}
private void SetTenantIdOnEntity(object entity)
{
var tenantedEntity = entity as ITenantedEntity;
if (tenantedEntity != null)
tenantedEntity.TenantId = _currentTenantId;
}
#endregion SaveChanges & StoreAsync------------------------------------
public IAsyncLoaderWithInclude<object> Include(string path)
{
throw new NotImplementedException();
}
public IAsyncLoaderWithInclude<T> Include<T>(Expression<Func<T, string>> path)
{
throw new NotImplementedException();
}
public IAsyncLoaderWithInclude<T> Include<T, TInclude>(Expression<Func<T, string>> path)
{
throw new NotImplementedException();
}
public IAsyncLoaderWithInclude<T> Include<T>(Expression<Func<T, IEnumerable<string>>> path)
{
throw new NotImplementedException();
}
public IAsyncLoaderWithInclude<T> Include<T, TInclude>(Expression<Func<T, IEnumerable<string>>> path)
{
throw new NotImplementedException();
}
#region LoadAsync -----------------------------------------------------
public async Task<T> LoadAsync<T>(string id, CancellationToken token = new CancellationToken())
{
T entity = await _dbSession.LoadAsync<T>(id, token);
if (entity == null
|| entity is ITenantedEntity tenantedEntity && tenantedEntity.TenantId == _currentTenantId)
return entity;
throw new ArgumentException("Incorrect ID");
}
public async Task<Dictionary<string, T>> LoadAsync<T>(IEnumerable<string> ids, CancellationToken token = new CancellationToken())
{
Dictionary<string, T> entities = await _dbSession.LoadAsync<T>(ids, token);
if (typeof(T).GetInterfaces().Contains(typeof(ITenantedEntity)))
return entities.Where(e => (e.Value as ITenantedEntity)?.TenantId == _currentTenantId).ToDictionary(i => i.Key, i => i.Value);
return null;
}
#endregion LoadAsync --------------------------------------------------
#region Query ---------------------------------------------------------
public IRavenQueryable<T> Query<T>(string indexName = null, string collectionName = null, bool isMapReduce = false)
{
var query = _dbSession.Query<T>(indexName, collectionName, isMapReduce);
if (typeof(T).GetInterfaces().Contains(typeof(ITenantedEntity)))
return query.Where(r => (r as ITenantedEntity).TenantId == _currentTenantId);
return query;
}
public IRavenQueryable<T> Query<T, TIndexCreator>() where TIndexCreator : AbstractIndexCreationTask, new()
{
var query = _dbSession.Query<T, TIndexCreator>();
var lastArgType = typeof(TIndexCreator).BaseType?.GenericTypeArguments?.LastOrDefault();
if (lastArgType != null && lastArgType.GetInterfaces().Contains(typeof(ITenantedEntity)))
return query.Where(r => (r as ITenantedEntity).TenantId == _currentTenantId);
return query;
}
#endregion Query ------------------------------------------------------
public void Dispose() => _dbSession.Dispose();
}
如果您也需要 Include()
,上面的代码可能需要一些爱。
我的最终解决方案不使用 listeners for RavenDb v3.x as I suggested earlier (see on why) or events 用于 RavenDb v4(因为很难修改其中的查询)。
当然,如果您编写了 patches 个 JavaScript 函数,则您必须手动处理多租户。
我正在权衡拥有单独的数据库(每个公司一个)还是一个多租户数据库(所有公司)。条件:
- 一个用户只能属于一个公司,不能访问其他公司的文档。
- 系统管理员需要维护所有公司的数据库。
- 数量companies/tenants——从几百到几万
- 所有 companies/tenants 都有一个带身份验证的入口点(它将解析租户并将其寻址到正确的数据库)。
问题 #1。在 RavenDB 中是否有任何用于设计多租户数据库的"good practices"?
有一个类似的post for MongoDB。 RavenDB 也会一样吗? 更多记录会影响 indexes,但它是否会潜在地使一些租户遭受其他租户积极使用索引的困扰?
如果我要为 RavenDB 设计一个多租户数据库,那么我将实现视为
- 每个 Company/Tenant 都有一个标签,因此一家公司的所有用户都有权使用公司标签,并且所有顶级文档都有标签(参见 KB on Auth Bundle)
- 有一个租户 ID 标签作为每个文档 ID 的前缀(由于 official recommendation 使用顺序标识符,我很高兴在服务器上生成 ID)
问题 #2.1。标记是利用 Authorization Bundle 解析用户权限并防止访问其他租户文档的最佳方式吗?
问题 #2.2。在顶级文档的 ID 前缀中包含租户 ID 有多重要? 我想,这里的主要考虑因素是通过标签解决权限问题后的性能,或者我遗漏了什么?
如果您要拥有数百家公司,那么每个公司一个数据库就可以了。 如果你打算拥有数万个,那么你想把它们全部放在一个数据库中。
一个数据库会消耗大量资源,拥有大量资源可能比单个较大的数据库要昂贵得多。
我建议不要使用授权包,它需要我们进行 O(N)
过滤。最好直接在查询中添加 TenantId = XYZ
,也许通过查询侦听器。
不要太担心顺序标识符。它们有影响,但它们并不那么重要,除非您每秒产生数万个。
查看处理多租户的侦听器示例。
将当前租户 ID 添加到所有查询的查询侦听器(过滤掉来自其他租户的条目):
public class TenantedEntityQueryListener : IDocumentQueryListener
{
private readonly ICurrentTenantIdResolver _resolver;
public TenantedEntityQueryListener(ICurrentTenantIdResolver resolver) : base(resolver)
{
_resolver = resolver;
}
public void BeforeQueryExecuted(IDocumentQueryCustomization customization)
{
var type = customization.GetType();
var entityType = type.GetInterfaces()
.SingleOrDefault(i => i.IsClosedTypeOf(typeof(IDocumentQuery<>))
|| i.IsClosedTypeOf(typeof(IAsyncDocumentQuery<>)))
?.GetGenericArguments()
.Single();
if (entityType != null && entityType.IsAssignableTo<ITenantedEntity>())
{
// Add the "AND" to the the WHERE clause
// (the method has a check under the hood to prevent adding "AND" if the "WHERE" is empty)
type.GetMethod("AndAlso").Invoke(customization, null);
// Add "TenantId = 'Bla'" into the WHERE clause
type.GetMethod( "WhereEquals",
new[] { typeof(string), typeof(object) }
)
.Invoke(customization,
new object[]
{
nameof(ITenantedEntity.TenantId),
_resolver.GetCurrentTenantId()
}
);
}
}
}
将当前租户 ID 设置为所有租户实体的商店侦听器:
public class TenantedEntityStoreListener : IDocumentStoreListener
{
private readonly ICurrentTenantIdResolver _resolver;
public TenantedEntityStoreListener(ICurrentTenantIdResolver resolver) : base(resolver)
{
_resolver = resolver;
}
public bool BeforeStore(string key, object entityInstance, RavenJObject metadata, RavenJObject original)
{
var tenantedEntity = entityInstance as ITenantedEntity;
if (tenantedEntity != null)
{
tenantedEntity.TenantId = _resolver.GetCurrentTenantId();
return true;
}
return false;
}
public void AfterStore(string key, object entityInstance, RavenJObject metadata) {}
}
由支持多租户的顶级实体实现的接口:
public interface ITenantedEntity
{
string TenantId { get; set; }
}
更新(2021 年 9 月):4 年后我制作了:
- 一篇博客 post“Multi-tenant applications with NoSQL”,深入探讨了 RavenDB 实施多租户的所有细节。
- YABT project 在 GitHub 上实现了多租户。
原回答:
我试图通过编辑他的 post 来让 @AyendeRahien 参与技术实施的讨论,但没有成功 :),所以下面我将解决我对上述问题的担忧:
1.多租户数据库与多个数据库
这里有一些Ayende's thoughts关于多租户的一般情况。
在我看来,问题归结为
- 预计租户数量
- 每个租户的数据库大小。
简而言之,在有大量记录的几个租户的情况下,将租户信息添加到索引中将不必要地增加索引大小,并且处理租户 ID 会带来一些您宁愿避免的开销,所以那就去买两个数据库吧。
2。多租户DB设计
第 1 步。将 TenantId
属性 添加到您要支持多租户的所有持久性文档。
/// <summary>
/// Interface for top-level entities, which belong to a tenant
/// </summary>
public interface ITenantedEntity
{
/// <summary>
/// ID of a tenant
/// </summary>
string TenantId { get; set; }
}
/// <summary>
/// Contact information [Tenanted document]
/// </summary>
public class Contact : ITenantedEntity
{
public string Id { get; set; }
public string TenantId { get; set; }
public string Name { get; set; }
}
第 2 步。实施 facade for the Raven's session(IDocumentSession
或 IAsyncDocumentSession
)来处理多租户实体。
示例代码如下:
/// <summary>
/// Facade for the Raven's IAsyncDocumentSession interface to take care of multi-tenanted entities
/// </summary>
public class RavenTenantedSession : IAsyncDocumentSession
{
private readonly IAsyncDocumentSession _dbSession;
private readonly string _currentTenantId;
public IAsyncAdvancedSessionOperations Advanced => _dbSession.Advanced;
public RavenTenantedSession(IAsyncDocumentSession dbSession, ICurrentTenantIdResolver tenantResolver)
{
_dbSession = dbSession;
_currentTenantId = tenantResolver.GetCurrentTenantId();
}
public void Delete<T>(T entity)
{
if (entity is ITenantedEntity tenantedEntity && tenantedEntity.TenantId != _currentTenantId)
throw new ArgumentException("Attempt to delete a record for another tenant");
_dbSession.Delete(entity);
}
public void Delete(string id)
{
throw new NotImplementedException("Deleting by ID hasn't been implemented");
}
#region SaveChanges & StoreAsync---------------------------------------
public Task SaveChangesAsync(CancellationToken token = new CancellationToken()) => _dbSession.SaveChangesAsync(token);
public Task StoreAsync(object entity, CancellationToken token = new CancellationToken())
{
SetTenantIdOnEntity(entity);
return _dbSession.StoreAsync(entity, token);
}
public Task StoreAsync(object entity, string changeVector, string id, CancellationToken token = new CancellationToken())
{
SetTenantIdOnEntity(entity);
return _dbSession.StoreAsync(entity, changeVector, id, token);
}
public Task StoreAsync(object entity, string id, CancellationToken token = new CancellationToken())
{
SetTenantIdOnEntity(entity);
return _dbSession.StoreAsync(entity, id, token);
}
private void SetTenantIdOnEntity(object entity)
{
var tenantedEntity = entity as ITenantedEntity;
if (tenantedEntity != null)
tenantedEntity.TenantId = _currentTenantId;
}
#endregion SaveChanges & StoreAsync------------------------------------
public IAsyncLoaderWithInclude<object> Include(string path)
{
throw new NotImplementedException();
}
public IAsyncLoaderWithInclude<T> Include<T>(Expression<Func<T, string>> path)
{
throw new NotImplementedException();
}
public IAsyncLoaderWithInclude<T> Include<T, TInclude>(Expression<Func<T, string>> path)
{
throw new NotImplementedException();
}
public IAsyncLoaderWithInclude<T> Include<T>(Expression<Func<T, IEnumerable<string>>> path)
{
throw new NotImplementedException();
}
public IAsyncLoaderWithInclude<T> Include<T, TInclude>(Expression<Func<T, IEnumerable<string>>> path)
{
throw new NotImplementedException();
}
#region LoadAsync -----------------------------------------------------
public async Task<T> LoadAsync<T>(string id, CancellationToken token = new CancellationToken())
{
T entity = await _dbSession.LoadAsync<T>(id, token);
if (entity == null
|| entity is ITenantedEntity tenantedEntity && tenantedEntity.TenantId == _currentTenantId)
return entity;
throw new ArgumentException("Incorrect ID");
}
public async Task<Dictionary<string, T>> LoadAsync<T>(IEnumerable<string> ids, CancellationToken token = new CancellationToken())
{
Dictionary<string, T> entities = await _dbSession.LoadAsync<T>(ids, token);
if (typeof(T).GetInterfaces().Contains(typeof(ITenantedEntity)))
return entities.Where(e => (e.Value as ITenantedEntity)?.TenantId == _currentTenantId).ToDictionary(i => i.Key, i => i.Value);
return null;
}
#endregion LoadAsync --------------------------------------------------
#region Query ---------------------------------------------------------
public IRavenQueryable<T> Query<T>(string indexName = null, string collectionName = null, bool isMapReduce = false)
{
var query = _dbSession.Query<T>(indexName, collectionName, isMapReduce);
if (typeof(T).GetInterfaces().Contains(typeof(ITenantedEntity)))
return query.Where(r => (r as ITenantedEntity).TenantId == _currentTenantId);
return query;
}
public IRavenQueryable<T> Query<T, TIndexCreator>() where TIndexCreator : AbstractIndexCreationTask, new()
{
var query = _dbSession.Query<T, TIndexCreator>();
var lastArgType = typeof(TIndexCreator).BaseType?.GenericTypeArguments?.LastOrDefault();
if (lastArgType != null && lastArgType.GetInterfaces().Contains(typeof(ITenantedEntity)))
return query.Where(r => (r as ITenantedEntity).TenantId == _currentTenantId);
return query;
}
#endregion Query ------------------------------------------------------
public void Dispose() => _dbSession.Dispose();
}
如果您也需要 Include()
,上面的代码可能需要一些爱。
我的最终解决方案不使用 listeners for RavenDb v3.x as I suggested earlier (see
当然,如果您编写了 patches 个 JavaScript 函数,则您必须手动处理多租户。