三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

山海万灵 HarmonyOS 文化知识实战(19):MySQL、Redis、OpenSearch 与 MinIO 的内容发布同步

山海万灵 HarmonyOS 文化知识实战(19):MySQL、Redis、OpenSearch 与 MinIO 的内容发布同步

文化知识应用里的“发布”不是把一条记录改成已发布那么简单。山海万灵把内容事实、读缓存、检索投影和媒体对象拆到不同基础设施后,真正需要守住的是:一次发布发生后,读者打开图鉴、搜索神兽、读取插画时不能看见彼此矛盾的版本。

本文拆解项目中的内容发布同步链路:MySQL 保存主数据与发布版本;Redis 只缓存可重建的读模型;OpenSearch 保存搜索投影;MinIO 提供受控媒体 URL。它们各有职责,也各有失败时的回退方式。

图中记录的是一次隔离环境中的真实回读:内容目录、Redis 缓存失效、OpenSearch 索引、MinIO 资源和发布事件返回结果均按同一条链路核对。

先确定谁拥有事实

内容服务不让缓存和索引反向成为事实来源。神兽条目、来源标注、发布状态和版本快照都以 MySQL 为准;发布事件只在主数据事务成立后驱动后续读模型。

层级保存内容可以重建吗发布后的职责
MySQL神兽主档、来源标注、版本快照、发布状态记录唯一的新版本与可发布条件
Redis图鉴列表、单条详情、首页投影删除受影响缓存,让下一次读取回到主库重建
OpenSearch可搜索的神兽投影用当前已发布条目刷新索引
MinIO封面、插画等媒体对象对象可重新上传生成受控的媒体访问地址

这条边界解决了两个常见问题:缓存过期不会丢失内容,搜索索引短暂滞后也不会改写主数据。ArkTS 页面仍然通过 Repository 请求稳定的内容接口,而不是分别访问数据库、缓存和对象存储。

发布事件先过权限和版本校验

内容服务把生命周期事件放在内部接口中,并要求调用方携带内部令牌。无令牌请求会得到CONTENT.EVENT_UNAUTHORIZED,不会写入发布状态;这让普通客户端无法伪造发布、下线或回滚动作。

事件中至少要有事件编号、类型、实体类型、实体编号、版本与发生时间。版本比较在事务内完成,较旧事件不会覆盖更新版本。

{ "eventId": "content-publish-20260808-001", "eventType": "content.published", "entityType": "BEAST", "entityId": "beast_baize", "contentVersion": 1, "publishedAt": "2026-08-08T05:45:00Z" }
if (!repository.record(event)) { return new EventConsumptionResult(true, false, false); } if (!publicationStateRepository.applyBeastVisibility(event, visibility)) { return new EventConsumptionResult(false, false, false); }

record(event)让同一个事件编号具备幂等性;applyBeastVisibility再比较内容版本和发生时间。即使投递方因为网络超时重复发送,已经处理过的事件也只返回重复结果,不再触发第二次状态写入。

来源条件拦住“未审校即发布”

内容条目不是只要存在就能公开。发布时会检查关联来源是否具有完整的校注版本、页码、审核人和审核时间,并且来源核验状态必须达到ANNOTATED_VERIFIED。条件缺失时,事件会被拒绝,缓存与搜索不会被刷新。

if (!publicationStateRepository.publishVerifiedBeastSource(event.entityId())) { throw new EventConsumptionException( "CONTENT.SOURCE_NOT_PUBLISHABLE", "published beast requires verified source evidence"); }

这一步把文化内容的真实性约束放在服务端事务里。页面上的“发布”按钮、后台任务和重放机制都走同一条判断,避免某个入口绕开来源标注。

发布服务把状态写入、来源条件、版本快照和读模型动作集中在一个事务入口。这里最重要的不是代码长度,而是每个返回分支都对应一类可观察结果:重复事件不重复写入;过期事件不移动可见状态;来源不合格时不刷新任何读模型。

@Transactional public EventConsumptionResult consume(ContentLifecycleEvent event) { validate(event); if (!publicationStateRepository.beastExists(event.entityId())) { throw new EventConsumptionException( "CONTENT.EVENT_ENTITY_NOT_FOUND", "published beast does not exist"); } if (!repository.record(event)) { return new EventConsumptionResult(true, false, false); } ContentVisibility visibility = "content.offline".equals(event.eventType()) ? ContentVisibility.OFFLINE : ContentVisibility.PUBLISHED; if (!publicationStateRepository.applyBeastVisibility(event, visibility)) { return new EventConsumptionResult(false, false, false); } if (visibility == ContentVisibility.PUBLISHED && !publicationStateRepository.publishVerifiedBeastSource(event.entityId())) { throw new EventConsumptionException( "CONTENT.SOURCE_NOT_PUBLISHABLE", "verified source is required"); } boolean cacheInvalidated = cache.invalidatePublishedBeast(event.entityId()); boolean searchIndexed = searchService.refreshPublishedBeasts(); return new EventConsumptionResult(false, cacheInvalidated, searchIndexed); }

Redis 只做加速,不保存发布结论

图鉴列表、详情和首页投影都有独立缓存键。发布事件成功后,服务只删除与该条目相关的缓存键:列表、首页和单条详情会在下一次读取时从 MySQL 重建。

List<String> keys = new ArrayList<>(List.of( BEASTS_KEY, HOME_PROJECTION_KEY, beastKey(beastId) )); redisTemplate.delete(keys);

如果 Redis 临时不可用,缓存删除返回失败,但发布主事务不回滚。后续读取仍可直接访问 MySQL;缓存恢复后再按 TTL 重建。这种降级把“性能层故障”和“内容事实故障”分开处理。

OpenSearch 刷新的是已发布投影

搜索服务不会把草稿直接送进索引。content.published成功后刷新已发布神兽;content.offline则移除对应条目。隔离环境回读中,OpenSearch 集群为 green,索引能够返回beast_baize,与发布事件的searchIndexed=true结果一致。

boolean searchIndexed = visibility == ContentVisibility.OFFLINE ? searchService.removeBeastFromIndex(event.entityId()) : searchService.refreshPublishedBeasts();

索引失败时不把旧索引伪装成新结果。内容接口仍从 MySQL 提供已发布条目;运维侧可以根据 Outbox 记录重试投递,待索引恢复后重新建立投影。

MinIO 把媒体访问与内容主档解耦

媒体表只保存对象桶、对象键、MIME 类型、尺寸、版权类型与发布状态,不把二进制内容塞进内容主表。内容接口依据这些元数据返回可访问 URL;隔离环境中,资源900001的 URL 已成功回读。

场景返回策略
对象存在且资源已发布返回受控媒体 URL 与资源元数据
对象暂时不可读返回明确的资源降级结果,不制造空 URL
条目下线不继续作为已发布资源出现在内容接口中
缓存或索引异常媒体元数据仍由 MySQL 的发布状态约束

这样,插画替换、缩略图补齐或对象迁移不会要求修改神兽正文;媒体系统也不能脱离发布状态独自暴露草稿资源。

public record ContentAssetFile( String assetId, String bucketName, String objectKey, String mimeType, String status ) {}

不把同步逻辑散落到页面

如果由 ArkTS 页面在“保存成功”后分别调用清缓存、刷新搜索和拼接媒体地址,任何一个请求超时都会留下半完成状态:详情已经更新,搜索仍是旧名称;正文已经可读,插画链接却失效。把这些动作留在服务端事件处理器中,前端只关心稳定的内容响应和加载、空态、错误态即可。

这也让移动端的状态恢复更简单。应用重新进入前台时,Repository 再次读取图鉴或搜索接口,得到的是服务端已收束后的结果,而不是根据本地按钮点击历史推测某一项同步是否完成。ArkTS 的类型和状态模型适合承接这种明确的接口边界,页面不需要了解 Redis 键名、索引名称或对象桶路径。ArkTS 开发入门 也强调以清晰类型和模块边界组织应用逻辑。

对于后台任务,Outbox 记录提供了另一层保护:主事务成功后才有待投递事件;投递失败可以按事件编号重放;接收方的幂等记录又阻止重复生效。这样即使 CMS 与内容服务短暂断连,也不会靠“再点一次发布”解决问题。

一次可回读的发布动作

本次运行按以下顺序完成:启动 MySQL、Redis、OpenSearch 与 MinIO;读取内容目录以建立缓存;发送无令牌事件确认权限拒绝;补齐可发布的来源标注后发送content.published;回读事件结果、OpenSearch 文档和 MinIO 资源 URL。

回读项结果
内容目录成功返回 18 条内容
无令牌事件返回CONTENT.EVENT_UNAUTHORIZED
已授权发布事件duplicate=false,缓存失效与索引刷新均成功
OpenSearch回读到beast_baize文档
MinIO健康接口可用,资源 URL 可返回

这组回读覆盖了发布同步最容易断裂的边界:主数据、权限、缓存、搜索和媒体。后续新增 CMS 发布入口时,仍然应通过 Outbox 投递同一种生命周期事件,而不是在页面层分别清缓存和改索引。

public record EventConsumptionResult( boolean duplicate, boolean cacheInvalidated, boolean searchIndexed ) {}

小结

山海万灵把“发布”落实为一条可验证的数据链:MySQL 先确认内容和来源,事件记录保证幂等,Redis 失效避免旧读模型,OpenSearch 重建搜索投影,MinIO 按发布状态提供媒体地址。读者看到的是一致的图鉴、搜索与插画结果,服务端保留的是可回放、可排错的同步边界。

参考资料:Spring Data Redis 文档、OpenSearch 文档、MinIO Java SDK 文档。

← 返回列表