分库分表后ID乱成一锅粥

你还记得某个周五下午5点半,本来准备愉快下班的你,突然被运营小姐姐的一个问题给拉回了现实:"技术哥哥,为什么我们的用户ID会重复啊?数据看起来好乱,这个月的报表没法做了...",你当时心里一紧,赶紧打开数据库一看,好家伙!用户表分了100张后,每张表的ID都从1开始自增,现在确实是一团糟:

makefile 复制代码
-- 用户表分片情况
user_table_001: 用户ID 1, 2, 3, 4...
user_table_002: 用户ID 1, 2, 3, 4...  ← 这不是重复了吗?
user_table_003: 用户ID 1, 2, 3, 4...  ← 完全分不清谁是谁!

这下可好,原本想通过分库分表提升性能,结果把自己给坑了。运营同学要做用户画像分析,结果发现同一个ID对应了100个不同的用户,这还怎么玩?看着运营小姐姐幽怨的眼神,我知道这个周末要加班了...

分库分表的甜蜜陷阱

说起分库分表,这可是每个后端工程师都会遇到的"成长烦恼"。刚开始做项目的时候,一张用户表走天下,简单粗暴,自增ID用得那叫一个爽:

ID从1开始,2、3、4...一路递增,多么美好的时光啊!但是好景不长,随着业务发展,用户量从1万变成10万,从10万变成100万,再从100万变成1000万...

性能问题接踵而至:

  1. 单表查询越来越慢,特别是分页查询
  2. 写入开始排队,TPS直线下降
  3. 数据库CPU经常飙到100%
  4. 备份和恢复时间越来越长
  5. 索引文件巨大,内存占用严重

这时候老板走过来拍拍你的肩膀:"小张啊,咱们这个系统能不能再快点?用户都在抱怨了..."于是,分库分表就成了救命稻草。我们把一张大表拆成了100张小表:

ini 复制代码
-- 分表策略:按用户ID取模
user_00, user_01, user_02, ... user_99

-- 分表路由逻辑
table_suffix = user_id % 100;
table_name = "user_" + String.format("%02d", table_suffix);

理论上,每张表只有原来1/100的数据量,查询速度嗖嗖的!但是等等,ID怎么办?

ID的三重困境

困境一:重复ID

最直接的问题就是ID重复。每张表都从1开始自增,结果就是:

scss 复制代码
// 用户分布情况
User zhangsan = User.builder().id(1L).username("zhangsan").build(); // 在user_01表
User lisi = User.builder().id(1L).username("lisi").build();         // 在user_02表  
User wangwu = User.builder().id(1L).username("wangwu").build();     // 在user_03表

这下好了,当我们要根据ID查询用户信息时,完全不知道该去哪张表找!更要命的是,如果有关联表(比如订单表),外键都是user_id,现在一个user_id对应100个用户,这数据还有啥意义?

困境二:全局查询

假设产品经理走过来说:"我想看看ID为1000的用户的详细信息。"在单表时代,这个需求简单得不能再简单:

less 复制代码
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    
    @Query("SELECT u FROM User u WHERE u.id = :id")
    Optional<User> findById(@Param("id") Long id);
}

@Service
@Transactional(readOnly = true)
public class UserService {
    
    @Autowired
    private UserRepository userRepository;
    
    public User findById(Long id) {
        return userRepository.findById(id).orElse(null);
    }
}

但是在分表后,你需要这样做:

typescript 复制代码
@Service
public class ShardedUserService {
    
    @Autowired
    private JdbcTemplate jdbcTemplate;
    
    public User findById(Long id) {
        // 天哪,要查100张表!
        for (int i = 0; i < 100; i++) {
            String tableName = "user_" + String.format("%02d", i);
            String sql = "SELECT * FROM " + tableName + " WHERE id = ?";
            
            try {
                List<User> users = jdbcTemplate.query(sql, 
                    new Object[]{id}, 
                    new BeanPropertyRowMapper<>(User.class));
                    
                if (!users.isEmpty()) {
                    return users.get(0);
                }
            } catch (DataAccessException e) {
                // 这张表没有,继续找下一张
                log.debug("表 {} 中未找到ID为 {} 的用户", tableName, id);
            }
        }
        return null; // 找了100张表都没有
    }
}

想想就头皮发麻对不对?而且性能还贼差!

困境三:数据迁移

更可怕的是数据迁移。假设我们要把两张表合并,或者重新分片,ID冲突问题会让你想哭

diff 复制代码
-- 原来的表
user_01: 1, 2, 3, 4, 5...
user_02: 1, 2, 3, 4, 5...

-- 要合并成一张表?ID全冲突!
-- 要重新分片?所有关联表的外键都要更新!

难道要写个复杂的数据迁移脚本,手动重新分配ID?那关联表的外键怎么办?订单表、评论表、收藏表...想想就是一个噩梦...

各种解决方案的江湖恩怨

面对这个问题,江湖上出现了各种门派的解决方案:

UUID简单粗暴派

UUID就像是一个直男,虽然能解决问题,但是一点都不优雅。

less 复制代码
@Entity
@Table(name = "user")
@Data
public class User {
    @Id
    @Column(name = "id", columnDefinition = "VARCHAR(36)")
    private String id = UUID.randomUUID().toString();
    
    private String username;
    private String email;
}

@Service
@Transactional
public class UserService {
    
    @Autowired
    private UserRepository userRepository;
    
    public User createUser(String username, String email) {
        User user = User.builder()
            .id(UUID.randomUUID().toString()) // 每次都生成新的UUID
            .username(username)
            .email(email)
            .build();
        return userRepository.save(user);
    }
}

优点:

  1. 全局唯一,绝对不会重复
  2. 生成简单,Spring Boot原生支持
  3. 分布式友好
  4. 无需额外配置

缺点:

  1. 36字符字符串,存储空间大(36字节 vs 8字节)
  2. 完全无序,数据库索引效率低
  3. 对用户不友好(谁能记住这么长的ID?)
  4. 作为外键时,关联查询性能差
  5. URL不美观

全局自增表中央集权派

这就像是皇帝制度,虽然管理简单,但是皇帝一挂,整个国家就乱了。

less 复制代码
@Entity
@Table(name = "id_generator")
@Data
public class IdGenerator {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "business_type", length = 50)
    private String businessType; // 'user', 'order', 'product'等
    
    @Column(name = "stub", length = 1)
    private String stub = ""; // 占位符
    
    @CreationTimestamp
    private LocalDateTime createdTime;
}

@Service
@Transactional
public class IdGeneratorService {
    
    @Autowired
    private IdGeneratorRepository idGeneratorRepository;
    
    public Long generateId(String businessType) {
        IdGenerator generator = IdGenerator.builder()
            .businessType(businessType)
            .stub("")
            .build();
        generator = idGeneratorRepository.save(generator);
        return generator.getId();
    }
}

优点:

  • ID简短、有序
  • 全局唯一
  • 实现简单
  • 可以按业务类型分别生成

缺点:

  • 单点故障风险
  • 性能瓶颈(所有服务都要来这里获取ID)
  • 高并发时容易成为系统瓶颈
  • 数据库压力大
  • 网络开销

号段模式批发零售派

这就像是批发商模式,虽然效率提高了,但是架构复杂度也上去了。

java 复制代码
@Component
@Slf4j
public class SegmentIdGenerator {
    
    private static final int DEFAULT_SEGMENT_SIZE = 1000;
    
    private volatile long currentId;
    private volatile long maxId;
    private final Object lock = new Object();
    
    @Autowired
    private IdSegmentRepository segmentRepository;
    
    public long generateId(String businessType) {
        synchronized (lock) {
            if (currentId >= maxId) {
                // 获取新的号段
                IdSegment segment = segmentRepository.getNextSegment(businessType, DEFAULT_SEGMENT_SIZE);
                currentId = segment.getMinId();
                maxId = segment.getMaxId();
                log.info("获取新号段: [{}, {}] for {}", currentId, maxId, businessType);
            }
            return ++currentId;
        }
    }
}

@Repository
public class IdSegmentRepository {
    
    @Autowired
    private JdbcTemplate jdbcTemplate;
    
    @Transactional
    public IdSegment getNextSegment(String businessType, int segmentSize) {
        // 原子性地获取号段
        String updateSql = "UPDATE id_segment SET current_value = current_value + ? WHERE business_type = ?";
        int updated = jdbcTemplate.update(updateSql, segmentSize, businessType);
        
        if (updated == 0) {
            // 首次使用,插入记录
            String insertSql = "INSERT INTO id_segment (business_type, current_value) VALUES (?, ?)";
            jdbcTemplate.update(insertSql, businessType, segmentSize);
        }
        
        String selectSql = "SELECT current_value FROM id_segment WHERE business_type = ?";
        Long maxId = jdbcTemplate.queryForObject(selectSql, Long.class, businessType);
        
        return IdSegment.builder()
            .minId(maxId - segmentSize + 1)
            .maxId(maxId)
            .build();
    }
}

优点:

  • 减少数据库访问次数
  • ID有序
  • 一定程度上解决了性能问题
  • 可以根据业务压力调整号段大小

缺点:

  • 实现复杂
  • 服务重启会浪费号段
  • 仍然依赖中央服务
  • 需要预估号段大小

Redis自增内存加速派

优点: 性能极高,内存操作,实现简单,支持过期时间,原子性操作,Spring Boot集成方便

缺点: 依赖Redis,增加了系统复杂度,Redis宕机会导致ID生成失败,重启后ID可能不连续,需要考虑Redis持久化

typescript 复制代码
@Configuration
public class RedisConfig {
    
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return template;
    }
}

@Component
@Slf4j
public class RedisIdGenerator {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    public long generateId(String businessType) {
        String key = "id_generator:" + businessType;
        Long id = redisTemplate.opsForValue().increment(key);
        if (id == null) {
            throw new RuntimeException("Redis生成ID失败");
        }
        return id;
    }
    
    public long generateIdWithExpire(String businessType, Duration expireTime) {
        String key = "id_generator:" + businessType;
        Long id = redisTemplate.opsForValue().increment(key);
        redisTemplate.expire(key, expireTime);
        return id != null ? id : 0L;
    }
}

王者归来

就在大家为ID问题焦头烂额的时候,Twitter开源了一个叫Snowflake的算法,一下子就征服了整个技术圈。为什么这么牛?因为它完美解决了所有痛点:

全局唯一 - 绝对不会重复

趋势递增 - 对数据库索引友好

高性能 - 单机每秒400万个ID

分布式 - 不依赖中央服务

信息丰富 - 可以解析出时间等信息

长度适中 - 64位长整型,存储高效

Spring Boot友好 - 易于集成

这简直就是ID生成界的"六边形战士"!

Snowflake算法深度解析

64位ID的精妙设计

Snowflake的核心思想是把一个64位的长整型数字分成几个部分:

diff 复制代码
Snowflake ID 结构 (64位)
+----------+----------+----------+----------+
| 1位符号位 | 41位时间戳 | 10位机器ID | 12位序列号 |
+----------+----------+----------+----------+
|    0     |   时间   |   机器   |   序列   |
+----------+----------+----------+----------+

让我们逐个分析:

1位符号位:礼貌的保留

虽然不用,但是保留符号位,确保生成的ID永远是正数。这就像是给别人让座一样,虽然自己不坐,但是体现了良好的教养。

41位时间戳:时间就是金钱

存储毫秒级时间戳与起始时间的差值。41位能表示2^41 ≈ 2.2万亿毫秒,也就是约69年!

csharp 复制代码
// 时间戳计算示例
public class SnowflakeTimeExample {
    
    // 起始时间:2021-01-01 00:00:00
    private static final long EPOCH = 1609459200000L;
    
    public static void main(String[] args) {
        long currentTime = System.currentTimeMillis();
        long timestamp = currentTime - EPOCH;
        
        // 41位最大值:2^41 - 1 = 2199023255551
        long maxTimestamp = (1L << 41) - 1;
        long maxYear = EPOCH + maxTimestamp;
        
        System.out.println("当前时间: " + new Date(currentTime));
        System.out.println("相对时间戳: " + timestamp);
        System.out.println("可用到: " + new Date(maxYear)); // 2090年左右
    }
}

如果我们把起始时间设为2021年1月1日,那么可以用到2090年。等到那时候,要么我们已经退休享受人生,要么人类已经移民火星了,反正不用我们操心了😄

10位机器ID:分工合作

支持最多1024台机器同时生成ID。这个数量对于绝大多数公司来说都够用了。就算是BAT这种体量的公司,专门用来生成ID的机器也用不了这么多。

还可以进一步拆分成5位数据中心ID + 5位机器ID,支持32个数据中心,每个数据中心32台机器。

csharp 复制代码
// 机器ID分配示例
public class WorkerIdExample {
    
    public static void main(String[] args) {
        int datacenterId = 1;    // 数据中心ID (0-31)
        int machineId = 1;       // 机器ID (0-31)
        int workerId = (datacenterId << 5) | machineId; // 组合成10位
        
        System.out.println("数据中心ID: " + datacenterId);
        System.out.println("机器ID: " + machineId);
        System.out.println("组合WorkerID: " + workerId);
    }
}

12位序列号:争分夺秒

同一毫秒内可以生成4096个不同的ID。也就是说,单台机器的理论QPS是:

csharp 复制代码
// 性能计算
public class PerformanceCalculation {
    
    public static void main(String[] args) {
        int maxSequence = (1 << 12) - 1; // 4095
        int theoreticalQPS = maxSequence * 1000; // 4,095,000/秒
        
        System.out.println("单毫秒最大序列号: " + maxSequence);
        System.out.println("理论QPS: " + theoreticalQPS); // 409.5万/秒
        System.out.println("这性能,说出去都没人敢信!");
    }
}

这个性能,说出去都没人敢信!

相关推荐
冒泡的肥皂几秒前
数据库最近学到的小知识(一
数据库·后端·架构
拾光拾趣录3 分钟前
前端面试真题深度解析:闭包、数组操作与 Promise 机制
前端·面试
博一波3 分钟前
【车联网kafka】Kafka核心架构与实战经验(第二篇)
架构
程序视点24 分钟前
电脑硬件检测必备!图吧工具箱11年免费良心软件!100+免费工具合集
前端·windows·后端
慌糖28 分钟前
以微服务为基础搭建一套脚手架开始前的介绍
微服务·云原生·架构
IguoChan31 分钟前
话一话职场PUA
后端
陈随易1 小时前
国产之光,把AI融入到语言级别的编程语言-MoonBit
前端·后端·程序员
数据智能老司机1 小时前
让流浪汉都能学会的大模型教程——关于大模型的误解、局限和惊人本事
架构·llm·aigc
ID_云泽1 小时前
多架构镜像整合全攻略:在Docker中实现单一镜像支持同时支持amd64和arm64架构
docker·容器·架构