主题编辑器的页面保存后与实际页面不一致的排查分析(源于并发请求时,后端处理完成顺序与前端请求顺序未完全一致所导致)
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()));















近期评论