【问题标题】:How to code a C# mutex block that is conditional on an entity id?如何编写以实体 ID 为条件的 C# 互斥块?
【发布时间】:2020-05-06 18:10:50
【问题描述】:

我正在寻找一种用于编码同步操作的 C# 模式,包括为特定实体写入两个不同的数据库,这样我就可以避免在同一实体上同时操作的竞争条件。

例如线程 1 和线程 2 同时处理实体 X 上的操作。该操作将 X 的信息写入数据库 A(在我的例子中,是对 MongoDB 的 upsert)和数据库 B(对 SqlServer 的插入)。线程 3 正在处理实体 Y 上的相同操作。期望的行为是:

  • 线程 1 在处理实体 X 对 A 和 B 的写入时阻塞线程 2。
  • 线程 2 一直等待,直到线程 1 完成对 A 和 B 的写入,然后为实体 X 写入 A 和 B。
  • 线程 3 未被阻塞,并且在线程 1 正在处理时处理实体 Y 对 A 和 B 的写入

我试图避免的行为是:

  • 线程 1 为实体 X 写入 A。
  • 线程 2 为实体 X 写入 A。
  • 线程 2 为实体 X 写入 B。
  • 线程 1 为实体 X 写入 B。

我可以在所有线程中使用互斥锁,但我真的不想阻止其他实体的操作。

【问题讨论】:

  • 什么数据库?为什么不同实体的写入需要跨线程同步?如何考虑读取?因此纯粹是一个单个进程中的问题吗?
  • 我更新了问题以包含数据库详细信息。不同实体的写入不需要同步。这是一个在多台服务器上运行的进程,每个进程通过 Hangfire 作业创建多个线程。

标签: c# sql-server mongodb synchronization mutex


【解决方案1】:

我建议使用简单的锁(如果它在代码的一个区域中)因为它会处理不同的对象(意味着 .net 对象)但具有相同的值(因为它是同一个实体)我宁愿去带有某种形式的实体代码。如果实体有某种形式的代码,我会使用它 - 例如:

当然,您必须注意死锁。而且 String.Intern 很棘手,因为只要应用程序运行,它就会对字符串进行实习。

lock(String.Intern(myEntity.Code))
{
   SaveToDatabaseA(myEntity);
   SaveToDatabaseB(myEntity);
}

但您似乎想要某种复制机制。那我宁愿在数据库级别(而不是代码级别)做它

[更新]

您用信息更新了问题,即它正在多台服务器上完成。而且这些信息在这里很重要:) 普通锁不起作用。

当然,您可以尝试在不同服务器之间同步锁,但这与分布式事务类似。理论上你可以做到,但大多数人只是尽可能避免它,他们玩弄解决方案的架构来简化流程。

[更新 2]

您可能还会觉得这很有趣:Distributed locking in .NET

:)

【讨论】:

  • 所以这基本上变成了一个命名锁,它只会阻塞其他正在处理同一实体的线程。你能解释一下这将如何陷入僵局吗?这两篇文章与复制无关,因为一个数据库是 MongoDB,另一个是 SqlServer(请参阅我对问题的编辑以包含这些详细信息)
  • 避免在此处使用string.Intern:实例从不从字符串池中释放。像这样使用/滥用string.Intern 是一种使未绑定数据集上的长时间运行进程随着时间的推移变得更慢(string.Intern 随着池大小的增加比字典更新慢得多)并最终崩溃的方法内存不足。
  • 如果您在具有相同代码的代码中的其他位置使用 String.Intern 则可以。这就是风险。所以也许更好的是添加一些“代码中的位置”标识符,这样它就可以保护你免受这种风险。那么它应该是这样的:String.Intern($"WritingEntities{myEntity.Code)")
  • @user2864740 我已经在答案中声明,它 String.Intern 字符串保留在内存中。如果您的实体集是 1000 或 10 000 - 不要打扰任何问题。但是,如果您要在 100 000 或 1 000 000 上使用此代码 - 这可能会成为一个问题。
  • @JimSweeney - 应用程序在多个服务器上运行的事实就像这里的决定性因素。当然,在这种情况下,您不能依赖 lock :D 这就是为什么,您必须详细描述您的问题,否则,有人会建议您解决不适合您情况的解决方案;)
【解决方案2】:

对于多个进程1 使用lock statement不足。即使named/system semaphores 也仅限于单机,因此无法从多台服务器中获取。

如果重复处理没问题并且可以选择“获胜者”,则只需重写/更新或使用optimistic concurrency 的风格就足够了。如果需要维护更强的 process-once 并发保证,则需要采用全局锁定机制 - SQL Server 通过sp_getapplock 支持这种机制。

同样,可以更新模型,以便每个代理“请求”下一个工作单元,以便可以集中控制调度,并且基于 ID 等的实体一次只提供给单个代理用于处理。另一种选择可能是使用消息系统,如RabbitMQ(或 Kafka 等,fsvo);对于 RabbitMQ,甚至可以使用Consistent Hashing 来确保(在大多数情况下)不同的消费者接收到不重叠的消息。具体细节因使用的实现而异。

由于 SQL RDBMS 和 MongoDB 的不同性质(尤其是用作“缓存”时),放宽限制和/或使用 MongoDB 作为读取来设计问题可能就足够了(这是一个很好的使用缓存的方法)。这可以缓解配对写入问题,尽管它不会阻止对相同项目的全局并发处理。

1即使锁语句全局不足,仍然可以在本地单个进程中的线程之间使用,以减少本地争用和/或最小化全局锁定。


下面的答案是针对原始问题的,假设单个进程

避免通过多个线程同时处理同一对象的“标准”方法是在特定对象上使用lock statement。在对象本身上获取锁,这样lock(X)lock(Y)!ReferenceEquals(X,Y) 时是独立的。

lock 语句获取给定对象的互斥锁,执行语句块,然后释放锁。 持有锁时,持有锁的线程可以再次获取并释放锁。 任何其他线程都被阻止获取锁并等待直到锁被释放

lock (objectBeingSaved) {
  // This code execution is mutually-exclusive over a specific object..
  // ..and independent (non-blocking) over different objects.
  Process(objectBeingSaved);
}

本地进程锁不一定转化为对数据库访问的充分保证,或者当访问溢出进程时。还应考虑锁的范围:例如。它应该涵盖所有处理、仅保存还是其他一些工作单元?

为了控制哪些对象被锁定并减少不希望的/意外锁定交互的机会,有时建议显式(并且仅用于)为建立锁定的目的向对象添加最具体的可见性字段。如果需要考虑的话,这也可以用于对应该相互锁定的对象进行分组。

也可以使用锁定池,尽管这往往是一个更“高级”的用例,只有特定的适用性。使用池还允许使用信号量(在更具体的用例中)以及简单的锁。

如果每个外部 ID 需要一个锁,一种方法是将正在处理的实体与池集成,在实体之间建立锁:

// Some lock pool. Variations of the strategy:
// - Weak-value hash table
// - Explicit acquire/release lock
// - Explicit acquire/release from ctor and finalizer (or Dispose)
var locks = CreateLockPool();
// When object is created, assign a lock object
var entity = CreateEntity();
// Returns same lock object (instance) for the given ID, and a different
// lock object (instance) for a different ID.
etity.Lock = GetLock(locks, entity.ID);

lock (entity.Lock) {
  // Mutually exclusive per whatever rules are to select the lock
  Process(entity);
}

另一个变体是本地化池,而不是每个实体本身都携带一个锁对象。它在概念上与上面的模型相同,只是从外向内翻转。这是一个要点。 YMMV。

private sealed class Locker { public int Count; }

IDictionary<int, Locker> _locks = new Dictionary<int, Locker>();

void WithLockOnId(int id, Action action) {
  Locker locker;
  lock (_locks) {
     // The _locks might have lots of contention; the work
     // done inside is expected to be FAST in comparison to action().
     if (!_locks.TryGetValue(id, out locker)
        locker = _locks[id] = new Locker();
     ++locker.Count;
  }
  lock (locker) {
     // Runs mutually-exclusive by ID, as established per creation of
     // distinct lock objects.
     action();
  }
  lock (_locks) {
     // Don't forget to take out the garbage..
     // This would be better with try/finally, which is left as an exercise
     // to the reader, along with fixing any other minor errors.
     if (--_locks[id].Count == 0)
       _locks.Remove(id);
  }
}

// And then..
WithLockOnId(x.ID, () => Process(x));

另一种方法是跨线程/处理单元“分片”实体。因此,保证每个线程永远不会与另一个线程处理相同的实体:X、Y、Z 总是转到#1,P、D、Q 总是转到#2。 (优化吞吐量有点复杂..)

var threadIndex = entity.ID % NumThreads;
QueueWorkOnThread(threadIndex, entity); // eg. add to List<ConcurrentQueue>

【讨论】:

  • 感谢您提供所有详细信息!我应该提到几件事——我的问题中提到的两个数据库是不同的引擎,MongoDB 和 SqlServer。此外,锁不应在特定对象上,而应在每个线程中由不同对象表示的单个实体上。我用数据库详细信息更新了我的问题。
  • 如果进程在多个服务器上运行,则不能“只”使用lock(甚至是信号量;命名信号量仍然是每个系统的),尽管它可以减少本地争用。但是,需要考虑外部构造:即。如果多个服务器同时写入数据库会发生什么?谁赢?有没有关系/冲突会发生什么?只要每个线程使用不同的 连接,这两个数据库确实 都支持并发连接(例如,连接本身不会损坏)。这仍然不能保证更大的原子性或互斥处理。
  • 您能否详细说明“使用 MongoDB 作为通读”?我必须向 MongoDB 写入一些数据,向 SqlServer 写入一些数据,最后一个线程“获胜”是可以的,这样它最后写入两个数据库,但我需要避免每个数据库有不同的获胜者。你是说即使使用来自两个不同服务器的线程写入,我也可以消除这种可能性?
  • 通过读取,从 MongoDB 读取的任何进程负责提供值(如果不存在):读取核心数据(如果需要)并缓存在不同的抽象中。然后记录系统完全变成了 RDBMS,MongoDB 提供了一个缓存层。 (尽管我强烈建议使用可以向外传播的单一事实来源,但这种方法可能并不总是可行。)
猜你喜欢
  • 1970-01-01
  • 2018-10-27
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2014-12-04
  • 1970-01-01
  • 1970-01-01
  • 2011-01-21
相关资源
最近更新 更多