WebSocket 集群问题

WebSocket 连接是有状态的、持久的,当客户端通过负载均衡连接到一个后端服务实例后,如果后续的请求没有被分配到同一个实例上处理,并且该请求需要写回 ws 数据,则会出现异常。

解决思路:粘性会话、共享状态、广播

粘性会话:确保客户端后续请求都分配到同一个实例上。
实现最简单,屏蔽了跨实例通信的需求。但不够灵活,后端可能不是所有的实例都要支持 ws 连接,但仍有 ws 写回数据的需求;

共享状态:将每个 ws 连接信息记录保存到 Redis 等共享存储层中,当需要写回 ws 信息时,若连接不位于当前实例中,查询 Redis 找到对应的服务实例进行远程调用(例如通过 nacos 拿到目标实例地址,调用其接口)。
提高了系统容错能力和灵活性,可以将 ws 服务独立出来。但是增加了复杂度,需要设计合理的共享状态数据同步机制。代码逻辑如下:

// 每当一个新的 ws 连接建立时,在 Redis 中记录下该连接的信息
public void onWebSocketConnect(String connectionId, String serverInstanceId) {
    redisTemplate.opsForValue().set("ws:" + connectionId, serverInstanceId);
}
 
// 消息转发逻辑:查询 Redis 获取负责该连接的服务实例ID,然后调用该实例的API来发送消息
public void sendMessageToClient(String connectionId, String message) {
    String targetServerInstanceId = redisTemplate.opsForValue().get("ws:" + connectionId);
    if (targetServerInstanceId != null) {
        // 假设有一个内部 HTTP 接口可以用来发送消息
        restTemplate.postForObject(targetServerInstanceId + "/sendMessage", new MessageDto(connectionId, message), Void.class);
    }
}
 
// 发送 WebSocket 消息的接口
@PostMapping("/sendMessage")
public void sendMessage(@RequestBody MessageDto messageDto) {
    sendMessage(messageDto.getConnectionId(), messageDto.getMessage());
}

广播:使用消息订阅发布机制,可以不额外保存 ws 连接信息。
使用消息中间件,所有 ws 后端实例都订阅相同的主题,当需要发送消息时,直接将要发送的目标和数据广播即可。ws 实例接收到消息后,查询目标连接是否在本地,若是则进行后续处理,不是则跳过。
当然如果不想引入额外的消息中间件,也可以使用注册中心 + 遍历发送 HTTP 请求实现基本的广播。

分布式 ID

参考一些开源解决方案:

美团 leaf 方案有两种模式对应两种思路,Segment 模式需要一个中心化的 leaf 服务来分配号段,应用程序需要与 Leaf 服务通信以获取号段,ID 生成逻辑只需要简单递增。Snowflake 模式无中心,使用 zk 来确定每个应用程序的机器 ID 和解决时钟回拨问题。

Snowflake 算法生成的 ID 是一个 64 bits 的整数,严格单调递增,包含以下部分:

  1. 符号位(1 bit):始终为 0,即保证为正数;
  2. 时间戳(41 bits):时间戳在高位用以实现递增;
  3. 数据中心 ID(5 bits):用来区分不同的数据中心或独立的服务器组;
  4. 工作机器 ID(5 bits):用于在同一数据中心内进一步区分不同的机器实例;
  5. 序列号(12 bits):在同一毫秒内递增的 ID 计数器,如果在同一个毫秒内生成超过 4096(2^12) 个 ID,则等待下一毫秒继续生成。

UUIDv7

如果不想维护机器 ID,且不需要严格单调递增,可以使用 UUIDv7 作为分布式 ID,和 Snowflake 相比,UUIDv7 是趋势递增(在同一个毫秒内乱序)。

UUIDv7128 bits 组成,包含以下部分:

+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           unix_ts_ms                          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          unix_ts_ms           |  ver  |       rand_a          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|var|                        rand_b                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  1. 48 bits unix 时间戳,可以表示几千年,无需考虑时间范围问题;
  2. 4 bits 版本号,固定为 0b0111 即十进制的 7;
  3. 12 bits 随机数,称为 rand_a;
  4. 2 bits 固定值为 0b10
  5. 62 bits 随机数,称为 rand_b。

可以直接使用第三方库:GitHub - f4b6a3/uuid-creator,它提供了三种生成 UUIDv7 的实现方式:

  1. Type 1 (default): 48 bits 时间戳 + 26 bits 计数器(递增 1) + 48 bits 始终随机
    • 时间戳与上次不同时,计数器取随机值,否则计数器 + 1;
    • 随机数部分始终随机;
  2. Type 2 (plus 1): 48 bits 时间戳 + 74 bits 单调随机(递增 1);
    • 时间戳与上次不同时,重新取随机数,否则随机数部分 + 1;
  3. Type 3 (plus n): 48 bits 时间戳 + 74 bits 单调随机(以随机数递增);
    • 时间戳与上次不同时,重新取随机数,否则随机数部分 + 随机数;

根据文档,三种实现类型性能分别是 UUID.randomUUID() 方法的 2、20、2 倍;

  • maven 坐标
<dependency>
    <groupId>com.github.f4b6a3</groupId>
    <artifactId>uuid-creator</artifactId>
    <version>6.1.1</version>
</dependency>
  • 使用
// 对应上面三种类型
UUID uuid = UuidCreator.getTimeOrderedEpoch();
UUID uuid = UuidCreator.getTimeOrderedEpochPlus1();
UUID uuid = UuidCreator.getTimeOrderedEpochPlusN();

常见 Web 安全问题

XSS

跨站脚本攻击 (Cross Site Scripting) 是代码注入的一种,攻击者将恶意脚本注入到网页中,使其在其他用户的浏览器中执行,从而窃取敏感信息、劫持会话、篡改页面内容等。

可以通过转义敏感字符、配置 CSP(Content Security Policy) 、使用 HttpOnly Cookie 来防止大部分 XSS 攻击。

参考:xss是什么,如何在富文本编辑器中解决 xss 问题 - 掘金

CSRF

跨站请求伪造 (Cross-Site Request Forgery) 指攻击者诱导用户在不知情的情况下,向已登录的受信任网站发起非本意的请求,从而执行敏感操作(如转账、修改密码等)。受信任网站无法区分请求是用户主动发起还是被诱导发起。

例如:

  1. 用户登录了银行网站 bank.com ,浏览器保存了登录 Cookie;
  2. 攻击者通过邮件链接等方式诱导用户点击访问一个恶意网站 evil.com
  3. 该网站偷偷发起一个请求:<img src="https://bank.com/transfer?to=hacker&amount=1000">
  4. 浏览器自动携带 Cookie,银行误以为是你本人操作,完成了转账。

解决方案:

  1. (可选)添加 Referer 头检查;
  2. (可选)高风险操作需要提示用户确认并输入验证码;
  3. (建议)设置 Cookie 的 SameSite 属性为 Strict 来限制 Cookie 仅在第一方上下文中发送;
  4. (必须)使用 CSRF Token,在每次请求中加入一个攻击者无法伪造的随机令牌,并在服务端进行校验;

CSRF Token 可以在用户登录或页面刷新时,由后端生成一个 token 发送给前端,前端在每次请求时都携带该 token,例如将该 token 放置在请求头 X-CSRF-Token 中。后端拿到该请求后判断 token 是否合法,从而决定是否放行。

为什么添加一个 Token 就可以防止 CSRF 攻击?
攻击者可以在恶意网站通过 form 表单、img 标签等形式实现请求目标接口并携带 Cookie;但无法读取到受信任网站上的响应内容、LocalStorage、Cookie、Dom 等内容,无法获取到 CSRF Token 拼接合法请求。

CSRF Token 可以保证防止 CSRF 攻击,除非 Token 被攻击者提前获取,例如被 XSS 攻击窃取。

重放攻击

重放攻击是指攻击者截获用户的合法请求数据(如支付请求),然后再次发送,从而欺骗服务器重复执行操作。

例如:

  1. 用户发起一次转账请求,金额为 ¥100;
  2. 攻击者通过抓包工具截获该请求;
  3. 攻击者将该请求重复发送多次;
  4. 如果没有防重放机制,服务器可能会执行多次转账。

如何防重放:

  1. 使用时间戳 + 参数签名,服务器验证签名,并判断时间戳是否在允许范围(如 3 分钟内);必须保证客户端与服务器端时钟基本同步,缺点是不能防止短时间内的重复请求;
  2. 使用Nonce + 参数签名,服务端验证签名,并判断 Nonce 近期是否被使用过;
    • Nonce 是 Number once 的缩写,在密码学中指一个一次性随机数;
    • 缺点是需要保存 Nonce 到 Redis 中,因为要清理 Nonce,所以无法防止经过长时间后的重复请求;

综上,所以应当使用 时间戳 + Nonce + 签名 的组合方案。服务器校验签名、时间戳是否过期、Nonce 是否被使用。

注意大前提是使用 HTTPS,以防止中间人窃取请求信息,确保前端签名算法的安全,否则无法保证请求信息不被篡改。

接口幂等性

接口幂等性 (Idempotent) 指无论客户端对同一接口发起多少次相同请求,服务端的最终结果都应保持一致。

尽管接口幂等性关注的是服务端资源状态的一致性,但在实际场景中,我们还需要考虑是否给客户端返回相同的响应内容,以确保客户端的后续行为符合预期。

如何解决:

  1. 部分场景可以基于业务逻辑判断,如判断数据状态变化是否合法、是否满足数据库唯一性约束等;
  2. 客户端请求时携带一个 Token,服务端根据该 Token 判断是否已被使用过,若执行过直接返回,避免重复执行。