问题 状态机在特定时间转换


简化示例:

我有一个待办事项。它可以是未来,当前或晚期,具体取决于它的时间。

  Time       State
  8:00 am    Future
  9:00 am    Current
  10:00 am   Late

因此,在此示例中,待办事项从上午9点到上午10点是“当前”。

最初,我考虑为“current_at”和“late_at”添加字段,然后使用实例方法返回状态。我可以查询所有“当前”待办事项 now > current and now < late

简而言之,我每次都会计算状态,或者使用SQL来提取我需要的状态集。

如果我想使用状态机,我会有一组状态,并将该状态名称存储在待办事项上。但是,如何在每个待办事项的特定时间触发州之间的过渡?

  • 每分钟运行一次cron作业以拉出状态但超过转换时间并更新它
  • 使用后台处理在将来的适当时间对转换作业进行排队,因此在上面的示例中,我将有两个作业:“在上午9点过渡到当前”和“过渡到上午10点”可能有逻辑保护反对删除的待办事项和“如果完成则不要迟到”等等。

在尝试在特定时间处理大量状态转换时,是否有人有管理这些选项的经验?

感觉就像一台状态机,我只是不确定管理所有这些转换的最佳方法。

回复后更新:

  • 是的,我需要查询“当前”或“未来”待办事项
  • 是的,我需要触发关于状态变化的通知(“你的待办事项没有完成”)

因此,我希望更多的是类似状态机的想法,以便我可以封装过渡。


5496
2017-10-24 15:34


起源



答案:


适用于中等大小数据集的一个简单解决方案是使用SQL数据库。每个待办事项记录应具有“state_id”,“current_at”和“late_at”字段。你可以省略“future_at”,除非你真的有四种状态。

这允许三种状态:

  1. 未来:何时 现在 <current_at
  2. 当前:当current_at <=时 现在 <late_at
  3. 晚:当late_at <=时 现在

将州存储为 state_id (可选地将一个外键设为名为“states”的查找表所在的位置 1: Future2: Current3: Late)基本上存储非规范化数据,这样可以避免在很少更改时重新计算状态。

如果您实际上并未根据状态查询待办事项记录(例如, ... WHERE state_id = 1)或在状态发生变化时触发一些副作用(例如发送电子邮件),也许您不需要管理状态。如果你只是向用户显示一个待办事项列表并指出哪些是迟到的,那么最便宜的实现甚至可能是计算客户端。为了回答,我假设您需要管理状态。

您有几个更新state_id的选项。我假设您正在执行约束 current_at < late_at

  • 最简单的是更新每条记录: UPDATE todos SET state_id = CASE WHEN late_at <= NOW() THEN 3 WHEN current_at <= NOW() THEN 2 ELSE 1 END;

  • 你可能会通过类似的东西获得更好的表现(在一次交易中) UPDATE todos SET state_id = 3 WHERE state_id <> 3 AND late_at <= NOW()UPDATE todos SET state_id = 2 WHERE state_id <> 2 AND NOW() < late_at AND current_at <= NOW()UPDATE todos SET state_id = 1 WHERE state_id <> 1 AND NOW() < current_at。这样可以避免检索不需要更新的行,但是您需要“late_at”和“future_at”的索引(您可以尝试索引“state_id”,请参阅下面的注释)。您可以根据需要经常运行这三个更新。

  • 上面的略微变化是首先获取记录的ID,因此您可以使用已更改状态的待办事项执行某些操作。这看起来像 SELECT id FROM todos WHERE state_id <> 3 AND late_at <= NOW() FOR UPDATE。然后你应该做更新 UPDATE todos SET state_id = 3 WHERE id IN (:ids)。现在,您仍然可以使用ID来执行某些操作(例如,通过电子邮件发送通知“20个任务已过期”)。

  • 为每个待办事项安排或排队更新作业(例如,在上午10点将此一个更新为“当前”并在晚上11点“更晚”更新)将导致 批量 预定作业,至少是待机数量的两倍,以及性能不佳 - 每个预定作业仅更新一条记录。

  • 您可以安排批量更新,例如 UPDATE state_id = 2 WHERE ID IN (1,2,3,4,5,...) 你已经预先计算了在某个特定时间附近将变为当前的待办事项ID列表。由于几个原因,这在实践中可能不会很好。一个是todo的 current_at 和 late_at 在您安排更新后,字段可能会更改。

注意:通过索引“state_id”可能无法获得太多收益,因为它只将数据集划分为三组。这可能不足以让查询计划程序考虑在查询中使用它 SELECT * FROM todos WHERE state_id = 1

 你没有讨论的这个问题是完成的待办事项会发生什么?如果您将它们留在此待办事项表中,该表将无限增长并且您的表现将会增加 会随着时间的推移而退化。解决方案是将数据分区为两个单独的表(如“completed_todos”和“pending_todos”)。然后你可以使用 UNION 在实际需要时连接两个表。


3
2017-11-08 21:42



上面有一些澄清,但也有一些具体的评论。在实践中,是的,我们需要todos的历史,但是它们的范围将被编入索引的其他字段,因此我们不会每次都击中所有[当前] 50万条记录。 - wesgarrison


答案:


适用于中等大小数据集的一个简单解决方案是使用SQL数据库。每个待办事项记录应具有“state_id”,“current_at”和“late_at”字段。你可以省略“future_at”,除非你真的有四种状态。

这允许三种状态:

  1. 未来:何时 现在 <current_at
  2. 当前:当current_at <=时 现在 <late_at
  3. 晚:当late_at <=时 现在

将州存储为 state_id (可选地将一个外键设为名为“states”的查找表所在的位置 1: Future2: Current3: Late)基本上存储非规范化数据,这样可以避免在很少更改时重新计算状态。

如果您实际上并未根据状态查询待办事项记录(例如, ... WHERE state_id = 1)或在状态发生变化时触发一些副作用(例如发送电子邮件),也许您不需要管理状态。如果你只是向用户显示一个待办事项列表并指出哪些是迟到的,那么最便宜的实现甚至可能是计算客户端。为了回答,我假设您需要管理状态。

您有几个更新state_id的选项。我假设您正在执行约束 current_at < late_at

  • 最简单的是更新每条记录: UPDATE todos SET state_id = CASE WHEN late_at <= NOW() THEN 3 WHEN current_at <= NOW() THEN 2 ELSE 1 END;

  • 你可能会通过类似的东西获得更好的表现(在一次交易中) UPDATE todos SET state_id = 3 WHERE state_id <> 3 AND late_at <= NOW()UPDATE todos SET state_id = 2 WHERE state_id <> 2 AND NOW() < late_at AND current_at <= NOW()UPDATE todos SET state_id = 1 WHERE state_id <> 1 AND NOW() < current_at。这样可以避免检索不需要更新的行,但是您需要“late_at”和“future_at”的索引(您可以尝试索引“state_id”,请参阅下面的注释)。您可以根据需要经常运行这三个更新。

  • 上面的略微变化是首先获取记录的ID,因此您可以使用已更改状态的待办事项执行某些操作。这看起来像 SELECT id FROM todos WHERE state_id <> 3 AND late_at <= NOW() FOR UPDATE。然后你应该做更新 UPDATE todos SET state_id = 3 WHERE id IN (:ids)。现在,您仍然可以使用ID来执行某些操作(例如,通过电子邮件发送通知“20个任务已过期”)。

  • 为每个待办事项安排或排队更新作业(例如,在上午10点将此一个更新为“当前”并在晚上11点“更晚”更新)将导致 批量 预定作业,至少是待机数量的两倍,以及性能不佳 - 每个预定作业仅更新一条记录。

  • 您可以安排批量更新,例如 UPDATE state_id = 2 WHERE ID IN (1,2,3,4,5,...) 你已经预先计算了在某个特定时间附近将变为当前的待办事项ID列表。由于几个原因,这在实践中可能不会很好。一个是todo的 current_at 和 late_at 在您安排更新后,字段可能会更改。

注意:通过索引“state_id”可能无法获得太多收益,因为它只将数据集划分为三组。这可能不足以让查询计划程序考虑在查询中使用它 SELECT * FROM todos WHERE state_id = 1

 你没有讨论的这个问题是完成的待办事项会发生什么?如果您将它们留在此待办事项表中,该表将无限增长并且您的表现将会增加 会随着时间的推移而退化。解决方案是将数据分区为两个单独的表(如“completed_todos”和“pending_todos”)。然后你可以使用 UNION 在实际需要时连接两个表。


3
2017-11-08 21:42



上面有一些澄清,但也有一些具体的评论。在实践中,是的,我们需要todos的历史,但是它们的范围将被编入索引的其他字段,因此我们不会每次都击中所有[当前] 50万条记录。 - wesgarrison


我设计并维护了几个管理大量这些小型状态机的系统。 (有些系统,最高100K /天,约100K /分钟)

我发现你的状态越多 明确地 摆弄,越有可能打破某个地方。或者用不同的方式,你说的更多 推断,解决方案越健壮。

话虽这么说,你必须保持 一些 州。但尽量保持尽可能小。

另外,保持状态机 逻辑 在一个地方使系统更健壮,更易于维护。也就是说,不要将状态机逻辑放在代码和数据库中。我更喜欢代码中的逻辑。

首选解决方案(简单的图片是最好的)。

对于你的例子,我会有一个非常简单的表:

task_id, current_at, current_duration, is_done, is_deleted, description...

推断 国家立足于 now 和---关联 current_at 和 current_duration。这非常有效。确保对表进行索引/分区 current_at

处理转型变革的逻辑

当您需要在转换更改时触发事件时,情况会有所不同。

将表格更改为如下所示:

task_id, current_at, current_duration, state, locked_by, locked_until, description...

保持你的索引 current_at,并添加一个 state 如果你喜欢。你现在正在破坏状态,所以由于并发或失败,事情会变得更脆弱,所以我们必须稍微使用它 locked_by 和 locked_until 对于乐观锁定,我将在下面描述。

我假设你的程序在处理过程中会失败 - 即使只是为了部署。

您需要一种机制将任务从一种状态转换到另一种状态。为了简化讨论,我将关注从FUTURE转到CURRENT,但无论过渡如何,逻辑都是一样的。

如果您的数据集足够大,您不断轮询数据库以发现发现需要转换的任务(当然,当没有任何事情可做时,线性或指数退避);否则你使用或你喜欢的调度程序是否是cron或 基于红宝石,如果您订阅Java / Scala / C#,则为Quartz。

选择需要从FUTURE移动到CURRENT的所有条目  目前尚未锁定。

更新:)

-- move from pending to current
select task_id
  from tasks
 where now >= current_at
   and (locked_until is null OR locked_until < now)
   and state == 'PENDING'
   and current_at >= (now - 3 days)         -- optimization
 limit :LIMIT                               -- optimization

扔掉所有这些 task_id进入你可靠的队列。或者,如果必须,只需在脚本中处理它们。

当您开始处理某个项目时,必须先使用我们的乐观锁定方案将其锁定:

update tasks
   set locked_by = :worker_id     -- unique identifier for host + process + thread
     , locked_until = now + 5 minutes -- however this looks in your SQL langage
 where task_id = :task_id         -- you can lock multiple tasks here if necessary
   and (locked_until is null OR locked_until < now) -- only if it's not locked!

现在,如果您实际更新了记录,则拥有该锁。 您现在可以触发特殊的转换逻辑。 (掌声。这就是让你与所有其他任务经理不同的原因,对吧?)

那是什么时候 成功,更新任务状态,确保仍然使用乐观锁定:

update tasks
   set state = :new_state
     , locked_until = null -- explicitly release the lock (an optimization, really)
 where task_id = :task_id
   and locked_by = :worker_id -- make sure we still own the lock
                              -- no-one really cares if we overstep our time-bounds

多线程/流程优化

只有在多个线程或进程批量更新任务时(例如在cron作业中或轮询数据库),才能执行此操作!问题是他们每个人都会从数据库中获得类似的结果,然后争先恐后地锁定每一行。这是低效的,因为它会减慢数据库的速度,并且因为你的线程基本上什么都不做,只会减慢其他线程。

因此,添加查询返回的结果数量限制并遵循此算法:

results = database.tasks_to_move_to_current_state :limit => BATCH_SIZE
while !results.empty
    results.shuffle! # make sure we're not in lock step with another worker
    contention_count = 0
    results.each do |task_id|
        if database.lock_task :task_id => task_id
           on_transition_to_current task_id
        else
           contention_count += 1
        end
        break if contention_count > MAX_CONTENTION_COUNT # too much contention!
    done
    results = database.tasks_to_move_to_current_state :limit => BATCH_SIZE
end

摆弄着 BATCH_SIZE 和 MAX_CONTENTION_COUNT 直到程序超快。


更新:

乐观锁定允许并行处理多个处理器。

通过锁定超时(通过 locked_until 字段)它允许在处理转换时失败。如果处理器发生故障,另一个处理器能够在超时后接收任务(上述代码中为5分钟)。因此,重要的是a)只在您要处理任务时锁定任务;和b)锁定任务执行任务所需的时间  慷慨的余地。

locked_by 字段主要用于调试目的,(这个进程/机器是什么?)这就足够了 locked_until 字段,如果您的数据库驱动程序返回更新的行数,但是 只要 如果您一次更新一行。


5
2017-11-09 05:41



首选解决方案:我现在是怎么做的。我更进一步,将current_at反规范化为“start_at,current_at,late_at”等等。但是,我们在一个脚本中运行它们已经超出了它们(这是个好问题!)而且我正在寻找解决问题的解决方案。我喜欢“你推断的状态越多,解决方案就越强大。”非常好的建议! - wesgarrison
什么样的东西让剧本变得粗糙?从一个州过渡到另一个州的事件? - Michael Deardeuff
@wesgarrison我概述的解决方案旨在处理高并发情​​况,因此您可以轻松添加另一个线程/进程。考虑到您的用例,我只会处理脚本中的内容并废弃可靠的队列 - 除非您在每次状态转换时有多个事件。即便如此,您可以添加更多状态而不是队列,但这可能会破坏一些抽象 - Michael Deardeuff
我正在发送关于转换的电子邮件,它们需要更长时间/有时会失败,再加上如果某个转换出现问题,它可以终止整个脚本并且在处理后没有任何内容,除非我开始/解救整个事情,然后传递该错误陈述等等,相反,我想我会想到一个更好的方法来做到这一点。 - wesgarrison
我更新了答案,以澄清卷 locked_until - Michael Deardeuff


在特定时间管理所有这些转换似乎很棘手。也许您可以使用像DelayedJob这样的东西来安排转换,这样每分钟就不需要一个cron作业,从故障中恢复会更加自动化?

否则 - 如果这是Ruby,使用Enumerable一个选项?

像这样(在未经测试的伪代码中,使用简单的方法)

ToDo课程

def state
  if to_do.future?
    return "Future"
  elsif to_do.current?
    return "Current"
  elsif to_do.late?
    return "Late"
  else 
    return "must not have been important"
  end
end

def future?
    Time.now.hour <= 8
end

def current?
    Time.now.hour == 9
end

def late?
    Time.now.hour >= 10
end

def self.find_current_to_dos
    self.find(:all, :conditions => " 1=1 /* or whatever */ ").select(&:state == 'Current')
end

4
2017-11-08 18:36



这个问题是我要选择所有项目(实例化它们并使用内存)只是用select()来减少集合。我肯定考虑过DelayedJob方法,但这是一直在运行的很多小工作,只是为了做一件事。 - wesgarrison
同意,很多小工作。你对实例化对象的观点很好。 DelayedJob的一个好处是它只会使Rails环境旋转一次(每个工作者),因此它的开销/滞后很小。 - Capncavedan
另外,如果我的数据库中有三个转换作业用于我现在的每个待办事项,那么我在该表中的行数为1.5M。 - wesgarrison
如果您不处理大量数据,这是一个很好的简单解决方案。 - dojosto


状态机是由某种东西驱动的。用户交互或流中的最后一个输入,对吧?在这种情况下,时间驱动状态机。我认为一个cron工作是正确的。这将是驱动机器的时钟。

对于它是值得的,在两列上设置一个有效的索引是非常困难的,你必须做这样的范围。

now> current && now <late将很难以高效的方式表示在数据库中作为任务的属性

ID |标题| future_time | CURRENT_TIME | late_time

1 |你好| 8:上午12点| 9:上午12点| 10:上午12点


2
2017-11-08 20:41





永远不要试图强迫模式成问题。事情是相反的。所以,直接找到一个好的解决方案。

这是一个想法:(我理解你的是)

使用持久警报和一个受监视的进程来“消耗”它们。其次,查询它们。

这将允许您:

  1. 把事情简单化
  2. 保持便宜。其次它也会让你在精神上更多 新鲜的做其他事情。
  3. 将所有逻辑仅保留在代码中(应该如此)。

我强调要使用某种监视程序监视该进程,以确保及时发送这些警报(或者,在最坏的情况下,在崩溃之后有一些延迟或类似的事情)。

请注意:保留这些警报的事实允许您执行以下两项操作:

  1. 使/保持系统弹性(更具容错性)和
  2. 使您能够查询未来和当前项目(通过查询警报的时间范围最适合您的需要)

1
2017-11-15 17:29



我非常喜欢这个想法,但是我仍然很难用500,000 todos(到目前为止)至少3次转换=我的警报表中的大量记录。也许我现在不必担心这个问题,但在实施它之前测试它是另一回事。谢谢你的建议! - wesgarrison
当然:)我认为这是你尝试之前无法真正了解的事情之一 - Sebastian Sastre


根据我的经验,当你有一个外部进程作用于某个东西,并用它的状态更新数据库时,SQL中的状态机最有用。例如,我们有一个上传和转换视频的流程。我们使用数据库随时跟踪视频发生的情况,以及接下来会发生什么。

在您的情况下,我认为您可以(并且应该)使用SQL来解决您的问题而不必担心使用状态机:

制作一个todo_states表:

todo_id  todo_state_id    datetime  notified
1        1 (future)       8:00      0
1        2 (current)      9:00      0
1        3 (late)         10:00     0

您的SQL查询,其中所有实际工作发生:

SELECT todo_id, MAX(todo_state_id) AS todo_state_id 
FROM todo_states
WHERE time < NOW()
GROUP BY todo_id

当前活动状态始终是您选择的状态。如果您只想通知用户一次,请插入带有notify = 0的原始状态,并在第一个选择时将其碰撞。

一旦任务“完成”,您可以将另一个状态插入到todo_states表中,或者只是删除与任务关联的所有状态,并在待办事项中提出“完成”标志,或者在您的情况下最有用的任何标志。

不要忘记清理过时状态。


0
2017-11-15 10:34