当前位置: 首页 > 工具软件 > efcore.pg > 使用案例 >

记一次EFCore6查询返回结果完全相同的问题排查

连文栋
2023-12-01

最近玩EFCore 6,发生了一个诡异的问题。封装好的代码,在一个简单的Where后发现取回来的结果数量确是对的,但列表里的每一个记录内容都一样。

sql如下:

select * from project_cost 
where year_month>=202201 and year_month<=202201 and project_id=1

这个是再简单不过的sql了,我到数据库里查一下,没问题。是返回了62条记录并且每条记录里的信息都不一样。

但在.NET Core里,我发现确实是返回了62条数据,但每条数据的内容都完全一模一样!于是开始我的各种神奇debug之旅。

1. 排查SQL问题

刚开始就怀疑是不是sql有问题。于是找网上的教程开打印sql的debug方式,结果没有一个好用的。后来发现了一个方式,可以在代码里debug出来要执行的sql。

代码如下:

/// <summary>
/// Gets objects from database by filter.
/// </summary>
/// <param name="predicate">Specified a filter</param>
/// <returns></returns>
public virtual async Task<IQueryable<TEntity>> WhereAsync(Expression<Func<TEntity, bool>> predicate)
{
    return await Task.Run(() =>
    {
        var result = _dbSet.Where<TEntity>(predicate).AsQueryable<TEntity>();
        var sql = result.ToQueryString();
        return result;
    });
}

debug结果如下:

DECLARE @__projectId_0 int = 3;
DECLARE @__startMonth_1 int = 202201;
DECLARE @__endMonth_2 int = 202207;

SELECT [p].[id], [p].[employee_id], [p].[employee_name], [p].[title_id], [p].[title_name], [p].[project_id], [p].[project_name], [p].[project_status], [p].[total_amount], [p].[total_cost], [p].[year_month]
FROM [project_cost] AS [p]
WHERE (([p].[project_id] = @__projectId_0) AND ([p].[year_month] >= @__startMonth_1)) AND ([p].[year_month] <= @__endMonth_2)

结果拿这个到数据库里直接跑,结果还是对的,但debug看result就是“记录数量一样,但每条记录都是第一条的内容”。

2. 排查视图问题

这边找不到问题,又开始看这个表跟其他表不一样的地方,这个不是一个实体表,是一个视图,为了方便直接在数据库中做了视图,然后代码里做了一个Entity对应就可以简化代码量。原先以为因为视图的原因,是不是EFCore什么地方的设置导致了视图和实体表的操作不一样。

但反向通过上面的例子,也证明了视图和实体表用起来没有区别。毕竟sql都打印出来了,跑的结果也出来了。但确实因为这个view的结果和其他表的同样Where操作结果就不一样。

因为这是一个共通函数,是所有repository的基类,所有EF的查询都要通过这个函数,其他实体表也有调用这个Where方法的,结果就没有问题。仅仅在目前这个View上发生了问题。

view粘一下。

SELECT
		1 id,
		p.id project_id,
		p.name project_name,
		p.project_status project_status,
		convert(int, LEFT ( TE.date, 6 )) year_month,
		e.id employee_id,
		e.name employee_name,
		t.id title_id,
		t.title title_name,
		SUM(te.TotalAmount) / 3600 total_amount,
		SUM ( TE.TotalAmount* t.rate ) / 3600 total_cost 
	FROM
		project p
		LEFT JOIN TimeEntry TE ON TE.projectid = p.gid
		LEFT JOIN employee e ON TE.userid= e.gid
		LEFT JOIN RoleTitle t ON t.id= e.title 
	GROUP BY
		p.id,
		p.name,
		p.project_status,
		convert(int, LEFT ( TE.date, 6 )),
		e.id,
		e.name,
		t.id,
	t.title

3. 灵光一现,发现问题

再次review这个视图,目光不知道为什么就突然聚焦到了这个不一样的id字段上。之前这个视图是没有这个字段的,但为了统一使用自定义的Repository,Repository要求一个Template TKey,是每个Entity要求一个主键的Key。

Repository定义部分如下:

public class Repository<TEntity> : IRepository<TEntity> where TEntity : class, new()
{
    protected readonly DbSet<TEntity> _dbSet;
    protected readonly IAppDbContext _unitOfWork;

    public Repository(IAppDbContext unitOfWork)
    {
        _unitOfWork = unitOfWork;
        _dbSet = ((AppDbContext)unitOfWork).Set<TEntity>();
    }
    ......
}

TEntity需要的IEntity如下:

public interface IEntity
{
    int Id { get; set; }
}

所以必须要一个id字段才能使用Repository,才能使用EF相关功能。
然后突然联想到上周五发生的一个奇怪的事情:我在PG数据库里批量插入的时候,总提醒我id的key有问题,有重复插入。但我数据库里配置的是id字段不是主键,可以重复插入,代码里entity上的[key]字段也拿掉了,批量插入的里面identity插入也没启用。后来跟到BulkInsert源代码才发现,即使没有关于key的定义,它也会把名字叫“id”的字段当成主键使用,必须启用identity insert模式才可以插入。

想到这个,我回头来看这个view的结果,唯一跟其他实体表不一样的就是,这个表的id返回的都是1,是同样的内容,会不会EFCore内部也默认这个id字段是主键,然后因为id一样,所以就自动给后面id一样的赋值跟前面一模一样的内容了。

想到这个,我立刻修改view的id字段如下,让sqlserver的view每一条记录都能返回一个独立的数字。

convert(int, row_number() over(order by p.name)) id,

debug,测试,发现数据正确了!

4. 总结

回想起来这2个问题,还真特别的巧合。因为之前也没有花时间在看EFCore相关的源代码,所以也从来没有想到过这一层。

咱也出个结论吧,只要是命名为id的字段,无论你如何定义,EFCore都会认为这个字段是主键,并且不会重复,相关的一些逻辑都是以这个为基础进行设计的。

也为了扩展,专门google了一下,发现这么一个结论:CodeFirst会自动认为你的ID字段是主键,即使你没有任何相关的定义。如果你要用其他方式做主键的话,你需要显示指定,一个是[Key],一个是在DbContext的定义中指定这个字段为主键

modelBuilder.Entity<AuthorCourseLesson>()
                .HasKey(p => new { p.AuthorId, p.CourseLessonId });

5. 有趣的发现

跟上面一样,沉浸式研究东西的时候经常容易灵光一闪。突然想到,那为什么EFCore会根据ID用同样的内容填充呢,有没有可能,它没有真实创建新对象填充,只是同一个对象不停地塞进List里呢?如果我来设计这块内容,为了性能和更高效地使用内存,我会将找到的id相同的对象拿过来直接塞进列表里去。于是就来验证了一下,在debug中断模式下,immediate窗口里做了一些实验:

l[0] == l[1]
true
l[0].GetHashCode()
33250821
l[1].GetHashCode()
33250821
l[2].GetHashCode()
33250821
l[61].GetHashCode()
33250821
l[62].GetHashCode()
'l[62].GetHashCode()' threw an exception of type 'System.ArgumentOutOfRangeException'
l.Add(l[0])
Expression has been evaluated and has no value
l[62].GetHashCode()
33250821
l.Add(new ProjectCost(){ Id=1})
Expression has been evaluated and has no value
l[63].GetHashCode()
10176385

结果果然验证了我的猜想。

一共62个object,尝试了一下,0,1,2以及61都是同一个hashcode。再塞一个0进去成62,hashcode也一样。创建新的new就是另外一个hashcode了(63)。我印象里.NET的hashcode和java应该一样,值对象是可以直接对比的,但对象类型如果想要做判等操作,不能只重载operator =,要同时重写hashcode,以确保两个对象的hashcode算法一致。如果没有特别重写hashcode,那两个对象即使所有字段都相同,hashcode也会返回不同的值。

为了最终验证,因为上面那个add的对象和其他对象内容不一样,我给Entity对象增加了一个自定义的Clone方法,每一个字段都手工赋值,保证内容一样,再进行测试,验证了想法的准确性。

l.Add(l[0].Clone(l[0]))
Expression has been evaluated and has no value
l[63]
'l[63]' threw an exception of type 'System.ArgumentOutOfRangeException'
l[62].GetHashCode()
20806745
l[61].GetHashCode()
49992845
l[60].GetHashCode()
49992845

最终确认,60和61相同,后用自定义clone添加的同样内容的对象62,hashcode不一样。

每天一点,紧跟潮流 :)

 类似资料: