summaryrefslogtreecommitdiff
path: root/2025/05
diff options
context:
space:
mode:
authormuqiuhan <[email protected]>2025-09-09 06:17:00 +0000
committermuqiuhan <[email protected]>2025-09-09 06:17:00 +0000
commitd5de65fdb1802cdf498d65d93397f290813c377c (patch)
tree92fe61a76d20d1203203665d4b48e7f151f088bd /2025/05
parent48efa2dfde7c263f84ee5bb0872747034908d607 (diff)
downloadblog-d5de65fdb1802cdf498d65d93397f290813c377c.tar.gz
deploy: 9665097f0fa0dae9f92123fac54d26c0818758a5
Diffstat (limited to '2025/05')
-rw-r--r--2025/05/07/nestjs-bullmq-mail-business/index.html41
-rw-r--r--2025/05/08/Multiplayer-Collaborative-Systems-tips/index.html82
-rw-r--r--2025/05/27/vertical-slicing-practice/index.html2
3 files changed, 75 insertions, 50 deletions
diff --git a/2025/05/07/nestjs-bullmq-mail-business/index.html b/2025/05/07/nestjs-bullmq-mail-business/index.html
index 6b36afbd..5eb3bcbd 100644
--- a/2025/05/07/nestjs-bullmq-mail-business/index.html
+++ b/2025/05/07/nestjs-bullmq-mail-business/index.html
@@ -192,48 +192,53 @@
</div>
</div>
<div class="post-content">
- <p>在 BullMQ(以及它在 NestJS 里包装的 <code>@Processor</code>&#x2F;<code>WorkerHost</code>)里,整个生命周期大致是这样的:</p>
+ <p>在 BullMQ(以及它在 NestJS 里包装的 <code>@Processor</code>/<code>WorkerHost</code>)里,整个生命周期大致是这样的:</p>
<ol>
-<li>队列(在 NestJS 里由 <code>@Processor</code> 装饰的类)会被一个底层的 <code>Worker</code> 订阅。 </li>
-<li>有新任务(job)进来时,Worker 会调用写在该类里的 <code>async process(job: Job)</code> 方法。 </li>
-<li>如果 <code>process()</code> 正常返回(即没有抛异常),Job 就被标记为 completed,然后才会去触发所有注册了 <code>@OnWorkerEvent(&#39;completed&#39;)</code> 的回调。</li>
+<li>队列(在 NestJS 里由 <code>@Processor</code> 装饰的类)会被一个底层的 <code>Worker</code> 订阅。</li>
+<li>有新任务(job)进来时,Worker 会调用写在该类里的 <code>async process(job: Job)</code> 方法。</li>
+<li>如果 <code>process()</code> 正常返回(即没有抛异常),Job 就被标记为 completed,然后才会去触发所有注册了 <code>@OnWorkerEvent('completed')</code> 的回调。</li>
</ol>
<p>也就是说:</p>
<ul>
-<li><code>process</code>:是真正“干活”的地方,收到 job 之后立刻被调用,任何主业务逻辑(发邮件/写数据库/第三方请求等)都应该放这里。 </li>
+<li><code>process</code>:是真正“干活”的地方,收到 job 之后立刻被调用,任何主业务逻辑(发邮件/写数据库/第三方请求等)都应该放这里。</li>
<li><code>onCompleted</code>:只是一个事件监听器,在 job 已经成功完成之后 才会被触发,不会影响 job 的重试逻辑(也就是说,在这里抛错,job 已经算完成了,也不会重试)。</li>
</ul>
<p>而我在此处的业务目的是 “用队列来做可靠的、可重试的邮件发送”,那么一定要把发送邮件的逻辑写到 <code>process()</code> 里,这样在 <code>commandBus.execute(new SendMailCommand(...))</code> 抛错时,BullMQ 会根据创建 JOB 时的重试策略(retry、backoff 等)自动重新入队。而把它放到 <code>onCompleted()</code>,只相当于 job 成功完成后的“事后通知”,一旦失败不会再重试,也无法利用 BullMQ 的锁、超时、重试机制。</p>
-<p>举个最简化的调整示例,删掉 <code>onCompleted</code>,把真正的发信放到 <code>process</code>: </p>
+<p>举个最简化的调整示例,删掉 <code>onCompleted</code>,把真正的发信放到 <code>process</code>:</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line">// ... existing imports ...</span><br><span class="line"></span><br><span class="line">@Processor(process.env.MAILER_QUEUE_NAME || &quot;gcpm-mailer&quot;)</span><br><span class="line">export class BullMQMailerProcesser extends WorkerHost &#123;</span><br><span class="line"> constructor(</span><br><span class="line"> private readonly commandBus: CommandBus,</span><br><span class="line"> private readonly logger: LoggingService,</span><br><span class="line"> ) &#123;</span><br><span class="line"> super();</span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line"> // ① 当有新 job 拉取到时,这个方法会被调用</span><br><span class="line"> public async process(job: Job): Promise&lt;void&gt; &#123;</span><br><span class="line"> const mailAggregate = new Mail(job.data.mail);</span><br><span class="line"> try &#123;</span><br><span class="line"> await this.commandBus.execute(new SendMailCommand(mailAggregate));</span><br><span class="line"> &#125; catch (err) &#123;</span><br><span class="line"> this.logger.error(`邮件发送失败,jobId=$&#123;job.id&#125;`, err);</span><br><span class="line"> // 抛出错误,触发重试或失败</span><br><span class="line"> throw err;</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line"> // ② onCompleted 仅在 process() 正常返回后触发,</span><br><span class="line"> // 不建议在这里执行核心业务(也无法触发重试)。</span><br><span class="line"> // @OnWorkerEvent(&quot;completed&quot;)</span><br><span class="line"> // async onCompleted(job: Job) &#123; … &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure>
-
<p>参考 BullMQ 官方文档:</p>
<ul>
-<li>“Workers → Sandboxed processors”:Worker 拉到 job 就调用注册的处理函数,然后根据返回&#x2F;抛错把 job 标记成 completed 或 failed。 </li>
+<li>“Workers → Sandboxed processors”:Worker 拉到 job 就调用注册的处理函数,然后根据返回/抛错把 job 标记成 completed 或 failed。</li>
<li>“Events → OnJobCompleted”:completed 事件只是一个监听钩子,不会参与重试。</li>
</ul>
<hr>
<p>而 重试次数本身并没有一个硬性上限,完全由添加 Job 时通过 <code>attempts</code> 这个选项来控制:</p>
<ul>
-<li>默认情况下,如果不传 <code>attempts</code>(或不在 <code>defaultJobOptions</code> 里配置),Job 不会自动重试(相当于 <code>attempts = 0</code>)。 </li>
-<li>如果在 <code>queue.add()</code>(或全局 <code>defaultJobOptions</code>)里设置了 <code>attempts: N</code>,那么 BullMQ 最多会让该 Job 运行 N 次(也就是初始执行 + N−1 次重试,或者根据文档含义最多触发 N 次失败) ,失败后才算真正移入失败集合。 </li>
+<li>默认情况下,如果不传 <code>attempts</code>(或不在 <code>defaultJobOptions</code> 里配置),Job 不会自动重试(相当于 <code>attempts = 0</code>)。</li>
+<li>如果在 <code>queue.add()</code>(或全局 <code>defaultJobOptions</code>)里设置了 <code>attempts: N</code>,那么 BullMQ 最多会让该 Job 运行 N 次(也就是初始执行 + N−1 次重试,或者根据文档含义最多触发 N 次失败) ,失败后才算真正移入失败集合。</li>
<li><code>attempts</code> 可以是任意的正整数(受 JavaScript <code>Number</code> 范围限制),BullMQ 本身不会再做额外的上限检查。</li>
</ul>
<p>示例(给某封邮件最多重试 3 次):</p>
<figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">mailerQueue</span>.<span class="title function_">add</span>(</span><br><span class="line"> id,</span><br><span class="line"> &#123; <span class="attr">mail</span>: <span class="keyword">new</span> <span class="title class_">Mail</span>(<span class="comment">/*…*/</span> ) &#125;,</span><br><span class="line"> &#123;</span><br><span class="line"> <span class="attr">attempts</span>: <span class="number">3</span>, <span class="comment">// 最多尝试 3 次</span></span><br><span class="line"> <span class="attr">backoff</span>: &#123; <span class="comment">// 重试时的延迟策略(可选)</span></span><br><span class="line"> <span class="attr">type</span>: <span class="string">&#x27;exponential&#x27;</span>,</span><br><span class="line"> <span class="attr">delay</span>: <span class="number">1000</span>,</span><br><span class="line"> &#125;,</span><br><span class="line"> &#125;,</span><br><span class="line">);</span><br></pre></td></tr></table></figure>
-
<hr>
<p>还有一个需要注意的地方,在我的业务中,邮件发送的是一种时间区间报告,这个报告包含了过去二十四小时的一些系统中的事件,但如果重试有延迟策略或重试本身就有计算成本的话,这封邮件就不是 “过去二十四小时” 的了,因为重试带来了一个真空期。</p>
<p>换言之,这个问题本质上是——重试导致「发送时刻」与「原始 24 小时窗口」错开,从而让邮件里报出来的数据不再精确。常见的解决思路就是:把「窗口定义」或者「报表内容」在调度时就固化下来,真正的队列任务只负责发送,而不再实时去重新计算时间区间。</p>
<p>我想到了两种解决方案:</p>
-<p>一、任务参数里带上「时间区间」<br> 在 enqueue 的时候,就算出 windowStart&#x2F;windowEnd,然后把它放到 <code>job.data</code> 里。无论后面 <code>process</code> 什么时候真正跑,都是基于同一个时间区间去查询:</p>
+<p>一、任务参数里带上「时间区间」<br>
+在 enqueue 的时候,就算出 windowStart/windowEnd,然后把它放到 <code>job.data</code> 里。无论后面 <code>process</code> 什么时候真正跑,都是基于同一个时间区间去查询:</p>
<figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 调度时</span></span><br><span class="line"><span class="keyword">const</span> now = <span class="keyword">new</span> <span class="title class_">Date</span>();</span><br><span class="line"><span class="keyword">const</span> windowStart = <span class="keyword">new</span> <span class="title class_">Date</span>(now.<span class="title function_">getTime</span>() - <span class="number">24</span> * <span class="number">60</span> * <span class="number">60</span> * <span class="number">1000</span>);</span><br><span class="line"><span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">mailerQueue</span>.<span class="title function_">add</span>(</span><br><span class="line"> id,</span><br><span class="line"> &#123;</span><br><span class="line"> <span class="attr">mail</span>: <span class="keyword">new</span> <span class="title class_">Mail</span>(&#123;</span><br><span class="line"> ...options,</span><br><span class="line"> id,</span><br><span class="line"> <span class="attr">sentAt</span>: now,</span><br><span class="line"> <span class="attr">status</span>: <span class="title class_">MailStatus</span>.<span class="property">PENDING</span>,</span><br><span class="line"> windowStart,</span><br><span class="line"> <span class="attr">windowEnd</span>: now,</span><br><span class="line"> &#125;),</span><br><span class="line"> &#125;,</span><br><span class="line"> &#123;</span><br><span class="line"> <span class="attr">attempts</span>: <span class="number">3</span>,</span><br><span class="line"> <span class="attr">backoff</span>: &#123; <span class="attr">type</span>: <span class="string">&#x27;exponential&#x27;</span>, <span class="attr">delay</span>: <span class="number">1000</span> &#125;,</span><br><span class="line"> &#125;,</span><br><span class="line">);</span><br><span class="line"></span><br><span class="line"><span class="comment">// process 里</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">async</span> <span class="title function_">process</span>(<span class="params"><span class="attr">job</span>: <span class="title class_">Job</span></span>) &#123;</span><br><span class="line"> <span class="keyword">const</span> &#123; windowStart, windowEnd &#125; = job.<span class="property">data</span>.<span class="property">mail</span>;</span><br><span class="line"> <span class="comment">// ① 只查询 [windowStart, windowEnd] 的事件</span></span><br><span class="line"> <span class="keyword">const</span> events = <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">reportService</span>.<span class="title function_">findEvents</span>(windowStart, windowEnd);</span><br><span class="line"> <span class="keyword">const</span> reportHtml = <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">reportService</span>.<span class="title function_">renderReport</span>(events);</span><br><span class="line"> <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">commandBus</span>.<span class="title function_">execute</span>(<span class="keyword">new</span> <span class="title class_">SendMailCommand</span>(job.<span class="property">data</span>.<span class="property">mail</span>, reportHtml));</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure>
-<p> ➜ 这样无是马上执行还是几次重试后才执行,数据规则都不会变。</p>
-<p>二、预先生成「静态报表内容」,挂到队列里<br> 如果计算成本很高,或者怕重复查询数据开销大,也可以在调度时就把最终的 HTML&#x2F;Text&#x2F;附件 都先打好,然后作为 <code>job.data</code> 传进去,真正的 <code>process()</code> 只做一次“发送”即可:<br> <figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 调度时:先生成报告</span></span><br><span class="line"><span class="keyword">const</span> now = <span class="keyword">new</span> <span class="title class_">Date</span>();</span><br><span class="line"><span class="keyword">const</span> windowStart = <span class="keyword">new</span> <span class="title class_">Date</span>(now.<span class="title function_">getTime</span>() - <span class="number">24</span>*<span class="number">3600</span>*<span class="number">1000</span>);</span><br><span class="line"><span class="keyword">const</span> events = <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">reportService</span>.<span class="title function_">findEvents</span>(windowStart, now);</span><br><span class="line"><span class="keyword">const</span> reportHtml = <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">reportService</span>.<span class="title function_">renderReport</span>(events);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 把静态内容塞到队列</span></span><br><span class="line"><span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">mailerQueue</span>.<span class="title function_">add</span>(</span><br><span class="line"> id,</span><br><span class="line"> &#123;</span><br><span class="line"> <span class="attr">mail</span>: <span class="keyword">new</span> <span class="title class_">Mail</span>(&#123; <span class="comment">/*…*/</span>, windowStart, <span class="attr">windowEnd</span>: now &#125;),</span><br><span class="line"> reportHtml, <span class="comment">// &lt;- 预渲染好的文本/HTML</span></span><br><span class="line"> <span class="attr">attachments</span>: […], <span class="comment">// &lt;- 如果有附件也一并塞</span></span><br><span class="line"> &#125;,</span><br><span class="line"> &#123; <span class="attr">attempts</span>: <span class="number">3</span>, <span class="attr">backoff</span>: &#123; <span class="attr">type</span>: <span class="string">&#x27;fixed&#x27;</span>, <span class="attr">delay</span>: <span class="number">5_000</span> &#125; &#125;,</span><br><span class="line">);</span><br><span class="line"></span><br><span class="line"><span class="comment">// process 里只关注发送</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">async</span> <span class="title function_">process</span>(<span class="params"><span class="attr">job</span>: <span class="title class_">Job</span></span>) &#123;</span><br><span class="line"> <span class="keyword">try</span> &#123;</span><br><span class="line"> <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">mailService</span>.<span class="title function_">send</span>(&#123;</span><br><span class="line"> <span class="attr">to</span>: job.<span class="property">data</span>.<span class="property">mail</span>.<span class="property">to</span>,</span><br><span class="line"> <span class="attr">subject</span>: <span class="string">`系统 24h 报表`</span>,</span><br><span class="line"> <span class="attr">html</span>: job.<span class="property">data</span>.<span class="property">reportHtml</span>,</span><br><span class="line"> <span class="attr">attachments</span>: job.<span class="property">data</span>.<span class="property">attachments</span>,</span><br><span class="line"> &#125;);</span><br><span class="line"> &#125; <span class="keyword">catch</span> (e) &#123;</span><br><span class="line"> <span class="keyword">throw</span> e; <span class="comment">// 触发重试</span></span><br><span class="line"> &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><br> ➜ 重试带来的任何延迟,都不影响邮件正文,始终是一份「事先约定好、并且静态化」的报告。</p>
-<p>这两种模式都能保证最终发送时的数据窗口或内容,与当初调度时的预期完全一致,不会因为重试延迟而出现“数据真空”或“多算&#x2F;少算”问题。</p>
-<h2 id="参考文档:"><a href="#参考文档:" class="headerlink" title="参考文档:"></a>参考文档:</h2><ul>
-<li>“Retrying failing jobs” · BullMQ Guide<br><a target="_blank" rel="noopener" href="https://docs.bullmq.io/guide/retrying-failing-jobs">https://docs.bullmq.io/guide/retrying-failing-jobs</a></li>
-<li>BullMQ Guide &amp; Patterns · Process Step Jobs (completed event only fires after process resolves)<br><a target="_blank" rel="noopener" href="https://docs.bullmq.io/patterns/process-step-jobs">https://docs.bullmq.io/patterns/process-step-jobs</a></li>
+<p>➜ 这样无是马上执行还是几次重试后才执行,数据规则都不会变。</p>
+<p>二、预先生成「静态报表内容」,挂到队列里<br>
+如果计算成本很高,或者怕重复查询数据开销大,也可以在调度时就把最终的 HTML/Text/附件 都先打好,然后作为 <code>job.data</code> 传进去,真正的 <code>process()</code> 只做一次“发送”即可:</p>
+ <figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 调度时:先生成报告</span></span><br><span class="line"><span class="keyword">const</span> now = <span class="keyword">new</span> <span class="title class_">Date</span>();</span><br><span class="line"><span class="keyword">const</span> windowStart = <span class="keyword">new</span> <span class="title class_">Date</span>(now.<span class="title function_">getTime</span>() - <span class="number">24</span>*<span class="number">3600</span>*<span class="number">1000</span>);</span><br><span class="line"><span class="keyword">const</span> events = <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">reportService</span>.<span class="title function_">findEvents</span>(windowStart, now);</span><br><span class="line"><span class="keyword">const</span> reportHtml = <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">reportService</span>.<span class="title function_">renderReport</span>(events);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 把静态内容塞到队列</span></span><br><span class="line"><span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">mailerQueue</span>.<span class="title function_">add</span>(</span><br><span class="line"> id,</span><br><span class="line"> &#123;</span><br><span class="line"> <span class="attr">mail</span>: <span class="keyword">new</span> <span class="title class_">Mail</span>(&#123; <span class="comment">/*…*/</span>, windowStart, <span class="attr">windowEnd</span>: now &#125;),</span><br><span class="line"> reportHtml, <span class="comment">// &lt;- 预渲染好的文本/HTML</span></span><br><span class="line"> <span class="attr">attachments</span>: […], <span class="comment">// &lt;- 如果有附件也一并塞</span></span><br><span class="line"> &#125;,</span><br><span class="line"> &#123; <span class="attr">attempts</span>: <span class="number">3</span>, <span class="attr">backoff</span>: &#123; <span class="attr">type</span>: <span class="string">&#x27;fixed&#x27;</span>, <span class="attr">delay</span>: <span class="number">5_000</span> &#125; &#125;,</span><br><span class="line">);</span><br><span class="line"></span><br><span class="line"><span class="comment">// process 里只关注发送</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">async</span> <span class="title function_">process</span>(<span class="params"><span class="attr">job</span>: <span class="title class_">Job</span></span>) &#123;</span><br><span class="line"> <span class="keyword">try</span> &#123;</span><br><span class="line"> <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">mailService</span>.<span class="title function_">send</span>(&#123;</span><br><span class="line"> <span class="attr">to</span>: job.<span class="property">data</span>.<span class="property">mail</span>.<span class="property">to</span>,</span><br><span class="line"> <span class="attr">subject</span>: <span class="string">`系统 24h 报表`</span>,</span><br><span class="line"> <span class="attr">html</span>: job.<span class="property">data</span>.<span class="property">reportHtml</span>,</span><br><span class="line"> <span class="attr">attachments</span>: job.<span class="property">data</span>.<span class="property">attachments</span>,</span><br><span class="line"> &#125;);</span><br><span class="line"> &#125; <span class="keyword">catch</span> (e) &#123;</span><br><span class="line"> <span class="keyword">throw</span> e; <span class="comment">// 触发重试</span></span><br><span class="line"> &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure>
+<p>➜ 重试带来的任何延迟,都不影响邮件正文,始终是一份「事先约定好、并且静态化」的报告。</p>
+<p>这两种模式都能保证最终发送时的数据窗口或内容,与当初调度时的预期完全一致,不会因为重试延迟而出现“数据真空”或“多算/少算”问题。</p>
+<h2 id="参考文档:"><a class="header-anchor" href="#参考文档:">¶</a>参考文档:</h2>
+<ul>
+<li>“Retrying failing jobs” · BullMQ Guide<br>
+<a target="_blank" rel="noopener" href="https://docs.bullmq.io/guide/retrying-failing-jobs">https://docs.bullmq.io/guide/retrying-failing-jobs</a></li>
+<li>BullMQ Guide &amp; Patterns · Process Step Jobs (completed event only fires after process resolves)<br>
+<a target="_blank" rel="noopener" href="https://docs.bullmq.io/patterns/process-step-jobs">https://docs.bullmq.io/patterns/process-step-jobs</a></li>
</ul>
</div>
diff --git a/2025/05/08/Multiplayer-Collaborative-Systems-tips/index.html b/2025/05/08/Multiplayer-Collaborative-Systems-tips/index.html
index 47a9feef..42f2895c 100644
--- a/2025/05/08/Multiplayer-Collaborative-Systems-tips/index.html
+++ b/2025/05/08/Multiplayer-Collaborative-Systems-tips/index.html
@@ -193,36 +193,41 @@
</div>
<div class="post-content">
<p>最近碰到一块业务:在系统中可以存在多个用户同时对某个项目信息进行编辑,这种多人协作的场景挺有意思的,不过在我们的业务中,并不需要实时协作,只需要保证不会出错就行,话虽如此,但也可以探索一下实时协作的实现方案,防止老年痴呆。</p>
-<p>先来看看第一个方案 —— CRDT(Conflict-free Replicated Data Type,无冲突可复制数据类型)是一类数据结构,它保证了在分布式节点(或多客户端)上进行离线&#x2F;并发更新后,无需中心协调、也无需人工干预,通过“合并策略”就能得到一致的最终状态。 </p>
+<p>先来看看第一个方案 —— CRDT(Conflict-free Replicated Data Type,无冲突可复制数据类型)是一类数据结构,它保证了在分布式节点(或多客户端)上进行离线/并发更新后,无需中心协调、也无需人工干预,通过“合并策略”就能得到一致的最终状态。</p>
<p>核心思想是:所有并发操作都是幂等(idempotent)、可交换(commutative)的。</p>
-<p>常见类型有:<br>一、G-Counter(只能增计数器)<br>二、PN-Counter(可增可减计数器)<br>三、LWW-Register(最后写入胜出)<br>四、结合 JSON 的树型 CRDT(如 <a target="_blank" rel="noopener" href="https://github.com/automerge/automerge">Automerge</a> &#x2F; <a target="_blank" rel="noopener" href="https://yjs.dev/">Yjs</a>) </p>
-<p>更多原理可参考 Decipad 博客“Collaborative and Offline Editing Using CRDTs”[^1]。</p>
+<p>常见类型有:<br>
+一、G-Counter(只能增计数器)<br>
+二、PN-Counter(可增可减计数器)<br>
+三、LWW-Register(最后写入胜出)<br>
+四、结合 JSON 的树型 CRDT(如 <a target="_blank" rel="noopener" href="https://github.com/automerge/automerge">Automerge</a> / <a target="_blank" rel="noopener" href="https://yjs.dev/">Yjs</a>)</p>
+<p>更多原理可参考 Decipad 博客“Collaborative and Offline Editing Using CRDTs”<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup>。</p>
<blockquote>
<p>有一个挺有趣的 Rust 项目 <a target="_blank" rel="noopener" href="https://github.com/loro-dev/loro">Loro: Make your JSON data collaborative and version-controlled with CRDTs</a></p>
</blockquote>
-<p>假设我的项目信息编辑页面允许多人实时&#x2F;离线修改某个研究项目的“名称”、“描述”字段,前端用 SvelteKit + GraphQL 获取和提交变更:</p>
+<p>假设我的项目信息编辑页面允许多人实时/离线修改某个研究项目的“名称”、“描述”字段,前端用 SvelteKit + GraphQL 获取和提交变更:</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">┌── 用户 A 离线修改了 “description” 的若干段文本 </span><br><span class="line">└── 用户 B 同时在线修改了同一字段的其他段落 </span><br></pre></td></tr></table></figure>
-
<p>如果后端使用 CRDT(比如把 <code>description</code> 用 JSON-CRDT 存储),两次修改只要在任意顺序合并都能得到完整的内容:</p>
-<p>首先,A 客户端本地 apply 操作并缓存,恢复网络后推给服务器;<br>然后,服务器用 CRDT merge(A.delta, B.delta),得到一致文档<br>最后,服务器广播新文档到所有客户端,A&#x2F;B 均得到相同结果 </p>
+<p>首先,A 客户端本地 apply 操作并缓存,恢复网络后推给服务器;<br>
+然后,服务器用 CRDT merge(A.delta, B.delta),得到一致文档<br>
+最后,服务器广播新文档到所有客户端,A/B 均得到相同结果</p>
<hr>
-<p>好了说点实际符合业务场景的方案,首先想到的是悲观锁(Pessimistic Locking) ,思路是:用户打开编辑界面时,向后端申请“锁” → 其它用户尝试编辑时被拒绝 → 编辑完成后释放锁&#x2F;超时自动释放。 </p>
+<p>好了说点实际符合业务场景的方案,首先想到的是悲观锁(Pessimistic Locking) ,思路是:用户打开编辑界面时,向后端申请“锁” → 其它用户尝试编辑时被拒绝 → 编辑完成后释放锁/超时自动释放。</p>
<p>假如有一个这样的锁表:</p>
-<pre><code class="sql">CREATE TABLE project_lock (
+<pre><code class="language-sql">CREATE TABLE project_lock (
project_id UUID PRIMARY KEY,
locked_by UUID NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
</code></pre>
<p>可以在事务内申请它:</p>
-<pre><code class="ts">const now = new Date();
+<pre><code class="language-ts">const now = new Date();
const expires = new Date(now.getTime() + 5*60*1000); // 5 分钟后过期
await prisma.$transaction(async tx =&gt; &#123;
const existing = await tx.project_lock.findUnique(&#123; where:&#123; project_id &#125; &#125;);
if (existing &amp;&amp; existing.expires_at &gt; now) &#123;
- throw new Error(&#39;项目正被人编辑&#39;);
- &#125;
+ throw new Error('项目正被人编辑');
+ &#125;
await tx.project_lock.upsert(&#123;
where: &#123; project_id &#125;,
@@ -232,14 +237,16 @@ await prisma.$transaction(async tx =&gt; &#123;
&#125;);
</code></pre>
<p>释放锁就直接从锁表里删掉对应的数据即可:</p>
-<pre><code class="ts">await prisma.project_lock.delete(&#123; where:&#123; project_id &#125; &#125;);
+<pre><code class="language-ts">await prisma.project_lock.delete(&#123; where:&#123; project_id &#125; &#125;);
</code></pre>
<p>前端的话,大概就是:</p>
-<p>在进入编辑前请求一下 <code>/api/project/:id/lock</code> 之类的 API,失败则提示“被占用”;<br>在 <code>onbeforeunload</code> 时执行 <code>/unlock</code>;<br>超时后后端自动允许新锁。 </p>
+<p>在进入编辑前请求一下 <code>/api/project/:id/lock</code> 之类的 API,失败则提示“被占用”;<br>
+在 <code>onbeforeunload</code> 时执行 <code>/unlock</code>;<br>
+超时后后端自动允许新锁。</p>
<hr>
-<p>第二个方案是乐观并发控制(Optimistic Concurrency) :记录资源的版本号或时间戳;客户端提交更新时带上自己的版本号,后端检查版本是否一致,不一致则认为冲突,返回 409,由客户端告知用户“数据已过期,请刷新后合并”。 </p>
+<p>第二个方案是乐观并发控制(Optimistic Concurrency) :记录资源的版本号或时间戳;客户端提交更新时带上自己的版本号,后端检查版本是否一致,不一致则认为冲突,返回 409,由客户端告知用户“数据已过期,请刷新后合并”。</p>
<p>具体实现中,可以尝试在 <code>project</code> 表加上 <code>version INT NOT NULL DEFAULT 1, updated_at TIMESTAMPTZ</code> ,然后更新项目时:</p>
-<pre><code class="ts">async updateProject(parent, &#123; id, version, input &#125;, ctx) &#123;
+<pre><code class="language-ts">async updateProject(parent, &#123; id, version, input &#125;, ctx) &#123;
const result = await prisma.$executeRaw`
UPDATE project
SET name = $&#123;input.name&#125;,
@@ -247,38 +254,51 @@ await prisma.$transaction(async tx =&gt; &#123;
version = version + 1,
updated_at = now()
WHERE id = $&#123;id&#125; AND version = $&#123;version&#125;
- `;
+ `;
if (result === 0) &#123;
- throw new ConflictException(&#39;版本冲突,请刷新后重试&#39;);
+ throw new ConflictException('版本冲突,请刷新后重试');
&#125;
return prisma.project.findUnique(&#123; where:&#123; id &#125; &#125;);
&#125;
</code></pre>
-<p>前端捕获到冲突错误可以用一个弹窗提示“另有用户已更新此项目,是否合并&#x2F;重新加载?” 之类的玩意儿。</p>
+<p>前端捕获到冲突错误可以用一个弹窗提示“另有用户已更新此项目,是否合并/重新加载?” 之类的玩意儿。</p>
<hr>
-<p>第三个方案是:操作转化(Operational Transformation,OT) </p>
-<p>也就是记录用户每次的“操作”(insert&#x2F;delete at position),服务器根据历史操作序列对并发操作做转化(transform),确保先到达的操作调整后再应用后到达的。 </p>
-<p>有一些实现案例:<br>一、ShareDB(Node.js)<br>二、Google Docs 中的同步算法 </p>
-<p>具体实现的话,可能要现在前端逐字符&#x2F;块地包装成操作并 WebSocket 推送,服务器再维护一个“操作历史队列”,每来一个 op 就 transform 并 broadcast,而客户端收到广播后,按顺序 replay 保证视图一致。</p>
+<p>第三个方案是:操作转化(Operational Transformation,OT)</p>
+<p>也就是记录用户每次的“操作”(insert/delete at position),服务器根据历史操作序列对并发操作做转化(transform),确保先到达的操作调整后再应用后到达的。</p>
+<p>有一些实现案例:<br>
+一、ShareDB(Node.js)<br>
+二、Google Docs 中的同步算法</p>
+<p>具体实现的话,可能要现在前端逐字符/块地包装成操作并 WebSocket 推送,服务器再维护一个“操作历史队列”,每来一个 op 就 transform 并 broadcast,而客户端收到广播后,按顺序 replay 保证视图一致。</p>
<hr>
<p>最后可能还可以用事件溯源(Event Sourcing)+ 场景命令模式来实现:</p>
-<p>不直接存状态,而是存所有“命令 &#x2F; 事件”(Event),回放事件得到当前状态。冲突通过合并策略或补偿事件(Compensating Events)解决。 </p>
-<p>例如:<br>在每次更新时推送 <code>ProjectUpdated &#123; projectId, fieldsChanged, userId, timestamp &#125;</code> ,<br>然后写入事件存储(如 Kafka &#x2F; EventStoreDB),<br>读端 Consumer 按顺序重建最新状态或按领域聚合 ,<br>最后在并发时如果两个事件都修改了同一字段,可在写端做校验&#x2F;补偿,或在读端做最后写入胜出等策略 。</p>
+<p>不直接存状态,而是存所有“命令 / 事件”(Event),回放事件得到当前状态。冲突通过合并策略或补偿事件(Compensating Events)解决。</p>
+<p>例如:<br>
+在每次更新时推送 <code>ProjectUpdated &#123; projectId, fieldsChanged, userId, timestamp &#125;</code> ,<br>
+然后写入事件存储(如 Kafka / EventStoreDB),<br>
+读端 Consumer 按顺序重建最新状态或按领域聚合 ,<br>
+最后在并发时如果两个事件都修改了同一字段,可在写端做校验/补偿,或在读端做最后写入胜出等策略 。</p>
<hr>
<p>总结来说,</p>
<ul>
-<li>CRDT 最擅长 去中心化、离线编辑、自动合并; </li>
-<li>若不引入 CRDT,可根据业务侧重点选用: <ol>
-<li>悲观锁 → 强制串行编辑,简单粗暴; </li>
-<li>乐观并发 → 适合大多数业务场景,成本低; </li>
-<li>OT → 适合富文本或实时协同场景,复杂度中等; </li>
+<li>CRDT 最擅长 去中心化、离线编辑、自动合并;</li>
+<li>若不引入 CRDT,可根据业务侧重点选用:
+<ol>
+<li>悲观锁 → 强制串行编辑,简单粗暴;</li>
+<li>乐观并发 → 适合大多数业务场景,成本低;</li>
+<li>OT → 适合富文本或实时协同场景,复杂度中等;</li>
<li>事件溯源 → 适合需要全历史审计、可回放的场景。</li>
</ol>
</li>
</ul>
<hr>
-<p>[^1]: Decipad 博客 “Collaborative and Offline Editing Using CRDTs”<br> <a target="_blank" rel="noopener" href="https://www.decipad.com/blog/decipads-innovative-method-collaborative-and-offline-editing-using-crdts">https://www.decipad.com/blog/decipads-innovative-method-collaborative-and-offline-editing-using-crdts</a></p>
-<p>[^2]: Hacker News 讨论(CRDT 相关线程)<br> <a target="_blank" rel="noopener" href="https://news.ycombinator.com/item?id=38289327">https://news.ycombinator.com/item?id=38289327</a></p>
+<hr class="footnotes-sep">
+<section class="footnotes">
+<ol class="footnotes-list">
+<li id="fn1" class="footnote-item"><p>Decipad 博客 “Collaborative and Offline Editing Using CRDTs”<br>
+<a target="_blank" rel="noopener" href="https://www.decipad.com/blog/decipads-innovative-method-collaborative-and-offline-editing-using-crdts">https://www.decipad.com/blog/decipads-innovative-method-collaborative-and-offline-editing-using-crdts</a> <a href="#fnref1" class="footnote-backref">↩︎</a></p>
+</li>
+</ol>
+</section>
</div>
diff --git a/2025/05/27/vertical-slicing-practice/index.html b/2025/05/27/vertical-slicing-practice/index.html
index 98dee1bb..206a3e98 100644
--- a/2025/05/27/vertical-slicing-practice/index.html
+++ b/2025/05/27/vertical-slicing-practice/index.html
@@ -210,7 +210,7 @@
<p>对于登录功能,需要考虑:</p>
<ul>
<li>UI 层:登录表单(输入邮箱、密码的地方)、提交按钮、错误提示信息。</li>
-<li>API&#x2F;服务层:接收登录请求、验证用户凭证的接口。</li>
+<li>API/服务层:接收登录请求、验证用户凭证的接口。</li>
<li>业务逻辑层:校验输入格式、查询用户信息、验证密码、生成会话(Session)或令牌(Token)。</li>
<li>数据访问层:从数据库中读取用户信息。</li>
</ul>