前端小帅:那我问你,你接口返回的id能保证不重复吗?

1,593 阅读13分钟

前言

Hello~大家好。我是秋天的一阵风

在日常前端开发中,我们最频繁的任务无疑是数据渲染。无论是借助 Vue 中的 v-for、React 的 map() 循环,还是 Angular 的 *ngFor 指令,我们都需要为每个渲染的元素指定一个唯一的 key。这一做法不仅有助于优化性能,还能确保组件在更新时的准确性。

但是在实际开发中,很多开发者为了贪图方便,选择使用数组的索引来作为唯一值。如下面代码所示:

  • React
const list = ['苹果', '香蕉', '橙子'];
const ListComponent = () => (
  <ul>
    {list.map((item, index) => (
      <li key={index}>{item}</li> // 不推荐使用索引作为 key
    ))}
  </ul>
);
  • Vue
<template>
  <ul>
    <li v-for="(item, index) in list" :key="index">{{ item }}</li> 
    <!-- 不推荐使用索引作为 key -->
  </ul>
</template>

<script>
export default {
  data() {
    return {
      list: ['苹果', '香蕉', '橙子']
    };
  }
};
</script>
  • Angular
<ul>
  <li *ngFor="let item of list; let i = index" [attr.key]="i">{{ item }}</li> 
  <!-- 不推荐使用索引作为 key -->
</ul>

一、使用数组索引可能引发的问题

在前端开发中,虽然可以使用数组的索引作为 key 的值,但这种做法可能会引发一系列问题,尤其是在数据动态变化时。以下是使用数组索引作为 key 可能会引发的问题:

1. 性能问题

当数组中的数据发生变化(如添加、删除或重新排序)时,使用索引作为 key 会导致框架(如 React、Vue 或 Angular)无法准确判断哪些元素发生了变化。这可能会导致以下性能问题:

  • 不必要的重新渲染:框架可能会重新渲染整个列表,而不是只更新变化的部分。这在列表较长时会显著增加渲染时间,消耗更多的计算资源。
  • 频繁的 DOM 操作:由于框架无法准确识别元素的变化,可能会频繁地插入、删除或更新 DOM 元素,导致性能下降。

2. 状态错乱

如果列表中的元素有内部状态(如输入框的值、展开/折叠状态等),使用索引作为 key 可能会导致状态错乱。例如:

  • 输入框值错乱:当用户在输入框中输入内容后,如果列表重新排序,使用索引作为 key 可能会导致输入框的值被错误地分配到其他元素上。
  • 展开/折叠状态错乱:如果列表中的元素可以展开或折叠,使用索引作为 key 可能会导致状态被错误地保留或丢失。

因此,我们推荐使用后端返回的数据 id 作为唯一标识。

那么问题就来了,后端接口返回的 ID 是否能够保证不重复呢?

针对这个问题:此时的前端小帅和后端小李便有了如下对话

二、对话场景:前端与后端的对话

前端小帅:小李,我有个问题想问你。 😕

image.png

后端小李 :怎么了,小帅?是不是接口又遇到什么难题了? 😄

image.png

前端小帅:不是接口的问题,是关于你们后端返回的ID。我怎么知道这些ID会不会重复呢? 🤔
后端小李:哈哈,这个问题问得好!其实我们后端有好几种方式来保证ID的唯一性,不用担心重复的问题。 ✅

前端小帅:哦?那都有哪些方式呢?快给我讲讲。 😄
后端小李:首先是最简单的数据库自增ID。这种方式在单体应用中很常见,数据库会自动为每条记录生成一个唯一的ID。

三、 数据库自增ID

mybati-plus为例,只需要在实体类中指定IdType.AUTOMyBatis-Plus就会让数据库自动为ID赋值


import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;

@TableName("user")
public class User {
    @TableId(type = IdType.AUTO) // 使用数据库自增ID
    private Long id;
    // 其他字段...
}

前端小帅:嗯,这个我知道,但好像不太适合分布式系统吧?
后端小李:你说得对。数据库自增ID主要适用于单体应用,因为它依赖于单个数据库实例来保证唯一性。在分布式系统中,多个数据库实例可能会导致ID冲突。不过,对于一些简单的应用,这种方式已经足够用了。 在分布式系统中,我们通常会用到UUID。UUID是通过MAC地址、时间戳、随机数等生成的,几乎可以保证全球唯一。

四、 UUID

UUID (Universally Unique Identifier),通用唯一识别码的缩写。UUID是由一组32位数的16进制数字所构成,所以UUID理论上的总数为 16^32=2^128,约等于 3.4 x 10^38。也就是说若每纳秒产生1兆个UUID,要花100亿年才会将所有UUID用完。

生成的UUID是由 8-4-4-4-12格式的数据组成,其中32个字符和4个连字符' - ',一般我们使用的时候会将连字符删除 uuid.toString().replaceAll("-","")

目前UUID的产生方式有5种版本,每个版本的算法不同,应用范围也不同。

  • 基于时间的UUID - 版本1: 这个一般是通过当前时间,随机数,和本地Mac地址来计算出来,可以通过 org.apache.logging.log4j.core.util包中的 UuidUtil.getTimeBasedUuid()来使用或者其他包中工具。由于使用了MAC地址,因此能够确保唯一性,但是同时也暴露了MAC地址,私密性不够好。
  • DCE安全的UUID - 版本2 DCE(Distributed Computing Environment)安全的UUID和基于时间的UUID算法相同,但会把时间戳的前4位置换为POSIX的UID或GID。这个版本的UUID在实际中较少用到。
  • 基于名字的UUID(MD5)- 版本3 基于名字的UUID通过计算名字和名字空间的MD5散列值得到。这个版本的UUID保证了:相同名字空间中不同名字生成的UUID的唯一性;不同名字空间中的UUID的唯一性;相同名字空间中相同名字的UUID重复生成是相同的。
  • 随机UUID - 版本4 根据随机数,或者伪随机数生成UUID。这种UUID产生重复的概率是可以计算出来的,但是重复的可能性可以忽略不计,因此该版本也是被经常使用的版本。JDK中使用的就是这个版本。
  • 基于名字的UUID(SHA1) - 版本5 和基于名字的UUID算法类似,只是散列值计算使用SHA1(Secure Hash Algorithm 1)算法。

1. 使用

在MyBatis-Plus中,只需要在实体类的@TableId注解中指定type = IdType.UUID即可。这样,MyBatis-Plus会在插入数据时自动为ID字段生成一个UUID。例如:

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;

@TableName("your_table_name")
public class YourEntity {
    @TableId(type = IdType.UUID) // 使用UUID作为ID生成策略
    private String id;
    // 其他字段...
}

2. 缺点

前端小帅:听起来很方便呢,那UUID有什么缺点吗?
后端小李UUID确实有一些缺点。首先,UUID的长度比较长,占用空间较大。其次,由于UUID是无序的,作为数据库主键时可能会导致数据库的B+树索引频繁分裂,影响性能。不过,对于一些对性能要求不高的应用,UUID仍然是一个很好的选择。

前端小帅:那有没有其他更好的方案呢?
后端小李:当然有。除了UUID,我们还可以使用雪花算法(Snowflake)生成ID。雪花算法生成的ID是有序的,性能更好,而且可以保证分布式环境下的唯一性。

五、 雪花算法(Snowflake)

1. 使用

MyBatis-Plus中,可以使用IdType.ID_WORKERIdType.ID_WORKER_STR来指定雪花算法生成ID。

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;

@TableName("user")
public class User {
    @TableId(type = IdType.ID_WORKER)
    private Long id;

    private String name;
    private String email;
    // 其他字段...
}

2. 什么是雪花算法

据国家大气研究中心的查尔斯·奈特称,一般的雪花大约由10^19个水分子组成。在雪花形成过程中,会形成不同的结构分支,所以说大自然中不存在两片完全一样的雪花,每一片雪花都拥有自己漂亮独特的形状。雪花算法表示生成的id如雪花般独一无二。

snowflake是 Twitter开源的分布式ID生成算法,结果是一个long型的ID。其核心思想是:使用41bit作为毫秒数,10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID),12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID),最后还有一个符号位,永远是0。

3. 雪花算法的结构

雪花算法生成的 ID 是一个 64 位的长整型数字,其结构如下:

  • 1 位符号位:最高位固定为 0,因为 Java 中的 long 类型是带符号的,最高位为 0 表示正数。
  • 41 位时间戳:记录自某个时间点(通常是 2022 年 1 月 1 日)以来的毫秒数。41 位的时间戳可以表示的最大时间范围约为 69 年,这完全满足大多数应用场景的需求。
  • 10 位机器 ID:用于区分不同的机器实例。10 位可以表示的最大值为 1023,这意味着在一个分布式系统中可以有 1024 个不同的机器实例。
  • 12 位序列号:在同一毫秒内,同一台机器上生成的 ID 的序列号。12 位可以表示的最大值为 4095,这保证了在同一毫秒内可以生成多个唯一的 ID。
image.png

4. 雪花算法的优点

  1. 全局唯一性:雪花算法生成的 ID 在分布式系统中是全局唯一的,这解决了分布式系统中 ID 重复的问题。
  2. 有序性:生成的 ID 是有序的,即生成时间越晚的 ID 值越大。这在某些场景下非常有用,例如在数据库中进行排序时。
  3. 高性能:雪花算法的生成过程非常快,几乎不会对系统性能产生影响。
  4. 可扩展性:通过机器 ID 和序列号,雪花算法可以支持大规模的分布式系统。

5. 雪花算法的缺点

  1. ID长度可能的限制:雪花算法生成的ID是64位的长整数,如果一个系统需要更长的ID,就不能使用雪花算法。
  2. ID连续性的问题:雪花算法生成的ID并不是连续的,这可能会影响到一些依赖ID连续性的系统或算法。
  3. 跨语言的问题:雪花算法是用Java编写的,如果一个系统使用的是其他语言,可能需要重新实现雪花算法,这增加了使用的复杂性。
  4. 依赖机器 ID:雪花算法需要为每台机器分配一个唯一的机器 ID,这在实际部署中可能会带来一定的复杂性。
  5. 时间回拨问题:在获取时间的时候,可能会出现时间回拨的问题,什么是时间回拨问题呢?就是服务器上的时间突然倒退到之前的时间。
  • 人为原因,把系统环境的时间改了。
  • 有时候不同的机器上需要同步时间,可能不同机器之间存在误差,那么可能会出现时间回拨问题。

六、百度UIDGenerator

前端小帅:雪花算法听起来不错,但缺点也非常明显。
后端小李:确实是这样的。所以我们还可以用百度的UidGenerator。它是雪花算法的改进版,解决了时钟回拨问题,性能也很好。

1. 什么是UIDGenerator

百度的 UidGenerator 是百度开源基于Java语言实现的唯一ID生成器,是在雪花算法 snowflake 的基础上做了一些改进。UidGenerator以组件形式工作在应用项目中, 支持自定义workerId位数初始化策略,适用于docker等虚拟化环境下实例自动重启、漂移等场景。

在实现上, UidGenerator通过借用未来时间来解决sequence天然存在的并发限制; 采用RingBuffer来缓存已生成的UID, 并行化UID的生产和消费, 同时对CacheLine补齐,避免了由RingBuffer带来的硬件级「伪共享」问题. 最终单机QPS可达600万。

在实现上,UidGenerator 提供了两种生成唯一ID方式,分别是 DefaultUidGeneratorCachedUidGenerator,官方建议如果有性能考虑的话使用 CachedUidGenerator 方式实现。

UidGenerator 依然是以划分命名空间的方式将 64-bit位分割成多个部分,只不过它的默认划分方式有别于雪花算法 snowflake。它默认是由 1-28-22-13 的格式进行划分。可根据你的业务的情况和特点,自己调整各个字段占用的位数。

  • 第1位仍然占用1bit,其值始终是0。
  • 第2位开始的28位是时间戳,28-bit位可表示2^28个数,这里不再是以毫秒而是以秒为单位,每个数代表秒则可用(1L<<28)/ (360024365) ≈ 8.51 年的时间。
  • 中间的 workId (数据中心+工作机器,可以其他组成方式)则由 22-bit位组成,可表示 2^22 = 4194304个工作ID。
  • 最后由13-bit位构成自增序列,可表示2^13 = 8192个数。
image.png

其中 workId (机器 id),最多可支持约420w次机器启动。内置实现为在启动时由数据库分配(表名为 WORKER_NODE),默认分配策略为用后即弃,后续可提供复用策略

七、前端如何处理返回的 ID

前端小帅:原来如此,看来你们后端确实有很多方法来保证ID的唯一性啊。 看来我得多向你学习学习后端知识了。

后端小李:哈哈,有啥问题随时问我。不过我倒是有个问题:你们前端如何处理得到大长度的id呢?好像会出现精度丢失的问题,这是怎么回事呀? 😕毕竟这个我可是听说别家公司因此还整出过生产事故呢。

前端小帅:哦,这个问题很常见。在JavaScript中,所有数字都是以双精度浮点数(64位)的形式存储的,这意味着它的最大安全整数值是 Number.MAX_SAFE_INTEGER,也就是 2^53 - 1(9007199254740991)。超过这个范围的整数就无法保证精度了。 😄

1.解决方案

64位ID很容易超出JavaScript的安全整数范围。不过,有几种方法可以解决这个问题:

  1. 使用字符串存储ID:将64位ID以字符串的形式传递和存储,避免将其转换为数字类型。这样可以完全避免精度丢失的问题。
  2. 使用BigInt类型:在现代JavaScript中,可以使用BigInt类型来处理大整数。BigInt允许你安全地表示和操作任意大小的整数。
const id = BigInt(response.id);
console.log(id);