MySQL 租户数据隔离在即时通讯源码后端中的落地方式

即时通讯源码进入实际工程后,后端不只要处理聊天消息,还要处理不同业务空间之间的数据边界、配置差异、端侧访问规则和部署环境隔离。

同一套 IM 后端,可能同时服务多个 App、多个业务线、多个客户环境,甚至需要在不同服务器中独立部署。此时,好友关系、单聊、群聊、文件、收藏、朋友圈、红包、钱包、语音视频、多人会议、i18n 多语言等能力,都不能只按"全局数据"处理,而要明确属于哪个租户、哪个应用、哪个部署环境。

如果没有租户隔离,后期很容易出现配置混用、数据串读、文件路径混乱、消息路由不清晰等工程问题。技术栈SpringBoot + MySQL + Redis + WebSocket + uniapp + Vue2 ,从多租户上下文、数据隔离、配置隔离、文件隔离、实时连接隔离和独立部署几个角度,整理即时通讯源码在后端工程中的实现思路。

一、先确定租户边界,再写业务逻辑

即时通讯系统里的租户,不一定只表示一个公司,也可以表示一个独立业务空间。

例如:

一个租户对应一个独立 App。

一个租户对应一套独立部署环境。

一个租户对应一个业务线。

一个租户对应一个私有化项目。

在这种模型下,用户 ID、群 ID、会话 ID、文件 ID、钱包账户、朋友圈动态,都不能只按全局 ID 理解。后端需要知道当前请求属于哪个租户。

比较常见的做法是在请求进入系统时解析 tenantId,并放入线程上下文。后续 Service、Mapper、Redis Key、文件路径都从上下文中读取租户信息。

javascript 复制代码
public class TenantContext {

    private static final ThreadLocal<String> TENANT_HOLDER = new ThreadLocal<>();

    public static void setTenantId(String tenantId) {
        TENANT_HOLDER.set(tenantId);
    }

    public static String getTenantId() {
        String tenantId = TENANT_HOLDER.get();
        if (tenantId == null || tenantId.isBlank()) {
            throw new IllegalStateException("TenantId is missing");
        }
        return tenantId;
    }

    public static void clear() {
        TENANT_HOLDER.remove();
    }
}

@Component
public class TenantFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        try {
            String tenantId = request.getHeader("X-Tenant-Id");
            TenantContext.setTenantId(tenantId);
            filterChain.doFilter(request, response);
        } finally {
            TenantContext.clear();
        }
    }
}

WebSocket 接入层也需要同样的租户识别。HTTP 请求可以从 Header、域名、Token 中解析租户;WebSocket 建连时则可以在握手阶段解析,并绑定到连接上下文。

这样后续处理单聊、群聊、文件发送、红包消息、朋友圈动态时,都能明确当前数据属于哪个租户。

二、会话数据要带租户维度

即时通讯系统中增长最快的数据通常是消息、会话、会话成员、群成员和文件记录。如果这些表没有租户字段,后续很难做数据隔离。

会话表、消息表、群成员表、好友关系表、钱包流水表、朋友圈动态表,都建议保留 tenant_id 字段。这样可以在逻辑层面保证每次查询都只查当前租户数据。

消息表可以这样设计:

javascript 复制代码
CREATE TABLE im_message (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    tenant_id VARCHAR(64) NOT NULL,
    server_msg_id VARCHAR(64) NOT NULL,
    conversation_id VARCHAR(64) NOT NULL,
    sender_id BIGINT NOT NULL,
    msg_seq BIGINT NOT NULL,
    msg_type VARCHAR(32) NOT NULL,
    content JSON NOT NULL,
    status TINYINT DEFAULT 0,
    send_time BIGINT NOT NULL,
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_tenant_msg (tenant_id, server_msg_id),
    UNIQUE KEY uk_tenant_conv_seq (tenant_id, conversation_id, msg_seq),
    KEY idx_tenant_sender_time (tenant_id, sender_id, send_time),
    KEY idx_tenant_conv_time (tenant_id, conversation_id, send_time)
);

这里的索引都把 tenant_id 放在前面,原因很简单:大多数查询都应该先限定租户,再查询会话、消息、发送人或时间范围。

例如聊天窗口加载历史消息时,查询条件不应该只是 conversation_id,而应该是 tenant_id + conversation_id

用户查看朋友圈时,也不应该只按用户 ID 查询,而要先限制当前租户。

钱包流水同样如此。不同租户里的用户 ID 可能存在重复,如果流水表没有租户维度,后续对账和查询都会变得混乱。

三、Redis Key 也需要租户前缀

很多即时通讯系统在 Redis 中保存在线状态、未读数、群成员缓存、临时 token、文件上传状态、语音视频信令状态等数据。

如果 Redis Key 没有租户前缀,不同租户之间就可能出现 Key 冲突。

例如,两个租户中都存在用户 10001,如果在线状态都写成:

im:online:10001

那么后写入的状态会覆盖先写入的状态。

更安全的格式是:

im:{tenantId}:online:{userId}

im:{tenantId}:unread:{userId}

im:{tenantId}:group:{groupId}:members

im:{tenantId}:call:{roomId}

这样好友、群聊、消息未读、多人会议房间、扫码 token 都可以按租户隔离。

Redis Key 的租户化不只是避免覆盖,也方便运维排查。某个租户出现连接异常、未读数异常、群成员缓存异常时,可以直接按 Key 前缀定位。

四、配置隔离不能只靠代码判断

即时通讯源码支持不同环境时,很多配置都可能不同。

例如:

是否开启红包和钱包。

文件上传大小限制。

语音视频开关。

多人会议人数上限。

朋友圈可见范围规则。

WebSocket 心跳间隔。

i18n 默认语言。

是否允许扫码入群。

是否允许游客访问 H5。

这些配置如果写死在代码里,后续维护会很困难。更合理的方式是按租户和环境拆分配置。

javascript 复制代码
im:
  tenant: tenant_a
  websocket:
    heartbeatSeconds: 30
    maxIdleSeconds: 90
  file:
    maxUploadSizeMb: 50
    allowTypes:
      - image/png
      - image/jpeg
      - application/pdf
  group:
    maxMembers: 500
    enableQrJoin: true
  meeting:
    enabled: true
    maxMembers: 50
  i18n:
    defaultLocale: zh_CN

这类配置可以放在配置中心,也可以在独立部署环境中通过 YAML、环境变量或数据库配置表维护。

重点是不要让业务代码到处写死判断。

例如红包、钱包、朋友圈、多人会议这些能力是否启用,应该由配置控制。业务层读取配置后决定是否允许当前租户使用对应能力。

这样同一套源码在不同部署环境中可以保持结构一致,只改变配置,不改变核心代码。

五、文件存储要按租户分目录

即时通讯系统中的文件资源包括图片、语音、视频、普通文件、朋友圈图片、聊天附件、群文件、头像、封面图等。

如果所有租户文件都写在同一个目录中,后续迁移、清理、备份、权限控制都会变得复杂。

更合理的文件路径可以按租户拆分:

/im/{tenantId}/avatar/

/im/{tenantId}/message/image/

/im/{tenantId}/message/file/

/im/{tenantId}/circle/image/

/im/{tenantId}/voice/

/im/{tenantId}/meeting/record/

这样做有几个好处。

第一,文件物理路径清晰。某个租户的数据迁移时,可以按目录处理。

第二,权限判断清晰。下载文件时,可以通过 resourceId 找到租户信息,再判断当前用户是否属于该租户。

第三,备份策略更清楚。不同租户可以有不同备份周期和存储位置。

第四,资源清理更安全。清理历史文件时,不会误处理其他租户的数据。

聊天消息中不要直接保存永久下载 URL,更适合保存 fileId、resourceType、size、mimeType 等元数据。下载时由后端根据租户、用户、资源权限生成临时访问地址。

六、WebSocket 连接要绑定租户和设备

多端即时通讯系统中,一个用户可能同时在线 Android、iOS、H5、PC。加入租户维度后,连接模型至少要包含:

tenantId

userId

device

nodeId

channelId

lastActiveTime

这样服务端才能判断某个租户下的某个用户在哪些端在线。

例如同一个 userId 在不同租户中都存在,不能只按 userId 查在线状态。消息投递时要按 tenantId + userId + device 定位连接。

WebSocket 连接建立后,可以把租户信息写入 Channel 属性或连接上下文。后续收到消息时,不需要客户端每次都重复传租户 ID,而是从连接上下文读取。

单聊消息、群聊消息、文件消息、已读回执、撤回事件、语音视频信令、多人会议邀请,都应该从连接上下文获取租户信息,再进入后端处理链路。

这样可以减少客户端伪造租户 ID 的风险,也能保证连接和数据属于同一租户空间。

七、会话 ID 要避免跨租户冲突

很多系统会把单聊会话 ID 设计成:

single_10001_20001

群聊会话 ID 设计成:

group_90001

在单租户环境中,这样写没有问题。但在多租户环境中,不同租户可能存在相同的用户 ID 或群 ID。如果会话 ID 没有租户维度,缓存、消息、未读数、历史记录都可能出现冲突。

可以采用两种方式。

一种方式是在所有查询中都使用 tenantId + conversationId 作为联合条件。

另一种方式是在会话 ID 生成时就带上租户标识,例如:

tenant_a:single:10001:20001

tenant_b:group:90001

第一种方式对数据库更友好,第二种方式对缓存和日志排查更直观。实际工程中可以结合使用:数据库表保留 tenant_id 字段,Redis Key 和日志中使用带租户前缀的会话标识。

八、业务配置要和端侧能力对应

即时通讯源码通常会支持 Android、iOS、H5、PC 四端。不同端的能力可能不完全一致。

例如:

PC 端更适合文件拖拽、群资料管理、聊天记录搜索。

移动端更适合扫一扫、语音视频、图片拍摄、消息推送。

H5 端更适合轻量访问和分享入口。

如果后端配置没有按端控制,就可能出现某些端显示入口,但后端不支持;或者后端支持能力,但某个端无法正确处理。

因此,配置表可以增加端类型维度:

tenantId

platform

featureCode

enabled

configValue

例如某租户允许 PC 端使用多人会议,H5 端只允许接收会议邀请;移动端允许扫一扫入群,PC 端只允许展示群二维码。

这类控制不应该散落在前端代码中,而应该由后端统一返回端侧配置。uniapp、Vue2 PC 端都根据配置渲染对应入口。

这样后端功能和端侧展示能保持一致,也能减少多端版本差异带来的问题。

九、独立部署要拆清楚环境变量

即时通讯系统独立部署时,不同环境需要明确区分。

常见环境包括:

开发环境

测试环境

预发布环境

生产环境

私有部署环境

不同环境中,MySQL、Redis、文件存储、WebSocket 域名、SSL/TLS 证书、日志级别、i18n 默认语言、文件大小限制都可能不同。

如果这些配置散落在代码中,部署时容易出错。更合理的方式是让所有环境差异都通过配置注入。

例如:

数据库连接不写死。

Redis 地址不写死。

文件存储路径不写死。

WebSocket 地址不写死。

证书路径不写死。

租户 ID 不写死。

这样同一套源码可以在不同环境中使用不同配置启动,避免为了部署环境改代码。

十、租户隔离下的日志也要带上下文

日志排查在即时通讯系统中非常重要。一个用户反馈消息异常时,后端需要知道消息属于哪个租户、哪个用户、哪个端、哪个会话、哪个连接节点。

日志中建议至少包含:

tenantId

traceId

userId

device

conversationId

eventType

nodeId

channelId

这样排查单聊、群聊、文件发送、红包状态、会议邀请时,可以按租户和链路追踪。

如果日志里没有 tenantId,多租户环境下会很难定位问题。不同租户可能有相同 userId、groupId、conversationId,只看业务 ID 很容易混淆。

日志链路中加入 traceId 后,可以把 WebSocket 接入、业务处理、Redis 状态、MySQL 落库、文件上传、推送分发串起来。

十一、数据备份和清理要按租户执行

多租户即时通讯系统不能只做全局备份和全局清理。

不同租户的数据保留周期可能不同。聊天记录、文件资源、朋友圈动态、钱包流水、会议记录、操作日志,都可能有不同保存策略。

例如:

普通聊天记录保存 180 天。

钱包流水长期保留。

文件资源按引用状态清理。

朋友圈动态由用户主动删除。

会议记录按租户配置保存。

操作日志按安全策略保存。

这些策略都应该支持按租户配置。数据清理任务执行时,也要带上 tenantId,避免跨租户误删。

尤其是文件资源清理,不能只看文件创建时间,还要看是否仍被聊天消息、收藏、朋友圈引用。多租户环境下,还要确保清理任务只处理当前租户的资源目录和资源记录。

十二、接口返回也要注意租户上下文

即时通讯系统里很多接口返回的数据看起来是公共结构,例如用户资料、群资料、文件信息、朋友圈动态、收藏记录。但在多租户环境下,这些数据都必须属于当前租户。

用户资料接口不能返回其他租户用户。

群成员接口不能跨租户查询。

历史消息接口必须限定 tenantId。

文件下载接口必须判断资源所属租户。

钱包流水接口必须限定当前租户和当前用户。

朋友圈列表也必须从当前租户的数据空间中读取。

这些判断不应该依赖前端传参,而应该由服务端上下文控制。前端可以传 userId、groupId、conversationId,但最终查询条件必须自动加上 tenantId。

这也是租户上下文存在的意义:让业务层和数据层默认处于当前租户空间中。

十三、工程目录可以按租户能力拆出基础层

多租户不是某个业务模块,而是基础能力。工程目录中可以单独拆出 tenant 基础层。

例如:

tenant-context:处理租户上下文。

tenant-config:处理租户配置。

tenant-datascope:处理数据范围。

tenant-storage:处理文件路径和存储隔离。

tenant-log:处理日志上下文。

tenant-security:处理租户访问校验。

业务模块如 message、group、friend、wallet、resource、circle、meeting,都依赖 tenant 基础层,而不是各自实现租户逻辑。

这样后期新增业务模块时,可以复用租户上下文和数据隔离规则。

十四、即时通讯源码里的多租户不是附加功能

在即时通讯系统中,多租户不是简单多一个字段。它会影响连接、缓存、消息、会话、文件、配置、日志、备份和端侧展示。

单聊、群聊、好友、群管理、文件、收藏、朋友圈、红包、钱包、语音视频、多人会议这些能力,都需要知道自己属于哪个租户空间。

SpringBoot 负责业务组织,WebSocket 负责实时连接,Redis 负责在线状态和高频缓存,MySQL 负责持久化数据。只有租户上下文贯穿这些层,系统才能在不同部署环境、不同业务空间中保持数据边界清晰。

即时通讯源码如果后期需要支持独立部署、多业务空间或私有环境,建议尽早把 tenantId、配置隔离、Redis Key 前缀、文件路径、日志上下文和数据查询条件纳入基础架构。否则后期再补租户隔离,通常会牵动用户、群、消息、文件、钱包、朋友圈和多端连接等大量代码。

相关联知识点的简要梳理宠友(IM即时通讯)app,支持语音、文件、图片、视频等多种类型消息的发送,安全可靠,交流轻松,私有化部署,快速开发,极简部署,支持群聊管理、语音视频通话...功能https://chongyou.info/1/product/im.html

相关推荐
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第163题】【06_Spring篇】第23题:说说SpringBoot 自动配置原理?
java·自动配置·springboot·spi·条件注解
江湖有缘1 小时前
【保姆级教程】使用Docker Compose一键搭建Picsur私有图床服务
java·docker·容器
一路向北North1 小时前
Spring Security OAuth2.0(18):资源服务测试
java·后端·spring
wear工程师2 小时前
MySQL 索引失效别再背八股:看懂 EXPLAIN,面试官追问也不慌
mysql·面试
jnrjian2 小时前
PG 切换用户 \c -
数据库·postgresql
Database_Cool_2 小时前
内存数据库持久化方案:阿里云 Tair 持久内存型数据不丢失
数据库·阿里云
treesforest2 小时前
高精度IP定位怎么选?从企业需求出发的选型指南
网络·数据库·tcp/ip·web安全·ip·高精度ip查询
极光代码工作室2 小时前
基于SpringBoot的订单管理系统
java·springboot·web开发·后端开发
xiaoduzi19912 小时前
ConcurrentHashMap学习
java·学习