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

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

当用户在快速编辑一些配置项时,可能会出现主题编辑器的页面数据与实际页面不一致的情况
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
在网络请求中查看最后一次请求后端的暂存 API的响应时间点:Tue, 22 Nov 2022 02:38:49 GMT
图2
5、在网络请求中查看请求后端的保存 API的响应时间点:Tue, 22 Nov 2022 02:38:49 GMT。如图3
在网络请求中查看请求后端的保存 API的响应时间点:Tue, 22 Nov 2022 02:38:49 GMT
图3
6、在网络请求中查看最后一次请求后端的暂存 API的时长:686.69 毫秒。如图4
在网络请求中查看最后一次请求后端的暂存 API的时长:686.69 毫秒
图4
7、在网络请求中查看请求后端的保存 API的时长:380.19 毫秒。如图5
在网络请求中查看请求后端的保存 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 次
<pre class="wp-block-syntaxhighlighter-code">

    /**
     * 获取 更新计数 中储存的数字值
     * @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++;
	}

</pre>
12、输出的文件如下。1669187163.3346 – 1669187162.8234 = 0.5112。1669187162.8234 – 1669187162.3034 = 0.52。创建时间相差大致为 0.5 秒,符合预期。如图8
输出的文件如下。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
在本地环境再次复现,已经无法复现,不过确定生成了文件:Publish-1669188133.9627-720653875.txt,其值为 1。表明在 保存 API 中执行了一次等待
图9
14、在网络请求中查看最后一次请求后端的暂存 API 的时长:2.79 秒,原因在于本地环境性能不佳。如图10
在网络请求中查看最后一次请求后端的暂存 API 的时长:2.79 秒,原因在于本地环境性能不佳
图10
15、在网络请求中查看请求后端的保存 API 的时长:3.37 秒,其长于暂存 API的时长。如图11
在网络请求中查看请求后端的保存 API 的时长:3.37 秒,其长于暂存 API的时长
图11
16、不过此方案仍然存在一定的概率丢失数据了。如果暂存接口到达服务器的时间晚于保存接口完成的时间的话。最终决定参考 Shopify 的实现。 17、参考 Shopify 的主题编辑器。查看其请求后端的保存 API,请求与响应变量如下。如图12
参考 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
初步推测,应该与 hashValue 有关系,只有当缓存中的数据的 hashValue 与请求参数中的 hashValue 相等时,才执行保存操作。其保存 API 的时长接近 1.5 秒左右
图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
第1次请求,当 hashValue 命中时,响应的冲突列表为 null。此时前端不再做处理
图15
24、第1次请求,当 hashValue 未命中时,响应的冲突列表不为 null。如图16
第1次请求,当 hashValue 未命中时,响应的冲突列表不为 null
图16
25、当第1次请求,响应的冲突列表不为 null 时,此时前端需要第2次请求,请求参数不变化。如图17
当第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()));


PHP / Laravel / Yii2 老项目维护与长期技术支持

如果你的 PHP / Laravel / Yii2 项目已经上线,但遇到原开发离职、Bug 长期无人修复、接口不稳定、性能下降、代码难以接手等问题,可以联系我做一次远程技术排查。

适合以下情况:
✅ 老旧 PHP 系统无人维护
✅ Laravel / Yii2 项目 Bug 修复
✅ 后台管理系统小功能迭代
✅ RESTful API 接口排查
✅ MySQL / Redis / Nginx 性能问题
✅ 长期远程兼职维护

可先从一次小问题开始:
✅ 线上报错排查
✅ 接口异常分析
✅ 慢查询与性能瓶颈定位
✅ 代码结构初步评估
✅ 部署环境与日志检查

如需咨询,请联系我,并注明:PHP 维护咨询

联系方式:
Telegram:@shuijingwan
微信:13980074657
邮箱:shuijingwanwq@gmail.com

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理