使用SQLite更新EF Core应用程序中的实体时出现DbUpdateConcurrencyException

wz1wpwve  于 2022-12-13  发布在  SQLite
关注(0)|答案(5)|浏览(351)

我尝试在EF Core中使用SQLite的乐观并发检查。最简单的正向场景(即使没有并发本身)给我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和timestamp列类型):

同时,如果我使用SQL Server(使用带注解的连接字符串+删除.HasDefaultValueSql("CURRENT_TIMESTAMP")),示例将正常工作并更新DB中的时间戳。
已使用的软件包:

<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
    }
}
68de4m5k

68de4m5k1#

EF Core SQLite提供程序在将标记为byte[][TimeStamp](或IsRowVersion())属性绑定到SQL查询参数时似乎未正确处理这些属性。它使用默认的byte[]hexstring的转换,这种转换在本例中不适用-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")
最终的工作Map为

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; }
}
0dxa2lsx

0dxa2lsx2#

受此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();
                }
            }
        }
    }
}
wfauudbj

wfauudbj3#

这是因为您使用了Guid

public Guid Id { get; set; }

此问题在Gitub中讨论和重现:
此处的错误是由ApplicationUser.ConcurrencyStamp属性引起的。标识中的ApplicationUser使用Guid类型的ConcurrencyStamp进行并发。创建新类时,它将该值设置为NewGuid()。当您创建新的ApplicationUser并将其状态设置为Modified时,EF Core在数据库中没有ConcurrencyStamp的相关数据。因此,它将使用在该项上设置的任何值(将为NewGuid())由于此值与数据库中的值不同,并且它用于update语句的where子句中,因此引发了异常,即0行在预期的1行修改时被修改。
使用并发令牌更新实体时,您无法创建新对象并直接发送更新。您必须从数据库中检索记录(以便获得ConcurrencyStamp的值),然后更新记录并调用SaveChanges。由于ApplicationUser.ConcurrencyStamp是客户端并发令牌,因此您还需要在更新记录时生成NewGuid()。因此它可以更新数据库中的值。
在此查找有关如何处理ApplicationUser.ConcurrencyStamp的更多信息。

fhity93d

fhity93d4#

到目前为止,我一直在使用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()));

(基于对GitHub问题的回复)

7uhlpewt

7uhlpewt5#

我无法使它与CURRENT_TIMESTAMP一起工作,因为它以在SQLite和EF的DB浏览器中显示的字符串结束。具有long类型的实体属性导致2022值,并阻止我保存列,因为很明显2022!= 2022-12-09 14:03:14。它也只有秒,这对并发性不利。
所以我决定采用另一种解决方案

byte[] + SetDefaultValueSql("randomblob(8)")+更新触发器

属性本身表示常规版本列

[Timestamp]
public byte[] Version { get; set; }

现在,我们需要添加一些反射魔法,以便在插入时一次性为所有实体分配默认值:

protected override void OnModelCreating(ModelBuilder builder)
{
    base.OnModelCreating(builder);
    var versionProperties = builder.Model
        .GetEntityTypes()
        .SelectMany(t => t.GetProperties().Where(p => p.ClrType == typeof(byte[]) && p is { ValueGenerated: ValueGenerated.OnAddOrUpdate, IsConcurrencyToken: true }));

    foreach (var property in versionProperties)
    {
        property.SetDefaultValueSql("randomblob(8)");
    }
}

还有最后一步:我们需要强制数据库在行更新时更新Version列。我们需要一个触发器,但我不希望在迁移过程中进行任何手动操作,因此下面是基于迁移和播种过程的自动化:

public class AppDbContextSeedingService
{
    private readonly string VersionColumnName = nameof(EntityBase.Version);
    private readonly string IdColumnName = nameof(EntityBase.Id);
    private readonly AppDbContext _dbContext;
    public AppDbContextSeedingService(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }
    public async Task EnsureMigratedAndSeededAsync(CancellationToken cToken = default)
    {
        await _dbContext.Database.MigrateAsync(cancellationToken: cToken).ConfigureAwait(false);
        var triggerNames = await _dbContext.Database.SqlQueryRaw<string>("SELECT name FROM sqlite_master WHERE type = 'trigger'")            .ToArrayAsync(cToken).ConfigureAwait(false);;
        foreach (var tableName in typeof(AppDbContext).GetProperties().Where(p => p.PropertyType.IsGenericType && p.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>)).Select(p => p.Name))
        {
            var updateTriggerName = GetUpdateTriggerName(tableName);
            if(triggerNames.Contains(updateTriggerName))
                continue;
            await _dbContext.Database.ExecuteSqlRawAsync(
                $"CREATE TRIGGER {updateTriggerName} AFTER UPDATE ON {tableName} BEGIN UPDATE {tableName} SET {VersionColumnName} = randomblob(8) WHERE {IdColumnName} = NEW.{IdColumnName}; END", cToken).ConfigureAwait(false);
        }
    }
    private static string GetUpdateTriggerName(string tableName) => $"{tableName}_OnUpdate";
}

因此,我们获取现有触发器的列表,然后遍历所有DBSet<>属性并插入缺失的触发器。

但SQLite方面存在限制。

EF Core在更新期间发出此请求:

UPDATE "PrinterOperations" SET "CommandJson" = @p0
WHERE "Id" = @p1 AND "Version" = @p2
RETURNING "Version";

因此,我们的想法是在列更新后使用新的RETURNING子句返回更新后的Version值。https://www3.sqlite.org/lang_returning.html
不幸的是:
RETURNING子句只返回由DELETE、INSERT或UPDATE语句直接修改的行。RETURNING子句不报告由外键约束条件或触发器导致的任何其他数据库更改。
这意味着在SaveChanges()之后返回的项将具有与之前相同的Version值,因为SQLite返回的是旧值而不是新值。因此,您将无法再次更改和保存它。您可以使用以下行轻松检查它:
var sqlQueryRaw = BitConverter.ToString(dbContext.Database.SqlQueryRaw<byte[]>("UPDATE PrinterOperations SET CommandJson = '111111' WHERE Id = '73cf2572-4ba3-4693-922c-b062be47707c' RETURNING Version").ToArray().First());
只要运行几次

相关问题