🔐 分布式全局唯一约束:让"唯一"真正唯一!

19 阅读10分钟

副标题:如何保证全球70亿用户的手机号不重复?🎯


🎬 开场:唯一约束的挑战

单机时代 vs 分布式时代

单机时代(很简单):

数据库唯一索引搞定:
CREATE TABLE users (
  id BIGINT PRIMARY KEY,
  phone VARCHAR(11) UNIQUE,  -- 唯一约束 ✅
  email VARCHAR(100) UNIQUE
);

INSERT INTO users VALUES (1, '13800138000', 'test@qq.com');
INSERT INTO users VALUES (2, '13800138000', 'test2@qq.com');
-- 报错:Duplicate entry '13800138000' for key 'phone' ✅


分布式时代(困难重重):

场景:分库分表后
┌─────────┐  ┌─────────┐  ┌─────────┐
│ DB-1    │  │ DB-2    │  │ DB-3    │
│ users   │  │ users   │  │ users   │
└─────────┘  └─────────┘  └─────────┘

问题:
同一个手机号可能同时在3个库中插入!❌

用户A → DB-1: INSERT phone='13800138000'  ✅
用户B → DB-2: INSERT phone='13800138000'  ✅
用户C → DB-3: INSERT phone='13800138000'  ✅

结果:同一个手机号注册了3次!💥

真实故障案例

某社交平台的灾难:

背景:用户表分库分表(16个库)

10:00  用户A使用手机号 13800138000 注册
       路由到 DB-1,注册成功 ✅

10:01  用户B也使用手机号 13800138000 注册
       路由到 DB-5,注册成功 ✅

10:05  发现问题:同一个手机号注册了2个账号!

影响:
├── 用户混乱:不知道该用哪个账号
├── 数据污染:积分、订单分散在两个账号
├── 安全隐患:可能被恶意利用
└── 客诉暴增:需要人工合并账号

损失:约50万用户数据需要手工清理

📚 核心挑战

1. 分库分表场景

问题:唯一索引失效

单库:
┌─────────────────┐
│  users_db       │
│  UNIQUE(phone)  │ ← 数据库保证唯一
└─────────────────┘

分库:
┌──────────┐  ┌──────────┐
│ DB-1     │  │ DB-2     │
│UNIQUE    │  │UNIQUE    │
└──────────┘  └──────────┘
    ↑             ↑
    └─────┬───────┘
          │
    只在各自库内唯一 ❌
    全局不唯一!

2. 微服务场景

问题:服务之间无法共享数据库约束

用户服务:
├── users_db
└── UNIQUE(phone)

订单服务:
├── orders_db
└── 无法访问 users_db

商品服务:
├── products_db
└── 无法访问 users_db

如何保证跨服务的唯一性?

🛠️ 解决方案

方案1:中心化唯一性校验服务 ⭐⭐⭐⭐⭐

最常用、最可靠的方案!

架构设计

┌────────────────────────────────────┐
│      唯一性校验服务                │
│    (Uniqueness Check Service)      │
│                                    │
│  ┌──────────────────────────────┐ │
│  │  Redis(存储已使用的值)     │ │
│  └──────────────────────────────┘ │
│                                    │
│  ┌──────────────────────────────┐ │
│  │  MySQL(持久化存储)         │ │
│  └──────────────────────────────┘ │
└────────────┬───────────────────────┘
             │
    ┌────────┴────────┬────────┐
    │                 │        │
┌───▼────┐      ┌────▼───┐  ┌─▼─────┐
│用户服务│      │订单服务│  │其他...│
└────────┘      └────────┘  └───────┘

数据库设计

-- 唯一性约束表
CREATE TABLE `unique_constraint` (
  `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
  `constraint_type` VARCHAR(32) NOT NULL COMMENT '约束类型: PHONE, EMAIL, USERNAME',
  `constraint_value` VARCHAR(255) NOT NULL COMMENT '约束值',
  `business_id` VARCHAR(64) NOT NULL COMMENT '业务ID(如用户ID)',
  `business_type` VARCHAR(32) NOT NULL COMMENT '业务类型: USER, MERCHANT',
  `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态: 1-有效, 0-已释放',
  `create_time` DATETIME NOT NULL,
  `update_time` DATETIME NOT NULL,
  UNIQUE KEY `uk_constraint` (`constraint_type`, `constraint_value`)  -- 唯一索引
) ENGINE=InnoDB COMMENT='全局唯一性约束表';

-- 索引
CREATE INDEX `idx_business` ON `unique_constraint`(`business_type`, `business_id`);

代码实现

/**
 * 唯一性校验服务
 */
@Service
public class UniquenessCheckService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private UniqueConstraintMapper constraintMapper;
    
    private static final String UNIQUE_PREFIX = "unique:";
    
    /**
     * 检查并占用(原子操作)
     */
    @Transactional
    public boolean checkAndOccupy(
        String constraintType,
        String constraintValue,
        String businessType,
        String businessId) {
        
        // 1. 先检查Redis(快速失败)
        String redisKey = buildRedisKey(constraintType, constraintValue);
        Boolean absent = redisTemplate.opsForValue()
            .setIfAbsent(redisKey, businessId, 7, TimeUnit.DAYS);
        
        if (Boolean.FALSE.equals(absent)) {
            // Redis中已存在
            log.warn("唯一性校验失败(Redis): {}-{}", constraintType, constraintValue);
            return false;
        }
        
        // 2. 写入数据库(持久化)
        UniqueConstraint constraint = new UniqueConstraint();
        constraint.setConstraintType(constraintType);
        constraint.setConstraintValue(constraintValue);
        constraint.setBusinessType(businessType);
        constraint.setBusinessId(businessId);
        constraint.setStatus(1);
        constraint.setCreateTime(new Date());
        constraint.setUpdateTime(new Date());
        
        try {
            constraintMapper.insert(constraint);
            log.info("唯一性校验成功: {}-{}", constraintType, constraintValue);
            return true;
            
        } catch (DuplicateKeyException e) {
            // 数据库唯一索引冲突
            log.warn("唯一性校验失败(DB): {}-{}", constraintType, constraintValue);
            
            // 删除Redis中的记录(保持一致)
            redisTemplate.delete(redisKey);
            
            return false;
        }
    }
    
    /**
     * 检查是否可用
     */
    public boolean checkAvailable(String constraintType, String constraintValue) {
        // 1. 先查Redis
        String redisKey = buildRedisKey(constraintType, constraintValue);
        if (Boolean.TRUE.equals(redisTemplate.hasKey(redisKey))) {
            return false;
        }
        
        // 2. 再查数据库
        UniqueConstraint constraint = constraintMapper.selectByConstraint(
            constraintType, constraintValue
        );
        
        return constraint == null || constraint.getStatus() == 0;
    }
    
    /**
     * 释放约束
     */
    @Transactional
    public void release(String constraintType, String constraintValue) {
        // 1. 删除Redis
        String redisKey = buildRedisKey(constraintType, constraintValue);
        redisTemplate.delete(redisKey);
        
        // 2. 更新数据库(软删除)
        constraintMapper.updateStatus(constraintType, constraintValue, 0);
        
        log.info("释放唯一约束: {}-{}", constraintType, constraintValue);
    }
    
    /**
     * 批量检查
     */
    public Map<String, Boolean> batchCheck(
        String constraintType,
        List<String> constraintValues) {
        
        Map<String, Boolean> result = new HashMap<>();
        
        for (String value : constraintValues) {
            result.put(value, checkAvailable(constraintType, value));
        }
        
        return result;
    }
    
    private String buildRedisKey(String type, String value) {
        return UNIQUE_PREFIX + type + ":" + value;
    }
}

使用示例

/**
 * 用户注册服务
 */
@Service
public class UserRegisterService {
    
    @Autowired
    private UniquenessCheckService uniquenessCheckService;
    
    @Autowired
    private UserMapper userMapper;
    
    /**
     * 注册用户
     */
    @Transactional
    public User register(RegisterRequest request) {
        String phone = request.getPhone();
        String email = request.getEmail();
        
        // 1. 校验手机号唯一性
        boolean phoneAvailable = uniquenessCheckService.checkAndOccupy(
            "PHONE", phone, "USER", null  // businessId暂时为null
        );
        
        if (!phoneAvailable) {
            throw new BusinessException("手机号已被注册");
        }
        
        // 2. 校验邮箱唯一性
        boolean emailAvailable = uniquenessCheckService.checkAndOccupy(
            "EMAIL", email, "USER", null
        );
        
        if (!emailAvailable) {
            // 回滚手机号占用
            uniquenessCheckService.release("PHONE", phone);
            throw new BusinessException("邮箱已被注册");
        }
        
        try {
            // 3. 创建用户
            User user = new User();
            user.setPhone(phone);
            user.setEmail(email);
            user.setUsername(request.getUsername());
            user.setPassword(encryptPassword(request.getPassword()));
            user.setCreateTime(new Date());
            
            userMapper.insert(user);
            
            // 4. 更新业务ID
            updateBusinessId("PHONE", phone, user.getId());
            updateBusinessId("EMAIL", email, user.getId());
            
            log.info("用户注册成功: userId={}, phone={}", user.getId(), phone);
            
            return user;
            
        } catch (Exception e) {
            // 失败回滚
            uniquenessCheckService.release("PHONE", phone);
            uniquenessCheckService.release("EMAIL", email);
            throw e;
        }
    }
    
    private void updateBusinessId(String type, String value, Long userId) {
        constraintMapper.updateBusinessId(type, value, String.valueOf(userId));
    }
}

方案2:分布式锁 + 数据库 ⭐⭐⭐⭐

适合高并发场景!

/**
 * 使用分布式锁保证唯一性
 */
@Service
public class UniquenessLockService {
    
    @Autowired
    private RedissonClient redissonClient;
    
    @Autowired
    private UserMapper userMapper;
    
    /**
     * 注册用户(分布式锁)
     */
    public User register(RegisterRequest request) {
        String phone = request.getPhone();
        String lockKey = "lock:register:phone:" + phone;
        
        RLock lock = redissonClient.getLock(lockKey);
        
        try {
            // 尝试获取锁,最多等待5秒
            boolean locked = lock.tryLock(5, 30, TimeUnit.SECONDS);
            
            if (!locked) {
                throw new BusinessException("系统繁忙,请稍后重试");
            }
            
            // 双重检查:手机号是否已存在
            User existingUser = userMapper.selectByPhone(phone);
            if (existingUser != null) {
                throw new BusinessException("手机号已被注册");
            }
            
            // 创建用户
            User user = new User();
            user.setPhone(phone);
            user.setEmail(request.getEmail());
            user.setUsername(request.getUsername());
            user.setCreateTime(new Date());
            
            userMapper.insert(user);
            
            log.info("用户注册成功: userId={}, phone={}", user.getId(), phone);
            
            return user;
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BusinessException("注册失败", e);
            
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

优缺点

优点 ✅:
- 强一致性保证
- 实现相对简单

缺点 ❌:
- 性能开销大(加锁解锁)
- 并发度受限
- 依赖Redis

适用场景:
- 并发不是特别高
- 对一致性要求极高

方案3:布隆过滤器 + 数据库 ⭐⭐⭐

快速判断,减少数据库查询!

/**
 * 布隆过滤器 + 数据库
 */
@Service
public class UniquenessBloomService {
    
    @Autowired
    private RedissonClient redissonClient;
    
    @Autowired
    private UserMapper userMapper;
    
    private RBloomFilter<String> phoneBloomFilter;
    
    @PostConstruct
    public void init() {
        // 初始化布隆过滤器
        phoneBloomFilter = redissonClient.getBloomFilter("user:phone:bloom");
        phoneBloomFilter.tryInit(100000000L, 0.01);  // 1亿用户,1%误判率
        
        // 加载已存在的手机号
        List<String> existingPhones = userMapper.selectAllPhones();
        existingPhones.forEach(phoneBloomFilter::add);
        
        log.info("布隆过滤器初始化完成,加载{}个手机号", existingPhones.size());
    }
    
    /**
     * 检查手机号是否可用
     */
    public boolean isPhoneAvailable(String phone) {
        // 1. 先用布隆过滤器判断
        if (phoneBloomFilter.contains(phone)) {
            // 可能存在,需要查数据库确认
            User user = userMapper.selectByPhone(phone);
            return user == null;
        }
        
        // 2. 布隆过滤器判断不存在,一定不存在
        return true;
    }
    
    /**
     * 注册用户
     */
    @Transactional
    public User register(RegisterRequest request) {
        String phone = request.getPhone();
        
        // 1. 快速判断
        if (!isPhoneAvailable(phone)) {
            throw new BusinessException("手机号已被注册");
        }
        
        // 2. 创建用户
        User user = new User();
        user.setPhone(phone);
        user.setEmail(request.getEmail());
        user.setCreateTime(new Date());
        
        try {
            userMapper.insert(user);
            
            // 3. 添加到布隆过滤器
            phoneBloomFilter.add(phone);
            
            log.info("用户注册成功: {}", phone);
            
            return user;
            
        } catch (DuplicateKeyException e) {
            // 并发创建,数据库唯一索引保底
            throw new BusinessException("手机号已被注册");
        }
    }
}

优缺点

优点 ✅:
- 快速判断(内存操作)
- 减少数据库查询
- 性能高

缺点 ❌:
- 存在误判(需要数据库二次确认)
- 内存占用
- 需要定期重建

适用场景:
- 数据量大
- 读多写少
- 可接受小概率误判

方案4:分段路由 + 数据库唯一索引 ⭐⭐⭐⭐

分库分表场景的最佳实践!

/**
 * 分段路由策略
 */
@Service
public class UniqueRoutingService {
    
    @Autowired
    private List<DataSource> dataSources;  // 多个数据源
    
    /**
     * 根据手机号路由到固定的库
     */
    private int routeByPhone(String phone) {
        // 使用一致性Hash保证同一个手机号总是路由到同一个库
        return Math.abs(phone.hashCode()) % dataSources.size();
    }
    
    /**
     * 注册用户
     */
    public User register(RegisterRequest request) {
        String phone = request.getPhone();
        
        // 1. 路由到固定的库
        int dbIndex = routeByPhone(phone);
        DataSource dataSource = dataSources.get(dbIndex);
        
        log.info("手机号{}路由到DB-{}", phone, dbIndex);
        
        // 2. 在该库中利用唯一索引保证唯一性
        try (Connection conn = dataSource.getConnection()) {
            // 检查是否已存在
            String checkSql = "SELECT id FROM users WHERE phone = ?";
            PreparedStatement checkStmt = conn.prepareStatement(checkSql);
            checkStmt.setString(1, phone);
            ResultSet rs = checkStmt.executeQuery();
            
            if (rs.next()) {
                throw new BusinessException("手机号已被注册");
            }
            
            // 插入用户
            String insertSql = "INSERT INTO users (phone, email, username, create_time) VALUES (?, ?, ?, NOW())";
            PreparedStatement insertStmt = conn.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS);
            insertStmt.setString(1, phone);
            insertStmt.setString(2, request.getEmail());
            insertStmt.setString(3, request.getUsername());
            insertStmt.executeUpdate();
            
            ResultSet generatedKeys = insertStmt.getGeneratedKeys();
            if (generatedKeys.next()) {
                Long userId = generatedKeys.getLong(1);
                log.info("用户注册成功: userId={}, phone={}, db={}", userId, phone, dbIndex);
                
                User user = new User();
                user.setId(userId);
                user.setPhone(phone);
                user.setEmail(request.getEmail());
                user.setUsername(request.getUsername());
                
                return user;
            }
            
        } catch (SQLException e) {
            if (e.getErrorCode() == 1062) {  // MySQL Duplicate entry
                throw new BusinessException("手机号已被注册");
            }
            throw new RuntimeException("注册失败", e);
        }
        
        return null;
    }
}

关键点

1. 路由规则固定
   同一个手机号必须始终路由到同一个库

2. 利用数据库唯一索引
   每个库内部的唯一性由数据库保证

3. 全局唯一性
   因为同一个手机号永远在同一个库,所以全局唯一

优点 ✅:
- 性能好(无额外依赖)
- 可靠性高(数据库保证)
- 易于扩展

缺点 ❌:
- 路由逻辑固定,不易调整
- 需要处理数据迁移

推荐场景:
- 分库分表
- 数据量大
- 推荐方案 ⭐⭐⭐⭐

🎯 实战场景

场景1:用户注册(手机号唯一)

/**
 * 完整的用户注册流程
 */
@Service
public class UserRegisterCompleteService {
    
    @Autowired
    private UniquenessCheckService uniquenessCheckService;
    
    @Autowired
    private UserMapper userMapper;
    
    @Autowired
    private SmsService smsService;
    
    /**
     * 注册流程
     */
    @Transactional(rollbackFor = Exception.class)
    public User register(RegisterRequest request) {
        String phone = request.getPhone();
        String smsCode = request.getSmsCode();
        
        // 1. 验证短信验证码
        if (!smsService.verify(phone, smsCode)) {
            throw new BusinessException("验证码错误");
        }
        
        // 2. 检查手机号唯一性并占用
        boolean available = uniquenessCheckService.checkAndOccupy(
            "PHONE", phone, "USER", null
        );
        
        if (!available) {
            throw new BusinessException("手机号已被注册");
        }
        
        try {
            // 3. 创建用户
            User user = new User();
            user.setPhone(phone);
            user.setUsername(generateUsername(phone));  // 自动生成用户名
            user.setAvatar(getDefaultAvatar());
            user.setStatus(UserStatus.NORMAL);
            user.setCreateTime(new Date());
            
            userMapper.insert(user);
            
            // 4. 更新唯一约束表的业务ID
            uniquenessCheckService.updateBusinessId(
                "PHONE", phone, "USER", String.valueOf(user.getId())
            );
            
            // 5. 发送欢迎短信
            smsService.sendWelcome(phone);
            
            log.info("用户注册成功: userId={}, phone={}", user.getId(), phone);
            
            return user;
            
        } catch (Exception e) {
            // 失败回滚:释放手机号占用
            uniquenessCheckService.release("PHONE", phone);
            throw e;
        }
    }
    
    /**
     * 注销用户(释放手机号)
     */
    @Transactional
    public void unregister(Long userId) {
        // 1. 查询用户
        User user = userMapper.selectById(userId);
        if (user == null) {
            throw new BusinessException("用户不存在");
        }
        
        // 2. 释放手机号
        uniquenessCheckService.release("PHONE", user.getPhone());
        
        // 3. 删除用户(软删除)
        userMapper.updateStatus(userId, UserStatus.DELETED);
        
        log.info("用户注销成功,手机号已释放: userId={}, phone={}", 
            userId, user.getPhone());
    }
}

场景2:商家入驻(营业执照唯一)

/**
 * 商家入驻服务
 */
@Service
public class MerchantRegisterService {
    
    @Autowired
    private UniquenessCheckService uniquenessCheckService;
    
    @Autowired
    private MerchantMapper merchantMapper;
    
    /**
     * 商家入驻
     */
    @Transactional
    public Merchant register(MerchantRegisterRequest request) {
        String licenseNo = request.getLicenseNo();  // 营业执照号
        String phone = request.getPhone();
        
        // 1. 检查营业执照号唯一性
        boolean licenseAvailable = uniquenessCheckService.checkAndOccupy(
            "LICENSE_NO", licenseNo, "MERCHANT", null
        );
        
        if (!licenseAvailable) {
            throw new BusinessException("该营业执照已被使用");
        }
        
        // 2. 检查手机号唯一性(商家维度)
        boolean phoneAvailable = uniquenessCheckService.checkAndOccupy(
            "MERCHANT_PHONE", phone, "MERCHANT", null
        );
        
        if (!phoneAvailable) {
            uniquenessCheckService.release("LICENSE_NO", licenseNo);
            throw new BusinessException("该手机号已被使用");
        }
        
        try {
            // 3. 创建商家
            Merchant merchant = new Merchant();
            merchant.setLicenseNo(licenseNo);
            merchant.setCompanyName(request.getCompanyName());
            merchant.setPhone(phone);
            merchant.setStatus(MerchantStatus.PENDING);  // 待审核
            merchant.setCreateTime(new Date());
            
            merchantMapper.insert(merchant);
            
            // 4. 更新业务ID
            String merchantId = String.valueOf(merchant.getId());
            uniquenessCheckService.updateBusinessId(
                "LICENSE_NO", licenseNo, "MERCHANT", merchantId
            );
            uniquenessCheckService.updateBusinessId(
                "MERCHANT_PHONE", phone, "MERCHANT", merchantId
            );
            
            log.info("商家入驻成功: merchantId={}, licenseNo={}", 
                merchant.getId(), licenseNo);
            
            return merchant;
            
        } catch (Exception e) {
            // 失败回滚
            uniquenessCheckService.release("LICENSE_NO", licenseNo);
            uniquenessCheckService.release("MERCHANT_PHONE", phone);
            throw e;
        }
    }
}

📊 方案对比

方案性能一致性复杂度推荐度适用场景
中心化服务⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐通用
分布式锁⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐低并发
布隆过滤器⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐大数据量
分段路由⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐分库分表

🎉 总结

核心要点

1. 问题本质
   分布式环境下,数据库唯一索引失效

2. 解决思路
   ├── 中心化管理(推荐)
   ├── 分布式锁
   ├── 快速过滤
   └── 固定路由

3. 关键技术
   ├── Redis(快速检查)
   ├── 数据库(持久化)
   ├── 分布式锁(并发控制)
   └── 布隆过滤器(快速过滤)

4. 最佳实践
   ├── Redis + 数据库双重保证
   ├── 原子操作(Lua脚本)
   ├── 失败回滚
   └── 监控告警

选择建议

场景1:用户注册(手机号唯一)
方案:中心化唯一性服务
理由:通用、可靠、易维护

场景2:分库分表
方案:分段路由 + 数据库唯一索引
理由:性能好、可靠性高

场景3:超大规模(亿级用户)
方案:布隆过滤器 + 中心化服务
理由:快速过滤,减少数据库压力

场景4:低并发场景
方案:分布式锁
理由:实现简单,足够用

记忆口诀

分布式唯一约束难,
数据库索引已失效。
四种方案来解决,
场景不同方案选。

中心化服务最常用,
Redis数据库双保险。
原子操作用Lua脚本,
失败回滚要记牢。

分布式锁强一致,
性能开销要考虑。
低并发可以用,
高并发要慎重。

布隆过滤性能高,
快速判断减查询。
大数据量适用它,
小概率误判可接受。

分段路由最优雅,
固定路由到同库。
利用数据库索引,
全局唯一有保证!

愿你的系统唯一约束坚不可摧,重复数据无处遁形! 🔐✨