在开发壁纸时,使用到了NestJS中的定时任务,但在发布生产后,却出现了数据重复问题。反复排查之后,最终发现问题的罪魁祸首是多进程环境导致的并发问题。在定时任务场景中,每个单独的服务进程都将执行一次定时任务,导致重复爬取。且因为各进程的执行时间差在毫秒级别,根本无法使用ID重复校验机制。

因此,只能通过加锁,或者其它类似的机制解决多进程并发问题。而加锁可以是在controller的action层面,也可以是在数据库层面,显然,数据库层面的代价是不小的(复杂性、性能),因此,只能考虑在controller层面解决。

然而,考虑到场景的特殊性,即,并不需要发挥多进程的并发优势,只需其中任何一个进程执行成功即可,因此,可以通过某种负载均衡的机制(算法)去调度执行定时任务的进程,也就规避了多进程带来的并发问题。实际上,这不就是典型的“负载均衡”算法的应用吗?

在友人的提示下,考虑采用某种一致性哈希算法进行任务调度,其推荐的算法是murmurhash。代码如下:

typescriptCopy code
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
const isCluster = this.configService.get('env.isCluster'); if (isCluster) { const hash = murmurhash(data[0][0].urlBase + '_' + data[1][0].urlBase, 20160124); const worker = (hash % getWorkerCount()) + 1; if (worker !== (cluster as any).worker.id) { return; } this.logger.infoRaw(`Task is served by worker: ${worker}`); }
const isCluster = this.configService.get('env.isCluster'); if (isCluster) { const hash = murmurhash(data[0][0].urlBase + '_' + data[1][0].urlBase, 20160124); const worker = (hash % getWorkerCount()) + 1; if (worker !== (cluster as any).worker.id) { return; } this.logger.infoRaw(`Task is served by worker: ${worker}`); }

其中:

  1. 先根据环境判断是否开启集群模式,仅在开启的情况下进行判断;
  2. 调用murmurhash生成一致性哈希;
  3. 获取进程数,对其取余,再同进程id进行判断,选择执行任务的具体进程。

如此,便解决了集群模式下的定时任务并发执行问题。

但,考虑到定时任务可能执行失败,因此,还需要在适当的时机“重启”定时任务,并输出日志:

typescriptCopy code
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
try { ... } catch (e) { await this.exceptionService.handleException(e); // if exists, delete first const retryTaskName = 'retryFetchWallpapers'; if (this.scheduler.doesExist('timeout', retryTaskName)) { this.scheduler.deleteTimeout(retryTaskName); } // retry after 1 hour const timeout = setTimeout(this.fetchWallpapers.bind(this), 60 * 60 * 1000); this.scheduler.addTimeout(retryTaskName, timeout); }
try { ... } catch (e) { await this.exceptionService.handleException(e); // if exists, delete first const retryTaskName = 'retryFetchWallpapers'; if (this.scheduler.doesExist('timeout', retryTaskName)) { this.scheduler.deleteTimeout(retryTaskName); } // retry after 1 hour const timeout = setTimeout(this.fetchWallpapers.bind(this), 60 * 60 * 1000); this.scheduler.addTimeout(retryTaskName, timeout); }