【问题标题】:Updating entity in EF Core application with SQLite gives DbUpdateConcurrencyException使用 SQLite 更新 EF Core 应用程序中的实体会产生 DbUpdateConcurrencyException
【发布时间】:2019-03-12 01:45:44
【问题描述】:

我尝试在带有 SQLite 的 EF Core 中使用乐观并发检查。 最简单的积极情景(即使没有并发本身)给了我 Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: 'Database operation expected to affect 1 row(s) but actually affected 0 row(s). Data may have been modified or deleted since entities were loaded.

实体:

public class Blog
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public byte[] Timestamp { get; set; }
}

上下文:

internal class Context : DbContext
{
    public DbSet<Blog> Blogs { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlite(@"Data Source=D:\incoming\test.db");
        ///optionsBuilder.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=Blogging;Trusted_Connection=True;");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Blog>()
            .HasKey(p => p.Id);

        modelBuilder.Entity<Blog>()
            .Property(p => p.Timestamp)
            .IsRowVersion()
            .HasDefaultValueSql("CURRENT_TIMESTAMP");
    }
}

示例:

internal class Program
{
    public static void Main(string[] args)
    {
        var id = Guid.NewGuid();
        using (var db = new Context())
        {
            db.Database.EnsureDeleted();
            db.Database.EnsureCreated();
            db.Blogs.Add(new Blog { Id = id, Name = "1" });
            db.SaveChanges();
        }

        using (var db = new Context())
        {
            var existing = db.Blogs.Find(id);
            existing.Name = "2";
            db.SaveChanges(); // Exception thrown: 'Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException'
        }

    }
}

我怀疑这与 EF 和 SQLite 之间的数据类型有关。 日志记录为我的更新提供了以下查询:

Executing DbCommand [Parameters=[@p1='2bcc42f5-5fd9-4cd6-b0a0-d1b843022a4b' (DbType = String), @p0='2' (Size = 1), @p2='0x323031382D31302D30372030393A34393A3331' (Size = 19) (DbType = String)], CommandType='Text', CommandTimeout='30']
UPDATE "Blogs" SET "Name" = @p0
WHERE "Id" = @p1 AND "Timestamp" = @p2;

但是 Id 和 Timestamp 的列类型都是 BLOB(SQLite 不提供 UUID 和时间戳列类型):


同时,如果我使用 SQL Server(使用注释连接字符串 + 删除 .HasDefaultValueSql("CURRENT_TIMESTAMP")),示例可以正常工作并更新数据库中的时间戳。

使用过的包:

<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.1.4" />

我是否为并发检查配置了错误的模型? 这让我发疯,我无法让它与这个最简单的场景一起工作。


更新:我最终是如何让它发挥作用的。这里只展示了一个想法,但它可能对任何人都有帮助:

public class Blog
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public long Version { get; set; }
}

internal class Context : DbContext
{
    public DbSet<Blog> Blogs { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlite(@"Data Source=D:\incoming\test.db");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Blog>()
            .HasKey(p => p.Id);

        modelBuilder.Entity<Blog>()
            .Property(p => p.Version)
            .IsConcurrencyToken();
    }
}

internal class Program
{
    public static void Main(string[] args)
    {
        var id = Guid.NewGuid();
        long ver;
        using (var db = new Context())
        {
            db.Database.EnsureDeleted();
            db.Database.EnsureCreated();
            var res = db.Blogs.Add(new Blog { Id = id, Name = "xxx", Version = DateTime.Now.Ticks});
            db.SaveChanges();
        }

        using (var db = new Context())
        {
            var existing = db.Blogs.Find(id);
            existing.Name = "yyy";
            existing.Version = DateTime.Now.Ticks;
            db.SaveChanges(); // success
        }

        using (var db = new Context())
        {
            var existing = db.Blogs.Find(id);
            existing.Name = "zzz";
            existing.Version = DateTime.Now.Ticks;
            db.SaveChanges(); // success
        }

        var t1 = Task.Run(() =>
        {
            using (var db = new Context())
            {
                var existing = db.Blogs.Find(id);
                existing.Name = "yyy";
                existing.Version = DateTime.Now.Ticks;
                db.SaveChanges();
            }
        });

        var t2 = Task.Run(() =>
        {
            using (var db = new Context())
            {
                var existing = db.Blogs.Find(id);
                existing.Name = "zzz";
                existing.Version = DateTime.Now.Ticks;
                db.SaveChanges();
            }
        });

        Task.WaitAll(t1, t2); // one of the tasks throws DbUpdateConcurrencyException
    }
}

【问题讨论】:

  • 您是否尝试只取消注释连接字符串代码?
  • @viveknuna,如果将 SQLite 连接字符串更改为 SQLServer 之一,我必须删除 .HasDefaultValueSql("CURRENT_TIMESTAMP") 因为它是 SQL Server 的非法值(Defaults无法在数据类型时间戳的列上创建。表“博客”,列“时间戳”。无法创建约束或索引。)
  • 你能用“getdate()”代替“current_timestamp”吗?
  • @viveknuna,首先,我对 SQLite 的解决方案很感兴趣。正如我所说,我的示例适用于 SQL Server。第二: TIMESTAMP 值由 SQL Server 自动生成和分配。不需要为此数据类型的列指定 DEFAULT 约束,因此不允许。
  • 我只建议 SQLite 的解决方案。你有哪个 EF Core 版本?

标签: c# sqlite entity-framework-core


【解决方案1】:

在将 [TimeStamp](或 IsRowVersion())标记为 byte[] 的属性绑定到 SQL 查询参数时,似乎 EF Core SQLite 提供程序无法正确处理它们。它使用默认的 byte[]hex string 转换,这在这种情况下不适用 - byte[] 实际上 string

首先考虑将其报告给他们的问题跟踪器。然后,直到它得到解决(如果有的话),作为一种解决方法,您可以使用以下自定义ValueConverter

class SqliteTimestampConverter : ValueConverter<byte[], string>
{
    public SqliteTimestampConverter() : base(
        v => v == null ? null : ToDb(v),
        v => v == null ? null : FromDb(v))
    { }
    static byte[] FromDb(string v) =>
        v.Select(c => (byte)c).ToArray(); // Encoding.ASCII.GetString(v)
    static string ToDb(byte[] v) =>
        new string(v.Select(b => (char)b).ToArray()); // Encoding.ASCII.GetBytes(v))
}

可惜没有办法告诉 EF Core 只用于参数,所以用.HasConversion(new SqliteTimestampConverter()) 赋值后,现在db 类型被认为是string,所以你需要添加.HasColumnType("BLOB")

最终的工作映射是

    modelBuilder.Entity<Blog>()
        .Property(p => p.Timestamp)
        .IsRowVersion()
        .HasConversion(new SqliteTimestampConverter())
        .HasColumnType("BLOB")
        .HasDefaultValueSql("CURRENT_TIMESTAMP");

您可以通过在OnModelCreating 末尾添加以下自定义 SQLite RowVersion“约定”来避免所有这些:

if (Database.IsSqlite())
{
    var timestampProperties = modelBuilder.Model
        .GetEntityTypes()
        .SelectMany(t => t.GetProperties())
        .Where(p => p.ClrType == typeof(byte[])
            && p.ValueGenerated == ValueGenerated.OnAddOrUpdate
            && p.IsConcurrencyToken);

    foreach (var property in timestampProperties)
    {
        property.SetValueConverter(new SqliteTimestampConverter());
        property.Relational().DefaultValueSql = "CURRENT_TIMESTAMP";
    }
}

因此您的属性配置可以缩减为

modelBuilder.Entity<Blog>()
    .Property(p => p.Timestamp)
    .IsRowVersion();

或完全删除并替换为数据注释

public class Blog
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    [Timestamp]
    public byte[] Timestamp { get; set; }
}

【讨论】:

  • 问题确实出在byte[] string 转换中。使用SqliteTimestampConverter 我成功地更新了我的示例中的实体,没有例外,但是并发检查无论如何都不起作用。我必须在实体 UPDATE 上添加触发器以刷新时间戳值(半手动)。我也用STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')(更精确)替换了CURRENT_TIMESTAMP,但没有运气。所以我放弃并开始使用long Ticks 作为并发令牌(请参阅我最初的问题中的削减)。
  • @flam3 啊,我忘了默认值只适用于插入。无论您为数据库中的列选择什么类型,EF 都希望它由数据库自动更新。所以我猜你需要一个数据库触发器来更新它(虽然不确定是什么类型的触发器,我对 SQLite 作为数据库一点也不熟悉)
  • @flam3 这里描述了触发器link
【解决方案2】:

thread on GitHub 和 Ivan 的回答的启发,我编写了这段代码,以确保在我的单元测试中模仿 SQL Server 并发。

var connection = new SqliteConnection("DataSource=:memory:");

var options = new DbContextOptionsBuilder<ActiveContext>()
               .UseSqlite(connection)
               .Options;

var ctx = new ActiveContext(options);

if (connection.State != System.Data.ConnectionState.Open)
{
    connection.Open();

    ctx.Database.EnsureCreated();

    var tables = ctx.Model.GetEntityTypes();

    foreach (var table in tables)
    {
        var props = table.GetProperties()
                        .Where(p => p.ClrType == typeof(byte[])
                        && p.ValueGenerated == Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAddOrUpdate
                        && p.IsConcurrencyToken);

        var tableName = table.Relational().TableName;

        foreach (var field in props)
        {
            string[] SQLs = new string[] {
                $@"CREATE TRIGGER Set{tableName}_{field.Name}OnUpdate
                AFTER UPDATE ON {tableName}
                BEGIN
                    UPDATE {tableName}
                    SET RowVersion = randomblob(8)
                    WHERE rowid = NEW.rowid;
                END
                ",
                $@"CREATE TRIGGER Set{tableName}_{field.Name}OnInsert
                AFTER INSERT ON {tableName}
                BEGIN
                    UPDATE {tableName}
                    SET RowVersion = randomblob(8)
                    WHERE rowid = NEW.rowid;
                END
                "
            };

            foreach (var sql in SQLs)
            {
                using (var command = connection.CreateCommand())
                {
                    command.CommandText = sql;
                    command.ExecuteNonQuery();
                }
            }
        }
    }
}

【讨论】:

  • 这不是最佳解决方案,但至少是唯一对我有用的解决方案
  • 不适用于我,行版本已更新,但我的属性不会随 SQLite 改变(我的代码适用于 SQLServer)
【解决方案3】:

这是因为你使用Guid

public Guid Id { get; set; }

这个问题在Gitub讨论和转载:

这里的错误是由于 ApplicationUser.ConcurrencyStamp 属性。 身份中的 ApplicationUser 使用 Guid 类型的 ConcurrencyStamp 并发。创建新类时,它将值设置为 NewGuid()。 当您像这样创建新的 ApplicationUser 并将其状态设置为 修改后的 EF Core 没有关于 ConcurrencyStamp 的数据 数据库。因此它将使用在项目上设置的任何值 (这将是 NewGuid())因为这个值不同于 数据库,它用于更新语句的where子句,异常 抛出预期 1 时修改了 0 行。

使用并发令牌更新实体时,您无法创建新的 对象并直接发送更新。您必须从 数据库(以便您具有 ConcurrencyStamp 的值)然后更新 记录并调用 SaveChanges。由于 ApplicationUser.ConcurrencyStamp 是您的客户端并发令牌 更新记录时还需要生成一个 NewGuid()。所以它可以 更新数据库中的值。

查找有关如何处理 ApplicationUser.ConcurrencyStamp here 的更多信息。

【讨论】:

  • 这是我用于并发的属性byte[] Timestamp,而不是Guid Id
【解决方案4】:

到目前为止,我一直在使用Ivan's answer 并取得了巨大的成功。但是,当我更新到 EntityFrameworkCore 3.1 时,我开始收到以下警告:

实体类型“{entity name}”上的属性“{column name}”是具有值转换器但没有值比较器的集合或枚举类型。设置一个值比较器以确保正确比较集合/枚举元素。

为了解决这个问题,我通过添加以下内容来增强他的解决方案:

property.SetValueComparer(new ValueComparer<byte[]>(
    (c1, c2) => c1.SequenceEqual(c2),
    c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
    c => c.ToArray()));

(基于a response 到 GitHub 问题)

【讨论】:

    猜你喜欢
    • 2020-07-03
    • 1970-01-01
    • 1970-01-01
    • 2020-09-04
    • 2022-10-18
    • 2021-11-06
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多