FreeSql分表功能实践

FreeSql分表功能实践

  • FreeSql更新了自动分表功能,主要的优点在于“不再需要”手动进行分表。

  • 目前支持按照时间自动分表,插入时根据指定的时间字段,自动插入数据到对应表中。

  • 查询时,采用多表UNION ALL来实现组合查询,支持根据查询条件对表进行优化,比如时间范围在2022-03-01到2022-05-16,那么只会在202203、202204、202205这三张表查询。

  • 目前主要应用在审计追踪模块,随着系统的运行,审计追踪的数据会快速增长,因此会有分表的需求。

一、分表实现

  1. 修改实体层的Table属性

    -- 原属性
    [Table(Name = "gxp_audit_trail")]
        
    -- 修改后属性
    [Table(Name = "gxp_audit_trail_{yyyyMM}", AsTable = "CreatedTime=2022-1-1(1 month)")]
    

    如上所示可以在TableAttribute中规定表名的格式(年月)、指定的分表字段(创建时间)、分表的间隔(按月),具体可以参考FreeSql的Wiki:https://github.com/dotnetcore/FreeSql/discussions/1066#

    AsTable会初始化一个起始时间,如果早于这个时间,FreeSql会抛出异常,数据无法插入

  2. 仓储层的增删改查方法并不需要修改,分表会自动执行。需要注意的是,查询的方法最好都加一个时间参数对分表字段进行过滤,这样查询的时候可以根据时间来缩小表的范围,优化查询效率

二、问题总结

1、自动分表功能对子表查询的处理存在问题

也就是Exists查询,对于主表会根据筛选条件查询符合条件的表,对于子表会重复查询,生成错误sql。示例代码:

//  beginTime = 2022-05-09 
            var source = Orm.Select<GxpAuditTrailEntity>()
                .Where(o => Orm.Select<GxpAuditTrailItemEntity>().Any(s => s.EntityId == dto.EntityId && s.EntityName == dto.EntityName 
                && s.AuditId == o.Id && s.CreatedTime > beginTime))
                .Where(e => e.OrganCode == organCode && e.CreatedTime > beginTime);

对于主表能够成功识别,只查询202205表,但是子表识别失败,会UNION ALL同一个子表多次查询。

-- 生成的sql
SELECT count(1) as1
FROM `gxp_audit_trail_202205` a
WHERE (exists(SELECT  * from (SELECT 1
    FROM `gxp_audit_trail_item_202205` s
    WHERE (s.`EntityId` = 0 AND s.`EntityName`  IS  NULL AND s.`AuditId` = a.`Id` AND s.`CreatedTime` > '2022-05-09 15:05:16.959')
    limit 0,1) ftb

    UNION ALL

    SELECT  * from (SELECT 1
    FROM `gxp_audit_trail_item_202205` s
    WHERE (s.`EntityId` = 0 AND s.`EntityName`  IS  NULL AND s.`AuditId` = a.`Id` AND s.`CreatedTime` > '2022-05-09 15:05:16.959')
    limit 0,1) ftb

    UNION ALL

    SELECT  * from (SELECT 1
    FROM `gxp_audit_trail_item_202205` s
    WHERE (s.`EntityId` = 0 AND s.`EntityName`  IS  NULL AND s.`AuditId` = a.`Id` AND s.`CreatedTime` > '2022-05-09 15:05:16.959')
    limit 0,1) ftb

    UNION ALL

    SELECT  * from (SELECT 1
    FROM `gxp_audit_trail_item_202205` s
    WHERE (s.`EntityId` = 0 AND s.`EntityName`  IS  NULL AND s.`AuditId` = a.`Id` AND s.`CreatedTime` > '2022-05-09 15:05:16.959')
    limit 0,1) ftb

    UNION ALL

    SELECT  * from (SELECT 1
    FROM `gxp_audit_trail_item_202205` s
    WHERE (s.`EntityId` = 0 AND s.`EntityName`  IS  NULL AND s.`AuditId` = a.`Id` AND s.`CreatedTime` > '2022-05-09 15:05:16.959')
    limit 0,1) ftb)) AND (a.`OrganCode` = '#000964' AND a.`CreatedTime` > '2022-05-09 15:05:16.959')

解决的方案是,对于子表采用AsTable手动分表,代码实现如下

            var beginTime = DateTime.Now.PeriodBegin(dto.Period);

            var sourceList = new List<GxpAuditTrailEntity>();
            var source = Orm.Select<GxpAuditTrailEntity>()
                .Where(e => e.OrganCode == organCode && e.CreatedTime > beginTime);

            #region 处理子表的分表
            var tableNames = FreeSqlAsTableHelper.GetMonthTableName<GxpAuditTrailItemEntity>(beginTime,DateTime.Now);

            var sourceItem = Orm.Select<GxpAuditTrailItemEntity>();
            foreach (var item in tableNames)
            {
                sourceItem = sourceItem.AsTable((_, old) => old.Replace($"{{{FreeSqlAsTableHelper.MonthFormat}}}", item));
            }
            #endregion

            source = source.Where(x => sourceItem.Any(s => s.EntityId == dto.EntityId && s.EntityName == dto.EntityName
                    && s.AuditId == x.Id && x.CreatedTime > beginTime));
    public class FreeSqlAsTableHelper
    {
        /// <summary>
        /// 按月分表的表名格式
        /// </summary>
        public const string MonthFormat = "yyyyMM";

        /// <summary>
        /// 根据时间范围,以按月分表的方式获取所有可能的表名
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="beginTime"></param>
        /// <param name="endTime"></param>
        /// <returns></returns>
        public static List<string> GetMonthTableName<T>(DateTime beginTime, DateTime endTime) where T : IEntity
        {
            var tableAttribute = typeof(T).GetAttribute<TableAttribute>()?.AsTable;
            Regex regex = new Regex(@"\d+-\d+-\d+");
            DateTime.TryParse(regex.Match(tableAttribute)?.Value, out DateTime minTime);

            var tempTime = beginTime < minTime ? minTime : beginTime;
            var tableNames = new List<string>();
            while (tempTime < endTime)
            {
                tableNames.Add(tempTime.ToString(MonthFormat));
                tempTime = tempTime.AddMonths(1);
            }
            return tableNames;
        }
    }

2、表达式中的时间条件不能使用Date模式

解决方案是,抽取出变量再传入参数,示例如下:

//无法识别
var source1 = Orm.Select<GxpAuditTrailItemEntity>()
    .Where(p => p.CreatedTime.Between(dto.CreatedTime.Date, dto.CreatedTime.Date.AddDays(1)));

//正常识别
var startTime = dto.CreatedTime.Date;
var endTime = dto.CreatedTime.Date.AddDays(1);
var source = Orm.Select<GxpAuditTrailItemEntity>()
    .Where(p => p.CreatedTime.Between(startTime, endTime));

三、数据迁移

由于分表采用日期结合的表名,历史数据无法和新的功能兼容,所以需要对历史数据进行迁移,我的方案是采用不分表的方式将数据全部捞取出来,再用分表的方式插入,实现代码如下:

/// <summary>
/// 迁移审计追踪数据
/// </summary>
public async Task<bool> MigrateAsync()
{
    var index = 1;
    var size = 10000;
    long total;
    do
    {
        var oldAuditTrailGroups = await Orm.Select<Data.Entity.AuditTrail.Migration.GxpAuditTrailItemEntity, Data.Entity.AuditTrail.Migration.GxpAuditTrailEntity>()
            .InnerJoin(e => e.t1.AuditId == e.t2.Id).Page(index, size).Count(out total).ToListAsync(e => new { e.t1, e.t2 });

        var oldAuditTrails = oldAuditTrailGroups.Select(e => e.t2).Distinct(new AuidtCompare()).ToList();
        var auditTrails = mapper.Map<List<GxpAuditTrailEntity>>(oldAuditTrails);
        var auditIndex = 0;
        var auditIndixDic = new Dictionary<long, int>();
        foreach (var audit in auditTrails)
        {
            auditIndixDic.Add(audit.Id, auditIndex++);
            audit.Id = default;
        }
        await gxpAuditTrailRepository.InsertAsync(auditTrails);

        var auditTrailItems = new List<GxpAuditTrailItemEntity>();
        foreach (var oldItem in oldAuditTrailGroups)
        {
            var item = mapper.Map<GxpAuditTrailItemEntity>(oldItem.t1);
            item.CreatedTime = oldItem.t2.CreatedTime.Value;
            item.AuditId = auditTrails[auditIndixDic[item.AuditId]].Id;
            item.Id = default;
            auditTrailItems.Add(item);
        }
        await gxpAuditTrailItemRepository.InsertAsync(auditTrailItems);
    }
    while (index++ * size < total);

    return true;
}

class AuidtCompare : IEqualityComparer<Data.Entity.AuditTrail.Migration.GxpAuditTrailEntity>
{
    public bool Equals([AllowNull] Data.Entity.AuditTrail.Migration.GxpAuditTrailEntity x, [AllowNull] Data.Entity.AuditTrail.Migration.GxpAuditTrailEntity y)
    {
        return x.Id == y.Id;
    }

    public int GetHashCode([DisallowNull] Data.Entity.AuditTrail.Migration.GxpAuditTrailEntity obj)
    {
        return obj.Id.GetHashCode();
    }
}

遇到难题1

迁移的过程中有一个比较难处理的问题,这里存在父表和子表,他们之间的引用关系是子表保存了父表主键作为外键引用。由于主键是自增的,迁移后父表的主键会改变,父子表的引用关系失效。解决逻辑如下:

  1. 先将父表和子表join查询,一起查询出来
  2. 根据主键过滤掉重复数据,拿到父表数据。直接Distinct会去重失败,因此自己实现了一个比较器
  3. 将原始父表数据存储到List中,再将原始主键和对应的List索引存到字典中Dictionary<long, int>中
  4. 将原始父表数据的主键Id置为空,然后插入数据库,此时FreeSql会自动更新List数据的主键Id为新的Id
  5. 遍历子表数据,根据其外键AuditId从字典Dictionary<long, int>获取到List索引,再根据索引获取到新的主键Id,赋值给AuditId
  6. 子表的主键Id置为空,创建时间置为父表数据的创建时间,插入数据库,完成数据迁移

遇到的难题2

因为需要迁移的数据量比较大,为了一次性操作入库的数据太大,采用了分页获取数据再分页插入的方式。如上代码所示一次性只取10000条,分批次插入。可能存在一个问题,同属于一条父数据的多条在两个批次中查出来,导致同一条父数据插入两次,这个问题暂时没有好的解决方案,但是影响相对较小,先搁置了。

posted @ 2022-05-18 15:18  东方未  阅读(1170)  评论(0)    收藏  举报