SpringBoot实战:设备唯一ID生成【雪花算法、分布式应用】
背景:
分布式高并发的环境下,设备ID需要在全国各地同一时间申请,毫秒级的时间下可能生成数万个设备ID,此时确保生成设备ID的唯一性变得至关重要。此外,在秒杀环境下,不仅要保障ID唯一性、还得确保ID生成的优先度。
snowflake(雪花算法)方案:
雪花算法生成的是一个 Long 类型的 ID,Long 占用 64 个 bit 位,该算法将 bit 位分为以下部分:
-
符号位:通常为 0,代表正数
-
时间戳:毫秒数,不是直接从 1970 年开始的时间戳,而是代表可使用时间(当前时间减去开始使用时间),大约可使用 2^41 毫秒约 69 年。
-
机器 id:高 5 位为数据中心 id,低 5 位为机器 id
-
序列号:在同一毫秒内从 0 开始的计数器,最大 2^12 为 4096 个,当超过 4096 时,则阻塞等待下一毫秒。既每秒单机可支持并发约为 400 万。
使用雪花算法的原因:
- 能满足高并发分布式系统环境下ID不重复
- 基于时间戳,可以保证基本有序递增(有些业务场景对这个又要求)
- 不依赖第三方的库或者中间件
- 生成效率极高
实现:
雪花算法生成ID:
1、引入工具类
package com.example.demo.code;
import lombok.extern.slf4j.Slf4j;
/**
* Twitter_Snowflake<br>
* SnowFlake的结构如下(每部分用-分开):<br>
* 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>
* 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0<br>
* 41位时间截(毫秒级),注意,41位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截)
* 得到的值),这里的的开始时间截,一般是我们的id生成器开始使用的时间,由我们程序来指定的(如下下面程序IdWorker类的startTime属性)。41位的时间截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>
* 10位的数据机器位,可以部署在1024个节点,包括5位datacenterId和5位workerId<br>
* 12位序列,毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生4096个ID序号<br>
* 加起来刚好64位,为一个Long型。<br>
* SnowFlake的优点是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),并且效率较高,经测试,SnowFlake每秒能够产生26万ID左右。
*/
@Slf4j
public class SnowflakeIdWorker {
// ==============================Fields===========================================
/** 开始时间截 (2015-01-01) */
private final long twepoch = 1420041600000L;
/** 机器id所占的位数 */
private final long workerIdBits = 5L;
/** 数据标识id所占的位数 */
private final long datacenterIdBits = 5L;
/** 支持的最大机器id,结果是31 (这个移位算法可以很快的计算出几位二进制数所能表示的最大十进制数) */
private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
/** 支持的最大数据标识id,结果是31 */
private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
/** 序列在id中占的位数 */
private final long sequenceBits = 12L;
/** 机器ID向左移12位 */
private final long workerIdShift = sequenceBits;
/** 数据标识id向左移17位(12+5) */
private final long datacenterIdShift = sequenceBits + workerIdBits;
/** 时间截向左移22位(5+5+12) */
private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
/** 生成序列的掩码,这里为4095 (0b111111111111=0xfff=4095) */
private final long sequenceMask = -1L ^ (-1L << sequenceBits);
/** 工作机器ID(0~31) */
private long workerId;
/** 数据中心ID(0~31) */
private long datacenterId;
/** 毫秒内序列(0~4095) */
private long sequence = 0L;
/** 上次生成ID的时间截 */
private long lastTimestamp = -1L;
//==============================Constructors=====================================
/**
* 构造函数
* @param workerId 工作ID (0~31)
* @param datacenterId 数据中心ID (0~31)
*/
public SnowflakeIdWorker(long workerId, long datacenterId) {
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}
// ==============================Methods==========================================
/**
* 获得下一个ID (该方法是线程安全的)
* @return SnowflakeId
*/
public synchronized long nextId() {
long timestamp = timeGen();
//如果当前时间小于上一次ID生成的时间戳,说明系统时钟回退过这个时候应当抛出异常
if (timestamp < lastTimestamp) {
throw new RuntimeException(
String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
}
//如果是同一时间生成的,则进行毫秒内序列
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
//毫秒内序列溢出
if (sequence == 0) {
//阻塞到下一个毫秒,获得新的时间戳
timestamp = tilNextMillis(lastTimestamp);
}
}
//时间戳改变,毫秒内序列重置
else {
sequence = 0L;
}
//上次生成ID的时间截
lastTimestamp = timestamp;
//移位并通过或运算拼到一起组成64位的ID
return ((timestamp - twepoch) << timestampLeftShift) //
| (datacenterId << datacenterIdShift) //
| (workerId << workerIdShift) //
| sequence;
}
/**
* 阻塞到下一个毫秒,直到获得新的时间戳
* @param lastTimestamp 上次生成ID的时间截
* @return 当前时间戳
*/
protected long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
/**
* 返回以毫秒为单位的当前时间
* @return 当前时间(毫秒)
*/
protected long timeGen() {
return System.currentTimeMillis();
}
//==============================Test=============================================
/** 测试 */
public static void main(String[] args) {
SnowflakeIdWorker idWorker = new SnowflakeIdWorker(0, 0);
int num = 1000000;
long start = System.currentTimeMillis();
for (int i = 0; i < num; i++) {
long id = idWorker.nextId();
// System.out.println(id);
}
long end = System.currentTimeMillis();
log.info("数据量:{}, 消耗时间:{}",num, (end - start) / 1000);
}
}
测试结果:100W条ID3秒
2、编写实体CodeEntity:
package com.example.demo.code;
import lombok.Data;
/**
* @author xh
* @Date 2022/9/24
*/
@Data
public class CodeEntity {
String codeId;
}
3、CodeController接口测试:
package com.example.demo.code;
import com.example.demo.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.List;
/**
* @author xh
* @Date 2022/9/24
*/
@RestController
@CrossOrigin
@Slf4j
public class CodeController {
/**
* 批量生成设备码
*/
@GetMapping("/getCode")
public Result<List<CodeEntity>> getCode(@RequestParam(required = false) Long num) {
if(num == null){
num = 1L; // 默认生成一个Num;
}
List<CodeEntity> codeEntities = new ArrayList<>();
SnowflakeIdWorker idWorker = new SnowflakeIdWorker(0, 0);
long start = System.currentTimeMillis();
for (int i = 0; i < num; i++) {
long id = idWorker.nextId();
CodeEntity codeEntity = new CodeEntity();
codeEntity.setCodeId(String.valueOf(id));
codeEntities.add(codeEntity);
}
long end = System.currentTimeMillis();
log.info("数据量:{}, 消耗时间:{}",num, (end - start) / 1000);
return new Result<List<CodeEntity>>().ok(codeEntities);
}
}
4、运行测试:
接下来,我们给Code加入状态:status,1代表已激活,0代表未激活
@Data
public class CodeEntity {
String codeId;
Integer status;
}
CodeController增加修改状态方法 :
@GetMapping("/updateStatus")
public Result<String> updateStatus(@RequestParam String url) {
Assert.notNull(url, "url不能为空");
Assert.notNull(!url.contains("num=") ? null : true, "num参数缺失");
String code = url.substring(url.indexOf("num=") + 4);
Assert.notNull(code, "code不能为空");
// TODO 根据Code修改状态
return new Result<String>().ok(code + "激活成功!");
}
二维码打包:
Code转二维码并打包ZIP:
1、导入依赖:
<!-- 二维码生成开始 -->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.2.1</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.3.0</version>
</dependency>
<!-- 二维码生成结束 -->
2、工具类:
package com.example.demo.code;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Hashtable;
import javax.imageio.ImageIO;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.BinaryBitmap;
import com.google.zxing.EncodeHintType;
import com.google.zxing.LuminanceSource;
import com.google.zxing.ReaderException;
import com.google.zxing.Result;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.common.HybridBinarizer;
import com.google.zxing.qrcode.QRCodeReader;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
/**
* 二维码工具类
*
*/
public class QrCodeUtils {
/**
* 生成二维码图片
* @param outputStream 文件输出流路径
* @param content 二维码携带信息
* @param qrCodeSize 二维码图片大小
* @param imageFormat 二维码的格式
* @throws WriterException
* @throws IOException
*/
public static boolean createQrCode(OutputStream outputStream, String content, int qrCodeSize, String imageFormat) throws WriterException, IOException{
//设置二维码纠错级别MAP
Hashtable<EncodeHintType, ErrorCorrectionLevel> hintMap = new Hashtable<>();
hintMap.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.L); // 矫错级别
QRCodeWriter qrCodeWriter = new QRCodeWriter();
//创建比特矩阵(位矩阵)的QR码编码的字符串
BitMatrix byteMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, qrCodeSize, qrCodeSize, hintMap);
// 使BufferedImage勾画QRCode (matrixWidth 是行二维码像素点)
int matrixWidth = byteMatrix.getWidth();
BufferedImage image = new BufferedImage(matrixWidth-200, matrixWidth-200, BufferedImage.TYPE_INT_RGB);
image.createGraphics();
Graphics2D graphics = (Graphics2D) image.getGraphics();
graphics.setColor(Color.WHITE);
graphics.fillRect(0, 0, matrixWidth, matrixWidth);
// 使用比特矩阵画并保存图像
graphics.setColor(Color.BLACK);
for (int i = 0; i < matrixWidth; i++){
for (int j = 0; j < matrixWidth; j++){
if (byteMatrix.get(i, j)){
graphics.fillRect(i-100, j-100, 1, 1);
}
}
}
return ImageIO.write(image, imageFormat, outputStream);
}
/**
* 解析二维码图片
*/
public static void readQrCode(InputStream inputStream) throws IOException{
//从输入流中获取字符串信息
BufferedImage image = ImageIO.read(inputStream);
//将图像转换为二进制位图源
LuminanceSource source = new BufferedImageLuminanceSource(image);
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
QRCodeReader reader = new QRCodeReader();
Result result = null ;
try {
result = reader.decode(bitmap);
} catch (ReaderException e) {
e.printStackTrace();
}
System.out.println("解析结果:"+result.toString());
System.out.println("二维码格式类型:"+result.getBarcodeFormat());
System.out.println("二维码文本内容:"+result.getText());
}
/**
* main方法
* @throws WriterException
*/
public static void main(String[] args) throws IOException, WriterException {
createQrCode(new FileOutputStream(new File("F:\\test.png")),"www.baidu.com",800,"PNG");
readQrCode(new FileInputStream(new File("F:\\test.png")));
}
}
生成结果:
接下来我们实现二维码批量打包:
1、修改QrCodeUtils中的createQrCode方法:
/**
* 生成二维码图片
* @param content 二维码携带信息
* @param qrCodeSize 二维码图片大小
* @param imageFormat 二维码的格式
* @throws WriterException
* @throws IOException
*/
public static InputStream createQrCode(String content, int qrCodeSize, String imageFormat) throws WriterException, IOException{
//设置二维码纠错级别MAP
Hashtable<EncodeHintType, ErrorCorrectionLevel> hintMap = new Hashtable<>();
hintMap.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.L); // 矫错级别
QRCodeWriter qrCodeWriter = new QRCodeWriter();
//创建比特矩阵(位矩阵)的QR码编码的字符串
BitMatrix byteMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, qrCodeSize, qrCodeSize, hintMap);
// 使BufferedImage勾画QRCode (matrixWidth 是行二维码像素点)
int matrixWidth = byteMatrix.getWidth();
BufferedImage image = new BufferedImage(matrixWidth-200, matrixWidth-200, BufferedImage.TYPE_INT_RGB);
image.createGraphics();
Graphics2D graphics = (Graphics2D) image.getGraphics();
graphics.setColor(Color.WHITE);
graphics.fillRect(0, 0, matrixWidth, matrixWidth);
// 使用比特矩阵画并保存图像
graphics.setColor(Color.BLACK);
for (int i = 0; i < matrixWidth; i++){
for (int j = 0; j < matrixWidth; j++){
if (byteMatrix.get(i, j)){
graphics.fillRect(i-100, j-100, 1, 1);
}
}
}
ByteArrayOutputStream os = new ByteArrayOutputStream();
try {
ImageIO.write(image, imageFormat, os);
InputStream input = new ByteArrayInputStream(os.toByteArray());
return input;
} catch (IOException e) {
}
return null;
}
2、编写CodeController中的getCodeZip方法:
/**
* 获取二维码压缩包
*
* @param response
* @param list 二维码字符串列表
*/
@PostMapping("/getCodeZip")
public void getCodeZip(HttpServletResponse response, @RequestBody List<CodeEntity> list) {
// 生成二维码图片
response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition", "attachment; filename=QrCode-" + System.currentTimeMillis() + ".zip");// 压缩包名
ZipOutputStream zos = null;
try {
zos = new ZipOutputStream(response.getOutputStream());
// zos.setLevel(5);//压缩等级
for (int j = 0; j < list.size(); j++) {
String codeString = list.get(j).getCodeId();// 获取二维码字符串
// OutputStream outputStream, String content, int qrCodeSize, String imageFormat
InputStream inputStream = QrCodeUtils.createQrCode(codeString, 900, "JPEG");// 生成二维码图片
zos.putNextEntry(new ZipEntry(codeString + ".JPEG")); // 压缩文件名称 设置ZipEntry对象
// zos.setComment("采样二维码"); // 设置注释
int temp = 0;
while ((temp = inputStream.read()) != -1) { // 读取内容
zos.write(temp); // 压缩输出
}
inputStream.close(); // 关闭输入流
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (null != zos) {
zos.flush();
zos.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
3、使用Apifox测试:
测试数据:
[
{"codeId": "123", "status": 0},
{"codeId": "12223", "status": 0},
{"codeId": "1232232", "status": 1},
{"codeId": "1223233", "status": 1}
]
测试结果:
多线程优化-批量插入:
设备批量插入及性能调优:
测试数据量:10W,单量插入:
@Test
void insertOneCode() {
Long num = 100000L;
List<CodeEntity> codeEntities = new ArrayList<>();
SnowflakeIdWorker idWorker = new SnowflakeIdWorker(0, 0);
long start = System.currentTimeMillis();
for (int i = 0; i < num; i++) {
long id = idWorker.nextId();
CodeEntity codeEntity = new CodeEntity();
codeEntity.setCodeId(String.valueOf(id));
codeEntities.add(codeEntity);
codeService.save(codeEntity);
}
long end = System.currentTimeMillis();
log.info("数据量:{}, 消耗时间:{}", num, (end - start) / 1000);
}
测试结果:数据量:100000, 消耗时间:727
这样的性能我们肯定不能接受,所以我们接下来将数据等分为10份,每1W条批量插入一次,我们再看看性能:
@Test
void insertCode() {
Long num = 100000L; // 总数据量
int singleNum = 10000; // 单次批量插入数据
int CirNum = 10; // 循环次数
SnowflakeIdWorker idWorker = new SnowflakeIdWorker(0, 0);
long start = System.currentTimeMillis();
// 数据等分为10份
for(int n = 0; n < CirNum; ++ n) {
List<CodeEntity> codeEntities = new ArrayList<>();
for (int i = 0; i < singleNum; i++) {
long id = idWorker.nextId();
CodeEntity codeEntity = new CodeEntity();
codeEntity.setCodeId(String.valueOf(id));
codeEntities.add(codeEntity);
}
codeService.saveBatch(codeEntities);
}
long end = System.currentTimeMillis();
log.info("数据量:{}, 消耗时间:{}", num, (end - start) / 1000);
}
测试结果:数据量:100000, 消耗时间:12
性能提升了不少
二维码识别+扫码激活:
我们重新修改更新状态的方法:
@GetMapping("/updateStatus")
public Result<String> updateStatus(@RequestParam String code) {
Assert.notNull(code, "code不能为空");
boolean status = codeService.updateStatus(code);
if(status) {
return new Result<String>().ok(code + "激活成功!");
}
return new Result<String>().ok(code + "激活失败!");
}
每一个设备ID都唯一,所以我们将设备ID设置为主键,加快查询速度
@Override
public boolean updateStatus(String code) {
CodeEntity codeEntity = new CodeEntity();
codeEntity.setCodeId(code);
codeEntity.setStatus(1);
baseMapper.updateById(codeEntity);
return true;
}
接下来我们测试一下设备状态修改:
对Code打包重新编写:
/**
* 批量生成设备码
*/
@GetMapping("/getCode")
public void getCode(HttpServletResponse response, @RequestParam(required = false) Long num) {
if(num == null){
num = 1L; // 默认生成一个Num;
}
List<CodeEntity> codeEntitiesAll = new ArrayList<>();
// 总数据量
int cirNum = 10; // 循环次数
int singleNum = (int) (num / cirNum); // 单次批量插入数据
SnowflakeIdWorker idWorker = new SnowflakeIdWorker(0, 0);
// 数据等分为10份
for(int n = 0; n < cirNum; ++ n) {
List<CodeEntity> codeEntities = new ArrayList<>();
for (int i = 0; i <= singleNum; i++) {
long id = idWorker.nextId();
CodeEntity codeEntity = new CodeEntity();
codeEntity.setCodeId(String.valueOf(id));
codeEntities.add(codeEntity);
codeEntitiesAll.add(codeEntity);
}
codeService.saveBatch(codeEntities);
}
getCodeZip(response, codeEntitiesAll);
}
/**
* 获取二维码压缩包
*
* @param response
* @param list 二维码字符串列表
*/
private void getCodeZip(HttpServletResponse response, List<CodeEntity> list) {
// 生成二维码图片
response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition", "attachment; filename=QrCode-" + System.currentTimeMillis() + ".zip");// 压缩包名
ZipOutputStream zos = null;
try {
zos = new ZipOutputStream(response.getOutputStream());
// zos.setLevel(5);//压缩等级
for (CodeEntity codeEntity : list) {
String codeString = codeEntity.getCodeId();// 获取二维码字符串
// OutputStream outputStream, String content, int qrCodeSize, String imageFormat
InputStream inputStream = QrCodeUtils.createQrCode(codeString, 900, "JPEG");// 生成二维码图片
zos.putNextEntry(new ZipEntry(codeString + ".JPEG")); // 压缩文件名称 设置ZipEntry对象
// zos.setComment("采样二维码"); // 设置注释
int temp = 0;
while ((temp = inputStream.read()) != -1) { // 读取内容
zos.write(temp); // 压缩输出
}
inputStream.close(); // 关闭输入流
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (null != zos) {
zos.flush();
zos.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
编写二维码扫码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>二维码:生成、扫描、识别</title>
<link rel="stylesheet" href="./css/base.css" />
<script src="https://hm.baidu.com/hm.js?29d14c9fb6158fcbec79d1a0d1425404"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
</head>
<body>
<menu class="menu" id="menu">
<nav class="active">二维码识别</nav>
</menu>
<main class="main">
<aside class="reader">
<button class="sweep" onclick="sweep()">扫一扫</button>
<div class="imgurl">
<img id="imgurl" src="https://img-blog.csdnimg.cn/4d4f1a8226584943b1849d6eb7aee233.png"
alt="当前识别的二维码" />
</div>
<textarea class="result" id="result" cols="32" rows="6" placeholder="二维码识别结果!"></textarea>
<canvas class="canvas" id="canvas"></canvas>
</aside>
</main>
<!-- 二维码识别 -->
<script src="./js/jimp.js"></script>
<script src="./js/jsqr.min.js"></script>
<script src="./js/base.js"></script>
<script>
const result = document.querySelector('#result');
const QrCode = new QrCodeRecognition({
sweepId: '#canvas',
uploadId: '#file',
error: function (err) {
// console.log(err)
// 识别错误反馈
result.value = err;
},
seuccess: function (res) {
// 识别成功反馈
console.log(res)
console.log(res.data);
result.value = res.data;
$.get(
`http://localhost:8080/updateStatus?code=${res.data}`,
function (data) {
console.log(data)
},
"json"
);
}
});
// 扫一扫
function sweep() {
result.value = '';
QrCode.sweep();
};
</script>
<!-- Demo页面交互 -->
<script>
const menu = [...document.querySelectorAll('nav')];
const aside = [...document.querySelectorAll('aside')];
menu.forEach((nav, n) => {
menu[n].classList.add('active');
aside[n].style.display = 'block';
});
</script>
</body>
</html>