wangguangwu
wangguangwu
发布于 2024-08-30 / 151 阅读
0
0

流水号生成器

背景

在许多系统中,需要按照一定的要求生成唯一且递增的流水号。流水号通常由特定的前缀、当前日期以及一个递增的数字组成,以便于唯一性和追踪。在单机环境下生成流水号相对简单,但在多服务器环境或高并发场景下,保证流水号的唯一性和递增性可能会带来额外的挑战。

1. 单机场景下生成流水号

1.1 代码实现

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.atomic.AtomicInteger;

/**
 - 流水号生成器。
 *
 - <p>
 - 单机环境下的线程安全流水号生成器,基于日期和计数器生成唯一流水号。
 - </p>
 *
 - @author wangguangwu
 */
 public final class RequestKeyNoGenerateUtil {

    private static final String PREFIX = "REQ_";
    private static final String DATE_FORMAT = "yyyyMMdd";
    private static final AtomicInteger COUNTER = new AtomicInteger(1);
    private static String currentDate = LocalDate.now().format(DateTimeFormatter.ofPattern(DATE_FORMAT));

    private RequestKeyNoGenerateUtil() {
    }
    /**
     - 生成唯一的流水号
     *
     - @return 生成的流水号
     */
    public static synchronized String generateRequestKeyNo() {
        String today = LocalDate.now().format(DateTimeFormatter.ofPattern(DATE_FORMAT));

        // 如果日期变更,重置计数器
        if (!today.equals(currentDate)) {
            currentDate = today;
            COUNTER.set(1);
        }

        // 获取当前计数器的值并格式化为五位数
        String formattedNumber = String.format("%05d", COUNTER.getAndIncrement());

        // 拼接生成的流水号
        return PREFIX + today + formattedNumber;
    }
}

1.2 代码说明

  1. AtomicInteger COUNTER:使用 AtomicInteger 保证计数器的原子性递增,避免多线程环境下的竞争条件。
  2. synchronized 关键字:将 generateRequestKeyNo 方法声明为 synchronized,确保同一时间只有一个线程能够执行该方法,从而避免并发问题。
  3. 日期变更检查:每次生成流水号时,检查当前日期与保存的日期是否一致,如果不一致,则重置计数器,使新的一天从 "00001" 开始。
  4. 格式化流水号:将计数器的值格式化为五位数,确保流水号始终是五位数递增的格式。

1.3 优点

  1. 线程安全:使用 synchronized 关键字和 AtomicInteger,确保在单机环境下,生成的流水号是线程安全的。
  2. 避免重置问题:通过检查日期变化,确保每天的流水号从 “00001” 开始递增。
  3. 简单易用:不依赖外部系统(如 Redis),适合单机环境。

1.4 缺点

  1. 多服务器场景下的线程安全问题:在多服务器环境中,单机的 synchronized 无法保证跨服务器的唯一性和递增性。
  2. 服务器重启后的记录丢失问题:当前计数器保存在内存中,服务器重启会导致计数器重置,无法保持连续性。
  3. 性能问题:synchronized 关键字可能会导致性能瓶颈,尤其是在高并发场景下,所有的请求都被阻塞等待锁释放。

1.5 优化点

针对上述缺点,可以从以下点进行优化:

  1. 多服务器场景下的线程安全:使用分布式锁(如 Zookeeper、Redis 分布式锁)或基于数据库的序列生成策略,确保多服务器环境中的唯一性和顺序性。
  2. 服务器重启后的记录持久化:将当前日期和计数器状态持久化到外部存储(如数据库或 Redis)中,以便在服务器重启后恢复记录状态。
  3. 性能优化:使用 ReentrantLock 或无锁算法(如 AtomicReference 和 compareAndSet)来代替 synchronized,提高并发性能。

2. 单机场景下的并发性能优化

在单机环境下,synchronized 关键字虽然能够保证线程安全,但它可能会导致性能瓶颈,因为它会阻塞其他线程的访问。为了在多线程环境中提高性能,可以使用 ReentrantLock 或无锁的 CAS(Compare-And-Swap)操作来代替 synchronized

2.1 使用 ReentrantLock

ReentrantLock 提供了更灵活的锁机制,例如可中断锁、尝试获取锁、定时锁等,它能够避免 synchronized 带来的线程阻塞问题

2.1.1 代码实现

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;

public final class RequestKeyNoGenerateUtil {

    private static final String PREFIX = "REQ_";
    private static final String DATE_FORMAT = "yyyyMMdd";
    private static final AtomicInteger COUNTER = new AtomicInteger(1);
    private static String currentDate = LocalDate.now().format(DateTimeFormatter.ofPattern(DATE_FORMAT));
    private static final ReentrantLock lock = new ReentrantLock();

    private RequestKeyNoGenerateUtil() {
    }

    public static String generateRequestKeyNo() {
        lock.lock();
        try {
            String today = LocalDate.now().format(DateTimeFormatter.ofPattern(DATE_FORMAT));

            // 如果日期变更,重置计数器
            if (!today.equals(currentDate)) {
                currentDate = today;
                COUNTER.set(1);
            }

            // 获取当前计数器的值并格式化为五位数
            String formattedNumber = String.format("%05d", COUNTER.getAndIncrement());

            // 拼接生成的流水号
            return PREFIX + today + formattedNumber;
        } finally {
            lock.unlock();
        }
    }
}

2.1.2 优点

  • 更灵活的锁机制ReentrantLock 可以设置为可中断锁,支持尝试获取锁、定时锁等操作,提高系统的响应能力。
  • 性能更高:相比于 synchronizedReentrantLock 能更细粒度地控制锁的使用,避免不必要的阻塞。

2.2 使用 CAS

CAS(Compare-And-Swap)是一种无锁的算法,能够通过原子操作来更新计数器,避免了锁竞争带来的性能问题。

CAS 的核心是通过硬件支持的原子指令来比较和更新内存中的值。

2.2.1 代码实现

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

public final class RequestKeyNoGenerateUtil {

    private static final String PREFIX = "REQ_";
    private static final String DATE_FORMAT = "yyyyMMdd";
    private static final AtomicInteger COUNTER = new AtomicInteger(1);
    private static final AtomicReference<String> currentDateRef = new AtomicReference<>(LocalDate.now().format(DateTimeFormatter.ofPattern(DATE_FORMAT)));

    private RequestKeyNoGenerateUtil() {
    }

    public static String generateRequestKeyNo() {
        String today = LocalDate.now().format(DateTimeFormatter.ofPattern(DATE_FORMAT));
        String previousDate = currentDateRef.get();

        // 无锁检查日期变更
        if (!today.equals(previousDate) && currentDateRef.compareAndSet(previousDate, today)) {
            // 日期变化且成功更新为新日期,重置计数器
            COUNTER.set(1);
        }

        // 获取当前计数器的值并格式化为五位数
        String formattedNumber = String.format("%05d", COUNTER.getAndIncrement());

        // 拼接生成的流水号
        return PREFIX + today + formattedNumber;
    }
}

2.2.2 优点

  • 无锁性能优化:CAS 是无锁的原子操作,可以减少锁竞争带来的开销,提高系统并发性能。
  • 更高效的并发支持:适合高并发场景下的流水号生成,性能优于基于锁的方案。

3. 多服务器场景下使用 redis

在多服务器或分布式环境中,需要确保流水号的唯一性和递增性,同时需要在服务器重启时恢复记录。使用 Redis 分布式锁和持久化存储可以很好地解决这些问题。

使用 Redis 实现分布式锁和数据持久化:

Redis 提供了简单有效的分布式锁机制,通过 SETNX 命令可以实现一个分布式锁。同时,Redis 还可以用来持久化计数器数据,以确保在服务器重启时能够恢复流水号的生成。

3.1 代码实现

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;

@Component
public class DistributedRequestKeyNoGenerateUtil {

    private static final String PREFIX = "REQ_";
    private static final String DATE_FORMAT = "yyyyMMdd";
    private static final String REDIS_KEY_PREFIX = "request_key_no:";

    @Resource
    private StringRedisTemplate redisTemplate;

    /**
     - 生成唯一的流水号
     *
     - @return 生成的流水号
     */
    public String generateSerialNumber() {
        String today = LocalDate.now().format(DateTimeFormatter.ofPattern(DATE_FORMAT));
        String redisKey = REDIS_KEY_PREFIX + today;

        // 使用 Redis 原子操作获取当前计数器的值并自增
        Long increment = redisTemplate.opsForValue().increment(redisKey, 1);

        // 设置 Redis 键的过期时间为 24 小时
        redisTemplate.expire(redisKey, 24, TimeUnit.HOURS);

        // 格式化流水号,确保五位数字
        String formattedNumber = String.format("%05d", increment);

        // 拼接生成的流水号
        return PREFIX + today + formattedNumber;
    }
}

3.2 代码说明

  • Redis 原子操作:使用 Redis 的 INCR 命令来保证计数器的原子性递增。
  • 过期时间管理:设置 Redis 键的过期时间为 24 小时,确保每天重新生成计数器。
  • 数据持久化:Redis 的持久化机制(RDB 或 AOF)确保即使服务器重启,数据也不会丢失。

3.3 优点

  • 分布式锁:使用 Redis 实现分布式锁,可以在多服务器环境中确保流水号的唯一性。
  • 持久化支持:Redis 提供的数据持久化功能确保数据不丢失,支持服务器重启后的数据恢复。
  • 高性能:Redis 原子操作非常高效,能够支持高并发请求。

4. 总结

在单机和多服务器场景下,流水号生成的要求和解决方案各有不同。在单机场景下,可以使用 synchronizedReentrantLock 或 CAS 等方式保证线程安全和提高性能。而在多服务器场景下,使用 Redis 分布式锁和持久化存储能够确保流水号的唯一性、递增性和持久性。根据具体的业务需求,可以选择不同的方案来实现高效的流水号生成。


评论