写点什么

性能测试中的唯一标识问题研究

作者:FunTester
  • 2024-04-07
    河北
  • 本文字数:4898 字

    阅读完需:约 16 分钟

在性能测试场景中,生成全局唯一标识符(GUID)是一个常见的需求,主要用于标识每个请求或者事务,以便于追踪和分析。这是因为在性能测试中,需要对系统的各个功能进行测试,而每个功能都需要有一个唯一的标识来区分。如果不使用全局唯一标识,则可能会出现重复标识的情况,导致测试结果不准确。


相信对于性能测试 er 来讲这些并不陌生,特别在并发场景中使用各类的解决方案。我最近在研究 Go 语言线程安全问题的时候也被其他人问到了。所以打算单独写一写唯一标识的主题,本来打算用一篇文章解决,但是在实践中方案概述、方案实践以及性能对比几个部分,内容着实有点多。所以分成了上下两篇,本篇讲述几种常见方案的概述和代码实践,下一期我会分享几种方案的性能。

UUID(Universally Unique Identifier)

UUID(通用唯一标识符)是一种标准化的用于标识信息的方法。通常用于分布式系统中的唯一标识,以防止不同系统中的数据重复或冲突。它在数据库记录、网络通信、消息队列等方面都有广泛的应用。它是由 128 位二进制数表示的唯一标识符,通常以 32 个十六进制数字的形式表示,每四个数字之间用连字符分隔。UUID 的唯一性主要基于其随机性和长度,尽管在某些情况下可能会出现重复,但重复的概率非常低。具体有多低呢,我查到资料是这么说的:每秒产生10亿笔UUID,100年后只产生一次重复的机率是50%。如果地球上每个人都各有6亿笔GUID,发生一次重复的机率是50%。。我暂时还没遇到重复的情况,各位遇到请告诉我一下概率。


由于这是个自带的包,可以使用java.util.UUID类生成 UUID,例如:


UUID uuid = UUID.randomUUID();String id = uuid.toString();
复制代码


大概长这样 245fee40-8b24-47d3-b5e1-09a5e48a08d1。查阅资料过程中,还有多种版本的 UUID,不知道是不是都这个格式。我用的 JDK17,如果又不一样格式的,兴许版本不同导致的。


UUID 的优点包括:


  1. 全局唯一性:UUID 基于其 128 位的长度和随机性,可以在全球范围内保证唯一性,极大地减少了数据冲突的可能性。

  2. 无序性:UUID 是无序的,不受时间和空间的限制,可以在任何地方、任何时间生成,不需要中心化管理。

  3. 高性能:生成 UUID 的速度非常快,几乎可以瞬间完成,不会造成系统性能瓶颈。

  4. 不可推测性:UUID 是随机生成的,不可预测,可以有效防止信息被猜测或破解。

  5. 可扩展性:UUID 采用 128 位的长度,可以灵活地扩展应用范围,适用于各种场景。


然而,UUID 也存在一些缺点:


  1. 长度较长:UUID 通常由 32 个十六进制数字和四个连字符组成,总共 36 个字符,相比其他标识符(如自增 ID)长度较长,占用存储空间较大。

  2. 不易读:UUID 是一串十六进制数字,对人类来说不够友好,不如自增 ID 那样直观易读。

  3. 不连续性:由于 UUID 是随机生成的,所以其生成的顺序是不连续的,不适合作为连续递增的标识符。

  4. 碰撞概率:虽然 UUID 的碰撞概率非常低,但随着数据量的增加,碰撞的可能性也会增加,需要进行适当的处理和预防。


UUID 适用于需要全局唯一标识且不依赖于中心化管理的场景,但在某些情况下可能会受到长度、可读性和碰撞概率等因素的限制,需要根据具体情况进行选择和权衡。如果我们在性能测试结束后清理数据的话,可以很大程序降低 UUID 重复的概率。

Redis/Zookeeper 等分布式服务生成 GUID

在分布式系统中,能够生成全局唯一 ID 是一个常见且重要的需求。全局唯一 ID 不仅可以用于标识分布在不同节点上的数据记录,还可以用于追踪分布式事务、消息队列等场景。传统的基于数据库自增序列或 UUID 等方式无法满足分布式环境下的需求,因此需要借助分布式服务来实现。


利用 Redis 的INCR命令可以实现一个简单的分布式 ID 生成器。Redis 是一个高性能的内存数据库,它提供了原子操作命令INCR用于对键值进行自增操作。我们可以在 Redis 中设置一个全局的键,每次调用INCR命令即可获取一个唯一的 ID 值。由于 Redis 是单线程处理命令,因此可以确保获取到的 ID 是全局唯一的。这种方式实现简单,但需要注意 Redis 的可用性和性能问题。


另一种方式是利用 Zookeeper 的有序临时节点特性。Zookeeper 是一个分布式协调服务,它允许客户端创建有序的临时节点,节点名称是一个递增的计数器。我们可以在 Zookeeper 上创建一个根节点,每个客户端在该节点下创建一个有序临时节点,临时节点的名称就是一个全局唯一的 ID。这种方式相对复杂,但可靠性和可用性更高,适合于关键任务型系统。


这种方式最大的缺点就是需要 N 多次的网络通信,即使强如 Redis 也很难提供强大的性能,所以直接再次直接放弃了。对于性能要求不甚高的场景来说还是非常好用的。同样地我在查阅资料中发现也有使用 MySQL 递增主键实现的,性能就更差了,绝对不推荐。

雪花算法

雪花算法(Snowflake)是一种用于生成分布式系统中全局唯一的 ID 的算法。它由 Twitter 公司设计,采用了时间戳、机器 ID 和序列号等信息,结合位运算的方式生成 64 位的唯一 ID。其中,时间戳部分用于保证 ID 的唯一性和递增性,机器 ID 部分用于标识不同的机器,序列号部分用于解决同一毫秒内并发生成 ID 时的冲突。雪花算法具有高效、高性能、高可用等特点,被广泛应用于分布式系统中的 ID 生成。


雪花算法很大程度上弥补了 UUID 的不足,而且使用非常灵活,几十行代码即可完成,还能够根据实际场景进行定制化,受到了越来越多码农的喜欢。这里我分享一个简单的例子:


package com.funtester.utils;    public class SnowflakeUtils {        private static final long START_TIMESTAMP = 1616489534000L; // 起始时间戳,2021-03-23 00:00:00        private long datacenterId; // 数据中心ID        private long workerId;     // 机器ID        private long sequence = 0L; // 序列号        private static final long MAX_WORKER_ID = 31L;// 机器ID最大值        private static final long MAX_DATA_CENTER_ID = 31L;// 数据中心ID最大值        private static final long SEQUENCE_BITS = 12L;// 序列号位数        private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;// 机器ID左移位数        private static final long DATA_CENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_SHIFT;// 数据中心ID左移位数        private static final long TIMESTAMP_LEFT_SHIFT = DATA_CENTER_ID_SHIFT + DATA_CENTER_ID_SHIFT;// 时间戳左移位数        private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);// 序列号掩码        private long lastTimestamp = -1L;        public SnowflakeUtils(long datacenterId, long workerId) {          if (datacenterId > MAX_DATA_CENTER_ID || datacenterId < 0) {              throw new IllegalArgumentException("Datacenter ID can't be greater than " + MAX_DATA_CENTER_ID + " or less than 0");          }          if (workerId > MAX_WORKER_ID || workerId < 0) {              throw new IllegalArgumentException("Worker ID can't be greater than " + MAX_WORKER_ID + " or less than 0");          }          this.datacenterId = datacenterId;          this.workerId = workerId;      }        /**       * 获取下一个ID       *     * @return       */      public synchronized long nextId() {          long timestamp = System.currentTimeMillis();          if (timestamp < lastTimestamp) {              throw new RuntimeException("Clock moved backwards. Refusing to generate id for " + (lastTimestamp - timestamp) + " milliseconds");          }          if (lastTimestamp == timestamp) {              sequence = (sequence + 1) & SEQUENCE_MASK;              if (sequence == 0) {                  timestamp = nextMillis(lastTimestamp);              }          } else {              sequence = 0L;          }          lastTimestamp = timestamp;          long l = ((timestamp - START_TIMESTAMP) << TIMESTAMP_LEFT_SHIFT) | (datacenterId << DATA_CENTER_ID_SHIFT) | (workerId << WORKER_ID_SHIFT) | sequence;          return l & Long.MAX_VALUE;      }        /**       * 获取下一个时间戳       *       * @param lastTimestamp       * @return       */      private long nextMillis(long lastTimestamp) {          long timestamp = System.currentTimeMillis();          while (timestamp <= lastTimestamp) {              timestamp = System.currentTimeMillis();          }          return timestamp;      }    }
复制代码


使用的方法如下:


 public static void main(String[] args) {        SnowflakeUtils snowflake = new SnowflakeUtils(1, 1); // 创建雪花算法实例,数据中心ID为1,机器ID为1        for (int i = 0; i < 5; i++) {            System.out.println("Next ID: " + snowflake.nextId());        }    }
复制代码


结果大概长这个样子:


Next ID: 3282842653393162240Next ID: 3307893926320410624Next ID: 3307893926320410625Next ID: 3307893926320410626Next ID: 3307893926320410627
复制代码


我在 com.funtester.utils.SnowflakeUtils#nextId 方法的最后一行,加上了 l & Long.MAX_VALUE 为了获取一个正的值。

线程独享变量

在非并发场景当中,我们要想获取一个全局唯一的标识符,最简单的就是来一个 i++ ,但这样并不能保障并发场景中的线程安全。尽管如此,我们依旧可以通过之前分享过的 将共享变独享 的思路改造一下,将每一个线程都分配一个 int i ,然后在线程内 i++ 保障数值的唯一性。然后再给每一个线程进行唯一性标记,这个在之前分享线程工厂类时候提到过。如果遇到分布式场景,抄袭一下前面成熟框架的方法,增加唯一的机器码标识即可。


下面是我使用的单机版本代码:


  // 创建threadlocal对象  static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>() {        @Override      protected Integer initialValue() {          return 0      }  }    public static void main(String[] args) {      setPoolMax(3)      for (int i = 0; i < 10; i++) {          fun {              increase()// 增加1              System.out.println(Thread.currentThread().getName() + " threadLocal.get() = " + threadLocal.get());// 打印threadLocal值          }      }  }    /**   * 增加1   * @return   */  static def increase() {      threadLocal.set(threadLocal.get() + 1)  }
复制代码


输出结果长这个样子:


F-3  threadLocal.get() = 1F-2  threadLocal.get() = 1F-1  threadLocal.get() = 1F-2  threadLocal.get() = 2F-1  threadLocal.get() = 2F-3  threadLocal.get() = 2F-2  threadLocal.get() = 3F-1  threadLocal.get() = 3F-3  threadLocal.get() = 3F-2  threadLocal.get() = 4
复制代码


基本是实现了设计需求。缺点就是 java.lang.ThreadLocal 可能会导致内存溢出。这一点在性能测试当中可以忽略,因为用例执行完之后,JVM自然也是要关闭的,如果是单 JVM 的性能测试服务,可以将 java.lang.ThreadLocal 对象设计成类成员属性规避内存溢出的问题。

线程共享变量

这个思路就简单了:新建一个全局线程安全的变量,每次获取一个值之后,安全地递增 1,这样一下子就解决了所有问题,是所有方案里面最简单使用的。方案的代码


演示代码如下:


// 定义全局变量,用于线程安全递增计数  static AtomicInteger index = new AtomicInteger(0)    public static void main(String[] args) {      setPoolMax(3)      for (int i = 0; i < 10; i++) {          fun {              println "递增结果: ${index.incrementAndGet()}"          }      }  }
复制代码


输出结果:


递增结果: 2递增结果: 3递增结果: 1递增结果: 4递增结果: 5递增结果: 6递增结果: 7递增结果: 8递增结果: 9递增结果: 10
复制代码


相信个性化的方案不止一种,如果你也有一些有趣的方案,欢迎一起交流分享。

发布于: 刚刚阅读数: 5
用户头像

FunTester

关注

公众号:FunTester,800篇原创,欢迎关注 2020-10-20 加入

Fun·BUG挖掘机·性能征服者·头顶锅盖·Tester

评论

发布
暂无评论
性能测试中的唯一标识问题研究_FunTester_InfoQ写作社区