主题编辑器的页面保存后与实际页面不一致的排查分析(源于并发请求时,后端处理完成顺序与前端请求顺序未完全一致所导致)
1、现在主题编辑器的实现原理为:当编辑器中的某配置项发生变化时,会请求后端的暂存 API,将数据缓存至 Redis 中。当用户点击保存按钮后,会请求后端的保存 API,将会从 Redis 中读取数据存储至 MySQL 中。
2、当用户在快速编辑一些配置项时,可能会出现主题编辑器的页面数据与实际页面不一致的情况。如图1
3、查看网络请求的发起顺序,最后一次请求后端的暂存 API,确定是在请求后端的保存 API 之前。那么可以推测得出的结论是,当在请求后端的保存 API ,后端从 Redis 中读取数据时,最后一次请求后端的暂存 API 还未将数据保存至 Redis 中,此时,读取的是上一次的暂存数据。举例说明如下,时间用毫秒计算:
最后一次请求后端的暂存 API 的发起时间点:2022/11/22 10:38:49.200
请求后端的保存 API 的发起时间点:2022/11/22 10:38:49.300
最后一次请求后端的暂存 API 将数据缓存至 Redis 的时间点:2022/11/22 10:38:49.800
请求后端的保存 API 从 Redis 中读取数据存储至 MySQL 的时间点:2022/11/22 10:38:49.600
4、在网络请求中查看最后一次请求后端的暂存 API的响应时间点:Tue, 22 Nov 2022 02:38:49 GMT。如图2
5、在网络请求中查看请求后端的保存 API的响应时间点:Tue, 22 Nov 2022 02:38:49 GMT。如图3
6、在网络请求中查看最后一次请求后端的暂存 API的时长:686.69 毫秒。如图4
7、在网络请求中查看请求后端的保存 API的时长:380.19 毫秒。如图5
8、现阶段有 2 个方案,第一个方案为前端处理,当用户点击保存按钮后,只有当所有的请求后端的暂存 API 结束后,再请求后端的保存 API。第二个方案为后端处理,只有当所有的请求后端的暂存 API 将数据缓存至 Redis 后,后端的保存 API 才从 Redis 中读取数据存储至 MySQL 中。
9、最终决定采用第二个方案,由后端处理。(1)当后端接收到暂存 API 的请求后,在当前会话中的 key :update_count 加 1,当将数据缓存至 Redis 后,update_count 减 1。当后端接收到保存 API 的请求后,判断当前会话中的 key :update_count,如果大于 0 ,则继续等待,在等待中会每间隔 500毫秒 再次判断当前会话中的 key :update_count,当判断达到 3 次后,继续往后执行。注:1秒= 1000毫秒; 1毫秒= 1000微秒。(2)从 Redis 中读取数据存储至 MySQL 中。
10、暂存 API 中的实现,基于 Redis 的命令:incr、decr 实现如下
/** * 将 更新计数 中储存的数字值增一 * @param string $sessionId * @return int */ public static function incrUpdateCount(string $sessionId): int { $key = self::getUpdateCountKey($sessionId); $incr = Redis::connection('cache')->incr($key); Redis::connection('cache')->expire($key, ThemePreviewInterface::TTL); return $incr; } /** * 将 更新计数 中储存的数字值减一 * @param string $sessionId * @return int */ public static function decrUpdateCount(string $sessionId): int { $key = self::getUpdateCountKey($sessionId); $decr = Redis::connection('cache')->decr($key); Redis::connection('cache')->expire($key, ThemePreviewInterface::TTL); return $decr; }
11、保存 API 中的实现,基于 while 判断,总计判断 3 次
/** * 获取 更新计数 中储存的数字值 * @param string $sessionId * @return mixed */ public static function getUpdateCount(string $sessionId) { return Redis::connection('cache')->get(self::getUpdateCountKey($sessionId)); } $i = 1; while (ThemePreview::getUpdateCount($sessionId) !== null && ThemePreview::getUpdateCount($sessionId) > 0 && $i <= 3) { file_put_contents(storage_path() . '/logs/Publish-' . microtime(true) . '-' . mt_rand() . '.txt', print_r($i, true), FILE_APPEND | LOCK_EX); usleep(500000); // 延迟执行 0.5 秒 $i++; }
12、输出的文件如下。1669187163.3346 – 1669187162.8234 = 0.5112。1669187162.8234 – 1669187162.3034 = 0.52。创建时间相差大致为 0.5 秒,符合预期。如图8
Publish-1669187162.3034-776928128.txt Publish-1669187162.8234-549007488.txt Publish-1669187163.3346-885156477.txt
13、在本地环境再次复现,已经无法复现,不过确定生成了文件:Publish-1669188133.9627-720653875.txt,其值为 1。表明在 保存 API 中执行了一次等待。如图9
14、在网络请求中查看最后一次请求后端的暂存 API 的时长:2.79 秒,原因在于本地环境性能不佳。如图10
15、在网络请求中查看请求后端的保存 API 的时长:3.37 秒,其长于暂存 API的时长。如图11
16、不过此方案仍然存在一定的概率丢失数据了。如果暂存接口到达服务器的时间晚于保存接口完成的时间的话。最终决定参考 Shopify 的实现。
17、参考 Shopify 的主题编辑器。查看其请求后端的保存 API,请求与响应变量如下。如图12
{ "eventMetadata": { "dirtyTransitionCountInSession": 13, "durationSeconds": 0, "lastViewport": "desktop", "pageType": "JSON" }, "sessionId": "etPGCT4PQr7YyCr87svY9KJz", "resourceHashes": [ { "hashValue": "fe8062f520ac1ab9be9a336c76d9f77517551223", "resourceId": "gid://shopify/OnlineStoreTheme/130440429753" }, { "hashValue": "17183337545547076327", "resourceId": "gid://shopify/OnlineStoreThemeJsonTemplate/index?theme_id=130440429753" } ], "ignoreConflicts": null }
{ "data": { "onlineStoreThemeEditorSessionPublish": { "conflicts": null, "userErrors": [], "updatedHash": { "resourceId": "gid:\/\/shopify\/OnlineStoreTheme\/130440429753", "hashValue": "fe8062f520ac1ab9be9a336c76d9f77517551223", "__typename": "OnlineStoreResourceHash" }, "__typename": "OnlineStoreThemeEditorSessionPublishPayload" } }, "extensions": { "cost": { "requestedQueryCost": 10, "actualQueryCost": 10, "throttleStatus": { "maximumAvailable": 1200.0, "currentlyAvailable": 1190, "restoreRate": 60.0 } } } }
18、初步推测,应该与 hashValue 有关系,只有当缓存中的数据的 hashValue 与请求参数中的 hashValue 相等时,才执行保存操作。其保存 API 的时长接近 1.5 秒左右。如图13
19、最终设计如下方案,在暂存接口中,基于请求体内容计算出 hashValue,并保存至 Redis 的 key 中。然后在保存接口中,基于请求参数中的 hashValue 与保存至 Redis 的 hashValue 进行比对,如果相等,则保存。如果不相等,但是 ignoreConflicts 下存在对应的 hashValue,也可以保存。 如果不相等,且 ignoreConflicts 下不存在对应的 hashValue,则不保存,在返回的 conflicts 包含对应的 hashValue。
20、现有的请求与响应。如图14
21、暂存 API 中的实现
/** * 获取更新时的哈希的缓存键 * @param string $sessionId * @param string $resourceId * @return string */ private static function getUpdateHashKey(string $sessionId, string $resourceId): string { return ThemePreviewInterface::THEME_EDITOR_SESSION_PREFIX . $sessionId . ':' . $resourceId . ':' . ThemePreviewInterface::UPDATE_HASH_KEY; } /** * 保存更新时的哈希 * @param string $sessionId * @param string $resourceId * @param string $hashValue * @return bool */ public static function saveUpdateHash(string $sessionId, string $resourceId, string $hashValue): bool { return self::getCacheTags($sessionId)->put(self::getUpdateHashKey($sessionId, $resourceId), $hashValue, ThemePreviewInterface::TTL); } /** * 获取更新时的哈希 * @param string $sessionId * @param string $resourceId * @return mixed */ public static function getUpdateHash(string $sessionId, string $resourceId) { return self::getCacheTags($sessionId)->get(self::getUpdateHashKey($sessionId, $resourceId)); } /** * 判断哈希值是否与缓存中的相等 * @param string $sessionId * @param string $resourceId * @param string $hashValue * @return bool */ public static function isHashEq(string $sessionId, string $resourceId, string $hashValue): bool { return self::getUpdateHash($sessionId, $resourceId) == $hashValue; } $variables = json_encode($args); ThemePreview::saveUpdateHash($sessionId, $templateBasename, sha1($variables));
22、保存 API 中的实现
public function __invoke($rootValue, array $args, GraphQLContext $context, ResolveInfo $resolveInfo) { $conflicts = null; $ignoreResourceIds = data_get($args, 'ignoreConflicts.*.resourceId'); $hashValue = $themeId; foreach ($args['resourceHashes'] as $resourceHash) { if ($this->isConflict($sessionId, $resourceHash, $ignoreResourceIds)) { $conflicts[] = [ 'resourceId' => $resourceHash['resourceId'], 'hashValue' => $resourceHash['hashValue'] ]; continue; } if ($resourceHash['resourceId'] == $themeId) { $hashValue = $resourceHash['hashValue']; $this->themeConfigLoader->publishSettingsData(); } else { $this->rawSchemaLoaderAdapter->publishTemplateSettings($resourceHash['resourceId']); } } return [ 'conflicts' => $conflicts, 'session' => ['sessionId' => $sessionId], 'updatedHash' => [ 'resourceId' => $themeId, 'hashValue' => $hashValue ], 'publishedAt' => $publishedAt ]; } /** * 判断哈希值是否冲突 * @param string $sessionId * @param array $resourceHash * @param ?array $ignoreResourceIds * @return bool */ private function isConflict(string $sessionId, array $resourceHash, ?array $ignoreResourceIds): bool { if (!ThemePreview::isHashEq($sessionId, $resourceHash['resourceId'], $resourceHash['hashValue'])) { if (!is_array($ignoreResourceIds) || !in_array($resourceHash['resourceId'], $ignoreResourceIds)) { return true; } } return false; }
23、第1次请求,当 hashValue 命中时,响应的冲突列表为 null。此时前端不再做处理。如图15
24、第1次请求,当 hashValue 未命中时,响应的冲突列表不为 null。如图16
25、当第1次请求,响应的冲突列表不为 null 时,此时前端需要第2次请求,请求参数不变化。如图17
26、第2次请求,当 hashValue 命中时,响应的冲突列表为 null。此时前端不再做处理。
27、当第2次请求,响应的冲突列表不为 null 时,此时前端需要第3次请求,请求参数 ignoreConflicts 的值与 resourceHashes 保持一致。如图18
28、参考:https://www.shuijingwanwq.com/2022/12/08/7260/ 。在 Laravel 6、Lighthouse 5 中,方法 __invoke 的参数 $args,其内部顺序与前端请求参数不一致。暂存接口最终实现如下
ThemePreview::saveUpdateHash($sessionId, $templateBasename, sha1($context->request()->getContent()));
近期评论