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

1、现在主题编辑器的实现原理为:当编辑器中的某配置项发生变化时,会请求后端的暂存 API,将数据缓存至 Redis 中。当用户点击保存按钮后,会请求后端的保存 API,将会从 Redis 中读取数据存储至 MySQL 中。

2、当用户在快速编辑一些配置项时,可能会出现主题编辑器的页面数据与实际页面不一致的情况。如图1

图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

图2

5、在网络请求中查看请求后端的保存 API的响应时间点:Tue, 22 Nov 2022 02:38:49 GMT。如图3

图3

6、在网络请求中查看最后一次请求后端的暂存 API的时长:686.69 毫秒。如图4

图4

7、在网络请求中查看请求后端的保存 API的时长:380.19 毫秒。如图5

图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

图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

图9

14、在网络请求中查看最后一次请求后端的暂存 API 的时长:2.79 秒,原因在于本地环境性能不佳。如图10

图10

15、在网络请求中查看请求后端的保存 API 的时长:3.37 秒,其长于暂存 API的时长。如图11

图11

16、不过此方案仍然存在一定的概率丢失数据了。如果暂存接口到达服务器的时间晚于保存接口完成的时间的话。最终决定参考 Shopify 的实现。

17、参考 Shopify 的主题编辑器。查看其请求后端的保存 API,请求与响应变量如下。如图12

图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

图13

19、最终设计如下方案,在暂存接口中,基于请求体内容计算出 hashValue,并保存至 Redis 的 key 中。然后在保存接口中,基于请求参数中的 hashValue 与保存至 Redis 的 hashValue 进行比对,如果相等,则保存。如果不相等,但是 ignoreConflicts 下存在对应的 hashValue,也可以保存。 如果不相等,且 ignoreConflicts 下不存在对应的 hashValue,则不保存,在返回的 conflicts 包含对应的 hashValue。

20、现有的请求与响应。如图14

图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

图15

24、第1次请求,当 hashValue 未命中时,响应的冲突列表不为 null。如图16

图16

25、当第1次请求,响应的冲突列表不为 null 时,此时前端需要第2次请求,请求参数不变化。如图17

图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()));
永夜