没有不值得去解决的问题,也没有不值得去学习的技术!

Preventing Data Loss When Concurrently Querying, Modifying, and Reassigning JSON Fields in MySQL 5.7 With JSON_ARRAY_APPEND

The JSON field stores an array. Example value: [249, 247, 250, 244, 243, 255, 246, 245, 257, 248, 259, 252, 256]

1. The JSON field stores an array. Example value: [249, 247, 250, 244, 243, 255, 246, 245, 257, 248, 259, 252, 256]. See Figure 1.

The JSON field stores an array. Example value: [249, 247, 250, 244, 243, 255, 246, 245, 257, 248, 259, 252, 256]
Figure 1

2. The existing code running in the queue job is implemented as follows, and the generated SQL is as follows.

PHP

$themeSaasTask = ThemeSaasTask::whereJsonContains('theme_task_ids', $this->themeInstallationTask->id)->first();
$fromThemeInstallationIds = json_decode($themeSaasTask->from_theme_installation_ids, true);
$fromThemeInstallationIds[] = $this->themeInstallation->originalThemeInstallation->id;
$themeSaasTask->from_theme_installation_ids = json_encode($fromThemeInstallationIds);
$themeSaasTask->updated_at = now()->utc()->toDateTimeString();
$themeSaasTask->save();

SQL

select * from `theme_saas_task` where json_contains(`theme_task_ids`, '393') limit 1

update `theme_saas_task` set `from_theme_installation_ids` = '[380]', `theme_saas_task`.`updated_at` = '2023-08-21 03:10:57' where `id` = 23

3. Because queue tasks can run simultaneously, concurrent execution causes values that need to be added to the JSON field to be lost. The values [251, 253, 254, 258] were lost. A large number of query, modify, and save operations were triggered at the exact same time, 2023-06-20 06:36:27, which led to the data loss. See Figure 2.

Because queue tasks can run simultaneously, concurrent execution causes values that need to be added to the JSON field to be lost. The values [251, 253, 254, 258] were lost. A large number of query, modify, and save operations were triggered at the exact same time, 2023-06-20 06:36:27, which led to the data loss
Figure 2

4. Reference: 12.17.4 Functions That Modify JSON Values JSON_ARRAY_APPEND  The code is implemented as follows, and the generated SQL is as follows. See Figure 3.

Reference: 12.17.4 Functions That Modify JSON Values JSON_ARRAY_APPEND
Figure 3
PHP

$themeSaasTask->from_theme_installation_ids = DB::raw('JSON_ARRAY_APPEND(from_theme_installation_ids, "$", ' . $this->themeInstallation->originalThemeInstallation->id . ')');
$themeSaasTask->updated_at = now()->utc()->toDateTimeString();
$themeSaasTask->save();

SQL

update
  `theme_saas_task`
set
  `from_theme_installation_ids` = JSON_ARRAY_APPEND(from_theme_installation_ids, "$", 403),
  `theme_saas_task`.`updated_at` = '2023-08-22 02:39:12'
where
  `id` = 30

5. In addition to appending values to the JSON array, there are also cases where values need to be removed from the JSON array. The existing implementation is as follows.

PHP

$themeSaasTask = ThemeSaasTask::whereJsonContains('theme_task_ids', $this->themeInstallationTask->id)->first();
if ($this->themeInstallation->type == ThemeInstallation::TYPE_UPDATE && $themeSaasTask) {
	$themeInstallationTaskIds = json_decode($themeSaasTask->theme_task_ids, true);
	$themeInstallationTaskIds = array_values(array_diff($themeInstallationTaskIds, [$this->themeInstallationTask->id]));
	if (empty($themeInstallationTaskIds)) {
		ThemeSaasTask::where('id', $themeSaasTask->id)->delete();
	} else {
		$themeSaasTask->theme_task_ids = json_encode($themeInstallationTaskIds);
		$themeSaasTask->save();
	}
}

6. When running concurrently in the queue, the `theme_task_ids` field contains a total of 17 values. Theoretically, after 17 executions, `id` = 22 should have been removed. However, in reality, the `theme_task_ids` field still contains [380, 381, 382, 383, 385, 386, 389, 394]. The executed SQL is as follows: See Figure 4.

When running concurrently in the queue, the `theme_task_ids` field contains a total of 17 values. Theoretically, after 17 executions, `id` = 22 should have been removed. However, in reality, the `theme_task_ids` field still contains [380, 381, 382, 383, 385, 386, 389, 394]
Figure 4
SQL

update `theme_saas_task` set `theme_task_ids` = '[379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395]', `theme_saas_task`.`updated_at` = '2023-08-23 01:54:15' where `id` = 22;

/* 删除掉 379 */
select * from `theme_saas_task` where json_contains(`theme_task_ids`, '379') limit 1;
update `theme_saas_task` set `theme_task_ids` = '[380,381,382,383,384,385,386,387,388,389,390,394]', `theme_saas_task`.`updated_at` = '2023-08-23 02:01:39' where `id` = 22; 

/* 只需要删除掉 380,但是 379 又被添加 */
select * from `theme_saas_task` where json_contains(`theme_task_ids`, '380') limit 1;
update `theme_saas_task` set `theme_task_ids` = '[379,381,382,383,384,385,386,387,388,389,390,394]', `theme_saas_task`.`updated_at` = '2023-08-23 02:01:39' where `id` = 22;

/* 只需要删除掉 381,但是 379,380 又被添加 */
select * from `theme_saas_task` where json_contains(`theme_task_ids`, '381') limit 1;
update `theme_saas_task` set `theme_task_ids` = '[379,380,382,383,384,385,386,387,388,389,390,394]', `theme_saas_task`.`updated_at` = '2023-08-23 02:01:39' where `id` = 22;

/* 只需要删除掉 384,但是 380,381 又被添加 */
select * from `theme_saas_task` where json_contains(`theme_task_ids`, '384') limit 1;
update `theme_saas_task` set `theme_task_ids` = '[380,381,382,383,385,386,387,388,389,390,394]', `theme_saas_task`.`updated_at` = '2023-08-23 02:01:40' where `id` = 22;


7. Reference: Removing data from a MySQL JSON array  . I found that the `search_str` parameter of JSON_SEARCH does not currently support numbers. See Figure 5.

Found that the `search_str` parameter of JSON_SEARCH does not currently support numbers
Figure 5
SQL

SELECT
	JSON_SEARCH( theme_task_ids, 'all', '375' ) 
FROM
	theme_saas_task 
WHERE
	id = 21;

8. I ultimately decided to convert values to strings before inserting them into the `theme_task_ids` field. The adjusted code running in the queue job is implemented as follows.

PHP

$themeInstallationTaskId = '' . $this->themeInstallationTask->id . '';
$themeSaasTask = ThemeSaasTask::whereJsonContains('theme_task_ids', $themeInstallationTaskId)->first();

$themeSaasTask->theme_task_ids = DB::raw('JSON_REMOVE(theme_task_ids,	JSON_UNQUOTE(JSON_SEARCH(theme_task_ids, "one", ' . $themeInstallationTaskId . ')))');
$themeSaasTask->save();

SQL

update
  `theme_saas_task`
set
  `theme_task_ids` = '[\"516\",\"517\",\"518\",\"519\",\"520\",\"521\",\"522\",\"523\",\"524\",\"525\",\"526\",\"527\",\"528\",\"529\",\"530\",\"531\",\"532\"]',
  `theme_saas_task`.`updated_at` = '2023-08-23 08:33:57'
where
  `id` = 30;

"JSON_REMOVE(theme_task_ids, JSON_UNQUOTE(JSON_SEARCH(theme_task_ids, \"one\", 519)))";

9. However, under concurrent conditions, some values still remained undeleted. Out of a total of 17, 12 were deleted, leaving 5. See Figure 6.

However, under concurrent conditions, some values still remained undeleted. Out of a total of 17, 12 were deleted, leaving 5
Figure 6

10. Because this piece of code runs very infrequently, I ultimately decided to implement it with a lock.

PHP

        $themeSaasTask = ThemeSaasTask::whereJsonContains('theme_task_ids', $this->themeInstallationTask->id)->first();

        if ($this->themeInstallation->type == ThemeInstallation::TYPE_UPDATE && $themeSaasTask) {

            app(ThemeManager::class)->cleanTheme($this->themeInstallation, $this->themeInstallation->theme);

            DB::beginTransaction();
            $themeSaasTask = ThemeSaasTask::lockForUpdate()->find($themeSaasTask->id);
            $themeInstallationTaskIds = json_decode($themeSaasTask->theme_task_ids, true);
            Log::info(
                '$themeInstallationTaskIds3-'.$this->themeInstallationTask->id,
                [$themeInstallationTaskIds]
            );
            $themeInstallationTaskIds = array_values(array_diff($themeInstallationTaskIds, [$this->themeInstallationTask->id]));
            if (empty($themeInstallationTaskIds)) {
                ThemeSaasTask::where('id', $themeSaasTask->id)->delete();
            } else {
                $themeSaasTask->theme_task_ids = json_encode($themeInstallationTaskIds);
                Log::info(
                    '$themeSaasTask->theme_task_ids3-'.$this->themeInstallationTask->id,
                    [$themeSaasTask->theme_task_ids]
                );
                $themeSaasTask->save();
            }
            DB::commit();
        }

11. I ultimately confirmed that this approach was problematic. The remaining values were [567, 568, 569, 570, 571, 572, 573, 574, 575, 576, 577, 578, 579, 580, 581]. The expected result was an empty array. Through log records, I found that the queried data did not meet expectations and had decreased significantly. I printed the logs for the 1st and 17th executions respectively.

Plaintext

[2023-08-24 10:10:06] local.INFO: $themeInstallationTaskIds3-567 [
    [
        567,
        568,
        569,
        570,
        571
    ]
] 
[2023-08-24 10:10:06] local.INFO: $themeSaasTask->theme_task_ids3-567 [
    "[568,569,570,571]"
] 

[2023-08-24 10:10:09] local.INFO: $themeInstallationTaskIds3-583 [
    [
        567,
        568,
        569,
        570,
        571,
        572,
        573,
        574,
        575,
        576,
        577,
        578,
        579,
        580,
        581,
        583
    ]
] 
[2023-08-24 10:10:09] local.INFO: $themeSaasTask->theme_task_ids3-583 [
    "[567,568,569,570,571,572,573,574,575,576,577,578,579,580,581]"
]

12. I finally found the root cause. The root cause was that for the same record, values were being continuously added to the `theme_task_ids` field elsewhere. Once added, they were immediately pushed to the queue, and then the queue continuously removed values from the `theme_task_ids` field. I decided to batch enqueue the values only after all additions to the `theme_task_ids` field were complete.

Implementation before adjustment:

PHP

foreach ($list as $theme) {
	$themeTaskIds = json_decode($themeSaasTask->theme_task_ids, true);
	$themeTaskIds[] = $themeInstallationTask->id;
	$themeSaasTask->theme_task_ids = json_encode($themeTaskIds);
	$themeSaasTask->updated_at = now()->utc()->toDateTimeString();
	$themeSaasTask->save();
	// 推送任务到队列
	InstallThemeToDb::dispatch($themeInstallationTask)->onQueue('longtime');
}

Implementation after adjustment:

PHP

$themeInstallationTasks = [];
foreach ($list as $theme) {
	$themeTaskIds = json_decode($themeSaasTask->theme_task_ids, true);
	$themeTaskIds[] = $themeInstallationTask->id;
	$themeSaasTask->theme_task_ids = json_encode($themeTaskIds);
	$themeSaasTask->updated_at = now()->utc()->toDateTimeString();
	$themeSaasTask->save();


	$themeInstallationTasks[] = $themeInstallationTask;
}

foreach ($themeInstallationTasks as $themeInstallationTask) {
	// 推送任务到队列
	InstallThemeToDb::dispatch($themeInstallationTask)->onQueue('longtime');
}

13. After confirming the adjustment, the approach in step 12 proved feasible. The final remaining value was []. The expected result was an empty array, which matched expectations. I printed the logs for the 1st, 16th, and 17th executions respectively.

Plaintext

[2023-08-24 13:42:24] local.INFO: $themeInstallationTaskIds6-635 [
    [
        635,
        636,
        637,
        638,
        639,
        640,
        641,
        642,
        643,
        644,
        645,
        646,
        647,
        648,
        649,
        650,
        651
    ]
] 
[2023-08-24 13:42:24] local.INFO: $themeSaasTask->theme_task_ids6-635 [
    "[636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651]"
]  

[2023-08-24 13:42:25] local.INFO: $themeInstallationTaskIds6-650 [
    [
        650,
        651
    ]
] 
[2023-08-24 13:42:25] local.INFO: $themeSaasTask->theme_task_ids6-650 [
    "[651]"
] 

[2023-08-24 13:42:26] local.INFO: $themeInstallationTaskIds6-651 [
    [
        651
    ]
] 

关于作者 · 持续学习与技术实践

我是一名拥有 15 年以上开发经验的技术从业者,自 2013 年起持续运营个人技术博客,记录工作与个人项目中遇到的真实问题,以及解决问题过程中的探索、实践和思考。

除了技术写作,我也在持续开发和维护自己的独立项目,探索新技术和新工具在实际场景中的应用。我始终相信,没有不值得去解决的问题,也没有不值得去学习的技术。

如果这篇文章对你有所帮助,欢迎浏览博客中的其他文章,也欢迎交流技术经验与想法。

关于我  ·  GitHub  ·  邮件联系