多租户数据库。文档ID和授权策略

Multi-tenanted DB. Strategy for Document ID and authorization

我正在权衡拥有单独的数据库(每个公司一个)还是一个多租户数据库(所有公司)。条件:

问题 #1。在 RavenDB 中是否有任何用于设计多租户数据库的"good practices"?

有一个类似的post for MongoDB。 RavenDB 也会一样吗? 更多记录会影响 indexes,但它是否会潜在地使一些租户遭受其他租户积极使用索引的困扰?


如果我要为 RavenDB 设计一个多租户数据库,那么我将实现视为

问题 #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 来让 @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 sessionIDocumentSessionIAsyncDocumentSession)来处理多租户实体。

示例代码如下:

/// <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 函数,则您必须手动处理多租户。