前三篇已经串起客户端、网关与内部 RPC。接下来需要回答另一组问题:注册后的用户保存在哪里?"用户已在其他地方登录"怎样判断?搜索昵称为什么需要 Elasticsearch?三种存储的数据会自动保持一致吗?
本文以 `master@4eff64b` 为基线,从用户资料和登录流程出发,解释 MySQL、Redis 与 Elasticsearch 的实际分工。重点不是比较谁"更快",而是明确每类数据回答什么问题,以及一次操作跨越多个存储后可能发生什么。
1. 三种存储不是同一份数据的三套备份
先看项目中的职责划分:

MySQL 保存用户、好友关系、好友申请、聊天会话、成员与消息记录,是这些长期业务数据的主要持久化来源。
Redis 保存登录凭证、在线占用标记和短期验证码状态。这些数据不是从 MySQL 用户表复制出来的:创建用户并不等于创建登录 Session,用户注册后也不自动拥有有效验证码。
Elasticsearch 保存面向搜索的用户文档与文字消息文档。搜索文档只包含需要的字段,不是把 MySQL 的每张表原样搬过去,更不应该包含密码摘要。
附件字节又是另一回事:图片、文件、语音和头像交给文件服务保存;数据库中的 `file_id`、文件名和大小用于描述、关联它们,而不是文件本身。
因此,不能把整体架构画成"MySQL 自动同步 Redis,再自动同步 ES"。当前跨存储操作由业务代码显式组织,各条链路还有不同的写入顺序。
2. MySQL 中的数据怎样关联起来
项目使用 ODB 将 C++ 实体映射到关系表。先不看所有字段,只抓住业务 ID:
| 实体/表 | 核心标识与关系 | 解决的问题 |
|---|---|---|
| `User` / `user` | `user_id`、昵称、密码表示、手机号、签名、头像 ID | 用户是谁、怎样验证登录、显示哪些资料 |
| `Relation` / `relation` | `user_id → peer_id` | 某个用户有哪些好友 |
| `FriendApplication` / `friend_apply` | `event_id`、申请人、被申请人 | 尚待处理的好友申请是什么 |
| `ChatSession` / `chat_session` | 会话 ID、名称、单聊/群聊类型 | 这段聊天是什么会话 |
| `ChatSessionMember` / `chat_session_member` | 聊天会话 ID、用户 ID | 谁属于这段会话 |
| `Message` / `message` | 消息 ID、聊天会话 ID、发送者、类型、时间、正文或附件引用 | 哪条消息属于哪段聊天 |
这是业务关联说明,不表示当前所有关联都通过数据库外键约束实现。
好友关系使用有向记录。A 与 B 成为好友时,关系对由 `A → B` 和 `B → A` 两条记录表达,而不是查询时默认一条记录代表双方。
聊天会话和登录会话也不要混淆:前者把成员与消息组织起来,后者是客户端证明身份的凭证。它们都可能简称 Session,但不是同一种实体,也不保存在同一套状态中。
3. 数据库工厂、实体和仓储分别做什么
从业务代码到 MySQL,大致有三层职责:
-
实体描述数据结构与映射,例如 `User` 的持久化字段、唯一索引和版本号。
-
`UserTable` 等表操作类执行 ODB 查询和事务,把异常转换为命中、缺失、冲突或存储故障等结果。
-
`OdbUserRepository` 将表层结果转换为用户服务使用的业务记录,避免 RPC 实现直接依赖所有持久化细节。
`DatabaseFactory::Create()` 则负责创建带连接池配置的 ODB/MySQL 数据库对象,再注入这些操作类。它不是"制造一个 MySQL 服务器",也不是调用后自动建好所有表。
类似地,`RedisClientFactory::Create()` 创建 Redis 客户端,`ESClientFactory::Create()` 创建 Elasticsearch 客户端。工厂集中组织连接参数与对象构造,不负责让三种存储自动同步;对象构造成功也不能替代实际请求的连通性验证。
4. 为什么"先查昵称是否存在"还不够
注册时预查昵称,可以尽早返回友好错误,但无法独自解决并发:两个请求可能都在插入前查到"不存在"。
当前 `User` 实体为业务用户 ID、昵称和手机号设置唯一索引。注册仓储先尝试插入,失败后再查询相应字段,以区分已存在与其他存储故障。
所以预查用于提前判断,数据库唯一约束才是这类并发插入的最后一道约束。不能用"我已经查过一次"替代唯一索引。
资料修改还要面对另一种并发:A 修改昵称,B 同时修改签名。两者如果都拿旧对象覆盖整行,可能互相覆盖字段。项目在 `User` 上启用 ODB 乐观并发控制,并设置版本字段:
```cpp
#pragma db object table("user") optimistic
// 以下为类内版本字段节选,不是完整实体。
#pragma db version
unsigned long _version = 0;
```
`TryUpdateNickname()`、`TryUpdateDescription()` 等操作在事务中读取实体,只修改目标字段,再更新并提交;遇到 `object_changed` 时重新读取并有界重试,最多尝试 3 次。
它保护的是相应 MySQL 更新路径,不能自动把 ES 索引写入也包含进来。表操作中的局部事务,也不意味着一次包含多个仓储调用的业务流程整体具有原子性。
密码方面,用户名注册写入的是带算法、迭代参数与随机盐的 PBKDF2-SHA256 表示,登录时读取并验证,不是明文密码。这里描述现有实现,不将密码摘要当作可以发送到客户端或写入搜索索引的普通资料。
5. Redis 的在线标记与登录 Session,到底有什么不同
这两类数据都涉及"登录",但查询方向不同:
| 数据 | 当前 Key 与 Value 的形式 | 主要用途 | 当前 TTL |
|---|---|---|---|
| 在线占用标记 | Key 为 `user_id`,Value 为空字符串 | 原子占用账号,阻止重复登录 | 无过期 |
| 登录 Session | Key 为 `session_id`,Value 为 `user_id` | 网关从凭证解析可信用户身份 | 无过期 |
| 验证码凭证 | `code:credential:<code_id>` 等 Key,包含状态、手机号、摘要、尝试次数 | 短期校验、冷却和一次性消费 | 有过期 |
前两行按当前源码的真实键形态说明,并没有统一加 `online:` 或 `session:` 前缀。实际例子不需要包含真实凭证;Session 本身应当作为敏感登录凭证处理。
在线标记回答的是"这个账号是否已被占用";Session 回答的是"这个请求属于谁"。网关拿到一个 Session 后查询用户 ID,不需要遍历所有在线用户。
但存在在线标记,不保证此刻一定有可用 WebSocket。它是登录占用状态,不是实时探测 socket 的结果;当前用户与具体连接的映射仍在网关进程内存中。
6. 一次用户名登录,怎样跨过 MySQL 和 Redis
先沿成功路径走一遍:

用户服务先从 MySQL 按昵称读取认证记录并校验密码;通过后生成 Session ID,调用 `RedisUserLoginState::CreateLogin()`。
这个适配器先调用 `TryMarkOnline()`。其关键代码是:
```cpp
const bool claimed = _client->set(
user_id,
"",
std::chrono::milliseconds{0},
::sw::redis::UpdateType::NOT_EXIST);
```
这里通过 Redis `SET NX` 语义,仅在 Key 不存在时写入。不同登录请求竞争同一个用户 Key,由这一步决定谁能抢占成功,而不是用"先 EXISTS,再 SET"两个命令拼接。`NX` 与过期选项的含义可参见 Redis SET 官方文档(https://redis.io/docs/latest/commands/set/)。
抢占成功后,再写入 `session_id → user_id`。两步都成功才向客户端返回登录凭证;已被占用则返回"用户已在其他地方登录",存储故障则返回服务暂不可用。
如果 Session 保存报告失败,实现会尝试删除刚才的在线标记作为补偿。但"补偿"不是数据库事务回滚:删除也可能失败,网络错误还可能让某次写入的实际结果不确定。当前代码没有将在线标记和 Session 写入组织为一个原子操作。
由于两类登录状态都没有 TTL,异常退出、连接清理失败或中间步骤崩溃,可能留下占用状态。正常关闭当前所有者连接时,网关尝试清理 Session 与在线标记;这不能替代完整的租期、条件清理与故障恢复设计。
7. 验证码已经不只是一个"验证码 ID → 数字"的缓存
基础存储类保留了简单保存、查询接口,但当前手机号业务走的是 `BeginIssue()`、`Activate()` 与 `Consume()` 状态流程。
签发时先建立 `pending` 凭证,记录手机号、摘要与尝试次数,同时更新该手机号的当前签发指针、设置冷却,并使旧凭证失效。发送器返回成功后,再把当前凭证激活为 `active`;发送明确失败时尝试条件清理 pending,仍保留冷却。
校验时,Lua 脚本检查当前签发、状态、手机号和候选摘要:匹配则删除凭证与当前指针,实现一次性消费;摘要不匹配则累计错误次数,达到上限后删除。默认凭证有效期 5 分钟、发送冷却 60 秒、错误次数上限 5 次。
这种检查与修改放在同一脚本中,避免并发请求同时消费同一凭证。这里的原子性范围在 Redis 脚本执行期间,不包含外部短信发送或后续 MySQL 操作。Redis Lua 文档(https://redis.io/docs/latest/develop/programmability/eval-intro/)说明了脚本的原子执行语义。
手机号注册、登录等操作先消费凭证,再继续后续业务。因此后续数据库失败、账号已在线等情况,不会自动恢复已消费的验证码,需要重新获取。
当前发送器仍是可替换的模拟短信实现,可显式配置开发态固定码;不能将这条业务流程写成已经接入真实运营商短信。Redis 保存摘要,也不意味着短数字验证码在存储泄漏后具有高强度的离线抗猜测能力。
8. ES 里存了什么,搜索又怎样发生
用户索引文档包含 `user_id`、手机号、昵称、签名和头像 ID。这里的"用户元信息"就是用于描述用户、辅助搜索的资料字段,不是特殊的二进制格式。
字段的搜索方式不同:用户 ID 和手机号是 `keyword`,用 `term` 精确匹配;昵称是使用 `ik_max_word` 的 `text`,用 `match` 进行分析后的匹配。签名和头像 ID 也保存在文档中,但当前 mapping 对它们关闭了索引,不能说所有字段都参与关键词检索。
好友搜索把手机号精确匹配、用户 ID 精确匹配和昵称分词匹配放进 `bool.should`,要求至少命中一个条件,并排除自己与已有好友。
这意味着输入一个 ID 片段,不等于能够按任意子串找到用户;昵称分词匹配也不等于任意位置的 SQL `%片段%` 查询。`match` 会分析输入,`term` 不分析输入,二者用途不同,见 ES match 文档(https://www.elastic.co/guide/en/elasticsearch/reference/7.17/query-dsl-match-query.html)与 term 文档(https://www.elastic.co/guide/en/elasticsearch/reference/7.17/query-dsl-term-query.html)。
好友服务拿到 ES 候选用户 ID 后,还会调用用户服务补全资料,而不是直接把搜索文档当成最终用户资料返回。补全能提供当前资料,但不能修复候选集合本身已经过时的问题:新昵称未入索引仍可能搜不到,旧昵称命中仍可能带出已改名的用户。
文字消息索引则包含发送者 ID、消息 ID、时间、聊天会话 ID 和正文。查询将会话 ID 的 `term` 条件与正文的 `match` 条件组合,限定在指定会话内搜索;会话过滤本身并不等于已经完成成员权限校验。
倒排索引可以理解为"词项 → 包含它的文档列表"。例如两条示意消息分别含有"服务发现"和"消息通知",分词后的词项帮助缩小候选文档范围,而不是扫描每条完整正文。但一次搜索还涉及分词、词项查找、候选集合、评分与返回,不能把 ES 搜索整体简化成"哈希查找 O(1)"。
9. 修改资料时,MySQL 与 ES 怎样保持关联
以修改昵称为例,用户服务先通过仓储修改 MySQL,拿到更新后的完整资料,再调用 `SyncProfileToIndex()` 尝试覆盖 ES 用户文档。

这个辅助函数把完整资料交给 `AppendData()`;ES 使用用户 ID 作为文档 ID,所以同一用户再次写入是覆盖相应文档,而不是每次创建一个新用户文档。
MySQL 更新失败时,不继续走这条索引同步;MySQL 成功而 ES 写入失败或抛出异常时,记录日志,业务响应仍成功。注册和头像、签名、手机号资料更新,也采用 MySQL 成功后尽力同步用户索引的语义。
因此当前是**业务层显式双写,且以 MySQL 结果决定这类操作的业务成功**。它不是分布式事务,不是 binlog 自动同步,也没有把 Redis 当成用户资料的第三份副本一起更新。
还有两个容易忽略的边界。
第一,日志里的"待后续补偿"不是已经实现了后台补偿任务。当前没有靠持久重试事件保证每次失败最终追平,所以更准确的表述是"尽力同步,存在不一致窗口",而不是"已保证最终一致性"。
第二,并发更新可能让旧资料最后写入 ES。假设请求 A 取得较早的资料快照后暂停,请求 B 提交更新并先写入新文档,随后 A 恢复并覆盖,就可能发生索引回退。MySQL 的乐观版本保护不会自动延伸到未携带版本条件的 ES 写入;即使索引写入在进程内串行,也不保证它按 MySQL 提交顺序执行。
此外,即使 ES 写入成功,也不保证立刻可被搜索看到。ES 的搜索可见性受 refresh 影响,写入结果与搜索可见性是两个层次,见 ES 7.17 近实时搜索说明(https://www.elastic.co/guide/en/elasticsearch/reference/7.17/near-real-time.html)。不能把所有短暂搜不到都归因于同步失败,也不能把长期缺失都解释成刷新延迟。
10. 不要把用户资料的写入顺序套到消息存储
当前用户资料是"MySQL → ES",但文字消息持久化处理器的顺序是"先写 ES,再写 MySQL";附件则是"先上传文件服务,再保存 MySQL 消息记录"。
所以文字消息可能出现 ES 已有文档、MySQL 消息记录却写入失败的情况。消息关键词搜索当前直接把 ES 命中转换为文字消息记录,再补全发送者资料;它没有先按命中 ID 逐条回查 MySQL 消息表确认存在。
这也解释了为什么"历史查询"和"关键词搜索"可能在故障时看到不一致结果。下一篇会结合 RabbitMQ、消费确认和重试详细展开,这里只强调:讨论数据同步时必须沿具体调用链核对,不能用一个统一箭头掩盖不同顺序。
如果后续需要增强一致性,可以考虑在 MySQL 事务中记录待同步事件,由后台可靠消费,再为索引更新增加版本条件、重试与对账;也可以评估基于变更日志的同步方案。但这些是改进方向,需要定义失败、重复、乱序与恢复行为,不能写成当前已有能力。
总结起来:MySQL 保存长期业务事实,Redis 管理独立的登录与短期状态,ES 提供搜索文档与查询能力。它们的协作由业务代码实现;每条链路的成功标准、写入顺序和故障后果,都需要分别说明。
下一篇讨论 RabbitMQ 与消息存储,进一步解释发送成功、队列接受、消费处理、落库与离线读取之间的关系。
上一篇:网关设计\](03-gateway-http-websocket.md) · \[返回系列目录