山海万灵 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 文档。
