把哈希表换成 tire 树,居然为公司省下了几千万
你有没有想过,仅仅省下1%的计算资源,能为一家大公司带来多大的影响?你可能觉得,1%听起来微不足道,完全不值得一提。但今天我们聊一下一个技术优化点,就是关于如何通过微小的优化,Cloudflare这样的大型网络公司如何省下了大量的计算资源,背后还有不少值得我们学习的智慧。
你也在为计算资源头疼吗?
如果你是个开发者,尤其是负责维护大规模服务的开发者,你一定对计算资源的消耗有深刻的体会。无论是服务器的 CPU 使用率还是内存消耗,甚至是网络带宽,稍有不慎就可能让成本暴增。而且,问题不止是花钱那么简单,资源浪费还会拖慢你的系统,影响用户体验,最终给公司带来巨大的损失。
我要说的是 Cloudfllare 公司的案例,它不是什么宏大的技术革新或颠覆性的变革,而是从一些不起眼的小地方着手,积少成多,最终实现了1%的节省。这背后到底有什么诀窍?我们一起来看看。
1. 换个数据结构,省时又省力
在Cloudflare的案例中,他们的第一个关键优化是引入了更高效的数据结构。在大规模的数据处理中,数据结构的选择往往是决定性能的关键因素。
他们提到了将原有的哈希表结构换成了更适合他们需求的trie(字典树)结构。
下述是然来的 hash 表结构
// PERF: heavy function: 1.7% CPU time
pub fn clear_internal_headers(request_header: &mut RequestHeader) {
INTERNAL_HEADERS.iter().for_each(|h| {
request_header.remove_header(h);
});
}
这是优化之后的版本
pub fn clear_internal_headers(request_header: &mut RequestHeader) {
let to_remove = request_header
.headers
.keys()
.filter_map(|name| INTERNAL_HEADER_SET.get(name))
.collect::<Vec<_>>();
to_remove.int0_iter().for_each(|k| {
request_header.remove_header(k);
});
可以看到,他是先构造了一颗 tire 树,然后在进行操作
那么,为什么是trie呢?因为它能更高效地存储和处理特定类型的数据,特别是字符串相关的操作。每次的字符串查找、匹配操作都能变得更加快速,减少了不必要的计算消耗。
这就好比我们平时在超市找商品,如果货架排布得井井有条,一目了然,那么找起东西来肯定又快又省力。
2. 不只是省电,它还能加速系统响应
有时候,节省计算资源并不仅仅体现在电费账单上,它还直接影响系统的响应时间。你有没有遇到过访问一个网站时,页面加载缓慢,让你心急如焚?这很大程度上与后台的计算效率有关。
Cloudflare在优化trie结构后,明显提升了系统的响应速度。
举个通俗的例子,如果原本的哈希表是一个在黑夜中摸索东西的场景,那优化后的trie结构就是在白天找东西——路径明确,操作直观,不浪费多余的时间。这种提升,虽然看起来只是毫秒级的,但在每天处理数以亿计的请求时,省下来的时间就变得非常可观了。
3. 别小看每一次小改动
也许你会问:“这1%的优化,真有那么重要吗?” Cloudflare给出的答案是肯定的。别看只是1%,但在他们这种大规模系统中,每天的请求数以亿计,这1%就意味着节省了大量的服务器计算资源。试想,如果你每天的电费能减少1%,一年下来呢?这可是一笔不小的费用。
而且,从另一个角度看,任何微小的改动都有可能是更大优化的开始。Cloudflare的工程师们通过这次的优化,深入分析了系统中的其他潜在问题,发掘出更多可以提升的地方。这就像是修车时,你发现一个小问题,结果一修就发现了更多的隐患,最后车子不仅恢复了正常,还比以前跑得更快。
4. 现在,从你的小项目开始优化吧
当然,Cloudflare的规模可能让很多普通开发者觉得遥不可及,但这并不意味着我们不能从中学到东西。你手上的小项目同样可以从数据结构、代码效率等方面着手进行优化。
比如,如果你正在处理大量的字符串数据,不妨考虑一下是否可以用trie这种结构来提升效率。或者你可以先从代码的性能分析开始,看看有没有哪个部分的计算特别耗时。通过一点点的优化,哪怕最终只提升了1%,也会让你长期受益。不过,注意,过早优化,依然是万恶之源,先使用优雅的代码实现,上线后做性能瓶颈分析才是正道,换句话说,一个只有 10 几个 PV的地方,那么是再好的数据结构和算法,也很难体现出商业成本上的价值。
5. 未来的路:每一秒都很重要
在如今这个对速度要求极高的互联网时代,每一秒、甚至每一毫秒的节省都至关重要。你以为的“小改动”,可能就是让你的服务脱颖而出的关键。
Cloudflare通过这些小优化,每天节省的计算资源不仅让他们的服务更加高效,也给其他同行树立了一个榜样:技术上的进步不一定是依靠大刀阔斧的改革,有时候,从细微处着手,能带来同样惊人的效果。
不知道,你是否也开始思考自己项目中的那些“隐形”问题了呢?或许,你已经意识到了一些可以优化的地方,但还没来得及动手。那为什么不从今天开始,试着优化一两个小地方呢?也许下次的1%提升,就来自于你的一点点努力。
这个世界上没有小优化,只有还没被发现的优化。
来源:juejin.cn/post/7412848251844280360
用rust写个flutter插件并上传 pub.dev
今天收到一个需求,要求在flutter端把任意类型的图片,转换成bmp类型的图片,然后把 bmp位图发送到条码打印机,生成的 bmp图片是 1 位深度的,只有黑白两种像素颜色
包已经上传到 pub.dev,pub.dev/packages/ld…
效果图
1.生成插件包
crates.io地址: crates.io/crates/frb_…
安装命令
cargo install frb_plugin_tool
使用很简单,输入frb_plugin_tool即可
按照提示输入插件名
创建后的项目目录大概像这样
2. 编写 rust代码
我这里图片转 bmp工具用的是rust image这个包
添加依赖
cd rust && cargo add image
然后在 src/api
目录下添加image.rs
文件
use std::{io::Cursor, time::Instant};
use bytesize::ByteSize;
use humantime::format_duration;
use image::{GrayImage, Luma};
use indicatif::ProgressBar;
use log::debug;
use super::entitys::{LddImageType, ResizeOpt};
///任意图像转 1 位深度的数据
pub fn convert_to_1bit_bmp(
input_data: &[u8],
image_type: LddImageType,
resize: Option<ResizeOpt>,
is_apply_ordered_dithering: Option<bool>,
) -> Vec<u8> {
let use_ordered_dithering = is_apply_ordered_dithering.map_or(false, |v| v);
let start = Instant::now();
debug!("开始转换,数据大小:{:?}", ByteSize(input_data.len() as u64));
let mut img =
image::load(Cursor::new(input_data), image_type.int0()).expect("Failed to load image");
if let Some(size) = resize {
debug!("开始格式化尺寸:{:?}", size);
img = img.resize(size.width, size.height, size.filter.int0());
debug!("✅格式化尺寸完成");
}
let mut gray_img = img.to_luma8(); // 转换为灰度图像
if use_ordered_dithering {
debug!("✅使用 h4x4a 抖动算法");
gray_img = apply_ordered_dithering(&gray_img);
}
let (width, height) = gray_img.dimensions();
let row_size = ((width + 31) / 32) * 4; // 每行字节数 4 字节对齐
let mut bmp_data = vec![0u8; row_size as usize * height as usize];
// 创建进度条
let progress_bar = ProgressBar::new(height as u64);
// 二值化处理并填充 BMP 数据(1 位深度)
let threshold = 128;
for y in 0..height {
let inverted_y = height - 1 - y; // 倒置行顺序
for x in 0..width {
let pixel = gray_img.get_pixel(x, y)[0];
if pixel >= threshold {
bmp_data[inverted_y as usize * (row_size as usize) + (x / 8) as usize] |=
1 << (7 - (x % 8));
}
}
progress_bar.inc(1); // 每处理一行,进度条增加一格
}
progress_bar.finish_with_message("Conversion complete!");
// BMP 文件头和 DIB 信息头
let file_size = 14 + 40 + 8 + bmp_data.len(); // 文件头 + DIB 头 + 调色板 + 位图数据
let bmp_header = vec![
0x42,
0x4D, // "BM"
(file_size & 0xFF) as u8,
((file_size >> 8) & 0xFF) as u8,
((file_size >> 16) & 0xFF) as u8,
((file_size >> 24) & 0xFF) as u8,
0x00,
0x00, // 保留字段
0x00,
0x00, // 保留字段
54 + 8,
0x00,
0x00,
0x00, // 数据偏移(54 字节 + 调色板大小)
];
let dib_header = vec![
40,
0x00,
0x00,
0x00, // DIB 头大小(40 字节)
(width & 0xFF) as u8,
((width >> 8) & 0xFF) as u8,
((width >> 16) & 0xFF) as u8,
((width >> 24) & 0xFF) as u8,
(height & 0xFF) as u8,
((height >> 8) & 0xFF) as u8,
((height >> 16) & 0xFF) as u8,
((height >> 24) & 0xFF) as u8,
1,
0x00, // 颜色平面数
1,
0x00, // 位深度(1 位)
0x00,
0x00,
0x00,
0x00, // 无压缩
0x00,
0x00,
0x00,
0x00, // 图像大小(可为 0,表示无压缩)
0x13,
0x0B,
0x00,
0x00, // 水平分辨率(2835 像素/米)
0x13,
0x0B,
0x00,
0x00, // 垂直分辨率(2835 像素/米)
0x02,
0x00,
0x00,
0x00, // 调色板颜色数(2)
0x00,
0x00,
0x00,
0x00, // 重要颜色数(0 表示所有颜色都重要)
];
// 调色板(黑白)
let palette = vec![
0x00, 0x00, 0x00, 0x00, // 黑色
0xFF, 0xFF, 0xFF, 0x00, // 白色
];
// 将所有部分组合成 BMP 文件数据
let mut bmp_file_data = Vec::with_capacity(file_size);
bmp_file_data.extend(bmp_header);
bmp_file_data.extend(dib_header);
bmp_file_data.extend(palette);
bmp_file_data.extend(bmp_data);
let duration = start.elapsed(); // 计算耗时
debug!(
"✅转换完成,数据大小:{:?},耗时:{}",
ByteSize(bmp_file_data.len() as u64),
format_duration(duration)
);
bmp_file_data
}
// 有序抖动矩阵(4x4 Bayer 矩阵)
const DITHER_MATRIX: [[f32; 4]; 4] = [
[0.0, 8.0, 2.0, 10.0],
[12.0, 4.0, 14.0, 6.0],
[3.0, 11.0, 1.0, 9.0],
[15.0, 7.0, 13.0, 5.0],
];
//h4x4a 抖动算法
fn apply_ordered_dithering(image: &GrayImage) -> GrayImage {
let (width, height) = image.dimensions();
let mut dithered_image = GrayImage::new(width, height);
for y in 0..height {
for x in 0..width {
let pixel = image.get_pixel(x, y)[0];
let threshold = DITHER_MATRIX[(y % 4) as usize][(x % 4) as usize] / 16.0 * 255.0;
let new_pixel_value = if pixel as f32 > threshold { 255 } else { 0 };
dithered_image.put_pixel(x, y, Luma([new_pixel_value]));
}
}
dithered_image
}
生成 dart代码,在项目根目录下执行
flutter_rust_bridge_codegen generate
会在dart lib下生成对应的文件
在项目中使用
编写 example , main.dart
.
import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:ldd_bmp/api/entitys.dart';
import 'package:ldd_bmp/api/image.dart';
import 'package:ldd_bmp/ldd_bmp.dart';
import 'dart:async';
import 'package:path_provider/path_provider.dart';
const reSize = ResizeOpt(
width: 200,
height: 200,
filter: LddFilterType.nearest,
);
Future<void> main() async {
await bmpSdkInit();
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
File? file;
Uint8List? bmpData;
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Native Packages'),
),
body: SingleChildScrollView(
child: Column(
children: [
FilledButton(onPressed: selectFile, child: const Text('选择文件')),
if (file != null)
Image.file(
file!,
width: 200,
height: 200,
),
ElevatedButton(
onPressed: file == null
? null
: () async {
final bts = await file!.readAsBytes();
bmpData = await convertTo1BitBmp(
inputData: bts,
imageType: LddImageType.jpeg,
isApplyOrderedDithering: true,
resize: const ResizeOpt(
width: 200,
height: 200,
filter: LddFilterType.nearest,
));
setState(() {});
},
child: const Text("转换")),
ElevatedButton(
onPressed: bmpData == null
? null
: () {
saveImageToFile(bmpData!);
},
child: const Text("保存图片"))
],
),
),
floatingActionButton: bmpData != null
? ConstrainedBox(
constraints:
const BoxConstraints(maxHeight: 300, maxWidth: 300),
child: Card(
elevation: 10,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
const Text('转换结果'),
Image.memory(bmpData!),
],
),
),
),
)
: null,
),
);
}
Future<void> selectFile() async {
setState(() {
file = null;
});
FilePickerResult? result = await FilePicker.platform.pickFiles();
if (result != null) {
file = File(result.files.single.path!);
setState(() {});
} else {
// User canceled the picker
}
}
}
Future<void> saveImageToFile(Uint8List imageData) async {
// 获取应用程序的文档目录
final directory = await getApplicationDocumentsDirectory();
// 设置文件路径和文件名
final filePath = '${directory.path}/image.bmp';
// 创建一个文件对象
final file = File(filePath);
// 将Uint8List数据写入文件
await file.writeAsBytes(imageData);
print('Image saved to $filePath');
}
转换速度还是挺快的,运行效果
上传到 pub.dev
这个包已经上传到仓库了,可以直接使用
pub.dev/packages/ld…
来源:juejin.cn/post/7412486655862734874
Java 语法糖,你用过几个?
你好,我是猿java。
这篇文章,我们来聊聊 Java 语法糖。
什么是语法糖?
语法糖(Syntactic Sugar)是编程语言中的一种设计概念,它指的是在语法层面上对某些操作提供更简洁、更易读的表示方式。这种表示方式并不会新增语言的功能,而只是使代码更简洁、更直观,便于开发者理解和维护。
语法糖的作用:
- 提高代码可读性:语法糖可以使代码更加贴近自然语言或开发者的思维方式,从而更容易理解。
- 减少样板代码:语法糖可以减少重复的样板代码,使得开发者可以更专注于业务逻辑。
- 降低出错率:简化的语法可以减少代码量,从而降低出错的概率。
因此,语法糖不是 Java 语言特有的,它是很多编程语言设计中的一些语法特性,这些特性使代码更加简洁易读,但并不会引入新的功能或能力。
那么,Java中有哪些语法糖呢?
Java 语法糖
1. 自动装箱与拆箱
自动装箱和拆箱 (Autoboxing and Unboxing)是 Java 5 引入的特性,用于在基本数据类型和它们对应的包装类之间自动转换。
// 自动装箱
Integer num = 10; // 实际上是 Integer.valueOf(10)
// 自动拆箱
int n = num; // 实际上是 num.intValue()
2. 增强型 for 循环
增强型 for 循环(也称为 for-each 循环)用于遍历数组或集合。
int[] numbers = {1, 2, 3, 4, 5};
for (int number : numbers) {
System.out.println(number);
}
3. 泛型
泛型(Generics)使得类、接口和方法可以操作指定类型的对象,提供了类型安全的检查和消除了类型转换的需要。
List<String> list = new ArrayList<>();
list.add("Hello");
String s = list.get(0); // 不需要类型转换
4. 可变参数
可变参数(Varargs)允许在方法中传递任意数量的参数。
public void printNumbers(int... numbers) {
for (int number : numbers) {
System.out.println(number);
}
}
printNumbers(1, 2, 3, 4, 5);
5. try-with-resources
try-with-resources 语句用于自动关闭资源,实现了 AutoCloseable
接口的资源会在语句结束时自动关闭。
try (BufferedReader br = new BufferedReader(new FileReader("file.txt"))) {
System.out.println(br.readLine());
} catch (IOException e) {
e.printStackTrace();
}
6. Lambda 表达式
Lambda 表达式是 Java 8 引入的特性,使得可以使用更简洁的语法来实现函数式接口(只有一个抽象方法的接口)。
List<String> list = Arrays.asList("a", "b", "c");
list.forEach(s -> System.out.println(s));
7. 方法引用
方法引用(Method References)是 Lambda 表达式的一种简写形式,用于直接引用已有的方法。
list.forEach(System.out::println);
8. 字符串连接
从 Java 5 开始,Java 编译器会将字符串的连接优化为 StringBuilder
操作。
String message = "Hello, " + "world!"; // 实际上是 new StringBuilder().append("Hello, ").append("world!").toString();
9. Switch 表达式
Java 12 引入的 Switch 表达式使得 Switch 语句更加简洁和灵活。
int day = 5;
String dayName = switch (day) {
case 1 -> "Sunday";
case 2 -> "Monday";
case 3 -> "Tuesday";
case 4 -> "Wednesday";
case 5 -> "Thursday";
case 6 -> "Friday";
case 7 -> "Saturday";
default -> "Invalid day";
};
10. 类型推断 (Type Inference)
Java 10 引入了局部变量类型推断,通过 var
关键字来声明变量,编译器会自动推断变量的类型。
var list = new ArrayList<String>();
list.add("Hello");
这些语法糖使得 Java 代码更加简洁和易读,但需要注意的是,它们并不会增加语言本身的功能,只是对已有功能的一种简化和封装。
总结
本文,我们介绍了 Java 语言中的一些语法糖,从上面的例子可以看出,Java 语法糖只是一些简化的语法,可以使代码更简洁易读,而本身并不增加新的功能。
学习交流
如果你觉得文章有帮助,请帮忙转发给更多的好友,或关注公众号:猿java,持续输出硬核文章。
来源:juejin.cn/post/7412672643633791039
BOE·IPC电竞大赛暨BOE无畏杯S2完美收官 BOE(京东方)竖立电竞产业生态新标杆
在开幕环节,高文宝博士表示:“电竞是年轻人的活动,年轻人有着活跃的思想和强大的创造力。近年来,BOE(京东方)通过BOE无畏杯和ChinaJoy等活动加深了和年轻人的沟通,与年轻人成为了真诚的伙伴和挚友,在这个过程中,BOE(京东方)也激发了新的创造灵感,做出更好更惊艳的产品。未来BOE(京东方)还会持续在技术、产品、活动等方面,与合作伙伴一起带来异彩纷呈的电竞体验,带动电竞产业链的价值提升,助力中国电竞再创高峰。”
今年举办的BOE无畏杯总决赛活动上,BOE(京东方)还特别打造"Best of Esports电竞高阶联盟"产品展区,集中展现了联盟伙伴们最新的电竞产品和尖端技术。依托于BOE(京东方)自主研发的ADS Pro、a-MLED等创新技术赋能,AGON爱攻 AG275QZW显示器支持260Hz超高刷新率,以1ms GTG疾速响应时间为玩家提供高帧率、低延迟的游戏画质,确保流畅丝滑的游戏体验;ROG枪神8 Plus超竞版笔记本,支持60-240Hz动态调频刷新率及3ms极速响应,玩家操作无比顺畅……一系列电竞黑科技产品凭借高清流畅的显示画面和酷炫的科技外观吸引了现场粉丝纷纷体验,为观众们呈现了一场融合竞技与科技的盛宴。
总决赛现场更是异彩纷呈,现场Coser开场秀、无畏契约水友赛等丰富的互动环节点燃了现场观众的热情,更有BOE无畏契约战队对战JDG无畏契约战队表演赛,BOE战队面对职业战队分毫不让、竞出风采,让决赛前的氛围达到了高潮。在总决赛启动仪式上,BOE(京东方)副总裁刘毅、虎牙直播商业化副总裁焦阳、京东集团3C数码事业群电脑组件业务部总经理蔡欣洋一起揭开BOE无畏杯《无畏契约》2024挑战赛总决赛的帷幕,总决赛最终在上一届亚军津门飞鹰战队与CCG战队的较量中展开对决,经过3局激战,CCG队获得最终胜利,拿下本届赛事的冠军。
多年来,BOE(京东方)以技术创新为驱动,通过高刷新率、护眼科技等技术产品优势、广泛的合作以及强大的品牌影响力,从技术、产品、生态等多个方面助力电竞产业发展,获得了众多全球一线客户的支持和好评,引领了整个电竞产业的升级和变革。未来,BOE(京东方)将继续秉持"Powered by BOE"的生态理念,充分发挥"Best of Esports电竞高阶联盟"在全业态布局、资源聚合和技术领先等方面的优势,通过持续不断的技术创新和产业链整合,为我国电竞生态贡献力量,为数字经济的高质量发展注入新的动力。
收起阅读 »Mysql中各种日志、缓冲区都是干嘛的?
介绍
本篇文章主要以innodb存储引擎为主;在了解mysql的过程中经常能听到它内部有各种log以及缓冲区,他们在mysql中具有重要作用,例如binlog
可以进行主从恢复,undo log
可以进行数据回滚等。这篇文章主要讲解在mysql运气期间每个区域都是用来做什么的。
写入数据流程
对于mysql来讲,读写任何数据都是在内存中进行操作的;下图为mysql写入数据的详细流程:
- 写入undo log,为了实现回滚的功能,在写入真实数据前需要记录它的回滚日志,防止写入完数据后无法进行回滚;
- 写入
buffer pool
或change buffer
,在缓存中记录下数据内容; - 为了防止mysql崩溃内存中的数据丢失,此时会记录下redo log,记录redo log时也是写入它的buffer,通过不同的刷盘策略刷入到磁盘redo log文件中;
- 为了实现主从同步,数据恢复功能,mysql提供了binlog日志,写入完redo log后写binlog文件;
- 为了使binlog和redo log保持数据一致,这里采用的二阶段提交,写入binlog成功会再在redo log buffer中写入commit;
- 对redo log进行刷盘,这里有三种刷盘策略,介绍一下刷盘策略;
- 对buffer pool中的数据进行刷盘。
undo log
undo log记录事务开始前的数据状态,它主要用于数据回滚和实现MVCC:
- 回滚操作:undo log记录了事务开始前的数据状态,当事务需要回滚时,以便可以恢复到原始状态。
- 多版本并发控制(MVCC) :在读取历史数据时,undo log允许读取到事务开始前的数据版本,从而实现非锁定读取。
MVCC的具体实现可以查看:MVCC实现
buffer pool
innodb中无论是查询还是写绝大部分都是在buffer pool中进行操作的,它相当于innodb的缓存区,可以通过show engine innodb status
来查看buffer pool的使用情况;可以通过innodb_buffer_pool_size
来设置buffer pool的大小,线上不要吝啬给几个G内存都是正常的,但无论给多大内存都会有不够的时候,innodb采用了变种的LRU算法对数据页进行淘汰;如下图:
传统的LRU算法当碰到扫描一张大表时可能会直接把buffer pool中的所有页都更换为该表的数据,但这张表可能就使用一次,并不是热点数据;
innodb为了避免这种场景发生,会把整个buffer pool按照 5:3分成了young区域和old区域;其中绿色区域就是young区域也就是热点数据区域,紫色区域就是old区域也就是冷数据区域;整体的淘汰流程为:
- 如果想访问绿色区域内的数据,会把访问页直接放在young head处;
- 如果想访问一个不存在的页,会把tail页淘汰掉,并且把新访问的数据页插入在old head处;
- 如果访问old区域的数据页,并且这个数据页在LRU链表中存在的时间超过了
innodb_old_blocks_time
(默认1000毫秒),就把它移动到yound head处; - 如果访问old区域的数据页,并且这个数据页在LRU链表中存在的时间短于
innodb_old_blocks_time
,把该页移动到old head处。
在上图中可以看到除了LRU链表还有一个Flush链表,它是用来管理脏页的;在写入数据时绝大部分都会先写入buffer pool中,再更改buffer pool中的页数据时,该页就变成了脏页,此时就会被加入到flush链表中,定时会把flush中的脏页刷到.idb数据文件中。
change buffer
在介绍buffer pool时用的是绝大部分
操作,是因为在innodb中还存在change buffer,还有一部分操作是写入change buffer的。change buffer
的定义是当需要更新一个数据页时,如果数据页在内存中就直接更新,而如果这个数据页还没有在内存中的话,在不影响数据一致性的前提下,innodb会将这些更新操作缓存在change buffer中,这样就不需要从磁盘中加载这个数据页了,如果有查询需要访问这个数据页的时候,再将数据页读到内存中,然后执行change buffer中与这个页有关的操作,这样就能保证这个数据的正确性。change buffer
用的是buffer pool中的内存,可以通过innodb_change_buffer_max_size
来设置它占用buffer pool的内存比例。使用change buffer的前提条件是该数据页还没被加载到buffer pool中,并且如果是根据唯一索引进行更新,由于要检查数据的唯一性,必须把数据页加载到buffer pool中是无法享受change buffer带来的收益的。
redo log 与 redo log buffer
redo log是为了防止由于mysql异常退出导致buffer pool中还未持久化的数据丢失而诞生的;
它也是一个环形文件写数据写满时会覆盖历史的数据,它记录了数据页的物理变化,并且是顺序写入的提升了写入的性能;当mysql重启时可以使用redo log来恢复数据。
每次写redo log时并不是直接写入redo log文件,而是写入redo log buffer中,通过三种刷盘策略把数据同步到redo log中,可以通过innodb_flush_log_at_trx_commit
参数来控制刷盘的时机
- 0:事务提交时,日志缓冲(log buffer)被写入到日志文件,但并不立即刷新到磁盘。日志文件的刷新操作由后台线程每隔一秒执行一次;
- 1:事务提交时,日志缓冲被写入到日志文件,并立即刷新到磁盘;
- 2:事务提交时,日志缓冲被写入到日志文件,但不立即刷新到磁盘。而是每秒由后台线程将日志文件刷新到磁盘。
如果对数据的正确性要求很高应该设置为1。
注:第一张图流程中,在第5步有二次commit,在数据恢复如果发现一个事务没有commit,则去binlog日志中查询,如果发现binlog中有相应数据则直接恢复,如果没有则丢弃。
binlog
binlog为了高效地记录和传输数据更改信息,它采用了二进制格式存储数据库的更改操作,这样还可以占用更小的存储空间;它可以实现数据恢复、数据同步等功能。默认mysql是关闭binlog日志的,可以通过
在[mysqlId]部分中设置log-bin
和server-id
来开启binlog日志。它也是在事务提交时才进行数据记录,它有以下三种数据格式:
- Statement:记录每一条执行的sql,但由于mysql中存在一些函数,例如一些随机生成函数,此时数据同步时会发生同步过去的数据不一致;
- Row:记录每行被修改成什么,这样可以解决statement带来的数据不一致问题,但由于记录的太详细如果出现了全表更新,那记录的数据量就会特别大;
- Mixed:Statement和Row的混合体,mysql会根据执行的每一条具体的SQL语句来区别对待记录的日志格式。
总结
为了实现更高的性能,在innodb中的任何操作都是优先在内存中操作的;为了支持数据的数据回滚、MVVC引入了undo log,进而可以实现查询历史版本或数据回滚;同时为了防止异常退出导致的数据丢失引入了redo log;为了支持数据同步等功能mysql引入了binlog日志。这就是各个区域的作用,由于篇幅原因本篇文章只对每个区域做了简单介绍,后续会写各个区域详细内容的文章。
创作不易,觉得文章写得不错帮忙点个赞吧!如转载请标明出处~
来源:juejin.cn/post/7411489477283856419
用SQL写游戏,可能吗?看看大佬是如何使用 SQL 写一个俄罗斯方块亮瞎你的钛合金狗眼的!
大家好,今天我要带你们一起来开开眼界。你知道SQL吗?就是那个我们平时用来和数据库打交道的语言——查询数据、插入数据、删除数据,嗯,数据库管理员的必备技能。但你能想象到有人用SQL做了什么吗?他用SQL做了一款俄罗斯方块!对,就是那个曾经风靡全球的经典游戏。
你可能会想,“这怎么可能?SQL不就是查查数据嘛,最多写点复杂的查询语句,能做游戏?”其实我一开始也是这个想法,但看了这个项目后,真的不得不感叹程序员的脑洞太大了!这篇文章就来和你聊聊,这个疯狂的项目到底是怎么实现的,以及为什么这个看似“不务正业”的尝试背后,可能藏着编程世界的一些终极奥秘。
还是先上一下项目地址吧:
1. Turing完备性,SQL到底有多强大?
首先,让我们聊聊一个稍微专业一点的概念:图灵完备性(Turing completeness) 。简单来说,如果一门编程语言是图灵完备的,那它理论上可以实现任何计算。我们平时接触的编程语言,比如Python、Java、C++,都是图灵完备的。但SQL呢?你可能想象不到,SQL也是图灵完备的,这意味着它也具备和其他编程语言一样的能力,只是我们平时大多只用它进行数据库操作。
项目的开发者正是看中了SQL的图灵完备性,才想出了用它来实现俄罗斯方块这个创意。虽然SQL天生并不是为游戏设计的,但通过一些巧妙的设计,开发者硬是把这个“不可能的任务”完成了。不得不说,这不仅仅是技术上的一种挑战,更是一种极致的创意和智慧的碰撞。
2. 用SQL写游戏,可能吗?
接下来,你可能很好奇了,具体怎么实现的呢?其实,开发者在SQL中用了一些非常“刁钻”的技巧。他利用了SQL中的递归查询(Common Table Expressions,简称CTE)和一些复杂的数学操作,来模拟俄罗斯方块的游戏逻辑。
WITH RECURSIVE t(i) AS (
-- non-recursive term
SELECT 1
UNION ALL
-- recursive term
SELECT i + 1 -- takes i of the previous row and adds 1
FROM t -- self-reference that enables recursion
WHERE i < 5 -- when i = 5, the CTE stops
)
SELECT *
FROM t;
i
----
1
2
3
4
5
(5 rows)
举个简单的例子,当俄罗斯方块下落时,我们需要判断它是否与底部或其他方块发生碰撞。通常这种逻辑我们会在游戏开发中使用循环来处理,而在SQL中,开发者通过递归查询来实现类似的循环效果。每次查询都相当于让方块“动”一下,并判断它是否碰到边界。
-- without i appended
...
-> Memoize (loops=999)
...
Hits: 998 Misses: 1 ...
-> Function Scan on dblink input (loops=1) -- only called once
...
-- with i appended
...
-> Nested Loop (loops=999)
-> WorkTable Scan on main main_1 (loops=999)
-> Function Scan on dblink input (loops=999) -- called every iteration
...
虽然说这个过程比传统的编程语言要复杂得多,但实际上,通过SQL,也能够非常清晰地描述出游戏的规则和状态变化。这其实也证明了图灵完备性的一个非常有趣的应用场景——我们可以用SQL来做的不仅仅是数据库操作,甚至是一些我们平时想都不敢想的事情。
3. 疯狂背后的深思:编程的边界在哪里?
或许你会觉得,用SQL做一个俄罗斯方块游戏纯粹是“哗众取宠”,为了博取眼球,没什么实际意义。但深入思考一下,这个项目实际上揭示了编程的一些非常深刻的哲学问题:编程的边界在哪里?
我们习惯性地把SQL、Python、Java等语言分门别类,用它们来解决不同类型的问题。但这个项目提醒我们,编程的真正边界,或许并不是由语言的设计来决定的,而是由开发者的想象力来定义的。一个看似“不合适”的工具,通过创意和技巧,也可以实现出乎意料的结果。这或许也是编程最迷人之处:没有什么是绝对不可能的。
4. 我们可以从这些疯狂的想法中能学到什么?
看完这个项目,你可能会想,“那我能从中学到什么呢?” 其实,除了技术上的启发之外,这个项目还给我们提供了一些更为重要的思维方式。
第一点,敢于挑战常规。 当我们学习编程时,往往会被一些固定的思维框架束缚住,比如SQL只能用于数据库操作,JavaScript才是做前端的。但这个项目告诉我们,有时候打破常规、尝试一些看似不可能的事情,可能会有意外的收获。
第二点,深入理解工具的本质。 学习一门编程语言不仅仅是掌握语法和基本操作,更重要的是理解它背后的能力和局限。这个项目通过SQL的图灵完备性展示了它的潜力,这种对工具的深刻理解,往往能帮助我们在关键时刻找到突破口。
第三点,保持对编程的好奇心。 编程是一门技术,但同时也是一门艺术。正如这位开发者一样,保持好奇心,不断尝试新东西,能够让我们在编程的世界里走得更远。
5. 最后,尝试一下吧!
看完了这篇文章,我猜你可能已经对这个项目充满了好奇。那就别犹豫了,去看看GitHub项目,甚至可以自己动手试试。即使你并不是SQL的高手,但通过这个项目,你一定能收获一些不一样的编程灵感。毕竟,编程的世界永远充满了无限可能,而这些可能性,就等待着你去探索和创造。
最后送你一句话:编程的乐趣,不在于完成任务,而在于不断发现和实现那些看似不可能的创意!
来源:juejin.cn/post/7411354460969222159
只因把 https 改成 http,带宽减少了 70%!
起因
是一个高并发的采集服务上线后,100m的上行很快就被打满了。
因为这是一条专线,并且只有这一个服务在使用,所以可以确定就是它导致的。
但是!这个请求只是一个 GET 请求,同时并没有很大的请求体,这是为什么呢?
于是使用 charles 重新抓包后发现,一个 request 的请求居然要占用 1.68kb 的大小!
其中TLS Handshake 就占了 1.27kb。
这种情况下,需要的上行带宽就是:1.68*20000/1024*8=262.5mbps
也就说明100mbps的上行为何被轻松打满
TLS Handshake是什么来头,竟然如此大?
首先要知道HTTPS全称是:HTTP over TLS,每次建立新的TCP连接通常需要进行一次完整的TLS Handshake。在握手过程中,客户端和服务器需要交换证书、公钥、加密算法等信息,这些数据占用了较多的字节数。
TLS Handshake的内容主要包括:
- 客户端和服务器的随机数
- 支持的加密算法和TLS版本信息
- 服务器的数字证书(包含公钥)
- 用于生成对称密钥的“Pre-Master Secret”
这个过程不仅耗时,还会消耗带宽和CPU资源。
因此想到最粗暴的解决方案也比较简单,就是直接使用 HTTP,省去TLS Handshake的过程,那么自然就不会有 TLS 的传输了。
那么是否真的有效呢?验证一下就知道。
将请求协议改成 http 后:
可以看到请求头确实不包含 TLS Handshake了!
整个请求只有 0.4kb,节省了 70% 的大小
目标达成
因此可以说明:在一些不是必须使用 https 的场景下,使用 http 会更加节省带宽。
同时因为减少了加密的这个过程,可以观察到的是,在相同的并发下,服务器的负载有明显降低。
那么问题来了
如果接口必须使用 https那怎么办呢?
当然还有另外一个解决方案,那就使用使用 Keep-Alive
。
headers 中添加 Connection: keep-alive
即可食用。
通过启用 Keep-Alive,
可以在同一TCP连接上发送多个HTTPS请求,
而无需每次都进行完整的TLS Handshake,
但第一次握手时仍然需要传输证书和完成密钥交换。
对于高并发的场景也非常适用。
要注意的是
keep-alive 是有超时时间的,超过时间连接会被关闭,再次请求需要重新建立链接。
Nginx 默认的 keep-alive
超时是 75 秒,
Apache HTTP 服务器 通常默认的 keep-alive
超时是 5 秒。
ps:
如果你的采集程序使用了大量的代理 ip那么 keep-alive 的效果并不明显~~
最好的还是使用 http
来源:juejin.cn/post/7409138396792881186
多人游戏帧同步策略
介绍解决该问题的基本概念和常见解决方案。
- Lockstep state update 锁步状态更新
- Client prediction 客户端预测
- server reconcilation 服务端和解
多人游戏的运作方式
游戏程序的玩家当前状态随时间和玩家的输入会进行变化。也就是说游戏是有状态的程序。多人游戏也不例外,但由于多人玩家之间存在交互,复杂性会更高。
例如贪吃蛇游戏,我们假设它的操作会发送到服务器,那它的核心游戏逻辑应该是:
- 客户端读取用户输入改变蛇的方向,也可以没有输入,然后发送给服务端
- 服务端接收消息,根据消息改变蛇的方向,将蛇的“头”移动一个单位空间
- 服务端检查蛇是否撞到了墙壁或者自己,如果撞到了游戏结束,给客户端发送响应消息,更新客户端的画面。如果没有撞到,则继续接收客户端发送的消息,同时也要响应给客户端消息,告诉客户端,蛇目前的状态。
服务端接收该消息做出对应的动作,这个过程会以固定的间隔运行。每一次循环都被称为 frame 或 tick。
客户端将解析服务端发送的消息,也就是每一帧的动作,渲染到游戏华中中。
锁步状态更新
为了确保所有客户端都同步帧,最简单的方法是让客户端以固定的间隔向服务器发送更新。发送的消息包含用户的输入,当然也可以发送 no user input。
服务器收集“所有用户”的输入后,就可以生成下一次 frame 帧。
上图演示了客户端与服务端的交互过程。T0 ~ T1 时间段,客户端保持等待,或者说空闲状态,直到服务器响应 frame,等待时间的大小取决于网络质量,约 50 毫秒到 500 毫秒,人眼能够注意到任何超过 100 毫秒的延迟,因此这个等待时间对于某些游戏来说是不可接受的。
锁步状态更新,还有一个问题。游戏的延迟来自最慢的用户 。
上图有两个客户端。客户端 B 的网络比较差,A 和 B 都在 T0 时间点向服务器发送了用户输入,A 的请求在 T1 到达服务端,B 的请求在 T2 到达服务端,前面我们提到,服务器需要收集“所有用户”的请求后才开始工作,因此需要到 T2 时间点才开始生成 frame。
因为 Client B 比较慢,我们“惩罚”了所有的玩家。
假如我们不等待所有客户端的用户输入,低延迟玩家又会获得优势,因为它的输入到达服务器的时间更短,会更快处理。例如,两个玩家 A、B 同时互相射击预期是同时死亡,但是 A 玩家延迟比 B 玩家更低,因此在处理 B 玩家的用户输入时,A 玩家已经干掉 B 玩家了。
小结一下,锁步状态更新存在的问题,如下。
- 游戏画面是否卡顿,取决于最慢的玩家
- 客户端需要等待来自服务器的响应,否则不会渲染画面
- 连接非常活跃,客户端需要定期发送一些无用的心跳包,以便服务器可以确定它拥有生成 frame 所需的所有信息
回合制类型的游戏大多数使用这种方法,因为玩家确实需要等待,例如《炉石传说》。
对于慢节奏的游戏,少量延迟也是可以接受的,例如《QQ农场》。
但是对于快节奏的游戏,锁步状态更新的这些问题都是致命的,不可能操纵游戏人物进入某一个建筑,500 毫秒后,我才能进入。我们一起来看看下一种方法。
客户端预测
客户端预测,在玩家的计算机上,运行游戏逻辑,来模拟游戏的行为,而不是等待服务器更新。
例如我们生成 Tn 时间点的游戏状态,我们需要 Tn-1 时间点的所有玩家状态和 Tn-1 时间点所有玩家的输入。
假设,我们现在的固定频率为 1 s,每 1s 需要给服务器发送一个请求,获取玩家状态并更新玩家的状态。
在 T0 时间点,客户端将用户的输入发送到服务器,用于获取 T1 时间点的游戏状态。在 T1 时间点,客户端已经可以渲染画面了,实际上客户端的响应是在 T3 时刻,也就是说客户端没有等待来自服务器的响应。
使用这个方法,需要满足一些前置条件:
- 客户端拥有游戏运行逻辑所需的所有条件
- 玩家状态的更新逻辑是确定性的,即没有随机性,或者可以以某种方式保证确定性,例如客户端和服务器使用同样的公式以及随机种子,可以保证具有随机性的同时,产生的结果具有确定性。这样保证了客户端和服务器在给定相同输入的情况下产生相同的游戏状态
满足这两点,客户端预测的结果也不一定总是对的。就比如刚提到的,使用相同的公式以及相同的随机种子,进行伪随机算法,但不同平台的浮点计算,可能会存在微小的差异。
再设想一个场景,如下图。
客户端 A 尝试使用 T0 时间点的信息模拟 T1 时间点上的游戏状态,但客户端 B 也在 T0 时间点提交了用户输入,客户端 A 并不知道这个用户输入。
这意味着客户端 A 对 T1 时间的预测将是错误的是,但!由于客户端 A 仍然从服务器接收 T1 时间点的状态,因此客户端有机会在 T3 时间点修正错误。
客户端需要知道,自己的预测是否正确,以及如何修正错误。
修正错误通常叫做 Reconcilation 和解。
需要根据上下文来实现和解部分,下面我们通过一个简单的例子来理解这个概念。这个例子只是抛弃我们的预测,并将其游戏状态替换为服务器响应的正确状态。
- 客户端需要维护 2 个缓冲区,一个用于预测 PredictionBuffer,一个用于用户输入 InputBuffer 。它们是预测这个行为需要的上下文,请记住,预测 Tn 时刻,需要 Tn-1 的状态和 Tn-1 时刻的用户输入。它们一开始都为空
- 玩家点击鼠标,移动游戏角色到下一个位置。此时,玩家输入的移动信息 Input 0 存储在 InputBuffer 中,客户端将生成预测 Prediction 1,存储在 PredictionBuffer 中,预测将展示在玩家画面中
- 客户端收到服务器响应的 State0 ,发现与客户端的预测不匹配,我们将Prediction 1 替换为 State 0,并使用 Input 0 和 State 0 重新计算,得到 Prediction 2,这个重新计算的过程,就是 Reconcilation 和解
- 和解后,我们从缓冲区中删除 State 0 和 Input 0
这种和解的方式有一个明显的缺点,如果服务器响应的游戏状态和客户端预测差异太大,则游戏画面可能会出现错误。例如我们预测敌人在 T0 时间点向南移动,但在 T3 时间点,我们意识到它在向北移动,然后通过使用服务器的响应进行和解,敌人将从北“飞到”正确的位置。
有一些方法可以解决此问题,这里不展开讨论,感兴趣可以搜一下实体插值 Entity Interpolation。
小结一下,客户端预测技术,让客户端以自己的更新频率运行,与服务器的更新频率无关,所以服务器如果出现阻塞,不会影响客户端的帧。
但它也带来复杂性,如下。
- 需要在客户端处理更多的状态和逻辑,比如我们前面提到的缓冲区和预测逻辑
- 需要和解来自服务器的状态(正确的游戏状态)与预测之前的冲突
还给我们带来了敌人从南飞到北的问题。
目前为止,我们都在讨论客户端,接下来看看服务端如何解决帧同步。
服务端和解
利用服务端解决帧同步问题,首先需要解决的是网络延迟带来的问题。如下图。
用户 A 在 T 处进行了操作(比如按下了一个技能键),该操作应该在 T+20ms 处理,但由于延迟,服务器在 T+120ms 才接收到输入。
在游戏中,用户做出指定操作后,应该立即有反应。立即有反应,这个立即是多久,取决于游戏的类型,比如之前我们提到的回合制,它的立即可能是几十秒。我们可以通过 T + X,表示立即反应的时间,T 代表用户的输入时刻,X 代表的是延迟。X 可以为 0,这代表真正的立即 :-)
解决这个问题的思路,与之前客户端预测中使用的办法类似,就是通过客户端的用户输入,来和解服务器中的玩家游戏状态。
所有的用户输入,都需要时间戳进行标记,该时间戳用于告诉服务器,什么时刻处理此用户输入。
为什么在同一水平线上,Client A 的时间是 Time X,而 Server 的时间是 Time Y?
因为客户端和服务端独立运行,通常时间会有所不同,在多人游戏中,我们可以特殊处理其中的差异。在特殊处理时,我们应该使客户端的时间大于服务端的时间,因为这样可以存在更大的灵活性
上图演示了一个客户端与服务端之间的交互。
- 客户端发送带有时间戳的输入。客户端告诉服务器在 X 时间点应该发生用户输入的效果
- 服务端在 Y 时间点收到请求
- 在 Y+1 时间点,即红色框的地方,服务端开始和解,服务端将 X 时间点的用户输入应用于最新的游戏状态,以保证 X 的 Input 发生在 X 时间点
- 服务端发送响应,该响应中包含时间戳
服务端和解部分(上图红色底色部分),主要维护 3 个部分,如下。
- GameStateHistory,在一定时间范围内玩家在游戏中的状态
- ProcessedUserInput,在一定时间范围内处理的用户输入的历史记录
- UnprocessedUserInput,已收到但未处理的用户输入,也是在一定的时间内
服务端和解过程,如下。
- 当服务端收到来自用户的输入时,首先将其放入 UnprocessedUserInput 中
- 等待服务端开始同步帧,检查 UnprocessedUserInput 中是否存在任何早于当前帧的用户输入
- 如果没有,只需要将最新的 GameState 更新为当前用户的输入,并执行游戏逻辑,然后广播到客户端
- 如果有,则表示之前生成的某些游戏状态由于缺少部分用户输入而出错,需要和解,也就是更正。首先需要找到最早的,未处理的用户输入,假设它在时间 N 上,我们需要从 GameStateHistory 中获取时间 N 对应的 GameState 以及从 ProcessedUserInput 获取时间 N 上用户的输入
- 使用这 3 条数据,就可以创建一个准确的游戏状态,然后将未处理的输入 N 移动到 ProcessingUserInput,用于之后的和解
- 更新 GameStateHistory 中的游戏状态
- 重复步骤 4 ~ 6,直到从 N 的时间点到最新的游戏状态
- 服务端将最新帧广播给所有玩家
我并没有做过这些工作,分享的知识都是我对它感兴趣,在网上看了许多经验后整理的。
来源:juejin.cn/post/7277489569958821900
如何让你喜欢的原神角色陪你写代码
如何让你喜欢的原神角色陪你写代码
每天上班,脑子里面就想着一件事,原神,啊不对,VsCode!启动!(doge 狗头保命),那么如何将这两件事情结合起来呢?特别是原神里面有那么多我喜欢的角色。
最终效果预览
- 右下角固定一只小可爱
- 全屏一只小可爱
省流直接抄作业版
vscode
下载background
插件
- 第一次使用这个插件会提示
vscode
已损坏让你restart vscode
,不要慌,因为插件底层是改 vscode 的样式 - 需要管理员权限,如果你安装到了 C 盘
copy
下面的配置到你的settings.json
文件
2.1 右下角固定图片配置
{
"background.customImages": [
"https://upload-bbs.miyoushe.com/upload/2023/02/24/196231062/8540249f2c0dd72f34c8236925ef45bc_3880538836005347690.png",
"https://user-images.githubusercontent.com/41776735/163673800-0e575aa3-afab-405b-a833-212474a51adc.png"
],
"background.enabled": true,
"background.style": {
"content": "''",
"width": "30%",
"height": "30%",
"opacity": 0.3,
"position": "absolute",
"bottom": "0",
"right": "7%",
"z-index": "99999",
"background-repeat": "no-repeat",
"background-size": "contain",
"pointer-events": "none"
},
"background.useDefault": false,
"background.useFront": true
}
2.2 全屏图片配置
{
"background.customImages": ["https://upload-bbs.miyoushe.com/upload/2024/01/15/196231062/1145c3a2f56b2f789a9be086b546305d_3972870625465222006.png"],
"background.enabled": true,
"background.style": {
"content": "''",
"pointer-events": "none",
"position": "absolute",
"z-index": "99999",
"height": "100%",
"width": "100%",
"background-repeat": "no-repeat",
"background-size": "cover",
"background-position": "content",
"opacity": 0.3
},
"background.useDefault": false,
"background.useFront": true
}
详细说明版
第一步,你还是需要下载 background
这个插件。
background
插件的官方中文文档
第二步,配置解析
我们看官方文档里面,有两个配置是需要说明一下的
background.customImages
:
- 是一个数组,默认是用第一张图片做背景图,在分屏的时候第二屏就用的是数组的第二个元素,以此类推
- 支持 https 协议和本地的 file 协议
- 因为我有公司机器和自己电脑,所以本地协议不太适用我,但是我之前一直用的是本地协议,并且下面会教大家如何白嫖图床
background.style
:
- 作为一个前端,看到上面的内容是不是十分的熟悉,其实这个插件的底层原理就是去改 vscode 的 css,所以你可以想想成一个需求,在一个界面上显示一张图片,css 可以怎么写,背景图就可以是啥样的
- 如果你看不懂 css 代码,你可以直接复制我上面提供的两种配置,一个全屏的,一个是在右下角固定一小只的
- 如果你觉得背景图太亮了,有点看不清代码,你可以修改
background.style.opacity
这个属性,降低图片的透明度。 - 发现最新版本支持
background.interval
来轮播背景图了
高清社死图片
OK,其实到这就没啥了,但是到这里和原神好像并没有什么太大的关系,那现在我就教你怎么白嫖米游社的图床,并且获取高清的原神图片
- 第一步,你需要有一个米游社的账号,当然,玩原神的不能没有吧
- 第二步,随便找个帖子进行评论回复,如果你怕麻烦别人,可以自己发一贴
- 第三步:新标签页打开
- 到这里,你就能白嫖米游社的图床了,如果你是评论的自己的图片,新标签页打开之后,删除 url 中
?
后面所有的内容,就可以得到原图 https 的链接了 - 但是又遇到一个问题,我就是很喜欢米游社的图片,但是图片的分辨率太低了怎么办?
- 提供两个用 ai 来放大图片的网站
- waifu2x.udp.jp/
- bigjpg.com/
- 把图片放大之后,在用上面白嫖图床的方法就可以了
最后
水一期,求三连 + 可以在评论区分享你的背景图嘛
来源:juejin.cn/post/7324143986759303194
NestJs: 定时任务+redis实现阅读量功能
抛个砖头
不知道大家用了这么久的掘金,有没有对它文章中的阅读量的实现有过好奇呢?
想象一下,你每次打开一篇文章时,都会有一个数字告诉你多少人已经读过这篇文章。那么这个数字是怎么得出来的呢?
有些人可能会认为,每次有人打开文章,数字就加1,不是很简单吗? 起初我也以为这样,哈哈(和大家站在同一个高度),但(好了,我不说了,继续往下看吧!)
引个玉
文章阅读量的统计看似简单,实则蕴含着巧妙的逻辑和高效的技术实现。我们想要得到的阅读量,并非简单的页面刷新次数,而是真正独立阅读过文章的人数。因此,传统的每次页面刷新加1的方法显然不够准确,它忽略了用户的重复访问。
同时,阅读作为一个高频操作,如果每次都直接写入数据库,无疑会给数据库带来巨大的压力,甚至可能影响到整个系统的性能和稳定性。这就需要我们寻找一种既能准确统计阅读量,又能减轻数据库压力的方法。
Redis,这个高性能的内存数据库,为我们提供了解决方案。我们可以利用Redis的键值对存储特性,将用户ID和文章ID的组合作为键,设置一个短暂的过期时间,比如15分钟。当用户首次访问文章时,我们在Redis中为这个键设置一个值,表示该用户已经阅读过这篇文章。如果用户在15分钟内再次访问,我们可以直接判断该键是否存在,如果存在,则不再增加阅读量,否则进行增加。
这种方法的优点在于,它能够准确地统计出真正阅读过文章的人数,而不是简单的页面刷新次数。同时,通过将阅读量先存储在Redis中,我们避免了频繁地写入数据库,从而大大减轻了数据库的压力。
最后,我们还需要考虑如何将Redis中的阅读量最终写入数据库。由于数据库的写入操作相对较重,我们不宜频繁进行。因此,我们可以选择在业务低峰期,比如凌晨2到4点,使用定时任务将Redis中的阅读量批量写入数据库。这样,既保证了阅读量的准确统计,又避免了频繁的数据库写入操作,实现了高效的系统运行。
思路梳理
- 😎Redis 助力阅读量统计,方法超好用!✨
- 🧐在 Redis 存用户和文章关系,轻松解决多次无效阅读!👏
- 💪定时任务来帮忙,Redis 数据写入数据库,不再
那么接下来就是实现环节
代码层面
项目使用的后端框架为NestJS
配置下redis
一、安装redis plugin
npm install --save redis
二、创建redis模块
三、初始化连接redis相关配置
@Module({
providers: [
RedisService,
{
provide: 'REDIS_CLIENT',
async useFactory(configService: ConfigService) {
console.log(configService.get('redis_server_host'));
const client = createClient({
socket: {
host: configService.get('redis_server_host'),
port: configService.get('redis_server_port'),
},
database: configService.get('redis_server_db'),
});
await client.connect();
return client;
},
inject: [ConfigService],
},
],
exports: [RedisService],
})
Redis是一个Key-Value型数据库,可以用作数据库,所有的数据以Key-Value的形式存在服务器的内存中,其中Value可以是多种数据结构,如字符串(String)、哈希(hashes)、列表(list)、集合(sets)和有序集合(sorted sets)等类型
在这里会用到字符串和哈希两种。
创建文章表和用户表
我的项目中创建有post.entity和user.entity这两个实体表,并为post文章表添加以下
这三个字段,在这里我们只拿 阅读量 说事。
访问文章详情接口-阅读量+1
/**
* @description 增加阅读量
* @param id
* @returns
*/
@Get('xxx/:id')
@RequireLogin()
async frontIncreViews(@Param('id') id: string, @Req() _req: any,) {
console.log('frontFindOne');
return await this.postService.frontIncreViews(+id, _req?.user);
}
前文已经说过,同一个用户多次刷新,如果不做处理,就会产生多次无效的阅读量。 因此,为了避免这种情况方式,我们需要为其增加一个用户文章id组合而成的标记,并设置在有效时间内不产生多次阅读量。
那么,有的掘友可能会产生一个疑问,如果用户未登录,那么以游客的身份去访问文章就不产生阅读记录了吗?
其实同理!
在我的项目中只是,要求需要用户登录后才能访问,
那么我这就会以 userID_postID_ 来组成标识区分用户和文章罢了。
而如果想以游客身份,我们可以获取用户 IP_postID 这样的组合来做标识即可
接下来说下postService
中调用的frontIncreViews
方法
直接贴代码:
const res = await this.redisService.hashGet(`post_${id}`);
if (res.viewCount === undefined) {
const post = await this.postRepository.findOne({ where: { id } });
post.viewCount++;
await this.postRepository.update(id, { viewCount: post.viewCount });
await this.redisService.hashSet(`post_${id}`, {
viewCount: post.viewCount,
likeCount: post.likeCount,
collectCount: post.collectCount,
});
// 在用户访问文章的时候在 redis 存一个 10 分钟过期的标记,有这个标记的时候阅读量不增加
await this.redisService.set(`user_${user.id}_post_${id}`, 1, 10);
return post.viewCount;
} else {
const flag = await this.redisService.get(`user_${user.id}_post_${id}`);
console.log(flag);
if (flag) {
return res.viewCount;
}
await this.redisService.hashSet(`post_${id}`, {
...res,
viewCount: +res.viewCount + 1,
});
await this.redisService.set(`user_${user.id}_post_${id}`, 1, 10);
return +res.viewCount + 1;
}
}
- 从Redis获取文章阅读量:
const res = await this.redisService.hashGet(`post_${id}`);
使用Redis的哈希表结构,从键post_${id}
中获取文章的信息,其中可能包含阅读量(viewCount
)、点赞数(likeCount
)和收藏数(collectCount
)。
2. 检查Redis中是否存在阅读量:
if (res.viewCount === undefined) {
如果Redis中没有阅读量数据,说明这篇文章的阅读量还没有被初始化。
3. 从数据库中获取文章并增加阅读量:
const post = await this.postRepository.findOne({ where: { id } });
post.viewCount++;
await this.postRepository.update(id, { viewCount: post.viewCount });
从数据库中获取文章,然后增加阅读量,并更新数据库中的文章阅读量。
4. 将更新后的文章信息存回Redis:
await this.redisService.hashSet(`post_${id}`, {
viewCount: post.viewCount,
likeCount: post.likeCount,
collectCount: post.collectCount,
});
将更新后的文章信息(包括新的阅读量、点赞数和收藏数)存回Redis的哈希表中。
5. 设置用户访问标记:
await this.redisService.set(`user_${user.id}_post_${id}`, 1, 10);
在用户访问文章时,在Redis中设置一个带有10分钟过期时间的标记,用于防止在10分钟内重复增加阅读量。
6. 返回阅读量:
return post.viewCount;
返回更新后的阅读量。
7. 如果Redis中存在阅读量:
} else {
const flag = await this.redisService.get(`user_${user.id}_post_${id}`);
console.log(flag);
如果Redis中存在阅读量数据,则检查用户是否已经访问过该文章。
8. 检查用户访问标记:
if (flag) {
return res.viewCount;
}
如果用户已经访问过该文章(标记存在),则直接返回当前阅读量,不增加。
9. 如果用户未访问过文章:
await this.redisService.hashSet(`post_${id}`, {
...res,
viewCount: +res.viewCount + 1,
});
await this.redisService.set(`user_${user.id}_post_${id}`, 1, 10);
return +res.viewCount + 1;
如果用户未访问过该文章,则增加阅读量,并重新设置用户访问标记。然后返回更新后的阅读量。
简而言之,目的是在用户访问文章时,确保文章阅读量只增加一次,即使用户在短时间内多次访问。
NestJS使用定时任务包,实现redis数据同步到数据库中
有的掘友可能疑问,既然已经用redis来做阅读量记录了,为什么还要同步到数据库中,前文开始的时候,就已经提到过了,一旦我们的项目重启, redis 数据就没了,而数据库却有着“数据持久性的优良品质”。不像redis重启后,又是个新生儿。但是它们的互补,又是1+1大于2的那种。
好了,不废话了
一、引入定时任务包 @nestjs/schedule
npm install --save @nestjs/schedule
在 app.module.ts
引入
二、创建定时任务模块和服务
nest g module task
nest g service task
你可以在同一个服务里面声明多个定时任务方法。在 NestJS 中,使用 @nestjs/schedule
库时,你只需要在服务类中为每个定时任务方法添加 @Cron()
装饰器,并指定相应的 cron 表达式。以下是一个示例,展示了如何在同一个服务中声明两个定时任务:
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
@Injectable()
export class TasksService {
// 第一个定时任务,每5秒执行一次
@Cron(CronExpression.EVERY_5_SECONDS)
handleEvery5Seconds() {
console.log('Every 5 seconds task executed');
}
// 第二个定时任务,每10秒执行一次
@Cron(CronExpression.EVERY_10_SECONDS)
handleEvery10Seconds() {
console.log('Every 10 seconds task executed');
}
}
三、实现定时任务中同步文章阅读量的任务
更新文章的阅读数据
await this.postService.flushRedisToDB();
// 查询出 key 对应的值,更新到数据库。 做定时任务的时候加上
async flushRedisToDB() {
const keys = await this.redisService.keys(`post_*`);
console.log(keys);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const res = await this.redisService.hashGet(key);
const [, id] = key.split('_');
await this.postRepository.update(
{
id: +id,
},
{
viewCount: +res.viewCount,
},
);
}
}
- 从 Redis 获取键:
const keys = await this.redisService.keys(
post_*);
: 使用 Redis 服务的keys
方法查询所有以post_
开头的键,并将这些键存储在keys
数组中。
console.log(keys);
: 打印出所有查询到的键。 - 遍历 Redis 键:
使用
for
循环遍历所有查询到的键。 - 从 Redis 获取哈希值:
const res = await this.redisService.hashGet(key);
: 对于每一个键,使用 Redis 服务的hashGet
方法获取其对应的哈希值,并将结果存储在res
中。 - 解析键以获取 ID:
const [, id] = key.split('_');
: 将键字符串按照_
分割,并取出第二个元素(索引为 1)作为id
。这假设键的格式是post_<id>
。 - 更新数据库:
使用
postRepository.update
方法更新数据库中的记录。
{ id: +id, }
: 指定要更新的记录的id
。+id
是将id
字符串转换为数字。
{ viewCount: +res.viewCount, }
: 指定要更新的字段及其值。这里将viewCount
字段更新为 Redis 中存储的值,并使用+res.viewCount
将字符串转换为数字。
等到第二天,哈,数据就同步来了
访问:

而产生的后台数据:
抛出问题
如果能看到这里的掘友,若能接下这个问题,说明你已经掌握了吖
问题1:
如何实现一个批量返回redis键值对的方法(这个方法问题2需要用到)
问题2:
用户查询文章列表的时候,如何整理数据后返回文章阅读量呈现给用户查看
来源:juejin.cn/post/7355554711166271540
领导问我:为什么一个点赞功能你做了五天?
公众号:【可乐前端】,每天3分钟学习一个优秀的开源项目,分享web面试与实战知识。
前言
可乐是一名前端切图仔,最近他们团队需要做一个文章社区平台。由于人手不够,前后端部分都是由前端同学来实现,后端部分用的技术栈是 nest.js
。
某一个周一,领导希望做一个给文章点赞的功能,在文章列表页与文章详情页需要看到该文章的点赞总数,以及当前登录的用户有没有对该文章点赞,即用户与文章的点赞关系。
交代完之后,领导就去出差了。等领导回来时已是周五,他问可乐:这期的需求进展如何?
可乐回答:点赞的需求我做完了,其他的还没开始。
领导生气的说:为什么点赞这样的一个小功能你做了五天才做完???
可乐回答:领导息怒。。请听我细细道来
往期文章
- 切图仔做全栈:React&Nest.js 社区平台(一)——基础架构与邮箱注册、JWT 登录实现
- 切图仔做全栈:React&Nest.js社区平台(二)——👋手把手实现优雅的鉴权机制
- React&Nest.js全栈社区平台(三)——🐘对象存储是什么?为什么要用它?
- React&Nest.js社区平台(四)——✏️文章发布与管理实战
- React&Nest.js全栈社区平台(五)——👋封装通用分页Service实现文章流与详情
初步设计
对于上面的这个需求,我们提炼出来有三点最为重要的功能:
- 获取点赞总数
- 获取用户的点赞关系
- 点赞/取消点赞
所以这里容易想到的是在文章表中冗余一个点赞数量字段 likes
,查询文章的时候一起把点赞总数带出来。
id | content | likes |
---|---|---|
1 | 文章A | 10 |
2 | 文章B | 20 |
然后建一张 article_lile_relation
表,建立文章点赞与用户之间的关联关系。
id | article_id | user_id | value |
---|---|---|---|
1 | 1001 | 2001 | 1 |
2 | 1001 | 2002 | 0 |
上面的数据就表明了 id
为 2001
的用户点赞了 id
为 1001
的文章; id
为 2002
的用户对 id
为 1001
的文章取消了点赞。
这是对于这种关联关系需求最容易想到的、也是成本不高的解决方案,但在仔细思考了一番之后,我放弃了这种方案。原因如下:
- 由于首页文章流中也需要展示用户的点赞关系,这里获取点赞关系需要根据当前文章
id
、用户id
去联表查询,会增加数据库的查询压力。 - 有关于点赞的信息存放在两张表中,需要维护两张表的数据一致性。
- 后续可能会出现对摸鱼帖子点赞、对用户点赞、对评论点赞等需求,这样的设计方案显然拓展性不强,后续再做别的点赞需求时可能会出现大量的重复代码。
基于上面的考虑,准备设计一个通用的点赞模块,以拓展后续各种业务的点赞需求。
表设计
首先来一张通用的点赞表, DDL
语句如下:
CREATE TABLE `like_records` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) DEFAULT NULL,
`target_id` int(11) DEFAULT NULL,
`type` int(4) DEFAULT NULL,
`created_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`value` int(4) DEFAULT '0',
PRIMARY KEY (`id`),
KEY `like_records_target_id_IDX` (`target_id`,`user_id`,`type`) USING BTREE,
KEY `like_records_user_id_IDX` (`user_id`,`target_id`,`type`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4;
解释一下上面各个字段的含义:
id
:点赞记录的主键id
user_id
:点赞用户的id
target_id
:被点赞的文章id
type
:点赞类型:可能有文章、帖子、评论等value
:是否点赞,1
点赞,0
取消点赞created_time
:创建时间updated_time
:更新时间
前置知识
在设计好数据表之后,再来捋清楚这个业务的一些特定属性与具体实现方式:
- 我们可以理解这是一个相对来说读比写多的需求,比如你看了
10
篇掘金的文章,可能只会对1
篇文章点赞 - 应该设计一个通用的点赞模块,以供后续各种点赞需求的接入
- 点赞数量与点赞关系需要频繁地获取,所以需要读缓存而不是读数据库
- 写入数据库与同步缓存需考虑数据一致性
所以可乐针对这样的业务特性上网查找了一些资料,发现有一些前置知识是他所欠缺的,我们一起来看看。
mysql事务
mysql
的事务是指一系列的数据库操作,这些操作要么全部成功执行,要么全部失败回滚。事务是用来确保数据库的完整性、一致性和持久性的机制之一。
在 mysql
中,事务具有以下四个特性,通常缩写为 ACID
:
- 原子性: 事务是原子性的,这意味着事务中的所有操作要么全部成功执行,要么全部失败回滚。
- 一致性: 事务执行后,数据库从一个一致的状态转换到另一个一致的状态。这意味着事务执行后,数据库中的数据必须满足所有的约束、触发器和规则,保持数据的完整性。
- 隔离性: 隔离性指的是多个事务之间的相互独立性。即使有多个事务同时对数据库进行操作,它们之间也不会相互影响,每个事务都感觉到自己在独立地操作数据库。
mysql
通过不同的隔离级别(如读未提交、读已提交、可重复读和串行化)来控制事务之间的隔离程度。 - 持久性: 持久性指的是一旦事务被提交,对数据库的改变将永久保存,即使系统崩溃也不会丢失。
mysql
通过将事务的提交写入日志文件来保证持久性,以便在系统崩溃后能够恢复数据。
这里以商品下单创建订单并扣除库存为例,演示一下 nest+typeorm
中的事务如何使用:
import { Injectable } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { EntityManager } from 'typeorm';
import { Order } from './order.entity';
import { Product } from './product.entity';
@Injectable()
export class OrderService {
constructor(
@InjectEntityManager()
private readonly entityManager: EntityManager,
) {}
async createOrderAndDeductStock(productId: number, quantity: number): Promise<Order> {
return await this.entityManager.transaction(async transactionalEntityManager => {
// 查找产品并检查库存是否充足
const product = await transactionalEntityManager.findOne(Product, productId);
if (!product || product.stock < quantity) {
throw new Error('Product not found or insufficient stock');
}
// 创建订单
const order = new Order();
order.productId = productId;
order.quantity = quantity;
await transactionalEntityManager.save(order);
// 扣除库存
product.stock -= quantity;
await transactionalEntityManager.save(product);
return order;
});
}
}
this.entityManager.transaction
创建了一个事务,在异步函数中,如果发生错误, typeorm
会自动回滚事务;如果没有发生错误,typeorm
会自动提交事务。
在这个实例中,尝试获取库存并创建订单和减库存,如果任何一个地方出错异常抛出,则事务就会回滚,这样就保证了多表间数据的一致性。
分布式锁
分布式锁是一种用于在分布式系统中协调多个节点并保护共享资源的机制。在分布式系统中,由于涉及多个节点并发访问共享资源,因此需要一种机制来确保在任何给定时间只有一个节点能够访问或修改共享资源,以防止数据不一致或竞争条件的发生。
对于同一个用户对同一篇文章频繁的点赞/取消点赞请求,可以加分布式锁的机制,来规避一些问题:
- 防止竞态条件: 点赞/取消点赞操作涉及到查询数据库、更新数据库和更新缓存等多个步骤,如果不加锁,可能会导致竞态条件,造成数据不一致或错误的结果。
- 保证操作的原子性: 使用分布式锁可以确保点赞/取消点赞操作的原子性,即在同一时间同一用户只有一个请求能够执行操作,从而避免操作被中断或不完整的情况发生。
- 控制并发访问: 加锁可以有效地控制并发访问,限制了频繁点击发送请求的数量,从而减少系统负载和提高系统稳定性。
在 redis
中实现分布式锁通常使用的是基于 SETNX
命令和 EXPIRE
命令的方式:
- 使用
SETNX
命令尝试将lockKey
设置为lockValue
,如果lockKey
不存在,则设置成功并返回1
;如果lockKey
已经存在,则设置失败并返回0
。 - 如果
SETNX
成功,说明当前客户端获得了锁,可以执行相应的操作;如果SETNX
失败,则说明锁已经被其他客户端占用,当前客户端需要等待一段时间后重新尝试获取锁。 - 为了避免锁被永久占用,可以使用
EXPIRE
命令为锁设置一个过期时间,确保即使获取锁的客户端在执行操作时发生故障,锁也会在一定时间后自动释放。
async getLock(key: string) {
const res = await this.redis.setnx(key, 'lock');
if (res) {
// 10秒锁过期
await this.redis.expire(key, 10);
}
return res;
}
async unLock(key: string) {
return this.del(key);
}
redis中的set结构
redis
中的 set
是一种无序集合,用于存储多个不重复的字符串值,set
中的每个成员都是唯一的。
我们存储点赞关系的时候,需要用到 redis
中的 set
结构,存储的 key
与 value
如下:
article_1001:[uid1,uid2,uid3]
这就表示文章 id
为 1001
的文章,有用户 id
为 uid1
、 uid2
、 uid3
这三个用户点赞了。
常用的 set
结构操作命令包括:
SADD key member [member ...]
: 将一个或多个成员加入到集合中。SMEMBERS key
: 返回集合中的所有成员。SISMEMBER key member
: 检查成员是否是集合的成员。SCARD key
: 返回集合元素的数量。SREM key member [member ...]
: 移除集合中一个或多个成员。SPOP key [count]
: 随机移除并返回集合中的一个或多个元素。SRANDMEMBER key [count]
: 随机返回集合中的一个或多个元素,不会从集合中移除元素。SUNION key [key ...]
: 返回给定所有集合的并集。SINTER key [key ...]
: 返回给定所有集合的交集。SDIFF key [key ...]
: 返回给定所有集合的差集。
下面举几个点赞场景的例子
- 当用户
id
为uid1
给文章id
为1001
的文章点赞时:sadd 1001 uid1
- 当用户
id
为uid1
给文章id
为1001
的文章取消点赞时:srem 1001 uid1
- 当需要获取文章
id
为1001
的点赞数量时:scard 1001
redis事务
在 redis
中,事务是一组命令的有序序列,这些命令在执行时会被当做一个单独的操作来执行。即事务中的所有命令要么全部执行成功,要么全部执行失败,不存在部分执行的情况。
以下是 redis
事务的主要命令:
- MULTI: 开启事务,在执行
MULTI
命令后,后续输入多个命令来组成一个事务。 - EXEC: 执行事务,在执行
EXEC
命令时,redis
会执行客户端输入的所有事务命令,如果事务中的所有命令都执行成功,则事务执行成功,返回事务中所有命令的执行结果;如果事务中的某个命令执行失败,则事务执行失败,返回空。 - DISCARD: 取消事务,在执行
DISCARD
命令时,redis
会取消当前事务中的所有命令,事务中的命令不会被执行。 - WATCH: 监视键,在执行
WATCH
命令时,redis
会监听一个或多个键,如果在执行事务期间任何被监视的键被修改,事务将会被打断。
比如说下面的代码给集合增加元素,并更新集合的过期时间,可以如下使用 redis
的事务去执行它:
const pipeline = this.redisService.multi();
const setKey = this.getSetKey(targetId, type);
if (value === ELike.LIKE) {
pipeline.sadd(setKey, userId);
} else {
pipeline.srem(setKey, userId);
}
pipeline.expire(setKey, this.ttl);
await pipeline.exec();
流程图设计
在了解完这些前置知识之后,可乐开始画一些实现的流程图。
首先是点赞/取消点赞接口的流程图:
简单解释下上面的流程图:
- 先尝试获取锁,获取不到的时候等待重试,保证接口与数据的时序一致。
- 判断这个点赞关系是否已存在,比如说用户对这篇文章已经点过赞,其实又来了一个对此篇文章点赞的请求,直接返回失败
- 开启
mysql
的事务,去更新点赞信息表,同时尝试去更新缓存,在缓存更新的过程中,会有3次的失败重试机会,如果缓存更新都失败,则回滚mysql事务;整体更新失败 mysql
更新成功,缓存也更新成功,则整个操作都成功
然后是获取点赞数量和点赞关系的接口
简单解释下上面的流程图:
- 首先判断当前文章
id
对应的点赞关系是否在redis
中存在,如果存在,则直接从缓存中读取并返回 - 如果不存在,此时加锁,准备读取数据库并更新
redis
,这里加锁的主要目的是防止大量的请求一下子打到数据库中。 - 由于加锁的时候,可能很多接口已经在等待,所以在锁释放的时候,再加多一次从
redis
中获取的操作,此时redis
中已经有值,可以直接从缓存中读取。
代码实现
在所有的设计完毕之后,可以做最后的代码实现了。分别来实现点赞操作与点赞数量接口。这里主要关注 service
层的实现即可。
点赞/取消点赞接口
async toggleLike(params: {
userId: number;
targetId: number;
type: ELikeType;
value: ELike;
}) {
const { userId, targetId, type, value } = params;
const LOCK_KEY = `${userId}::${targetId}::${type}::toggleLikeLock`;
const canGetLock = await this.redisService.getLock(LOCK_KEY);
if (!canGetLock) {
console.log('获取锁失败');
await wait();
return this.toggleLike(params);
}
const record = await this.likeRepository.findOne({
where: { userId, targetId, type },
});
if (record && record.value === value) {
await this.redisService.unLock(LOCK_KEY);
throw Error('不可重复操作');
}
await this.entityManager.transaction(async (transactionalEntityManager) => {
if (!record) {
const likeEntity = new LikeEntity();
likeEntity.targetId = targetId;
likeEntity.type = type;
likeEntity.userId = userId;
likeEntity.value = value;
await transactionalEntityManager.save(likeEntity);
} else {
const id = record.id;
await transactionalEntityManager.update(LikeEntity, { id }, { value });
}
const isSuccess = await this.tryToFreshCache(params);
if (!isSuccess) {
await this.redisService.unLock(LOCK_KEY);
throw Error('操作失败');
}
});
await this.redisService.unLock(LOCK_KEY);
return true;
}
private async tryToFreshCache(
params: {
userId: number;
targetId: number;
type: ELikeType;
value: ELike;
},
retry = 3,
) {
if (retry === 0) {
return false;
}
const { targetId, type, value, userId } = params;
try {
const pipeline = this.redisService.multi();
const setKey = this.getSetKey(targetId, type);
if (value === ELike.LIKE) {
pipeline.sadd(setKey, userId);
} else {
pipeline.srem(setKey, userId);
}
pipeline.expire(setKey, this.ttl);
await pipeline.exec();
return true;
} catch (error) {
console.log('tryToFreshCache error', error);
await wait();
return this.tryToFreshCache(params, retry - 1);
}
}
可以参照流程图来看这部分实现代码,基本实现就是使用 mysql
事务去更新点赞信息表,然后去更新 redis
中的点赞信息,如果更新失败则回滚事务,保证数据的一致性。
获取点赞数量、点赞关系接口
async getLikes(params: {
targetId: number;
type: ELikeType;
userId: number;
}) {
const { targetId, type, userId } = params;
const setKey = this.getSetKey(targetId, type);
const cacheExsit = await this.redisService.exist(setKey);
if (!cacheExsit) {
await this.getLikeFromDbAndSetCache(params);
}
const count = await this.redisService.getSetLength(setKey);
const isLike = await this.redisService.isMemberOfSet(setKey, userId);
return { count, isLike };
}
private async getLikeFromDbAndSetCache(params: {
targetId: number;
type: ELikeType;
userId: number;
}) {
const { targetId, type, userId } = params;
const LOCK_KEY = `${targetId}::${type}::getLikesLock`;
const canGetLock = await this.redisService.getLock(LOCK_KEY);
if (!canGetLock) {
console.log('获取锁失败');
await wait();
return this.getLikeFromDbAndSetCache(params);
}
const setKey = this.getSetKey(targetId, type);
const cacheExsit = await this.redisService.exist(setKey);
if (cacheExsit) {
await this.redisService.unLock(LOCK_KEY);
return true;
}
const data = await this.likeRepository.find({
where: {
targetId,
userId,
type,
value: ELike.LIKE,
},
select: ['userId'],
});
if (data.length !== 0) {
await this.redisService.setAdd(
setKey,
data.map((item) => item.userId),
this.ttl,
);
}
await this.redisService.unLock(LOCK_KEY);
return true;
}
由于读操作相当频繁,所以这里应当多使用缓存,少查询数据库。读点赞信息时,先查 redis
中有没有,如果没有,则从 mysql
同步到 redis
中,同步的过程中也使用到了分布式锁,防止一开始没缓存时请求大量打到 mysql
。
同时,如果所有文章的点赞信息都同时存在 redis
中,那 redis
的存储压力会比较大,所以这里会给相关的 key
设置一个过期时间。当用户重新操作点赞时,会更新这个过期时间。保障缓存的数据都是相对热点的数据。
通过组装数据,获取点赞信息的返回数据结构如下:
返回一个 map
,其中 key
文章 id
, value
里面是该文章的点赞数量以及当前用户是否点赞了这篇文章。
前端实现
文章流列表发生变化的时候,可以监听列表的变化,然后去获取点赞的信息:
useEffect(() => {
if (!article.list) {
return;
}
const shouldGetLikeIds = article.list
.filter((item: any) => !item.likeInfo)
.map((item: any) => item.id);
if (shouldGetLikeIds.length === 0) {
return;
}
console.log("shouldGetLikeIds", shouldGetLikeIds);
getLikes({
targetIds: shouldGetLikeIds,
type: 1,
}).then((res) => {
const map = res.data;
const newList = [...article.list];
for (let i = 0; i < newList.length; i++) {
if (!newList[i].likeInfo && map[newList[i].id]) {
newList[i].likeInfo = map[newList[i].id];
}
}
const newArticle = { ...article };
newArticle.list = newList;
setArticle(newArticle);
});
}, [article]);
点赞操作的时候前端也需要加锁,接口执行完毕了再把锁释放。
<Space
onClick={(e) => {
e.stopPropagation();
if (lockMap.current?.[item.id]) {
return;
}
lockMap.current[item.id] = true;
const oldValue = item.likeInfo.isLike;
const newValue = !oldValue;
const updateValue = (value: any) => {
const newArticle = { ...article };
const newList = [...newArticle.list];
const current = newList.find(
(_) => _.id === item.id
);
current.likeInfo.isLike = value;
if (value) {
current.likeInfo.count++;
} else {
current.likeInfo.count--;
}
setArticle(newArticle);
};
updateValue(newValue);
toggleLike({
targetId: item.id,
value: Number(newValue),
type: 1,
})
.catch(() => {
updateValue(oldValue);
})
.finally(() => {
lockMap.current[item.id] = false;
});
}}
>
<LikeOutlined
style={
item.likeInfo.isLike ? { color: "#1677ff" } : {}
}
/>
{item.likeInfo.count}
</Space>
解释
可乐:从需求分析考虑、然后研究网上的方案并学习前置知识,再是一些环境的安装,最后才是前后端代码的实现,领导,我这花了五天不过份吧。
领导(十分无语):我们平台本来就没几个用户、没几篇文章,本来就是一张关联表就能解决的问题,你又搞什么分布式锁又搞什么缓存,还花了那么多天时间。我不管啊,剩下没做的需求你得正常把它正常做完上线,今天周五,周末你也别休息了,过来加班吧。
最后
以上就是本文的全部内容,如果你觉得有意思的话,点点关注点点赞吧~
来源:juejin.cn/post/7349437605858066443
昨天晚上,RPC 线程池被打满了,原因哭笑不得
大家好,我是五阳。
1. 故障背景
昨天晚上,我刚到家里打开公司群,就看见群里有人讨论:线上环境出现大量RPC请求报错,异常原因:被线程池拒绝。虽然异常量很大,但是异常服务非核心服务,属于系统旁路,服务于数据核对任务,即使有大量异常,也没有实际的影响。
原来有人在线上刷数据,产生了大量 binlog,数据核对任务的请求量大幅上涨,导致线程池被打满。因为并非我负责的工作内容,也不熟悉这部分业务,所以没有特别留意。
第二天我仔细思考了一下,觉得疑点很多,推导过程过于简单,证据链不足,最终结论不扎实,问题根源也许另有原因。
1.1 疑点
- 请求量大幅上涨, 上涨前后请求量是多少?
- 线程池被打满, 线程池初始值和最大值是多少,线程池队列长度是多少?
- 线程池拒绝策略是什么?
- 影响了哪些接口,这些接口的耗时波动情况?
- 服务的 CPU 负载和 GC情况如何?
- 线程池被打满的原因仅仅是请求量大幅上涨吗?
带着以上的几点疑问,第二天一到公司,我就迫不及待地打开各种监控大盘,开始排查问题,最后还真叫我揪出问题根源了。
因为公司的监控系统有水印,所以我只能陈述结论,不能截图了。
2. 排查过程
2.1 请求量的波动情况
- 单机 RPC的 QPS从 300/s 涨到了 450/s。
- Kafka 消息 QPS 50/s 无 明显波动。
- 无其他请求入口和 无定时任务。
这也能叫请求量大幅上涨,请求量增加 150/s 能打爆线程池?就这么糊弄老板…… ,由此我坚定了判断:故障另有根因
2.2 RPC 线程池配置和监控
线上的端口并没有全部被打爆,仅有 1 个 RPC 端口 8001 被打爆。所以我特地查看了8001 的线程池配置。
- 初始线程数 10
- 最大线程数 1024(数量过大,配置的有点随意了)
- 队列长度 0
- 拒绝策略是抛出异常立即拒绝。
- 在 20:11到 20:13 分,线程从初始线程数10,直线涨到了1024 。
2.3 思考
QPS 450次/秒 需要 1024 个线程处理吗?按照我的经验来看,只要接口的耗时在 100ms 以内,不可能需要如此多的线程,太蹊跷了。
2.4 接口耗时波动情况
- 接口 平均耗时从 5.7 ms,增加到 17000毫秒。
接口耗时大幅增加。后来和他们沟通,他们当时也看了接口耗时监控。他们认为之所以平均耗时这么高,是因为RPC 请求在排队,增加了处理耗时,所以监控平均耗时大幅增长。
这是他们的误区,错误的地方有两个。
- 此RPC接口线程池的队列长度为 0,拒绝策略是抛出异常。当没有可用线程,请求会即被拒绝,请求不会排队,所以无排队等待时间。
- 公司的监控系统分服务端监控和调用端监控,服务端的耗时监控不包含 处理连接的时间,不包含 RPC线程池排队的时间。仅仅是 RPC 线程池实际处理请求的耗时。 RPC 调用端的监控包含 RPC 网络耗时、连接耗时、排队耗时、处理业务逻辑耗时、服务端GC 耗时等等。
他们误认为耗时大幅增加是因为请求在排队,因此忽略了至关重要的这条线索:接口实际处理阶段的性能严重恶化,吞吐量大幅降低,所以线程池大幅增长,直至被打满。
接下来我开始分析,接口性能恶化的根本原因是什么?
- CPU 被打满?导致请求接口性能恶化?
- 频繁GC ,导致接口性能差?
- 调用下游 RPC 接口耗时大幅增加 ?
- 调用 SQL,耗时大幅增加?
- 调用 Redis,耗时大幅增加
- 其他外部调用耗时大幅增加?
2.5 其他耗时监控情况
我快速的排查了所有可能的外部调用耗时均没有明显波动。也查看了机器的负载情况,cpu和网络负载 均不高,显然故障的根源不在以上方向。
- CPU 负载极低。在故障期间,cpu.busy 负载在 15%,还不到午高峰,显然根源不是CPU 负载高。
- gc 情况良好。无 FullGC,youngGC 1 分钟 2 次(younggc 频繁,会导致 cpu 负载高,会使接口性能恶化)
- 下游 RPC 接口耗时无明显波动。我查看了服务调用 RPC 接口的耗时监控,所有的接口耗时无明显波动。
- SQL 调用耗时无明显波动。
- 调用 Redis 耗时无明显波动。
- 其他下游系统调用无明显波动。(如 Tair、ES 等)
2.6 开始研究代码
为什么我一开始不看代码,因为这块内容不是我负责的内容,我不熟悉代码。
直至打开代码看了一眼,恶心死我了。代码非常复杂,分支非常多,嵌套层次非常深,方法又臭又长,堪称代码屎山的珠穆朗玛峰,多看一眼就能吐。接口的内部分支将近 10 个,每个分支方法都是一大坨代码。
这个接口是上游 BCP 核对系统定义的 SPI接口,属于聚合接口,并非单一职责的接口。看了 10 分钟以后,还是找不到问题根源。因此我换了问题排查方向,我开始排查异常 Trace。
2.7 从异常 Trace 发现了关键线索
我所在公司的基建能力还是很强大的。系统的异常 Trace 中标注了各个阶段的处理耗时,包括所有外部接口的耗时。如SQL、 RPC、 Redis等。
我发现确实是内部代码处理的问题,因为 trace 显示,在两个 SQL 请求中间,系统停顿长达 1 秒多。不知道系统在这 1 秒执行哪些内容。我查看了这两个接口的耗时,监控显示:SQL 执行很快,应该不是SQL 的问题
机器也没有发生 FullGC,到底是什么原因呢?
前面提到,故障接口是一个聚合接口,我不清楚具体哪个分支出现了问题,但是异常 Trace 中指明了具体的分支。
我开始排查具体的分支方法……, 然而捏着鼻子扒拉了半天,也没有找到原因……
2.8 山穷水复疑无路,柳暗花明又一村
这一坨屎山代码看得我实在恶心,我静静地冥想了 1 分钟才缓过劲。
- 没有外部调用的情况下,阻塞线程的可能性有哪些?
- 有没有加锁? Synchiozed 关键字?
于是我按着关键字搜索Synchiozed
关键词,一无所获,代码中基本没有加锁的地方。
马上中午了,肚子很饿,就当我要放弃的时候。随手扒拉了一下,在类的属性声明里,看到了 Guava限流器。
激动的心,颤抖的手
private static final RateLimiter RATE_LIMITER = RateLimiter.create(10, 20, TimeUnit.SECONDS);
限流器:1 分钟 10次调用。
于是立即查看限流器的使用场景,和异常 Trace 阻塞的地方完全一致。
嘴角出现一丝很容易察觉到的微笑。
破案了,真相永远只有一个。
3. 问题结论
Guava 限流器的阈值过低,每秒最大请求量只有10次。当并发量超过这个阈值时,大量线程被阻塞,RPC线程池不断增加新线程来处理新的请求,直到达到最大线程数。线程池达到最大容量后,无法再接收新的请求,导致大量的后续请求被线程池拒绝。
于是我开始建群、摇人。把相关的同学,还有老板们,拉进了群里。把相关截图和结论发到了群里。
由于不是紧急问题,所以我开开心心的去吃午饭了。后面的事就是他们优化代码了。
4. 思考总结
4.1 八股文不是完全无用,有些八股文是有用的
有人质疑面试为什么要问八股文? 也许大部分情况下,大部分八股文是没有用的,然而在排查问题时,多知道一些八股文是有助于排查问题的。
例如要明白线程池的原理、要明白 RPC 的请求处理过程、要知道影响接口耗时的可能性有哪些,这样才能带着疑问去追踪线索。
4.2 可靠好用的监控系统,能提高排查效率
这个问题排查花了我 1.5 小时,大部分时间是在扒拉代码,实际查看监控只用了半小时。如果没有全面、好用的监控,线上出了问题真的很难快速定位根源。
此外应该熟悉公司的监控系统。他们因为不清楚公司监控系统,误认为接口监控耗时包含了线程池排队时间,忽略了 接口性能恶化 这个关键结论,所以得出错误结论。
4.2 排查问题时,要像侦探一样,不放过任何线索,确保证据链完整,逻辑通顺。
故障发生后第一时间应该是止损,其次才是排查问题。
出现故障后,故障制造者的心理往往处于慌乱状态,逻辑性较差,会倾向于为自己开脱责任。这次故障后,他们给定的结论就是:因为请求量大幅上涨,所以线程池被打满。 这个结论逻辑不通。没有推导过程,跳过了很多推导环节,所以得出错误结论,掩盖了问题的根源~
其他人作为局外人,逻辑性会更强,可以保持冷静状态,这是老板们的价值所在,他们可以不断地提出问题,在回答问题、质疑、继续排查的循环中,不断逼近事情的真相。
最终一定要重视证据链条的完整。别放过任何线索。
出现问题不要慌张,也不要吃瓜嗑瓜子。行动起来,此时是专属你的柯南时刻
我是五阳,关注我,追踪更多我在大厂的工作经历和大型翻车现场。
来源:juejin.cn/post/7409181068597313573
别再混淆了!一文带你搞懂@Valid和@Validated的区别
上篇文章我们简单介绍和使用了一下Springboot的参数校验,同时也用到了 @Valid 注解和 @Validated 注解,那它们之间有什么不同呢?
区别
先总结一下它们的区别:
- 来源
- @Validated :是Spring框架特有的注解,属于Spring的一部分,也是JSR 303的一个变种。它提供了一些 @Valid 所没有的额外功能,比如分组验证。
- @Valid:Java EE提供的标准注解,它是JSR 303规范的一部分,主要用于Hibernate Validation等场景。
- 注解位置
- @Validated : 用在类、方法和方法参数上,但不能用于成员属性。
- @Valid:可以用在方法、构造函数、方法参数和成员属性上。
- 分组
- @Validated :支持分组验证,可以更细致地控制验证过程。此外,由于它是Spring专有的,因此可以更好地与Spring的其他功能(如Spring的依赖注入)集成。
- @Valid:主要支持标准的Bean验证功能,不支持分组验证。
- 嵌套验证
- @Validated :不支持嵌套验证。
- @Valid:支持嵌套验证,可以嵌套验证对象内部的属性。
这些理论性的东西没什么好说的,记住就行。我们主要看分组和嵌套验证是什么,它们怎么用。
实操阶段
话不多说,通过代码来看一下分组和嵌套验证。
为了提示友好,修改一下全局异常处理类:
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 参数校检异常
* @param e
* @return
*/
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public ResponseResult handle(MethodArgumentNotValidException e) {
BindingResult bindingResult = e.getBindingResult();
StringJoiner joiner = new StringJoiner(";");
for (ObjectError error : bindingResult.getAllErrors()) {
String code = error.getCode();
String[] codes = error.getCodes();
String property = codes[1];
property = property.replace(code ,"").replaceFirst(".","");
String defaultMessage = error.getDefaultMessage();
joiner.add(property+defaultMessage);
}
return handleException(joiner.toString());
}
private ResponseResult handleException(String msg) {
ResponseResult result = new ResponseResult<>();
result.setMessage(msg);
result.setCode(500);
return result;
}
}
分组校验
分组验证是为了在不同的验证场景下能够对对象的属性进行灵活地验证,从而提高验证的精细度和适用性。一般我们在对同一个对象进行保存或修改时,会使用同一个类作为入参。那么在创建时,就不需要校验id,更新时则需要校验用户id,这个时候就需要用到分组校验了。
对于定义分组有两点要特别注意:
- 定义分组必须使用接口。
- 要校验字段上必须加上分组,分组只对指定分组生效,不加分组不校验。
有这样一个需求,在创建用户时校验用户名,修改用户时校验用户id。下面对我们对这个需求进行一个简单的实现。
- 创建分组
CreationGr0up 用于创建时指定的分组:
public interface CreationGr0up {
}
UpdateGr0up 用于更新时指定的分组:
public interface UpdateGr0up {
}
- 创建用户类
创建一个UserBean用户类,分别校验 username
字段不能为空和id
字段必须大于0,然后加上CreationGr0up
和 UpdateGr0up
分组。
/**
* @author 公众号-索码理(suncodernote)
*/
@Data
public class UserBean {
@NotEmpty( groups = {CreationGr0up.class})
private String username;
@Min(value = 18)
private Integer age;
@Email(message = "邮箱格式不正确")
private String email;
@Min(value = 1 ,groups = {UpdateGr0up.class})
private Long id;
}
- 创建接口
在ValidationController 中新建两个接口 updateUser
和 createUser
:
@RestController
@RequestMapping("validation")
public class ValidationController {
@GetMapping("updateUser")
public UserBean updateUser(@Validated({UpdateGr0up.class}) UserBean userBean){
return userBean;
}
@GetMapping("createUser")
public UserBean createUser(@Validated({CreationGr0up.class}) UserBean userBean){
return userBean;
}
}
- 测试
先对 createUser
接口进行测试,我们将id的值设置为0,也就是不满足id必须大于0的条件,同样 username 不传值,即不满足 username 不能为空的条件。 通过测试结果我们可以看到,虽然id没有满足条件,但是并没有提示,只提示了username不能为空。
再对 updateUser
接口进行测试,条件和测试 createUser
接口的条件一样,再看测试结果,和 createUser
接口测试结果完全相反,只提示了id最小不能小于1。
至此,分组功能就演示完毕了。
嵌套校验
介绍嵌套校验之前先看一下两个概念:
- 嵌套校验(Nested Validation) 指的是在验证对象时,对对象内部包含的其他对象进行递归验证的过程。当一个对象中包含另一个对象作为属性,并且需要对这个被包含的对象也进行验证时,就需要进行嵌套校验。
- 嵌套属性指的是在一个对象中包含另一个对象作为其属性的情况。换句话说,当一个对象的属性本身又是一个对象,那么这些被包含的对象就可以称为嵌套属性。
有这样一个需求,在保存用户时,用户地址必须要填写。下面来简单看下示例:
- 创建地址类 AddressBean
在AddressBean 设置 country
和city
两个属性为必填项。
@Data
public class AddressBean {
@NotBlank
private String country;
@NotBlank
private String city;
}
- 修改用户类,将AddressBean作为用户类的一个嵌套属性
特别提示:想要嵌套校验生效,必须在嵌套属性上加 @Valid
注解。
@Data
public class UserBean {
@NotEmpty(groups = {CreationGr0up.class})
private String username;
@Min(value = 18)
private Integer age;
private String email;
@Min(value = 1 ,groups = {UpdateGr0up.class})
private Long id;
//嵌套验证必须要加上@Valid
@Valid
@NotNull
private AddressBean address;
}
- 创建一个嵌套校验测试接口
@PostMapping("nestValid")
public UserBean nestValid(@Validated @RequestBody UserBean userBean){
System.out.println(userBean);
return userBean;
}
- 测试
我们在传参时,只传 country
字段,通过响应结果可以看到提示了city
字段不能为空。
可以看到使用了 @Valid
注解来对 Address 对象进行验证,这会触发对其中的 Address 对象的验证。通过这种方式,可以确保嵌套属性内部的对象也能够参与到整体对象的验证过程中,从而提高验证的完整性和准确性。
总结
本文介绍了@Valid
注解和@Validated
注解的不同,同时也进一步介绍了Springboot 参数校验的使用。不管是 JSR-303、JSR-380又或是 Hibernate Validator ,它们提供的参数校验注解都是有限的,实际工作中这些注解可能是不够用的,这个时候就需要我们自定义参数校验了。下篇文章将介绍一下如何自定义一个参数校验器。
来源:juejin.cn/post/7344958089429434406
Jenkins:运维早搞定了,但我就是想偷学点前端CI/CD!
前言:运维已就绪,但好奇心作祟
- 背景故事: 虽然前端开发人员平时可能不会直接操控Jenkins,运维团队已经把这一切搞得井井有条。然而,你的好奇心驱使你想要深入了解这些自动化的流程。本文将带你一探究竟,看看Jenkins如何在前端项目中发挥作用。
- 目标介绍: 了解Jenkins如何实现从代码提交到自动部署的全过程,并学习如何配置和优化这一流程。
- 背景故事: 虽然前端开发人员平时可能不会直接操控Jenkins,运维团队已经把这一切搞得井井有条。然而,你的好奇心驱使你想要深入了解这些自动化的流程。本文将带你一探究竟,看看Jenkins如何在前端项目中发挥作用。
- 目标介绍: 了解Jenkins如何实现从代码提交到自动部署的全过程,并学习如何配置和优化这一流程。
Jenkins基础与部署流程
1. Jenkins到底是个什么东西?
- Jenkins简介: Jenkins是一个开源的自动化服务器,用于持续集成和持续交付。它能够自动化各种开发任务,提高开发效率和软件质量。
- 核心功能: Jenkins可以自动化构建、测试和部署任务,帮助开发团队实现快速、高效的开发流程。
- Jenkins简介: Jenkins是一个开源的自动化服务器,用于持续集成和持续交付。它能够自动化各种开发任务,提高开发效率和软件质量。
- 核心功能: Jenkins可以自动化构建、测试和部署任务,帮助开发团队实现快速、高效的开发流程。
2. 自动化部署流程详解
- 流程概述: 本文将重点介绍前端自动化部署的完整流程,包括代码提交、构建、打包、部署等步骤。具体流程如下:
- 代码提交:
- 开发人员通过
git push
将代码提交到远程仓库。
- 触发Jenkins自动构建:
- Jenkins配置为在代码提交时自动触发构建任务。
- 拉取代码仓库代码:
- Jenkins从仓库拉取最新代码。
- 构建打包:
- Jenkins运行构建命令(如
npm run build
),将源代码编译成可部署版本。
- 生成dist文件:
- 构建生成
dist
文件夹,包含打包后的静态资源。
- 压缩dist文件:
- 使用压缩工具(如
tar
或 zip
)将 dist
文件夹压缩成 dist.tar
或 dist.zip
。
- 迁移到指定环境目录下:
- 将压缩包迁移到目标环境目录(如
/var/www/project/
)。
- 删除旧dist文件:
- 删除目标环境目录下旧的
dist
文件,以确保保留最新版本。
- 解压迁移过来的dist.tar:
- 在目标环境目录下解压新的
dist.tar
文件。
- 删除dist.tar:
- 解压后删除压缩包,节省存储空间。
- 部署成功:
- 自动化流程完成,新的前端版本已经成功部署。
- 流程概述: 本文将重点介绍前端自动化部署的完整流程,包括代码提交、构建、打包、部署等步骤。具体流程如下:
- 代码提交:
- 开发人员通过
git push
将代码提交到远程仓库。
- 开发人员通过
- 触发Jenkins自动构建:
- Jenkins配置为在代码提交时自动触发构建任务。
- 拉取代码仓库代码:
- Jenkins从仓库拉取最新代码。
- 构建打包:
- Jenkins运行构建命令(如
npm run build
),将源代码编译成可部署版本。
- Jenkins运行构建命令(如
- 生成dist文件:
- 构建生成
dist
文件夹,包含打包后的静态资源。
- 构建生成
- 压缩dist文件:
- 使用压缩工具(如
tar
或zip
)将dist
文件夹压缩成dist.tar
或dist.zip
。
- 使用压缩工具(如
- 迁移到指定环境目录下:
- 将压缩包迁移到目标环境目录(如
/var/www/project/
)。
- 将压缩包迁移到目标环境目录(如
- 删除旧dist文件:
- 删除目标环境目录下旧的
dist
文件,以确保保留最新版本。
- 删除目标环境目录下旧的
- 解压迁移过来的dist.tar:
- 在目标环境目录下解压新的
dist.tar
文件。
- 在目标环境目录下解压新的
- 删除dist.tar:
- 解压后删除压缩包,节省存储空间。
- 部署成功:
- 自动化流程完成,新的前端版本已经成功部署。
- 代码提交:
准备工作
话不多说干就完了!!!
安装git
yum install -y git
查看是否安装成功
git --version
生成秘钥
ssh-keygen -t rsa -b 4096 -C "your_email@example.com"
查看公钥
cat ~/.ssh/id_rsa.pub
将公钥添加到GitHub或其他代码库的SSH Keys

yum install -y git
查看是否安装成功
git --version
生成秘钥
ssh-keygen -t rsa -b 4096 -C "your_email@example.com"
查看公钥
cat ~/.ssh/id_rsa.pub
将公钥添加到GitHub或其他代码库的SSH Keys
Docker安装
直接查看 菜鸟教程
安装完之后,配置docker镜像源详情参考 24年6月国内Docker镜像源失效解决办法...
编辑 /etc/resolv.conf
文件:
sudo vim /etc/resolv.conf
添加或修改以下行以使用 Cloudflare 的 DNS 服务器:
nameserver 1.1.1.1
nameserver 1.0.0.1
创建完成的docker-hub镜像输出示例:
查看docker相关的rpm源文件是否存在
rpm -qa |grep docker
作用
rpm -qa
:列出所有已安装的 RPM 包。grep docker
:筛选出包名中包含 docker
的条目。
示例输出
启动Docker服务:
- 启动Docker服务并设置为开机自启:
sudo systemctl start docker
sudo systemctl enable docker
直接查看 菜鸟教程
安装完之后,配置docker镜像源详情参考 24年6月国内Docker镜像源失效解决办法...
编辑 /etc/resolv.conf
文件:
sudo vim /etc/resolv.conf
添加或修改以下行以使用 Cloudflare 的 DNS 服务器:
nameserver 1.1.1.1
nameserver 1.0.0.1
创建完成的docker-hub镜像输出示例:
查看docker相关的rpm源文件是否存在
rpm -qa |grep docker
作用
rpm -qa
:列出所有已安装的 RPM 包。grep docker
:筛选出包名中包含docker
的条目。
示例输出 启动Docker服务:
- 启动Docker服务并设置为开机自启:
sudo systemctl start docker
sudo systemctl enable docker
Docker安装Docker Compose
Docker Compose 可以定义和运行多个 Docker 容器
应用的工具。它允许你使用一个单独的文件(通常称为 docker-compose.yml)来配置应用程序的服务,然后使用该文件快速启动整个应用的所有服务。
第一步,下载安装
curl -L https://get.daocloud.io/docker/compose/releases/download/v2.4.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose
第二步,查看是否安装成功
docker-compose -v

第三步,给/docker/jenkins_home
目录设置最高权限,所有用户都具有读、写、执行这个目录的权限。(等建了/docker/jenkins_home
目录之后设置)
chmod 777 /docker/jenkins_home
Docker Compose 可以定义和运行多个 Docker 容器
应用的工具。它允许你使用一个单独的文件(通常称为 docker-compose.yml)来配置应用程序的服务,然后使用该文件快速启动整个应用的所有服务。
第一步,下载安装
curl -L https://get.daocloud.io/docker/compose/releases/download/v2.4.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose
第二步,查看是否安装成功
docker-compose -v
第三步,给/docker/jenkins_home
目录设置最高权限,所有用户都具有读、写、执行这个目录的权限。(等建了/docker/jenkins_home
目录之后设置)
chmod 777 /docker/jenkins_home
创建Docker相关文件目录
可以命令创建或者相关shell可视化工具创建, 命令创建如下:
mkdir /docker
mkdir /docker/compose
mkdir /docker/jenkins_home
mkdir /docker/nginx
mkdir /docker/nginx/conf
mkdir /docker/html
mkdir /docker/html/dev
mkdir /docker/html/release
mkdir /docker/html/pro
创建docker-compose.yml
、nginx.conf
配置文件
cd /docker/compose touch docker-compose.yml
cd /docker/nginx/conf touch nginx.conf
完成后目录结构如下: 

可以命令创建或者相关shell可视化工具创建, 命令创建如下:
mkdir /docker
mkdir /docker/compose
mkdir /docker/jenkins_home
mkdir /docker/nginx
mkdir /docker/nginx/conf
mkdir /docker/html
mkdir /docker/html/dev
mkdir /docker/html/release
mkdir /docker/html/pro
创建docker-compose.yml
、nginx.conf
配置文件
cd /docker/compose touch docker-compose.yml
cd /docker/nginx/conf touch nginx.conf
完成后目录结构如下:
编写nginx.conf
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
gzip on;
#dev环境
server {
#监听的端口
listen 8001;
server_name localhost;
#设置日志
access_log logs/dev.access.log main;
#定位到index.html
location / {
#linux下HTML文件夹,就是你的前端项目文件夹
root /usr/share/nginx/html/dev/dist;
# root /home/html/dev/dist;
#输入网址(server_name:port)后,默认的访问页面
index index.html;
try_files $uri $uri/ /index.html;
}
}
#release环境
server {
#监听的端口
listen 8002;
server_name localhost;
#设置日志
access_log logs/release.access.log main;
#定位到index.html
location / {
#linux下HTML文件夹,就是你的前端项目文件夹
root /usr/share/nginx/html/release/dist;
# root /home/html/release/dist;
#输入网址(server_name:port)后,默认的访问页面
index index.html;
try_files $uri $uri/ /index.html;
}
}
#pro环境
server {
#监听的端口
listen 8003;
server_name localhost;
#设置日志
access_log logs/pro.access.log main;
#定位到index.html
location / {
#linux下HTML文件夹,就是你的前端项目文件夹
root /usr/share/nginx/html/pro/dist;
# root /home/html/pro/dist;
#输入网址(server_name:port)后,默认的访问页面
index index.html;
try_files $uri $uri/ /index.html;
}
}
# include /etc/nginx/conf.d/*.conf;
}
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
gzip on;
#dev环境
server {
#监听的端口
listen 8001;
server_name localhost;
#设置日志
access_log logs/dev.access.log main;
#定位到index.html
location / {
#linux下HTML文件夹,就是你的前端项目文件夹
root /usr/share/nginx/html/dev/dist;
# root /home/html/dev/dist;
#输入网址(server_name:port)后,默认的访问页面
index index.html;
try_files $uri $uri/ /index.html;
}
}
#release环境
server {
#监听的端口
listen 8002;
server_name localhost;
#设置日志
access_log logs/release.access.log main;
#定位到index.html
location / {
#linux下HTML文件夹,就是你的前端项目文件夹
root /usr/share/nginx/html/release/dist;
# root /home/html/release/dist;
#输入网址(server_name:port)后,默认的访问页面
index index.html;
try_files $uri $uri/ /index.html;
}
}
#pro环境
server {
#监听的端口
listen 8003;
server_name localhost;
#设置日志
access_log logs/pro.access.log main;
#定位到index.html
location / {
#linux下HTML文件夹,就是你的前端项目文件夹
root /usr/share/nginx/html/pro/dist;
# root /home/html/pro/dist;
#输入网址(server_name:port)后,默认的访问页面
index index.html;
try_files $uri $uri/ /index.html;
}
}
# include /etc/nginx/conf.d/*.conf;
}
编写docker-compose.yml
networks:
frontend:
external: true
services:
docker_jenkins:
user: root # root权限
restart: always # 重启方式
image: jenkins/jenkins:lts # 使用的镜像
container_name: jenkins # 容器名称
ports: # 对外暴露的端口定义
- 8999:8080
- 50000:50000
environment:
- TZ=Asia/Shanghai ## 设置时区 否则默认是UTC
#- "JENKINS_OPTS=--prefix=/jenkins_home"
## 自定义 jenkins 访问前缀(设置了的话访问路径就为你的ip:端口/jenkins_home,反之则直接为ip:端口)
volumes: # 卷挂载路径
- /docker/jenkins_home/:/var/jenkins_home
# 挂载到容器内的jenkins_home目录
# docker-compose up 就会自动生成一个jenkins_home文件夹
- /usr/local/bin/docker-compose:/usr/local/bin/docker-compose
docker_nginx_dev:
restart: always
image: nginx
container_name: nginx_dev
ports:
- 8001:8001
volumes:
- /docker/nginx/conf/nginx.conf:/etc/nginx/nginx.conf
- /docker/html:/usr/share/nginx/html
- /docker/nginx/logs:/var/log/nginx
docker_nginx_release:
restart: always
image: nginx
container_name: nginx_release
ports:
- 8002:8002
volumes:
- /docker/nginx/conf/nginx.conf:/etc/nginx/nginx.conf
- /docker/html:/usr/share/nginx/html
- /docker/nginx/logs:/var/log/nginx
environment:
- TZ=Asia/Shanghai
docker_nginx_pro:
restart: always
image: nginx
container_name: nginx_pro
ports:
- 8003:8003
volumes:
- /docker/nginx/conf/nginx.conf:/etc/nginx/nginx.conf
- /docker/html:/usr/share/nginx/html
- /docker/nginx/logs:/var/log/nginx
networks:
frontend:
external: true
services:
docker_jenkins:
user: root # root权限
restart: always # 重启方式
image: jenkins/jenkins:lts # 使用的镜像
container_name: jenkins # 容器名称
ports: # 对外暴露的端口定义
- 8999:8080
- 50000:50000
environment:
- TZ=Asia/Shanghai ## 设置时区 否则默认是UTC
#- "JENKINS_OPTS=--prefix=/jenkins_home"
## 自定义 jenkins 访问前缀(设置了的话访问路径就为你的ip:端口/jenkins_home,反之则直接为ip:端口)
volumes: # 卷挂载路径
- /docker/jenkins_home/:/var/jenkins_home
# 挂载到容器内的jenkins_home目录
# docker-compose up 就会自动生成一个jenkins_home文件夹
- /usr/local/bin/docker-compose:/usr/local/bin/docker-compose
docker_nginx_dev:
restart: always
image: nginx
container_name: nginx_dev
ports:
- 8001:8001
volumes:
- /docker/nginx/conf/nginx.conf:/etc/nginx/nginx.conf
- /docker/html:/usr/share/nginx/html
- /docker/nginx/logs:/var/log/nginx
docker_nginx_release:
restart: always
image: nginx
container_name: nginx_release
ports:
- 8002:8002
volumes:
- /docker/nginx/conf/nginx.conf:/etc/nginx/nginx.conf
- /docker/html:/usr/share/nginx/html
- /docker/nginx/logs:/var/log/nginx
environment:
- TZ=Asia/Shanghai
docker_nginx_pro:
restart: always
image: nginx
container_name: nginx_pro
ports:
- 8003:8003
volumes:
- /docker/nginx/conf/nginx.conf:/etc/nginx/nginx.conf
- /docker/html:/usr/share/nginx/html
- /docker/nginx/logs:/var/log/nginx
启动Docker-compose
cd /docker/compose
docker-compose up -d
此时就会自动拉取jenkins镜像与Nginx镜像
查看运行状态
docker-compose ps -a
示例输出

cd /docker/compose
docker-compose up -d
此时就会自动拉取jenkins镜像与Nginx镜像
查看运行状态
docker-compose ps -a
示例输出
验证Nginx
在/docker/html/dev/dist
目录下新建index.html
,文件内容如下:
html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello Nginxtitle>
head>
<body>
<h1>Hello Nginxh1>
body>
html>
浏览器打开,输入服务器地址:8001
看到下面的页面说明nginx配置没问题,同样的操作可测试下8002端口和8003端口

在/docker/html/dev/dist
目录下新建index.html
,文件内容如下:
html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello Nginxtitle>
head>
<body>
<h1>Hello Nginxh1>
body>
html>
浏览器打开,输入服务器地址:8001
看到下面的页面说明nginx配置没问题,同样的操作可测试下8002端口和8003端口
验证Jenkins
浏览器输入服务器地址:8999
查看jenkins初始密码
docker logs jenkins

安装插件


浏览器输入服务器地址:8999
查看jenkins初始密码
docker logs jenkins
安装插件
安装Publish Over SSH、NodeJS
【Dashboard】——>【Manage Jenkins】——>【Plugins】——>【Available plugins】,搜索Publish Over SSH
、NodeJS
,安装后重启。 

- Publish Over SSH配置远程服务器
找到SSH Servers

点击Test Configuration
显示successs
则成功,之后再Apply
并且Save
。

- NodeJS配置
找到Nodejs

坑点!!! 使用阿里云镜像
否则会容易报错

阿里云node镜像地址
https://mirrors.aliyun.com/nodejs-release/

【Dashboard】——>【Manage Jenkins】——>【Plugins】——>【Available plugins】,搜索Publish Over SSH
、NodeJS
,安装后重启。
- Publish Over SSH配置远程服务器
找到
SSH Servers
点击
Test Configuration
显示successs
则成功,之后再Apply
并且Save
。
- NodeJS配置
找到
Nodejs
坑点!!! 使用阿里云镜像
否则会容易报错
阿里云node镜像地址
https://mirrors.aliyun.com/nodejs-release/
添加凭据
添加凭据,也就是GitHub或者其他远程仓库的凭据可以是账号密码也可以是token,方便之后使用。话不多说,Lets go!

我这里使用的是ssh
,原因用账号密码
拉不下来源码不知道为啥

添加凭据,也就是GitHub或者其他远程仓库的凭据可以是账号密码也可以是token,方便之后使用。话不多说,Lets go!
我这里使用的是
ssh
,原因用账号密码
拉不下来源码不知道为啥
创建Job

选择自由风格 
- 配置
git
仓库地址 
- 构建环境 在 Jenkins 中将 Node.js 和 npm 的 bin 文件夹添加到
PATH
中,否则可能就会报错。 
- 选择
nodejs
版本


- 创建
shell
命令


- 自动部署到对应环境项目目录:上面打包到了Jenkins中的workspace中,但是我们设置的项目环境路径跟这个不同,比如开发环境项目目录是
/docker/html/dev/dist/
,所以需要打包后,把dist文件内容推送到/docker/html/dev/dist/
目录下- 修改一下上面的
shell
脚本
#!/bin/bash
echo "Node版本:"
node -v
pnpm i
echo "依赖安装成功"
pnpm build
echo "打包成功"
rm -rf dist.tar # 每次构建删除已存在的dist压缩包
tar -zcvf dist.tar ./dist #将dist文件压缩成dist.tar
echo $PATH
- 然后
Add build step
选择Send files or execute commands over SSH
,Send files or execute

- 通过SSH连接到远程服务器执行命令和发送文件:
选择自由风格
- 配置
git
仓库地址 - 构建环境 在 Jenkins 中将 Node.js 和 npm 的 bin 文件夹添加到
PATH
中,否则可能就会报错。 - 选择
nodejs
版本
- 创建
shell
命令
- 自动部署到对应环境项目目录:上面打包到了Jenkins中的workspace中,但是我们设置的项目环境路径跟这个不同,比如开发环境项目目录是
/docker/html/dev/dist/
,所以需要打包后,把dist文件内容推送到/docker/html/dev/dist/
目录下- 修改一下上面的
shell
脚本
#!/bin/bash
echo "Node版本:"
node -v
pnpm i
echo "依赖安装成功"
pnpm build
echo "打包成功"
rm -rf dist.tar # 每次构建删除已存在的dist压缩包
tar -zcvf dist.tar ./dist #将dist文件压缩成dist.tar
echo $PATH
- 然后
Add build step
选择Send files or execute commands over SSH
,Send files or execute
- 通过SSH连接到远程服务器执行命令和发送文件:
- 修改一下上面的
ps:
脚本解释
cd /docker/html/dev
- 切换到
/docker/html/dev
目录。这是你要进行操作的工作目录。
- 切换到
rm -rf dist/
- 递归地删除
dist/
目录及其所有内容。这是为了确保旧的dist
目录被完全移除。
- 递归地删除
tar zxvf dist.tar
- 解压
dist.tar
文件,并将其中的内容解压到当前目录下。通常,dist.tar
会包含dist/
目录,解压后就会生成一个新的dist/
目录。
- 解压
rm dist.tar
- 解压完成后,删除
dist.tar
文件。这样可以节省存储空间并清理不再需要的压缩包。
- 解压完成后,删除
至此全部配置完成
- 测试ci/cd 点击
Build Now
开始构建
查看构建中任务的Console Output
,日志出现Finished: SUCCESS
即为成功
预览产物:
其他配置
GitHub webHooks配置
payload URL
为:http://ip:jenkins端口/github-webhook/
在Github webHooks
创建好后,到jenkins
中开启触发器 这样配置完后在push到相应分支就会自动构建发布
其他的个性化配置例如:钉钉通知、邮箱通知、pipeline配置等等就不做学习了,毕竟运维的事,我也学不会。😊😊
完结撒花🎉🎉🎉
来源:juejin.cn/post/7407333344889896998
多用户抢红包,如何保证只有一个抢到
前言
在一个百人群中,群主发了个红包,设置的3个人瓜分。如何能够保证只有3个人能抢到。100个人去抢,相当于就是100个线程去争夺这3个资源,如果处理不好,可能就会发生“超卖”,产生脏数据,威胁系统的正常运行。
当100个人同时去抢,也就是线程1,线程2,线程3...,此时线程1和线程2已经抢到了,就还剩一个红包了,而此时线程3和线程4同时发出抢红包的命令,线程3查询数据库发现还剩1个,抢下成功,而线程3还未修改库存时,线程4也来读取,发现还剩一个,也抢成功。结果这就发生“超卖”,红包被抢了4个,数据库一看红包剩余为-1。
解决思路
为了保证资源的安全,不能让多个用户同时访问到资源,也就是需要互斥的访问共有资源,同一时刻只能让一个用户访问,也就是给共享资源加上一个悲观锁,只有拿到锁的线程才能正常访问资源,拿不到锁的线程也不能让他一直等着,直接返回用户让他稍后重试。
JVM本地锁
JVM本地锁由ReentrantLock或synchronized实现
//抢红包方法加锁
public synchronized void grabRedPaper(){
...业务处理
}
不过这种同步锁粒度太大,我们需要的是针对抢同一红包的用户互斥,而这种方式是所有调用grabRedPaper方法的线程都需要等待,即限制所有人抢红包操作,效率低且不符合业务需求。每个红包应该都有一个唯一性ID,在单个红包上加锁效率就会高很多,也是单进程常用的使用方式。
private Map<String, Object> lockMap = new HashMap<>();
//抢红包方法
public void grabRedPaper(String redPaperId) {
Object lock = getLock(redPaperId);
synchronized (lock) {
// 在这里进行对业务的互斥访问操作
}
}
//获取红包ID锁对象
private Object getLock(String redPaperId) {
if (!lockMap.containsKey(redPaperId)) {
lockMap.put(redPaperId, new Object());
}
return lockMap.get(redPaperId);
}
Redis分布式锁
但当我们使用分布式系统中,一个业务功能会打包部署到多台服务器上,也就是会有多个进程来尝试获取共享资源,本地JVM锁也就无法完成需求了,所以我们需要第三方统一控制资源的分配,也就是分布式锁。
分布式锁一般一般需要满足四个基本条件:
- 互斥:同一时刻,只能有一个线程获取到资源。
- 可重入:获取到锁资源后,后续还能继续获取到锁。
- 高可用:锁服务一个宕机后还能有另一个接着服务;再者即使发生了错误,一定时间内也能自动释放锁,避免死锁发生。
- 非阻塞:如果获取不到锁,不能无限等待。
有关分布式锁的具体实现我之前的文章有讲到Java实现Redis分布式锁 - 掘金 (juejin.cn)
Mysql行锁
再者我们还可以通过Mysql的行锁实现,SELECT...FOR UPDATE,这种方式会将查询时的行锁住,不允许其他事务修改,直到读取完毕。将行锁和修改红包剩余数量放在一个事务中,也能做到互斥。不过这种做法效率较差,不推荐使用。
总结
方案 | 实现举例 | 优点 | 缺点 |
---|---|---|---|
JVM本地锁 | synchronized | 实现简单,性能较好 | 只能在单个 JVM 进程内使用,无法用于分布式环境 |
Mysql行锁 | SELECT...FOR UPDATE | 保证并发情况下的隔离性,避免出现脏数据 | 增加了数据库的开销,特别是在高并发场景下;对应用程序有一定的侵入性,需要在 SQL 语句中正确使用锁定机制。 |
分布式锁 | Redis分布式锁 | 可用于分布式,性能较高 | 实现相对复杂,需要考虑锁的续租、释放等问题。 |
来源:juejin.cn/post/7398038222985543692
二维码扫码登录业务详解
二维码扫码登录业务详解
前言
二维码登录 顾名思义 重要是在于登录这俩个字
登录简单点来说可以概括为俩点
- 告诉系统
我是谁
- 向系统证明
我是谁
下面我们就会围绕着这俩点来展开详细说明
原理解析
其实大部分的二维码 都是一个url
地址
我们以掘金扫码登录为例来进行剖析
我们进行一个解析
我们可以发现她实际就是这样的一个url
所以说 我们二维码的一个操作 做出来的就是一个url地址
那么我们知道这个后 我们就可以来进行一个流程的解析。
就是一个这样简单的流程
流程概述
简单来说氛围下面的步骤:
- PC端:进入二维码登录页面,请求服务端获取二维码的ID。
- 服务端:生成二维码ID,并将其与请求的设备绑定后,返回有效的二维码ID。
- PC端:根据二维码ID生成二维码图片,并展示出来。
- 移动端:扫描二维码,解析出二维码ID。
- 移动端:使用移动端的token和二维码ID请求服务端进行登录。
- 服务端:解析验证请求,绑定用户信息,并返回给移动端一个用于二次确认的临时token。
- PC端:展示二维码为“待确认”状态。
- 移动端:使用二维码ID、临时token和移动端的token进行确认登录。
- 服务端:验证通过后,修改二维码状态,并返回给PC端一个登录的token。
下面我们来用一个python的代码来描述一下这个过程。
首先是服务端:
from flask import Flask, request, jsonify
import uuid
import time
app = Flask(__name__)
# 存储二维码ID和对应的设备信息以及临时token
qr_code_store = {}
temporary_tokens = {}
@app.route('/generate_qr', methods=['POST'])
def generate_qr():
device_id = request.json['device_id']
qr_id = str(uuid.uuid4())
qr_code_store[qr_id] = {'device_id': device_id, 'timestamp': time.time(), 'status': 'waiting'}
return jsonify({'qr_id': qr_id})
@app.route('/scan_qr', methods=['POST'])
def scan_qr():
qr_id = request.json['qr_id']
token = request.json['token']
if qr_id in qr_code_store:
qr_code_store[qr_id]['status'] = 'scanned'
temp_token = str(uuid.uuid4())
temporary_tokens[temp_token] = {'qr_id': qr_id, 'timestamp': time.time()}
return jsonify({'temp_token': temp_token})
return jsonify({'error': 'Invalid QR code'}), 400
@app.route('/confirm_login', methods=['POST'])
def confirm_login():
qr_id = request.json['qr_id']
temp_token = request.json['temp_token']
mobile_token = request.json['mobile_token']
if temp_token in temporary_tokens and temporary_tokens[temp_token]['qr_id'] == qr_id:
login_token = str(uuid.uuid4())
qr_code_store[qr_id]['status'] = 'confirmed'
return jsonify({'login_token': login_token})
return jsonify({'error': 'Invalid confirmation'}), 400
if __name__ == '__main__':
app.run(debug=True)
之后来看PC端:
import requests
import json
# 1. 请求生成二维码ID
response = requests.post('http://localhost:5000/generate_qr', json={'device_id': 'PC_device'})
qr_id = response.json()['qr_id']
# 2. 根据二维码ID生成二维码图片 (此处省略,可以使用第三方库生成二维码图片)
print(f"QR Code ID: {qr_id}")
# 7. 显示二维码进入“待确认”状态
print("QR Code Status: Waiting for confirmation")
之后再来看移动端的代码:
import requests
# 4. 扫描二维码,解析出二维码ID
qr_id = '解析出的二维码ID'
token = '移动端token'
# 5. 请求服务端进行登录
response = requests.post('http://localhost:5000/scan_qr', json={'qr_id': qr_id, 'token': token})
temp_token = response.json()['temp_token']
# 8. 使用二维码ID、临时token和移动端的token进行确认登录
response = requests.post('http://localhost:5000/confirm_login', json={'qr_id': qr_id, 'temp_token': temp_token, 'mobile_token': token})
login_token = response.json().get('login_token')
if login_token:
print("登录成功!")
else:
print("登录失败!")
这样一个简单的二维码登录的流程就出来了
案例解析
了解了流程之后我们来看看其他大型网站是如何实施的 这里拿哔哩哔哩来举例。
我们可以看到她的那个json实例
{
"code": 0,
"message": "0",
"ttl": 1,
"data": {
"url": "",
"refresh_token": "",
"timestamp": 0,
"code": 86101,
"message": "未扫码"
}
}
我们可以发现他是不断的去发送这个请求 每过1s大概
之后当我们扫描后发现已经变成等待确认
当我们确认后 他会返回
和我们说的流程大概的相同
来源:juejin.cn/post/7389952503041884170
数据无界,存储有方:MinIO,为极致性能而生!
MinIO: 数据宇宙的超级存储引擎,解锁云原生潜能- 精选真开源,释放新价值。
概览
MinIO,这一高性能分布式对象存储系统的佼佼者,正以开源的力量重塑企业数据存储的版图。它设计轻巧且完全兼容Amazon S3接口,成为连接全球开发者与企业的桥梁,提供了一个既强大又灵活的数据管理平台。超越传统存储桶的范畴,MinIO是专为应对大规模数据挑战而生,尤其适用于AI深度学习、大数据分析等高负载场景,其单个对象高达5TB的存储容量设计,确保了数据密集应用运行无阻,流畅高效。
无论是部署于云端、边缘计算环境还是本地服务器,MinIO展现了极高的适配性和灵活性。它以超简单的安装步骤、直观的管理界面以及平滑的扩展能力,极大地降低了企业构建复杂数据架构的门槛。利用MinIO,企业能够快速部署数据湖,以集中存储海量数据,便于分析挖掘;构建高效的内容分发网络(CDN),加速内容的全球分发;或建立可靠的备份存储方案,确保数据资产的安全无虞。
MinIO的特性还包括自动数据分片、跨区域复制、以及强大的数据持久性和高可用性机制,这些都为数据的全天候安全与访问提供了坚实保障。此外,其微服务友好的架构,使得集成到现有的云原生生态系统中变得轻而易举,加速了DevOps流程,提升了整体IT基础设施的响应速度和灵活性。
主要功能
你可以进入官方网站下载体验:min.io/download
- 高性能存储
- 优化的I/O路径:MinIO通过精心设计的I/O处理逻辑,减少了数据访问的延迟,确保了数据读写操作的高速执行。
- 并发设计:支持高并发访问,能够有效利用多核处理器,即使在高负载情况下也能维持稳定的吞吐量,特别适合处理大数据量的读写请求。
- 裸机级性能:通过底层硬件的直接访问和资源高效利用,使得在普通服务器上也能达到接近硬件极限的存储性能,为PB级数据的存储与处理提供强大支撑。
- 分布式架构
- 多节点部署:允许用户根据需求部署多个节点,形成分布式存储集群,横向扩展存储容量和处理能力。
- 纠删码技术:采用先进的纠删码(Erasure Coding)代替传统的RAID,即使在部分节点故障的情况下,也能自动恢复数据,确保数据的完整性和服务的连续性,提高了系统的容错能力。
- 高可用性与持久性:通过跨节点的数据复制或纠删码,确保数据在不同地理位置的多个副本,即使面临单点故障,也能保证数据的不间断访问,满足严格的SLA要求。
- 全面的S3兼容性
- 无缝集成:MinIO完全兼容Amazon S3 API,这意味着现有的S3应用程序、工具和库可以直接与MinIO对接,无需修改代码。
- 迁移便利:企业可以从AWS S3或任何其他S3兼容服务平滑迁移至MinIO,降低迁移成本,加速云原生应用的部署进程。
- 安全与合规
- 加密传输:支持SSL/TLS协议,确保数据在传输过程中加密,防止中间人攻击,保障数据通信安全。
- 访问控制:提供细粒度的访问控制列表(ACLs)和策略管理,实现用户和群体的权限分配,确保数据访问权限的严格控制。
- 审计与日志:记录详细的系统活动日志,便于监控和审计,符合GDPR、HIPAA等国际安全标准和法规要求。
- 简易管理与监控
- 直观Web界面:用户可通过Web UI进行集群配置、监控和日常管理,界面友好,操作简便。
- Prometheus集成:集成Prometheus监控系统,实现存储集群的实时性能监控和告警通知,帮助管理员及时发现并解决问题,确保系统稳定运行。
信息
截至发稿概况如下:
- 软件地址:github.com/minio/minio
- 软件协议:AGPL 3.0
- 编程语言:
语言 | 占比 |
---|---|
Go | 99.0% |
Other | 1.0% |
- 收藏数量:44.3K
面对不断增长的数据管理挑战,MinIO不仅是一个存储解决方案,更是企业在数字化转型旅程中的核心支撑力量,助力各行各业探索数据价值的无限可能。无论是优化存储成本、提升数据处理效率,还是确保数据安全与合规,MinIO持续推动技术边界,邀请每一位技术探索者加入其活跃的开源社区,共同参与讨论,贡献智慧,共同塑造数据存储的未来。
尽管MinIO凭借其卓越的性能与易于使用的特性在存储领域独树一帜,但面对数据量的指数级增长和环境的日益复杂,一系列新挑战浮出水面。首要任务是在海量数据中实现高效的数据索引与查询机制,确保信息的快速提取与分析。其次,在混合云及多云部署的趋势下,如何平滑实现数据在不同平台间的迁移与实时同步,成为提升业务连续性和灵活性的关键。再者,数据安全虽为根基,但在成本控制上也不可忽视,优化存储策略,在强化防护的同时降低开支,实现存储经济性与安全性的完美平衡,是当前亟待探讨与解决的课题。这些问题不仅考验着技术的极限,也为MinIO及其用户社区带来了新的研究方向与实践机遇。
热烈欢迎各位在评论区分享交流心得与见解!!!
来源:juejin.cn/post/7363570869065498675
数字签名 Signature
这一章,我们将简单的介绍以太坊中的数字签名ECDSA
,以及如何利用它发放NFT
白名单。代码中的ECDSA
库由OpenZeppelin
的同名库简化而成。
数字签名
如果你用过opensea
交易NFT
,对签名就不会陌生。下图是小狐狸(metamask
)钱包进行签名时弹出的窗口,它可以证明你拥有私钥的同时不需要对外公布私钥。
以太坊使用的数字签名算法叫双椭圆曲线数字签名算法(ECDSA
),基于双椭圆曲线“私钥-公钥”对的数字签名算法。它主要起到了三个作用:
- 身份认证:证明签名方是私钥的持有人。
- 不可否认:发送方不能否认发送过这个消息。
- 完整性:消息在传输过程中无法被修改。
ECDSA
合约
ECDSA
标准中包含两个部分:
- 签名者利用
私钥
(隐私的)对消息
(公开的)创建签名
(公开的)。 - 其他人使用
消息
(公开的)和签名
(公开的)恢复签名者的公钥
(公开的)并验证签名。 我们将配合ECDSA
库讲解这两个部分。本教程所用的私钥
,公钥
,消息
,以太坊签名消息
,签名
如下所示:
私钥: 0x227dbb8586117d55284e26620bc76534dfbd2394be34cf4a09cb775d593b6f2b
公钥: 0xe16C1623c1AA7D919cd2241d8b36d9E79C1Be2A2
消息: 0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c
以太坊签名消息: 0xb42ca4636f721c7a331923e764587e98ec577cea1a185f60dfcc14dbb9bd900b
签名: 0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c
创建签名
1. 打包消息: 在以太坊的ECDSA
标准中,被签名的消息
是一组数据的keccak256
哈希,为bytes32
类型。我们可以把任何想要签名的内容利用abi.encodePacked()
函数打包,然后用keccak256()
计算哈希,作为消息
。我们例子中的消息
是由一个address
类型变量和一个uint256
类型变量得到的:
/*
* 将mint地址(address类型)和tokenId(uint256类型)拼成消息msgHash
* _account: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
* _tokenId: 0
* 对应的消息msgHash: 0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c
*/
function getMessageHash(address _account, uint256 _tokenId) public pure returns(bytes32){
return keccak256(abi.encodePacked(_account, _tokenId));
}
2. 计算以太坊签名消息: 消息
可以是能被执行的交易,也可以是其他任何形式。为了避免用户误签了恶意交易,EIP191
提倡在消息
前加上"\x19Ethereum Signed Message:\n32"
字符,并再做一次keccak256
哈希,作为以太坊签名消息
。经过toEthSignedMessageHash()
函数处理后的消息,不能被用于执行交易:
/**
* @dev 返回 以太坊签名消息
* `hash`:消息
* 遵从以太坊签名标准:https://eth.wiki/json-rpc/API#eth_sign[`eth_sign`]
* 以及`EIP191`:https://eips.ethereum.org/EIPS/eip-191`
* 添加"\x19Ethereum Signed Message:\n32"字段,防止签名的是可执行交易。
*/
function toEthSignedMessageHash(bytes32 hash) internal pure returns (bytes32) {
// 哈希的长度为32
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
}
处理后的消息为:
以太坊签名消息: 0xb42ca4636f721c7a331923e764587e98ec577cea1a185f60dfcc14dbb9bd900b
3-1. 利用钱包签名: 日常操作中,大部分用户都是通过这种方式进行签名。在获取到需要签名的消息之后,我们需要使用metamask
钱包进行签名。metamask
的personal_sign
方法会自动把消息
转换为以太坊签名消息
,然后发起签名。所以我们只需要输入消息
和签名者钱包account
即可。需要注意的是输入的签名者钱包account
需要和metamask
当前连接的account一致才能使用。
因此首先把例子中的私钥
导入到小狐狸钱包,然后打开浏览器的console
页面:Chrome菜单-更多工具-开发者工具-Console
。在连接钱包的状态下(如连接opensea,否则会出现错误),依次输入以下指令进行签名:
ethereum.enable()
account = "0xe16C1623c1AA7D919cd2241d8b36d9E79C1Be2A2"
hash = "0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c"
ethereum.request({method: "personal_sign", params: [account, hash]})
在返回的结果中(Promise
的PromiseResult
)可以看到创建好的签名。不同账户有不同的私钥,创建的签名值也不同。利用教程的私钥创建的签名如下所示:
0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c
3-2. 利用web3.py签名: 批量调用中更倾向于使用代码进行签名,以下是基于web3.py的实现。
from web3 import Web3, HTTPProvider
from eth_account.messages import encode_defunct
private_key = "0x227dbb8586117d55284e26620bc76534dfbd2394be34cf4a09cb775d593b6f2b"
address = "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4"
rpc = 'https://rpc.ankr.com/eth'
w3 = Web3(HTTPProvider(rpc))
#打包信息
msg = Web3.solidityKeccak(['address','uint256'], [address,0])
print(f"消息:{msg.hex()}")
#构造可签名信息
message = encode_defunct(hexstr=msg.hex())
#签名
signed_message = w3.eth.account.sign_message(message, private_key=private_key)
print(f"签名:{signed_message['signature'].hex()}")
运行的结果如下所示。计算得到的消息,签名和前面的案例一致。
消息:0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c
签名:0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c
验证签名
为了验证签名,验证者需要拥有消息
,签名
,和签名使用的公钥
。我们能验证签名的原因是只有私钥
的持有者才能够针对交易生成这样的签名,而别人不能。
4. 通过签名和消息恢复公钥: 签名
是由数学算法生成的。这里我们使用的是rsv签名
,签名
中包含r, s, v
三个值的信息。而后,我们可以通过r, s, v
及以太坊签名消息
来求得公钥
。下面的recoverSigner()
函数实现了上述步骤,它利用以太坊签名消息 _msgHash
和签名 _signature
恢复公钥
(使用了简单的内联汇编):
// @dev 从_msgHash和签名_signature中恢复signer地址
function recoverSigner(bytes32 _msgHash, bytes memory _signature) internal pure returns (address){
// 检查签名长度,65是标准r,s,v签名的长度
require(_signature.length == 65, "invalid signature length");
bytes32 r;
bytes32 s;
uint8 v;
// 目前只能用assembly (内联汇编)来从签名中获得r,s,v的值
assembly {
/*
前32 bytes存储签名的长度 (动态数组存储规则)
add(sig, 32) = sig的指针 + 32
等效为略过signature的前32 bytes
mload(p) 载入从内存地址p起始的接下来32 bytes数据
*/
// 读取长度数据后的32 bytes
r := mload(add(_signature, 0x20))
// 读取之后的32 bytes
s := mload(add(_signature, 0x40))
// 读取最后一个byte
v := byte(0, mload(add(_signature, 0x60)))
}
// 使用ecrecover(全局函数):利用 msgHash 和 r,s,v 恢复 signer 地址
return ecrecover(_msgHash, v, r, s);
}
参数分别为:
// 以太坊签名消息
_msgHash:0xb42ca4636f721c7a331923e764587e98ec577cea1a185f60dfcc14dbb9bd900b
// 签名
_signature:0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c
5. 对比公钥并验证签名: 接下来,我们只需要比对恢复的公钥
与签名者公钥_signer
是否相等:若相等,则签名有效;否则,签名无效:
/**
* @dev 通过ECDSA,验证签名地址是否正确,如果正确则返回true
* _msgHash为消息的hash
* _signature为签名
* _signer为签名地址
*/
function verify(bytes32 _msgHash, bytes memory _signature, address _signer) internal pure returns (bool) {
return recoverSigner(_msgHash, _signature) == _signer;
}
参数分别为:
_msgHash:0xb42ca4636f721c7a331923e764587e98ec577cea1a185f60dfcc14dbb9bd900b
_signature:0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c
_signer:0xe16C1623c1AA7D919cd2241d8b36d9E79C1Be2A2
利用签名发放白名单
NFT
项目方可以利用ECDSA
的这个特性发放白名单。由于签名是链下的,不需要gas
。方法非常简单,项目方利用项目方账户把白名单发放地址签名(可以加上地址可以铸造的tokenId
)。然后mint
的时候利用ECDSA
检验签名是否有效,如果有效,则给他mint
。
SignatureNFT
合约实现了利用签名发放NFT
白名单。
状态变量
合约中共有两个状态变量:
signer
:公钥
,项目方签名地址。mintedAddress
是一个mapping
,记录了已经mint
过的地址。
函数
合约中共有4个函数:
- 构造函数初始化
NFT
的名称和代号,还有ECDSA
的签名地址signer
。 mint()
函数接受地址address
,tokenId
和_signature
三个参数,验证签名是否有效:如果有效,则把tokenId
的NFT
铸造给address
地址,并将它记录到mintedAddress
。它调用了getMessageHash()
,ECDSA.toEthSignedMessageHash()
和verify()
函数。getMessageHash()
函数将mint
地址(address
类型)和tokenId
(uint256
类型)拼成消息
。verify()
函数调用了ECDSA
库的verify()
函数,来进行ECDSA
签名验证。
contract SignatureNFT is ERC721 {
address immutable public signer; // 签名地址
mapping(address => bool) public mintedAddress; // 记录已经mint的地址
// 构造函数,初始化NFT合集的名称、代号、签名地址
constructor(string memory _name, string memory _symbol, address _signer)
ERC721(_name, _symbol)
{
signer = _signer;
}
// 利用ECDSA验证签名并mint
function mint(address _account, uint256 _tokenId, bytes memory _signature)
external
{
bytes32 _msgHash = getMessageHash(_account, _tokenId); // 将_account和_tokenId打包消息
bytes32 _ethSignedMessageHash = ECDSA.toEthSignedMessageHash(_msgHash); // 计算以太坊签名消息
require(verify(_ethSignedMessageHash, _signature), "Invalid signature"); // ECDSA检验通过
require(!mintedAddress[_account], "Already minted!"); // 地址没有mint过
_mint(_account, _tokenId); // mint
mintedAddress[_account] = true; // 记录mint过的地址
}
/*
* 将mint地址(address类型)和tokenId(uint256类型)拼成消息msgHash
* _account: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
* _tokenId: 0
* 对应的消息: 0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c
*/
function getMessageHash(address _account, uint256 _tokenId) public pure returns(bytes32){
return keccak256(abi.encodePacked(_account, _tokenId));
}
// ECDSA验证,调用ECDSA库的verify()函数
function verify(bytes32 _msgHash, bytes memory _signature)
public view returns (bool)
{
return ECDSA.verify(_msgHash, _signature, signer);
}
}
总结
这一讲,我们介绍了以太坊中的数字签名ECDSA
,如何利用ECDSA
创建和验证签名,还有ECDSA
合约,以及如何利用它发放NFT
白名单。代码中的ECDSA
库由OpenZeppelin
的同名库简化而成。
- 由于签名是链下的,不需要
gas
,因此这种白名单发放模式比Merkle Tree
模式还要经济; - 但由于用户要请求中心化接口去获取签名,不可避免的牺牲了一部分去中心化;
- 额外还有一个好处是白名单可以动态变化,而不是提前写死在合约里面了,因为项目方的中心化后端接口可以接受任何新地址的请求并给予白名单签名。
来源:juejin.cn/post/7376324160484327424
我写了个ffmpeg-spring-boot-starter 使得Java能剪辑视频!!
最近工作中在使用FFmpeg,加上之前写过较多的SpringBoot的Starter,所以干脆再写一个FFmpeg的Starter出来给大家使用。
首先我们来了解一下FFmpeg能干什么,FFmpeg 是一个强大的命令行工具和库集合,用于处理多媒体数据。它可以用来做以下事情:
- 解码:将音频和视频从压缩格式转换成原始数据。
- 编码:将音频和视频从原始数据压缩成各种格式。
- 转码:将一种格式的音频或视频转换为另一种格式。
- 复用:将音频、视频和其他流合并到一个容器中。
- 解复用:从一个容器中分离出音频、视频和其他流。
- 流媒体:在网络上传输音频和视频流。
- 过滤:对音频和视频应用各种效果和调整。
- 播放:直接播放媒体文件。
FFmpeg支持广泛的编解码器和容器格式,并且由于其开源性质,被广泛应用于各种多媒体应用程序中,包括视频会议软件、在线视频平台、编辑软件等。
例如
作者很喜欢的一款截图软件ShareX就使用到了FFmpeg的功能。
现在ffmpeg-spring-boot-starter已发布,maven地址为
ffmpeg-spring-boot-starter
那么如何使用ffmpeg-spring-boot-starter 呢?
第一步,新建一个SpringBoot项目
SpringBoot入门:如何新建SpringBoot项目(保姆级教程)
第二步,在pom文件里面引入jar包
<dependency>
<groupId>io.gitee.wangfugui-ma</groupId>
<artifactId>ffmpeg-spring-boot-starter</artifactId>
<version>${最新版}</version>
</dependency>
第三步,配置你的ffmpeg信息
在yml或者properties文件中配置如下信息
ffmpeg.ffmpegPath=D:\\ffmpeg-7.0.1-full_build\\bin\\
注意这里要配置为你所安装ffmpeg的bin路径,也就是脚本(ffmpeg.exe)所在的目录,之所以这样设计的原因就是可以不用在系统中配置环境变量,直接跳过了这一个环节(一切为了Starter)
第四步,引入FFmpegTemplate
@Autowired
private FFmpegTemplate ffmpegTemplate;
在你的项目中直接使用Autowired
注解注入FFmpegTemplate
即可使用
第五步,使用FFmpegTemplate
execute(String command)
- 功能:执行任意FFmpeg命令,捕获并返回命令执行的输出结果。
- 参数:
command
- 需要执行的FFmpeg命令字符串。 - 返回:命令执行的输出结果字符串。
- 实现:使用
Runtime.getRuntime().exec()
启动外部进程,通过线程分别读取标准输出流和错误输出流,确保命令执行过程中的所有输出都被记录并可被进一步分析。 - 异常:抛出
IOException
和InterruptedException
,需在调用处妥善处理。
FFmpeg执行器,这是这里面最核心的方法,之所以提供这个方法,是来保证大家的自定义的需求,例如FFmpegTemplate中没有封装的方法,可以灵活自定义ffmpeg的执行参数。
convert(String inputFile, String outputFile)
- 功能:实现媒体文件格式转换。
- 参数:
inputFile
- 待转换的源文件路径;outputFile
- 转换后的目标文件路径。 - 实现:构建FFmpeg命令,调用FFmpeg执行器完成媒体文件格式的转换。
就像这样:
@Test
void convert() {
ffmpegTemplate.convert("D:\\video.mp4","D:\\video.avi");
}
extractAudio(String inputFile)
- 功能:精确提取媒体文件的时长信息。
- 参数:
inputFile
- 需要提取时长信息的媒体文件路径。 - 实现:构造特定的FFmpeg命令,仅请求媒体时长数据,直接调用FFmpeg执行器并解析返回的时长值。
就像这样:
@Test
void extractAudio() { System.out.println(ffmpegTemplate.extractAudio("D:\\video.mp4"));
}
copy(String inputFile, String outputFile)
- 功能:执行流复制,即在不重新编码的情况下快速复制媒体文件。
- 参数:
inputFile
- 源媒体文件路径;outputFile
- 目标媒体文件路径。 - 实现:创建包含流复制指令的FFmpeg命令,直接调用FFmpeg执行器,以达到高效复制的目的。
就像这样:
@Test
void copy() {
ffmpegTemplate.copy("D:\\video.mp4","D:\\video.avi");
}
captureVideoFootage(String inputFile, String outputFile, String startTime, String endTime)
- 功能:精准截取视频片段。
- 参数:
inputFile
- 源视频文件路径;outputFile
- 截取片段的目标文件路径;startTime
- 开始时间;endTime
- 结束时间。 - 实现:构造FFmpeg命令,指定视频片段的开始与结束时间,直接调用FFmpeg执行器,实现视频片段的精确截取。
@Test
void captureVideoFootage() {
ffmpegTemplate.captureVideoFootage("D:\\video.mp4","D:\\cut.mp4","00:01:01","00:01:12");
}
scale(String inputFile, String outputFile, Integer width, Integer height)
- 功能:调整媒体文件的分辨率。
- 参数:
inputFile
- 源媒体文件路径;outputFile
- 输出媒体文件路径;width
- 目标宽度;height
- 目标高度。 - 实现:创建包含分辨率调整指令的FFmpeg命令,直接调用FFmpeg执行器,完成媒体文件分辨率的调整。
@Test
void scale() {
ffmpegTemplate.scale("D:\\video.mp4","D:\\video11.mp4",640,480);
}
cut(String inputFile, String outputFile, Integer x, Integer y, Integer width, Integer height)
- 功能:实现媒体文件的精确裁剪。
- 参数:
inputFile
- 源媒体文件路径;outputFile
- 裁剪后媒体文件路径;x
- 裁剪框左上角X坐标;y
- 裁剪框左上角Y坐标;width
- 裁剪框宽度;height
- 裁剪框高度。 - 实现:构造FFmpeg命令,指定裁剪框的坐标与尺寸,直接调用FFmpeg执行器,完成媒体文件的精确裁剪。
@Test
void cut() {
ffmpegTemplate.cut("D:\\video.mp4","D:\\video111.mp4",100,100,640,480);
}
embedSubtitle(String inputFile, String outputFile, String subtitleFile)
- 功能:将字幕文件内嵌至视频中。
- 参数:
inputFile
- 视频文件路径;outputFile
- 输出视频文件路径;subtitleFile
- 字幕文件路径。 - 实现:构造FFmpeg命令,将字幕文件内嵌至视频中,直接调用FFmpeg执行器,完成字幕的内嵌操作。
@Test
void embedSubtitle() {
ffmpegTemplate.embedSubtitle("D:\\video.mp4","D:\\video1211.mp4","D:\\srt.srt");
}
merge(String inputFile, String outputFile)
- 功能: 通过外部ffmpeg工具将多个视频文件合并成一个。
- 参数:
inputFile
: 包含待合并视频列表的文本文件路径。outputFile
: 合并后视频的输出路径。
是这样用的:
@Test
void merge() {
ffmpegTemplate.merge("D:\\mylist.txt","D:\\videoBig.mp4");
}
注意,这个mylist.txt文件长这样:
后续版本考虑支持
- 添加更多丰富的api
- 区分win和Linux环境(脚本执行条件不同)
- 支持在系统配置环境变量(用户如果没有配置配置文件的ffmpegPath信息可以自动使用环境变量)
来源:juejin.cn/post/7391326728461647872
微信公众号推送消息笔记
根据业务需要,开发一个微信公众号的相关开发,根据相关开发和整理总结了一下相关的流程和需要,进行一些整理和总结分享给大家,最近都在加班和忙碌,博客已经很久未更新了,打气精神,再接再厉,申请、认证公众号的一系列流程就不在这里赘述了,主要进行的是技术的分享,要达到的效果如下图:
开发接入
首先说明我这里用的是PHP开发语言来进行的接入,设置一个url让微信公众号的服务回调这个url,在绑定之前需要一个token的验证,设置不对会提示token不正确的提示
官方提供的测试Url工具:developers.weixin.qq.com/apiExplorer…
private function checkSignature()
{
$signature = isset($_GET["signature"]) ? $_GET["signature"] : '';
$timestamp = isset($_GET["timestamp"]) ? $_GET["timestamp"] : '';
$nonce = isset($_GET["nonce"]) ? $_GET["nonce"] : '';
$echostr = isset($_GET["echostr"]) ? $_GET["echostr"] : '';
$token = 'klsg2024';
$tmpArr = array($token, $timestamp, $nonce);
sort($tmpArr, SORT_STRING);
$tmpStr = implode( $tmpArr );
$tmpStr = sha1( $tmpStr );
if( $tmpStr == $signature ){
return $echostr;
}else{
return false;
}
}
在设置的地方调用: 微信公众号的 $echostr 和 自定义的匹配上说明调用成功了
public function console(){
//关注公众号推送
$posts = $this->posts;
if(!isset($_GET['openid'])){
$res = $this->checkSignature();
if($res){
echo $res;
return true;
}else{
return false;
}
}
}
设置access_token
公众号的开发的所有操作的前提都是先设置access_token,在于验证操作的合法性,所需要的token在公众号后台的目录中获取:公众号-设置与开发-基本设置 设置和查看:
#POST https://api.weixin.qq.com/cgi-bin/token
{
"grant_type": "client_credential",
"appid": "开发者ID(AppID)",
"secret": "开发者密码(AppSecret)"
}
返回的access_token,过期时间2个小时,Http url 返回结构如下:
{
"access_token": "82_W8kdIcY2TDBJk6b1VAGEmA_X_DLQnCIi5oSZBxVQrn27VWL7kmUCJFVr8tjO0S6TKuHlqM6z23nzwf18W1gix3RHCw6uXKAXlD-pZEO7JcAV6Xgk3orZW0i2MFMNGQbAEARKU",
"expires_in": 7200
}
为了方便起见,公众号平台还开放了一个稳定版的access_token,参数略微有不同。
POST https://api.weixin.qq.com/cgi-bin/stable_token
{
"grant_type": "client_credential",
"appid": "开发者ID(AppID)",
"secret": "开发者密码(AppSecret)",
"force_refresh":true
}
自定义菜单
第一个疑惑是公众号里的底部菜单 是怎么搞出来的,在官方文档中获取到的,如果公众号后台没有设置可以根据自定义菜单来进行设置。
1、创建菜单,参数自己去官方文档上查阅
POST https://api.weixin.qq.com/cgi-bin/menu/create?access_token=ACCESS_TOKEN
2、查询菜单接口,文档和调试工具给的有点不一样,我使用的是调试工具给出的url
GET https://api.weixin.qq.com/cgi-bin/menu/get?access_token=ACCESS_TOKEN
3、删除菜单
GET https://api.weixin.qq.com/cgi-bin/menu/delete?access_token=ACCESS_TOKEN
事件拦截
在公众号的开发后台里会设置一个Url,每次在操作公众号时都会回调接口,用事件去调用和处理,操作公众号后,微信公众平台会请求到设置的接口上,公众号的openid 比较重要,是用来识别用户身份的唯一标识,openid即当前用户。
{
"signature": "d43a23e838e2b580ca41babc78d5fe78b2993dea",
"timestamp": "1721273358",
"nonce": "1149757628",
"openid": "odhkK64I1uXqoUQjt7QYx4O0yUvs"
}
用户进行相关操作时,回调接口会收到这样一份请求,都是用MsgType和Event去区分,下面是关注的回调:
{
"ToUserName": "gh_d98fc9c8e089",
"FromUserName": "用户openID",
"CreateTime": "1721357413",
"MsgType": "event",
"Event": "subscribe",
"EventKey": []
}
下面是点击菜单跳转的回调:
{
"ToUserName": "gh_d98fc9c8e089",
"FromUserName": "用户openID",
"CreateTime": "1721381657",
"MsgType": "event",
"Event": "VIEW",
"EventKey": "https:\/\/zhjy.rchang.cn\/api\/project_audit\/getOpenid?type=1",
"MenuId": "421351906"
}
消息推送
消息能力是公众号中最核心的能力,我们这次主要分享2个,被动回复用户消息和模板推送能力。
被动回复用户消息
被动回复用户消息,把需要的参数拼接成xml格式的,我觉得主要是出于安全上的考虑作为出发点。
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>12345678</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[你好]]></Content>
</xml>
在php代码里的实现即为:
protected function subscribe($params)
{
$time = time();
$content = "欢迎的文字";
$send_msg = '<xml>
<ToUserName><![CDATA['.$params['FromUserName'].']]></ToUserName>
<FromUserName><![CDATA['.$params['ToUserName'].']]></FromUserName>
<CreateTime>'.time().'</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA['.$content.']]></Content>
</xml>';
echo $send_msg;
return false;
}
模板推送能力
模版推送的两个关键是申请了模版,还有就是模版的data需要和模版中的一致,才能成功发送,模版设置和申请的后台位置在 广告与服务-模版消息
public function project_message()
{
$touser = '发送人公众号openid';
$template_id = '模版ID';
$url = 'https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=' . $this->access_token;
$details_url = '点开链接,需要跳转的详情url';
$thing1 = '模版里定义的参数';
$time2 = '模版里定义的参数';
$const3 = '模版里定义的参数';
$send_data = [
'touser' => $touser,
'template_id' => $template_id,
'url' => $details_url,
'data' => [
'thing1' => ['value' => $thing1],
'time2' => ['value' => $time2],
'const3' => ['value' => $const3],
]
];
$result = curl_json($url, $send_data);
}
错误及解决方式
1、公众号后台: 设置与开发-安全中心-IP白名单 把IP地址加入白名单即可。
{
"errcode": 40164,
"errmsg": "invalid ip 47.63.30.93 ipv6 ::ffff:47.63.30.93, not in whitelist rid: 6698ef60-27d10c40-100819f9"
}
2、模版参数不正确时,接口返回
{
"errcode": 47003,
"errmsg": "argument invalid! data.time5.value invalid rid: 669df26e-538a8a1a-15ab8ba4"
}
3、access_token不正确
{
"errcode": 40001,
"errmsg": "invalid credential, access_token is invalid or not latest, could get access_token by getStableAccessToken, more details at https://mmbizurl.cn/s/JtxxFh33r rid: 669df2f1-74be87a6-05e77d20"
}
4、access_token超过调用次数
{
"errcode": 45009,
"errmsg": "reach max api daily quota limit, could get access_token by getStableAccessToken, more details at https:\/\/mmbizurl.cn\/s\/JtxxFh33r rid: 669e5c4c-2bb4e05f-61d6917c"
}
文档参考
公众号开发文档首页: developers.weixin.qq.com/doc/offiacc…
分享一个微信公众号的调试工具地址,特别好用 : mp.weixin.qq.com/debug/
来源:juejin.cn/post/7394392321988575247
一文揭秘:火山引擎云基础设施如何支撑大模型应用落地
2024年被普遍认为是“大模型落地应用元年”,而要让大模型真正落地应用到企业的生产环节中,推理能力至关重要。所谓“推理能力”,即大模型利用输入的新数据,一次性获得正确结论的过程。除模型本身的设计外,还需要强大的硬件作为基础。
在8月21日举办的2024火山引擎AI创新巡展上海站活动上,火山引擎云基础产品负责人罗浩发表演讲,介绍了火山引擎AI全栈云在算力升级、资源管理、性能和稳定性等方面做出的努力,尤其是分享了针对大模型推理问题的解决方案。
罗浩表示,在弹性方面,与传统的云原生任务相比,推理任务,以及面向AI native应用,由于其所对应的底层资源池更加复杂,因此面临的弹性问题也更加复杂。传统的在线任务弹性,主要存在于CPU、内存、存储等方面,而AI native应用的弹性问题,则涉及模型弹性、GPU弹性、缓存弹性,以及RAG、KV Cache等机制的弹性。
同时,由于底层支撑算力和包括数据库系统在内的存储都发生了相应的变化,也导致对应的观测体系和监控体系出现不同的变化,带来新的挑战。
在具体应对上,火山引擎首先在资源方面,面向不同的需求,提供了更多类型的多达几百种计算实例,包括推理、训练以及不同规格推理和训练的实例类型,同时涵盖CPU和GPU。
在选择实例时,火山引擎应用了自研的智能选型产品,当面训练场景或推理场景时,在给定推理引擎,以及该推理引擎所对应的模型时,都会给出更加适配的GPU或CPU实例。该工具也会自动探索模型参数,包括推理引擎性能等,从而找到最佳匹配实例。
最后,结合整体资源调度体系,可以通过容器、虚拟机、Service等方式,满足对资源的需求。
而在数据领域,目前在训练场景,最主要会通过TOS、CFS、VPFS支持大模型的训练和分发,可以看到所有的存储、数据库等都在逐渐转向高维化,提供了对应的存储和检索能力。
在数据安全方向,当前的存储数据,已经有了更多内容属性,企业和用户对于数据存储的安全性也更加在意。对此,火山引擎在基础架构层面提供全面的路审计能力,可通过专区形式,支持从物理机到交换机,再到专属云以及所有组件的对应审计能力。
对此,罗浩以火山引擎与游戏公司沐瞳的具体合作为例给予了解释。在对移动端游戏里出现的语言、行为进行审计和审核时,大量用到各种各样的云基础,以及包括大模型在内的多种AI产品,而火山引擎做到了让所有的产品使用都在同一朵云上,使其在整体调用过程当中,不出现额外的流量成本,也使整体调用延时达到最优化。
另外,在火山引擎与客户“美图”合作的案例中,在面对新年、元旦、情人节等流量高峰时,美图通过火山引擎弹性的资源池,同时利用火山潮汐的算力,使得应用整体使用GPU和CPU等云资源时,成本达到最优化。
罗浩最后表示,未来火山引擎AI全栈云在算力、资源管理、性能及稳定性等方面还将继续探索,为AI应用在各行业的落地,奠定更加坚实的基础,为推动各行业智能化和数字化转型的全新助力。(作者:李双)
收起阅读 »逻辑删除用户账号合规吗?
事情的起因是这样:
有一个小伙伴说自己用某电动车 App,由于种种原因后来注销了账号,注销完成之后,该 App 提示 “您的账户已删除。与您的账户关联的所有个人数据也已永久删除”。当时当他重新打开 App 之后,发现账户名变为了 unknown,邮箱和电话变成了账号的
uid@delete.account.品牌.com
。更炸裂的是,这个 App 此时还是可以正常控制电动车,可以查看定位、电量、客服记录、维修记录等等信息。
小伙伴觉得心塞,感觉被这个 App 耍了,明明就没有删除个人信息,却信誓旦旦的说数据已经永久删除了。
其实咱们做后端服务的小伙伴都知道,基本上都是逻辑删除,很少很少有物理删除。
大部分公司可能都是把账号状态标记为删除,然后踢用户下线;有点良心的公司除了将账号状态标记为删除,还会将用户信息脱敏;神操作公司则把账号状态标记为删除,但是忘记踢用户下线。
于是就出现了咱们小伙伴遇到的场景了。
逻辑删除这事,其实不用看代码,就从商业角度稍微分析就知道不可能是物理删除。比如国内很多 App 对新用户都会送各种优惠券、代金券等等,如果物理删除岂不是意味着可以反复薅平台羊毛。
当然这个是各个厂的实际做法,那么这块有没有相关规定呢?松哥专门去查看了一下相关资料。
根据 GB/T 35273
中的解释,我挑两段给大家看下。
首先文档中解释了什么是删除:
去除用户个人信息的行为,使其保持不可被检索、访问的状态。
理论上来说,逻辑删除也能够实现用户信息不可被检索和访问。
再来看关于用户注销账户的规范:
删除个人信息或者匿名化处理。
从这两处解释大家可以看到,平台逻辑删除用户信息从合规上来说没有问题。
甚至可能物理删除了反而有问题。
比如张三注册了一个聊天软件实施诈骗行为,骗到钱了光速注销账号,平台也把张三的信息删除了,最后取证找不到人,在目前这种情况下,平台要不要背锅?如果平台要背锅,那你说平台会不会就真把张三信息给清空了?
对于这个小伙伴的遭遇,其实算是一个系统 BUG,账户注销,应该强制退出登录,退出之后,再想登录肯定就登录不上去了,所以也看不到自己之前的用户信息了。
小伙伴们说说,你们的系统是怎么处理这种场景的呢?
来源:juejin.cn/post/7407274895929638964
localhost和127.0.0.1的区别是什么?
今天在网上逛的时候看到一个问题,没想到大家讨论的很热烈,就是标题中这个:
localhost和127.0.0.1的区别是什么?
前端同学本地调试的时候,应该没少和localhost打交道吧,只需要执行 npm run 就能在浏览器中打开你的页面窗口,地址栏显示的就是这个 http://localhost:xxx/index.html
可能大家只是用,也没有去想过这个问题。
联想到我之前合作过的一些开发同学对它们俩的区别也没什么概念,所以我觉得有必要普及下。
localhost是什么呢?
localhost是一个域名,和大家上网使用的域名没有什么本质区别,就是方便记忆。
只是这个localhost的有效范围只有本机,看名字也能知道:local就是本地的意思。
张三和李四都可以在各自的机器上使用localhost,但获取到的也是各自的页面内容,不会相互打架。
从域名到程序
要想真正的认清楚localhost,我们还得从用户是如何通过域名访问到程序说起。
以访问百度为例。
1、当我们在浏览器输入 baidu.com 之后,浏览器首先去DNS中查询 baidu.com 的IP地址。
为什么需要IP地址呢?打个比方,有个人要寄快递到你的公司,快递单上会填写:公司的通讯地址、公司名称、收件人等信息,实际运输时快递会根据通信地址进行层层转发,最终送到收件人的手中。网络通讯也是类似的,其中域名就像公司名称,IP地址就像通信地址,在网络的世界中只有通过IP地址才能找到对应的程序。
DNS就像一个公司黄页,其中记录着每个域名对应的IP地址,当然也有一些域名可能没做登记,就找不到对应的IP地址,还有一些域名可能会对应多个IP地址,DNS会按照规则自动返回一个。我们购买了域名之后,一般域名服务商会提供一个域名解析的功能,就是把域名和对应的IP地址登记到DNS中。
这里的IP地址从哪里获取呢?每台上网的电脑都会有1个IP地址,但是个人电脑的IP地址一般是不行的,个人电脑的IP地址只适合内网定位,就像你公司内部的第几栋第几层,公司内部人明白,但是直接发给别人,别人是找不到你的。如果你要对外部提供服务,比如百度这种,你就得有公网的IP地址,这个IP地址一般由网络服务运营商提供,比如你们公司使用联通上网,那就可以让联通给你分配一个公网IP地址,绑定到你们公司的网关服务器上,网关服务器就像电话总机,公司内部的所有网络通信都要通过它,然后再在网关上设置转发规则,将网络请求转发到提供网络服务的机器上。
2、有了IP地址之后,浏览器就会向这个IP地址发起请求,通过操作系统打包成IP请求包,然后发送到网络上。网络传输有一套完整的路由协议,它会根据你提供的IP地址,经过路由器的层层转发,最终抵达绑定该IP的计算机。
3、计算机上可能部署了多个网络应用程序,这个请求应该发给哪个程序呢?这里有一个端口的概念,每个网络应用程序启动的时候可以绑定一个或多个端口,不同的网络应用程序绑定的端口不能重复,再次绑定时会提示端口被占用。通过在请求中指定端口,就可以将消息发送到正确的网络处理程序。
但是我们访问百度的时候没有输入端口啊?这是因为默认不输入就使用80和443端口,http使用80,https使用443。我们在启动网络程序的时候一定要绑定一个端口的,当然有些框架会自动选择一个计算机上未使用的端口。
localhost和127.0.0.1的区别是什么?
有了上边的知识储备,我们就可以很轻松的搞懂这个问题了。
localhost是域名,上文已经说过了。
127.0.0.1 呢?是IP地址,当前机器的本地IP地址,且只能在本机使用,你的计算机不联网也可以用这个IP地址,就是为了方便开发测试网络程序的。我们调试时启动的程序就是绑定到这个IP地址的。
这里简单说下,我们经常看到的IP地址一般都是类似 X.X.X.X 的格式,用"."分成四段。其实它是一个32位的二进制数,分成四段后,每一段是8位,然后每一段再转换为10进制的数进行显示。
那localhost是怎么解析到127.0.0.1的呢?经过DNS了吗?没有。每台计算机都可以使用localhost和127.0.0.1,这没办法让DNS来做解析。
那就让每台计算机自己解决了。每台计算机上都有一个host文件,其中写死了一些DNS解析规则,就包括 localhost 到 127.0.0.1 的解析规则,这是一个约定俗成的规则。
如果你不想用localhost,那也可以,随便起个名字,比如 wodehost,也解析到 127.0.0.1 就行了。
甚至你想使用 baidu.com 也完全可以,只是只能自己自嗨,对别人完全没有影响。
域名的等级划分
localhost不太像我们平常使用的域名,比如 http://www.juejin.cn 、baidu.com、csdn.net, 这里边的 www、cn、com、net都是什么意思?localhost为什么不需要?
域名其实是分等级的,按照等级可以划分为顶级域名、二级域名和三级域名...
顶级域名(TLD):顶级域名是域名系统中最高级别的域名。它位于域名的最右边,通常由几个字母组成。顶级域名分为两种类型:通用顶级域名和国家顶级域名。常见的通用顶级域名包括表示工商企业的.com、表示网络提供商的.net、表示非盈利组织的.org等,而国家顶级域名则代表特定的国家或地区,如.cn代表中国、.uk代表英国等。
二级域名(SLD):二级域名是在顶级域名之下的一级域名。它是由注册人自行选择和注册的,可以是个性化的、易于记忆的名称。例如,juejin.cn 就是二级域名。我们平常能够申请到的也是这种。目前来说申请 xxx.com、xxx.net、xxx.cn等等域名,其实大家不太关心其顶级域名com\net\cn代表的含义,看着简短好记是主要诉求。
三级域名(3LD):三级域名是在二级域名之下的一级域名。它通常用于指向特定的服务器或子网。例如,在blog.example.com中,blog就是三级域名。www是最常见的三级域名,用于代表网站的主页或主站点,不过这只是某种流行习惯,目前很多网站都推荐直接使用二级域名访问了。
域名级别还可以进一步细分,大家可以看看企业微信开放平台这个域名:developer.work.weixin.qq.com,com代表商业,qq代表腾讯,weixin代表微信,work代表企业微信,developer代表开发者。这种逐层递进的方式有利于域名的分配管理。
按照上边的等级定义,我们可以说localhost是一个顶级域名,只不过它是保留的顶级域,其唯一目的是用于访问当前计算机。
多网站共用一个IP和端口
上边我们说不同的网络程序不能使用相同的端口,其实是有办法突破的。
以前个人博客比较火的时候,大家都喜欢买个虚拟主机,然后部署个开源的博客程序,抒发一下自己的感情。为了挣钱,虚拟主机的服务商会在一台计算机上分配N多个虚拟主机,大家使用各自的域名和默认的80端口进行访问,也都相安无事。这是怎么做到的呢?
如果你有使用Nginx、Apache或者IIS等Web服务器的相关经验,你可能会接触到主机头这个概念。主机头其实就是一个域名,通过设置主机头,我们的程序就可以共用1个网络端口。
首先在Nginx等Web程序中部署网站时,我们会进行一些配置,此时在主机头中写入网站要使用的域名。
然后Nginx等Web服务器启动的时候,会把80端口占为己有。
然后当某个网站的请求到达Nginx的80端口时,它会根据请求中携带的域名找到配置了对应主机头的网络程序。
然后再转发到这个网络程序,如果网络程序还没有启动,Nginx会把它拉起来。
私有IP地址
除了127.0.0.1,其实还有很多私有IP地址,比如常见的 192.168.x.x。这些私有IP地址大部分都是为了在局域网内使用而预留的,因为给每台计算机都分配一个独立的IP不太够用,所以只要局域网内不冲突,大家就可劲的用吧。你公司可以用 192.168.1.1,我公司也可以用192.168.1.1,但是如果你要访问我,就得通过公网IP进行转发。
大家常用的IPv4私有IP地址段分为三类:
A类:从10.0.0.0至10.255.255.255
B类:从172.16.0.0至172.31.255.255
C类:从192.168.0.0至192.168.255.255。
这些私有IP地址仅供局域网内部使用,不能在公网上使用。
--
除了上述三个私有的IPv4地址段外,还有一些保留的IPv4地址段:
用于本地回环测试的127.0.0.0至127.255.255.255地址段,其中就包括题目中的127.0.0.1,如果你喜欢也可以给自己分配一个127.0.0.2的IP地址,效果和127.0.0.1一样。
用于局域网内部的169.254.0.0至169.254.255.255地址段,这个很少接触到,如果你的电脑连局域网都上不去,可能会看到这个IP地址,它是临时分配的一个局域网地址。
这些地址段也都不能在公网上使用。
--
近年来,还有一个现象,就是你家里或者公司里上网时,光猫或者路由器对外的IPv4地址也不是公网IP了,这时候获得的可能是一个类似 100.64.x.x 的地址,这是因为随着宽带的普及,运营商手里的公网IP也不够了,所以运营商又加了一层局域网,而100.64.0.0 这个网段是专门分给运营商做局域网用的。如果你使用阿里云等公有云,一些云产品的IP地址也可能是这个,这是为了将客户的私有网段和公有云厂商的私有网段进行有效的区分。
--
其实还有一些不常见的专用IPv4地址段,完整的IP地址段定义可以看这里:http://www.iana.org/assignments…
IPv6
你可能也听说过IPv6,因为IPv4可分配的地址太少了,不够用,使用IPv6甚至可以为地球上的每一粒沙子分配一个IP。只是喊了很多年,大家还是喜欢用IPv4,这里边原因很多,这里就不多谈了。
IPv6地址类似:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX
它是128位的,用":"分成8段,每个X是一个16进制数(取值范围:0-F),IPv6地址空间相对于IPv4地址有了极大的扩充。比如:2001:0db8:3c4d:0015:0000:0000:1a2f:1a2b 就是一个有效的IPv6地址。
关于IPv6这里就不多说了,有兴趣的可以再去研究下。
关注萤火架构,加速技术提升!
来源:juejin.cn/post/7321049446443417638
前端如何将git的信息打包进html
为什么要做这件事
- 定制化项目我们没有参与或者要临时更新客户的包的时候,需要查文档才知道哪个是最新分支
- 当测试环境打包的时候,不确定是否是最新的包,需要点下功能看是否是最新的代码,不能直观的看到当前打的是哪个分支
- 多人开发的时候,某些场景下可能被别人覆盖了,需要查下jenkins或者登录服务器看下
实现效果
如下,当打开F12,可以直观的看到打包日期、分支、提交hash、提交时间
如何做
主要是借助 git-revision-webpack-plugin的能力。获取到git到一些后,将这些信息注入到变量中,html读取这个变量即可
1. 安装dev的依赖
npm install --save-dev git-revision-webpack-plugin
2. 引入依赖并且初始化
const { GitRevisionPlugin } = require('git-revision-webpack-plugin')
const gitRevisionPlugin = new GitRevisionPlugin()
3. 注入变量信息
我这里用的是vuecli,可直接在chainWebpack中注入,当然你可以使用DefinePlugin进行声明
config.plugin('html').tap((args) => {
args[0].banner = {
date: dayjs().format('YYYY-MM-DD HH:mm:ss'),
branch: gitRevisionPlugin.branch(),
commitHash: gitRevisionPlugin.commithash(),
lastCommitDateTime: dayjs(gitRevisionPlugin.lastcommitdatetime()).format('YYYY-MM-DD HH:mm:ss'),
}
return args
});
4. 使用变量
在index.html的头部插入注释
<!-- date <%= htmlWebpackPlugin.options.banner.date %> -->
<!-- branch <%= htmlWebpackPlugin.options.banner.branch %> -->
<!-- commitHash <%= htmlWebpackPlugin.options.banner.commitHash %> -->
<!-- lastCommitDateTime <%= htmlWebpackPlugin.options.banner.lastCommitDateTime %> -->
5. 查看页面
6. 假如你用的是vuecli
当你使用的是vuecli,构建完后会发现index.html上这个注释丢失了
原因如下
vueCLi 对html的打包用的html-webpack-plugin,默认在打包的时候会把注释删除掉
修改vuecli的配置如下可以解决,将removeComments设置为false即可
module.exports = {
// 其他配置项...
chainWebpack: config => {
config
.plugin('html')
.tap(args => {
args[0].minify = {
removeComments: false,
// 其他需要设置的参数
};
return args;
});
},
};
7. 假如你想给qiankun的子应用添加一些git的注释信息,可以在meta中添加
<meta name="description"
content="Date:<%= htmlWebpackPlugin.options.banner.date %>,Branch:<%= htmlWebpackPlugin.options.banner.branch %>,commitHash: <%= htmlWebpackPlugin.options.banner.commitHash %>,lastCommitDateTime:<%= htmlWebpackPlugin.options.banner.lastCommitDateTime %>">
渲染在html上如下,也可以快速的看到子应用的构建时间和具体的分支
总结
- 借助git-revision-webpack-plugin的能力读取到git的一些信息
- 将变量注入在html中或者使用DefinePlugin进行声明变量
- 读取变量后显示在html上或者打印在控制台上可以把关键的信息保留,方便我们排查问题
来源:juejin.cn/post/7403185402347634724
三个月内遭遇的第二次比特币勒索
早前搭过一个wiki (可点击wiki.dashen.tech 查看),用于"团队协作与知识分享".把游客账号给一位前同事,其告知登录出错.
用我记录的账号密码登录,同样报错; 打开数据库一看,疑惑全消.
To recover your lost Database and avoid leaking it: Send us 0.05 Bitcoin (BTC) to our Bitcoin address 3F4hqV3BRYf9JkPasL8yUPSQ5ks3FF3tS1 and contact us by Email with your Server IP or Domain name and a Proof of Payment. Your Database is downloaded and backed up on our servers. Backups that we have right now: mm_wiki, shuang. If we dont receive your payment in the next 10 Days, we will make your database public or use them otherwise.
(按照今日比特币价格,0.05比特币折合人民币4 248.05元..)
大多时候不使用该服务器上安装的mysql,因而账号和端口皆为默认,密码较简单且常见,为在任何地方navicat也可连接,去掉了ip限制...对方写一个脚本,扫描各段ip地址,用常见的几个账号和密码去"撞库",几千几万个里面,总有一两个能得手.
被窃取备份而后删除的两个库,一个是来搭建该wiki系统,另一个是用来亲测mysql主从同步,详见此篇,价值都不大
实践告诉我们,不要用默认账号,不要用简单密码,要做ip限制。…
- 登录服务器,登录到mysql:
mysql -u root -p
- 修改密码:
尝试使用如下语句来修改
set password for 用户名@yourhost = password('新密码');
结果报错;查询得知是最新版本更改了语法,需用
alter user 'root'@'localhost' identified by 'yourpassword';
成功~
但在navicat里,原连接依然有效,而输入最新的密码,反倒是失败
打码部分为本机ip
在服务器执行
-- 查询所有用户
select user from mysql.user;
再执行
select host,user,authentication_string from mysql.user;
user及其后的host组合在一起,才构成一个唯一标识;故而在user表中,可以存在同名的root
使用
alter user 'root'@'%' identified by 'xxxxxx';
注意主机此处应为%
再使用
select host,user,authentication_string from mysql.user;
发现 "root@%" 对应的authentication_string
已发生改变;
在navicat中旧密码已失效,需用最新密码才可登录
参考:
这不是第一次遭遇"比特币勒索",在四月份,收到了这么一封邮件:
后来证明这是唬人的假消息,但还是让我学小扎,把Mac的摄像头覆盖了起来..
来源:juejin.cn/post/7282666367239995392
让生成式 AI 触手可及:火山引擎推出 NVIDIA NIM on VKE 最佳部署实践
技术行业近来对大语言模型(LLM)的关注正开始转向生产环境的大规模部署,将 AI 模型接入现有基础设施以优化系统性能,包括降低延迟、提高吞吐量,以及加强日志记录、监控和安全性等。然而这一路径既复杂又耗时,往往需要构建专门的平台和流程。
在部署 AI 模型的过程中,研发团队通常需要执行以下步骤:
环境搭建与配置:首先需要准备和调试运行环境,这包括但不限于 CUDA、Python、PyTorch 等依赖项的安装与配置。这一步骤往往较为复杂,需要细致地调整各个组件以确保兼容性和性能。
模型优化与封装:接下来进行模型的打包和优化,以提高推理效率。这通常涉及到使用 NVIDIA TensorRT 软件开发套件或 NVIDIA TensorRT-LLM 库等专业工具来优化模型,并根据性能测试结果和经验来调整推理引擎的配置参数。这一过程需要深入的 AI 领域知识,并且工具的使用具有一定的学习成本。
模型部署:最后,将优化后的模型部署到生产环境中。对于非容器化环境,资源的准备和管理也是一个需要精心策划的环节。
为了简化上述流程并降低技术门槛,火山引擎云原生团队推出基于 VKE 的 NVIDIA NIM 微服务最佳实践。通过结合 NIM 一站式模型服务能力,以及火山引擎容器服务 VKE 在成本节约和极简运维等方面的优势,这套开箱即用的技术方案将帮助企业更加快捷和高效地部署 AI 模型。
AI 微服务化:NVIDIA NIM
NVIDIA NIM 是一套经过优化的企业级生成式 AI 微服务,它包括推理引擎,通过 API 接口对外提供服务,帮助企业和个人开发者更简单地开发和部署 AI 驱动的应用程序。
NIM 使用行业标准 API,支持跨多个领域的 AI 用例,包括 LLM、视觉语言模型(VLM),以及用于语音、图像、视频、3D、药物研发、医学成像等的模型。同时,它基于 NVIDIA Triton™ Inference Server、NVIDIA TensorRT™、NVIDIA TensorRT-LLM 和 PyTorch 构建,可以在加速基础设施上提供最优的延迟和吞吐量。
为了进一步降低复杂度,NIM 将模型和运行环境做了解耦,以容器镜像的形式为每个模型或模型系列打包。其在 Kubernetes 内的部署形态如下:
NVIDIA NIM on Kubernetes
火山引擎容器服务 VKE(Volcengine Kubernetes Engine)通过深度融合新一代云原生技术,提供以容器为核心的高性能 Kubernetes 容器集群管理服务,可以为 NIM 提供稳定可靠高性能的运行环境,实现模型使用和运行的强强联合。
同时,模型服务的发布和运行也离不开发布管理、网络访问、观测等能力,VKE 深度整合了火山引擎高性能计算(ECS/裸金属)、网络(VPC/EIP/CLB)、存储(EBS/TOS/NAS)、弹性容器实例(VCI)等服务,并与镜像仓库、持续交付、托管 Prometheus、日志服务、微服务引擎等云产品横向打通,可以实现 NIM 服务构建、部署、发布、监控等全链路流程,帮助企业更灵活、更敏捷地构建和扩展基于自身数据的定制化大型语言模型(LLMs),打造真正的企业级智能化、自动化基础设施。
NVIDIA NIM on VKE 部署流程
下面,我们将介绍 NIM on VKE 的部署流程,助力开发者快速部署和访问 AI 模型。
准备工作
部署 NVIDIA NIM 前,需要做好如下准备:
1. VKE 集群中已安装 csi-nas / prometheus-agent / vci-virtual-kubelet / cr-credential-controller 组件
2. 在 VKE 集群中使用相适配的 VCI GPU 实例规格,具体软硬件支持情况可以查看硬件要求
3. 创建 NAS 实例,作为存储类,用于模型文件的存储
4. 创建 CR(镜像仓库) 实例,用于托管 NIM 镜像
5. 开通 VMP(托管 Prometheus)服务
6. 向 NVIDIA 官方获取 NIM 相关镜像的拉取权限(下述以 llama3-8b-instruct:1.0.0 为例),并生成 API Key
部署
1. 在国内运行 NIM 官方镜像时,为了避免网络访问影响镜像拉取速度,可以提前拉取相应 NIM 镜像并上传到火山引擎镜像仓库 CR,操作步骤如下:
2. Download the code locally, go to the Helm Chart directory of the code, and push Helm Chart to Container Registry (Helm version > 3.7):
下载代码到本地,进入到代码的 helm chart 目录中,把 helm chart 推送到镜像仓库(helm 版本大于 3.7):
3. 在 vke 的应用中心的 helm 应用中选择创建 helm 应用,并选择对应 chart,集群信息,并点击 values.yaml 的编辑按钮进入编辑页
4. 覆盖 values 内容为如下值来根据火山引擎环境调整参数配置,提升部署性能,点击确定完成参数改动,再继续在部署页点击确定完成部署
5. 若 Pod 日志出现如下内容或者 Pod 状态变成 Ready,说明服务已经准备好:
6. 在 VKE 控制台获取 LB Service 地址(Service 名称为
7. 访问 NIM 服务
The output is as follows:
会有如下输出:
监控
NVIDIA NIM 在 Grafana Dashboard 上提供了丰富的观测指标,详情可参考 Observability
在 VKE 中,可通过如下方法搭建 NIM 监控:
1. 参考文档搭建 Grafana:https://www.volcengine.com/docs/6731/126068
2. 进入 Grafana 中,在 dashboard 菜单中选择 import:
3. 观测面板效果如下:
结语
相比构建大模型镜像,基于 VKE 使用 NVIDIA NIM 部署和访问模型有如下优点:
● 易用性:NIM 提供了预先构建好的模型容器镜像,用户无需从头开始构建和配置环境,配合 VKE 与 CR 的应用部署能力,极大简化了部署过程
● 性能优化:NIM 的容器镜像是经过优化的,可以在 NVIDIA GPU 上高效运行,充分利用 VCI 的硬件性能
● 模型选择:NIM 官方提供了多种大语言模型,用户可以根据需求选择合适的模型,部署在 VKE 中仅需对values.yaml 配置做修改即可
● 自动更新:通过 NGC,NIM 可以自动下载和更新模型,用户无需手动管理模型版本
● 可观测性:NIM 内置了丰富的观测指标,配合 VKE 与 VMP 观测能力开箱即用
目前火山引擎容器服务 VKE 已开放个人用户使用,为个人和企业用户提供高性能、高可靠、极致弹性的企业级容器管理能力,结合 NIM 强大易用的模型部署服务,进一步帮助开发者快速部署 AI 模型,并提供高性能、开箱即用的模型 API 服务。(作者:李双)
收起阅读 »好好的短链,url?1=1为啥变成了url???1=1
运营小伙伴突然找到我们说,我们的一个短链有三个?
第一反应就是不可能,但是事实胜于雄辩,还真的就是和运营小伙伴说的一模一样。
到底发生了什么呢?跟着我一起Review一下。
一、URL结构
1.1 URL概述
URL(统一资源定位符)是一个用于标识互联网上资源的地址。一个典型的URL结构通常包括以下几个部分:
- 协议(Scheme) :也称为"服务方式",位于URL的开头,指定了浏览器与服务器之间通信的方式。常见的协议有
http
(超文本传输协议)、https
(安全超文本传输协议)、ftp
(文件传输协议)等。 - 子域名(Subdomain) :可选部分,位于域名之前,通常用于区分不同的服务或组织。例如,在
sub.example.com
中,sub
是子域名。 - 域名(Domain Name) :URL的核心部分,用于唯一标识一个网站。通常是一个组织或公司的名字,如
example.com
。 - 端口号(Port) :可选部分,用于指定服务器上的特定服务。如果省略,浏览器将使用默认端口,例如
http
和https
的默认端口是80和443。 - 路径(Path) :指定服务器上的资源位置。路径可以包含多个部分,用斜杠
/
分隔。例如,在/path/to/resource
中,path/to/resource
是资源的路径。 - 查询字符串(Query String) :可选部分,位于路径之后,用于传递额外的参数或数据。查询字符串以问号
?
开始,后面跟着一系列的参数,参数之间用和号&
分隔。例如,在?key1=value1&key2=value2
中,key1
和key2
是参数名,value1
和value2
是对应的值。 - 片段标识符(Fragment Identifier) :可选部分,用于指向页面内的特定部分。片段标识符以井号
#
开始,通常用于锚点链接。例如,在#section2
中,section2
是页面内的一个锚点。
1.2 URL示例
示例:一个完整的URL示例可能是下面这样的
https://www.xxx.com:8080/path/to/resource?key1=value1&key2=value2#section2
在上面的示例中,详细拆解如下:
https
是协议。
http://www.xxx.com
是域名。
8080
是端口号。
/path/to/resource
是路径。
key1=value1&key2=value2
是查询字符串。
#section2
是片段标识符。
二、URL的意义
URL(统一资源定位符)的意义在于它提供了一种标准化的方法来标识和访问互联网上的资源。它是互联网的基础构件之一,它不仅使得资源的定位和访问变得简单,还支持了互联网的组织、导航、安全和分享等多种功能。以下是URL的几个关键意义:
这些意义做开发的都懂,不懂的就自己百度吧,这里不做赘述。
三、硬菜:url?1=1为啥变成了url???1=1
3.1 故事背景
我们有一个自己的短链项目,用户访问短链的时候,我们自己服务器会进行重定向,这样的好处是分享出去的链接都是很短的,会有效提升用户的使用体验。
短链触发和服务器的交互流程如下:
sequenceDiagram
用户->>+短链: 点击
短链->>+服务器: 请求
服务器->>+服务器: 找到映射的长链地址
服务器->>+用户: 重定向到长链
用户->>+长链: 请求并得到响应
3.2 事故现场
上面弄清楚了短链的基本触发流程,那我我们看看到底发生了什么。
- 客户端事故现场截图
从这个截图就可以明显的看出,这里有三个?,这是不合理的...
- 数据库存储的事故现场数据截图
哎,数据库里面只有一个问号吧?
3.3 问题分析和解决方案
- 问题分析
上面数据库看着正常的,别着急,咱们换个方式看看,我们执行下面这个SQL看看数据存储的实际长度是多少。
SELECT
LENGTH(
CONVERT ( full_link USING utf8 )) AS actual_length
FROM
t_short_link
WHERE
id = '0fcc75b3e1b243c4b36d71b1d58b3b41';
执行结果:
上面sql执行实际得到的长度是52,但是我们长链的实际长度却是49,那么问题就出来了,数据库里面多了两个我们肉眼看不见的字符,三个问号就是这个来的
- 解决方案
从上面分析了事故现场,我们已经知道是多了两个字符了,删掉即可。
注意:因为数据库看不到,所以不能直接编辑,可以选择一些可以看到的编辑器编辑之后更新,例如notepad++。
3.4 额外发现
在写文章的时候,我将连接复制到了掘金的MD编辑器,发现这里也是暴露了问题,上面提到的解决方案,大家也是可以复制进来然后删除多余字符的。
四、总结
程序员大多数都非常自信,相信自己的代码没有bug,相信有bug也不是我的问题,有的时候怼天怼地。
但是真的遇到问题,需要三思而后行,谋定而后动;是不是自己的问题,先检查检查,避免后面发现是自己的问题很尴尬。
希望本文对您有所帮助。如果有任何错误或建议,请随时指正和提出。
同时,如果您觉得这篇文章有价值,请考虑点赞和收藏。这将激励我进一步改进和创作更多有用的内容。
感谢您的支持和理解!
来源:juejin.cn/post/7399985723674394633
太方便了!Arthas,生产问题大杀器
一、一个难查的生产问题
一天,小王发现生产环境上偶发性地出现某接口耗时过高,但在测试环境又无法复现,小王一筹莫展😔。小王“幻想”到:如果有个工具能记录生产上各个函数的耗时该多好,这样一看不就知道时间花在哪了?
这不是幻想,Arthas 已经帮我们解决了这个问题。在介绍它之前,我们先了解下相关背景。
一天,小王发现生产环境上偶发性地出现某接口耗时过高,但在测试环境又无法复现,小王一筹莫展😔。小王“幻想”到:如果有个工具能记录生产上各个函数的耗时该多好,这样一看不就知道时间花在哪了?
这不是幻想,Arthas 已经帮我们解决了这个问题。在介绍它之前,我们先了解下相关背景。
二、动态追踪
现在互联网和大家生活的各个方面都息息相关。相应地,互联网应用的用户规模也变得越来越大。江湖大了,什么风浪都有。开发者们不断被各种诡异问题打扰,接口耗时过大、CPU 占用过高、内存溢出、只有生产环境会报错......
这些问题出现的概率可能是千分之一、乃至万分之一。如果我们能不修改代码、不修改配置、不重启服务,就能看到程序内部在执行什么,这该多好,再大的问题心里也有底了。
动态追踪技术出现了,它诞生于21 世纪初。Sun Microsystems 公司的工程师在解决一个复杂问题时被繁琐的排查过程所困扰,痛定思痛,他们创造了 DTrace 动态跟踪框架。DTrace 奠定了动态追踪的基础,Bryan Cantrill, Mike Shapiro, and Adam Leventhal 三位作者也多次获得行业荣誉。
动态追踪技术出现的时间早,但 Java 语言相关的调试工具链一直不太完善。直到进入移动互联网时代,Java 的发展才进入了快车道。2018 年,Alibaba 开源 Arthas,Java 的动态追踪才真正好用起来。
动态追踪可以看作是构建了一个运行时“只读数据库”,这个数据库内部保存了实时变化的进程运行信息,我们通过调用这个“数据库”开放的接口,就能看到进程内部发生了什么。
经验丰富的读者可能会有疑问,现在微服务都用上了 Skywalking 这样的分布式链路追踪技术,通过它也能分阶段地看到各个部分的执行情况,为什么还需要 Arthas?
Arthas 有两大特点:
- 低侵入;不需要程序中进行额外配置,更不需要手动埋点。
- 功能强大;Arthas 提供了四十多种命令:从查看线程调用链,到查看输入、输出,到反编译代码等,应有尽有。
对于排查接口耗时长这样的情况,Skywalking 可以和 Arthas 配合起来,先用 Skywalking 定位出异常微服务,再用 Arthas 分析单个进程的情况,找到根因。
现在互联网和大家生活的各个方面都息息相关。相应地,互联网应用的用户规模也变得越来越大。江湖大了,什么风浪都有。开发者们不断被各种诡异问题打扰,接口耗时过大、CPU 占用过高、内存溢出、只有生产环境会报错......
这些问题出现的概率可能是千分之一、乃至万分之一。如果我们能不修改代码、不修改配置、不重启服务,就能看到程序内部在执行什么,这该多好,再大的问题心里也有底了。
动态追踪技术出现了,它诞生于21 世纪初。Sun Microsystems 公司的工程师在解决一个复杂问题时被繁琐的排查过程所困扰,痛定思痛,他们创造了 DTrace 动态跟踪框架。DTrace 奠定了动态追踪的基础,Bryan Cantrill, Mike Shapiro, and Adam Leventhal 三位作者也多次获得行业荣誉。
动态追踪技术出现的时间早,但 Java 语言相关的调试工具链一直不太完善。直到进入移动互联网时代,Java 的发展才进入了快车道。2018 年,Alibaba 开源 Arthas,Java 的动态追踪才真正好用起来。
动态追踪可以看作是构建了一个运行时“只读数据库”,这个数据库内部保存了实时变化的进程运行信息,我们通过调用这个“数据库”开放的接口,就能看到进程内部发生了什么。
经验丰富的读者可能会有疑问,现在微服务都用上了 Skywalking 这样的分布式链路追踪技术,通过它也能分阶段地看到各个部分的执行情况,为什么还需要 Arthas?
Arthas 有两大特点:
- 低侵入;不需要程序中进行额外配置,更不需要手动埋点。
- 功能强大;Arthas 提供了四十多种命令:从查看线程调用链,到查看输入、输出,到反编译代码等,应有尽有。
对于排查接口耗时长这样的情况,Skywalking 可以和 Arthas 配合起来,先用 Skywalking 定位出异常微服务,再用 Arthas 分析单个进程的情况,找到根因。
三、Arthas常用场景
相信你对动态追踪有了基本的了解,Arthas 可以理解为动态追踪在 Java 领域落地的具体工具。下面以场景助学,大家可以参考这些方案,因事制宜来解决自己的问题。
Arthas 的安装和基础使用见官方文档:Introduction | arthas。
相信你对动态追踪有了基本的了解,Arthas 可以理解为动态追踪在 Java 领域落地的具体工具。下面以场景助学,大家可以参考这些方案,因事制宜来解决自己的问题。
Arthas 的安装和基础使用见官方文档:Introduction | arthas。
3.1.接口慢/吞吐量低
在文章开头,小王就遇到了这个问题。现在小王依靠老道的排查经验确定了 MathGame 服务肯定有问题,但具体的点却找不到。小王仔细学习了这篇文章,决定用 Arthas 分以下三步来排查:
- profile 明确整体的耗时情况
profile 命令支持为应用生成火焰图,在 Arthas 终端输入以下命令:
# 开始对应用中当前执行的活动采样 30 秒,采样结束后默认会生成 HTML 文件
[arthas@5555]$ profiler start -d 30
打开 HTML 文件能看到这样的结构:

火焰图
MathGame 类下的 run 方法占用了大部分的执行时间,接下来我们看看 run 方法内部的耗时情况。
- trace 详细查看单个调用的内部耗时
[arthas@5555]$ trace --skipJDKMethod false demo.MathGame run
PrintStream 类的 print 方法占据了 87% 的时间,这是 JDK 自带的类,这说明我们程序本身并无耗时问题,但 MathGame 类的 primeFactors 方法抛出了异常,我们可以看看具体的异常,再思考怎么优化。

run方法的trace流
另外,trace 可以选择性地进行调用拦截,比如设置只拦截大于 20ms 的调用:
[arthas@5555]$ trace demo.MathGame run '#cost > 20'
- watch 查看真实的调用数据
拦截 primeFactors 方法抛出的异常:
[arthas@5555]$ watch demo.MathGame primeFactors -e "throwExp"

拦截异常
小王从大到小、逐步分析,找出了问题的原因是 primeFactors 抛出了异常,修正参数后,程序恢复了正常。
在文章开头,小王就遇到了这个问题。现在小王依靠老道的排查经验确定了 MathGame 服务肯定有问题,但具体的点却找不到。小王仔细学习了这篇文章,决定用 Arthas 分以下三步来排查:
- profile 明确整体的耗时情况
profile 命令支持为应用生成火焰图,在 Arthas 终端输入以下命令:
# 开始对应用中当前执行的活动采样 30 秒,采样结束后默认会生成 HTML 文件
[arthas@5555]$ profiler start -d 30
打开 HTML 文件能看到这样的结构:
火焰图MathGame 类下的 run 方法占用了大部分的执行时间,接下来我们看看 run 方法内部的耗时情况。
- trace 详细查看单个调用的内部耗时
[arthas@5555]$ trace --skipJDKMethod false demo.MathGame run
PrintStream 类的 print 方法占据了 87% 的时间,这是 JDK 自带的类,这说明我们程序本身并无耗时问题,但 MathGame 类的 primeFactors 方法抛出了异常,我们可以看看具体的异常,再思考怎么优化。
run方法的trace流另外,trace 可以选择性地进行调用拦截,比如设置只拦截大于 20ms 的调用:
[arthas@5555]$ trace demo.MathGame run '#cost > 20'
- watch 查看真实的调用数据
拦截 primeFactors 方法抛出的异常:
[arthas@5555]$ watch demo.MathGame primeFactors -e "throwExp"
拦截异常
小王从大到小、逐步分析,找出了问题的原因是 primeFactors 抛出了异常,修正参数后,程序恢复了正常。
3.2.CPU 占用过高
CPU 是程序运行的核心计算资源,一旦出现 CPU 占用过高,必定对大部分用户的访问耗时产生影响。针对这类问题,要定位出有问题的线程,并获取该线程当前执行的代码位置。
使用 top + jstack 命令可以定位这类问题(见参考资料三),Arthas 也提供了更便捷的一体化工具:
- 定位目标线程
# 调用线程看板,并刷新数据三次
[arthas@5555]$ dashboard -n 3

示例程序的CPU占用不算高
DashBoard 刷新三次后,在最新状态中发现示例程序里自己的线程 “main” 占用不算高。说明程序运行正常。如果是要排错,这里就要找出 CPU 占用最高的用户线程的 ID。
- 查看目标线程执行的代码位置
# “1” 是上一步定位到的 main 的线程ID
[arthas@5555]$ thread 1

线程正在“睡觉”,没什么大问题。
CPU 是程序运行的核心计算资源,一旦出现 CPU 占用过高,必定对大部分用户的访问耗时产生影响。针对这类问题,要定位出有问题的线程,并获取该线程当前执行的代码位置。
使用 top + jstack 命令可以定位这类问题(见参考资料三),Arthas 也提供了更便捷的一体化工具:
- 定位目标线程
# 调用线程看板,并刷新数据三次
[arthas@5555]$ dashboard -n 3
示例程序的CPU占用不算高DashBoard 刷新三次后,在最新状态中发现示例程序里自己的线程 “main” 占用不算高。说明程序运行正常。如果是要排错,这里就要找出 CPU 占用最高的用户线程的 ID。
- 查看目标线程执行的代码位置
# “1” 是上一步定位到的 main 的线程ID
[arthas@5555]$ thread 1
线程正在“睡觉”,没什么大问题。
3.3 生产环境的效果和测试不一样
有些时候你发现:测试环境正常,但生产就报错了。这类问题主要靠做好上线流程的管控,但也有可能是打包的依赖库出现冲突,造成程序行为不一致。接下来,我们看看怎么用 Arthas 反编译代码,以及怎么对比依赖库的版本。
- 反编译代码
# demo.MathGame 是目标类的全限定名
[arthas@5555]$ jad demo.MathGame

- 查看目标类所属的依赖包
# demo.MathGame 是目标类的全限定名
[arthas@5555]$ sc -d demo.MathGame

目标类所属的包
如果这里是依赖包,code-source 还可以显示所属包的版本。这样就可以对比本地的代码,从而在打包时设置正确的依赖版本。
有些时候你发现:测试环境正常,但生产就报错了。这类问题主要靠做好上线流程的管控,但也有可能是打包的依赖库出现冲突,造成程序行为不一致。接下来,我们看看怎么用 Arthas 反编译代码,以及怎么对比依赖库的版本。
- 反编译代码
# demo.MathGame 是目标类的全限定名
[arthas@5555]$ jad demo.MathGame
- 查看目标类所属的依赖包
# demo.MathGame 是目标类的全限定名
[arthas@5555]$ sc -d demo.MathGame
如果这里是依赖包,code-source 还可以显示所属包的版本。这样就可以对比本地的代码,从而在打包时设置正确的依赖版本。
目标类所属的包
3.4 内存溢出
生产问题中内存溢出也有不小的比例。内存溢出的关键是找出高内存占用的对象。命令行操作会比较麻烦,建议转储 Heap Dump 等文件后,通过 Eclipse Memory Analyzer(MAT) 等工具进行分析。
四、运行 Arthas 报错
在有些运行环境下,Arthas 会出现报错。对于以下两种情况,读者可参照文档解决:
- 不兼容 Skywalking
Compatible-with-other-javaagent-bytecode-processing - Alpine 镜像的容器无法生成火焰图
Alpine容器镜像中生成火焰图错误的其它解决方案
五、参考资料
作者:立子
来源:juejin.cn/post/7308230350374256666
来源:juejin.cn/post/7308230350374256666
SpringBoot 这么实现动态数据源切换,就很丝滑!
大家好,我是小富~
简介
项目开发中经常会遇到多数据源同时使用的场景,比如冷热数据的查询等情况,我们可以使用类似现成的工具包来解决问题,但在多数据源的使用中通常伴随着定制化的业务,所以一般的公司还是会自行实现多数据源切换的功能,接下来一起使用实现自定义注解的形式来实现一下。
基础配置
yml配置
pom.xml
文件引入必要的Jar
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.6</version>
</parent>
<groupId>com.dynamic</groupId>
<artifactId>springboot-dynamic-datasource</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<mybatis.plus.version>3.5.3.1</mybatis.plus.version>
<mysql.connector.version>8.0.32</mysql.connector.version>
<druid.version>1.2.6</druid.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- springboot核心包 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- mysql驱动包 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>${mysql.connector.version}</version>
</dependency>
<!-- lombok工具包 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- MyBatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis.plus.version}</version>
</dependency>
<!-- druid -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>${druid.version}</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.7</version>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
管理数据源
我们应用ThreadLocal来管理数据源信息,通过其中内容的get,set,remove方法来获取、设置、删除当前线程对应的数据源。
/**
* ThreadLocal存放数据源变量
*
* @author 公众号:程序员小富
* @date 2023/11/27 11:02
*/
public class DataSourceContextHolder {
private static final ThreadLocal<String> DATASOURCE_HOLDER = new ThreadLocal<>();
/**
* 获取当前线程的数据源
*
* @return 数据源名称
*/
public static String getDataSource() {
return DATASOURCE_HOLDER.get();
}
/**
* 设置数据源
*
* @param dataSourceName 数据源名称
*/
public static void setDataSource(String dataSourceName) {
DATASOURCE_HOLDER.set(dataSourceName);
}
/**
* 删除当前数据源
*/
public static void removeDataSource() {
DATASOURCE_HOLDER.remove();
}
}
重置数据源
创建 DynamicDataSource 类并继承 AbstractRoutingDataSource,这样我们就可以重置当前的数据库路由,实现切换成想要执行的目标数据库。
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import javax.sql.DataSource;
import java.util.Map;
/**
* 重置当前的数据库路由,实现切换成想要执行的目标数据库
*
* @author 公众号:程序员小富
* @date 2023/11/27 11:02
*/
public class DynamicDataSource extends AbstractRoutingDataSource {
public DynamicDataSource(DataSource defaultDataSource, Map<Object, Object> targetDataSources) {
super.setDefaultTargetDataSource(defaultDataSource);
super.setTargetDataSources(targetDataSources);
}
/**
* 这一步是关键,获取注册的数据源信息
* @return
*/
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSource();
}
}
配置数据库
在 application.yml 中配置数据库信息,使用dynamic_datasource_1
、dynamic_datasource_2
两个数据库
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
druid:
master:
url: jdbc:mysql://127.0.0.1:3306/dynamic_datasource_1?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 12345
driver-class-name: com.mysql.cj.jdbc.Driver
slave:
url: jdbc:mysql://127.0.0.1:3306/dynamic_datasource_2?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 12345
driver-class-name: com.mysql.cj.jdbc.Driver
再将多个数据源注册到DataSource
.
import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;
/**
* 注册多个数据源
*
* @author 公众号:程序员小富
* @date 2023/11/27 11:02
*/
@Configuration
public class DateSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.druid.master")
public DataSource dynamicDatasourceMaster() {
return DruidDataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.druid.slave")
public DataSource dynamicDatasourceSlave() {
return DruidDataSourceBuilder.create().build();
}
@Bean(name = "dynamicDataSource")
@Primary
public DynamicDataSource createDynamicDataSource() {
Map<Object, Object> dataSourceMap = new HashMap<>();
// 设置默认的数据源为Master
DataSource defaultDataSource = dynamicDatasourceMaster();
dataSourceMap.put("master", defaultDataSource);
dataSourceMap.put("slave", dynamicDatasourceSlave());
return new DynamicDataSource(defaultDataSource, dataSourceMap);
}
}
启动类配置
在启动类的@SpringBootApplication
注解中排除DataSourceAutoConfiguration
,否则会报错。
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
到这多数据源的基础配置就结束了,接下来测试一下
测试切换
准备SQL
创建两个库dynamic_datasource_1、dynamic_datasource_2,库中均创建同一张表 t_dynamic_datasource_data。
CREATE TABLE `t_dynamic_datasource_data` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`source_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
);
dynamic_datasource_1.t_dynamic_datasource_data表中插入
insert int0 t_dynamic_datasource_data (source_name) value ('dynamic_datasource_master');
dynamic_datasource_2.t_dynamic_datasource_data表中插入
insert int0 t_dynamic_datasource_data (source_name) value ('dynamic_datasource_slave');
手动切换数据源
这里我准备了一个接口来验证,传入的 datasourceName 参数值就是刚刚注册的数据源的key。
/**
* 动态数据源切换
*
* @author 公众号:程序员小富
* @date 2023/11/27 11:02
*/
@RestController
public class DynamicSwitchController {
@Resource
private DynamicDatasourceDataMapper dynamicDatasourceDataMapper;
@GetMapping("/switchDataSource/{datasourceName}")
public String switchDataSource(@PathVariable("datasourceName") String datasourceName) {
DataSourceContextHolder.setDataSource(datasourceName);
DynamicDatasourceData dynamicDatasourceData = dynamicDatasourceDataMapper.selectOne(null);
DataSourceContextHolder.removeDataSource();
return dynamicDatasourceData.getSourceName();
}
}
传入参数master时:127.0.0.1:9004/switchDataSource/master
传入参数slave时:127.0.0.1:9004/switchDataSource/slave
通过执行结果,我们看到传递不同的数据源名称,已经实现了查询对应的数据库数据。
注解切换数据源
上边已经成功实现了手动切换数据源,但这种方式顶多算是半自动,下边我们来使用注解方式实现动态切换。
定义注解
我们先定一个名为DS
的注解,作用域为METHOD方法上,由于@DS中设置的默认值是:master,因此在调用主数据源时,可以不用进行传值。
/**
* 定于数据源切换注解
*
* @author 公众号:程序员小富
* @date 2023/11/27 11:02
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface DS {
// 默认数据源master
String value() default "master";
}
实现AOP
定义了@DS
注解后,紧接着实现注解的AOP逻辑,拿到注解传递值,然后设置当前线程的数据源
import com.dynamic.config.DataSourceContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Objects;
/**
* 实现@DS注解的AOP切面
*
* @author 公众号:程序员小富
* @date 2023/11/27 11:02
*/
@Aspect
@Component
@Slf4j
public class DSAspect {
@Pointcut("@annotation(com.dynamic.aspect.DS)")
public void dynamicDataSource() {
}
@Around("dynamicDataSource()")
public Object datasourceAround(ProceedingJoinPoint point) throws Throwable {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
DS ds = method.getAnnotation(DS.class);
if (Objects.nonNull(ds)) {
DataSourceContextHolder.setDataSource(ds.value());
}
try {
return point.proceed();
} finally {
DataSourceContextHolder.removeDataSource();
}
}
}
测试注解
再添加两个接口测试,使用@DS
注解标注,使用不同的数据源名称,内部执行相同的查询条件,看看结果如何?
@DS(value = "master")
@GetMapping("/dbMaster")
public String dbMaster() {
DynamicDatasourceData dynamicDatasourceData = dynamicDatasourceDataMapper.selectOne(null);
return dynamicDatasourceData.getSourceName();
}
@DS(value = "slave")
@GetMapping("/dbSlave")
public String dbSlave() {
DynamicDatasourceData dynamicDatasourceData = dynamicDatasourceDataMapper.selectOne(null);
return dynamicDatasourceData.getSourceName();
}
通过执行结果,看到通过应用@DS
注解也成功的进行了数据源的切换。
事务管理
在动态切换数据源的时候有一个问题是要考虑的,那就是事务管理是否还会生效呢?
我们做个测试,新增一个接口分别插入两条记录,其中在插入第二条数据时将值设置超过了字段长度限制,会产生Data too long for column
异常。
/**
* 验证一下事物控制
*/
// @Transactional(rollbackFor = Exception.class)
@DS(value = "slave")
@GetMapping("/dbTestTransactional")
public void dbTestTransactional() {
DynamicDatasourceData datasourceData = new DynamicDatasourceData();
datasourceData.setSourceName("test");
dynamicDatasourceDataMapper.insert(datasourceData);
DynamicDatasourceData datasourceData1 = new DynamicDatasourceData();
datasourceData1.setSourceName("testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest");
dynamicDatasourceDataMapper.insert(datasourceData1);
}
经过测试发现执行结果如下,即便实现动态切换数据源,本地事务依然可以生效。
- 不加上
@Transactional
注解第一条记录可以插入,第二条插入失败 - 加上
@Transactional
注解两条记录都不会插入成功
本文案例地址:github.com/chengxy-nds…
来源:juejin.cn/post/7316202800663363594
京东企业业务前端监控实践
作者:零售企业业务苏子刚
监控的背景和意义
在现代前端开发中,接入监控系统是一个很重要的环节,它可以帮助开发者、产品、运营了解应用的性能表现,用户的实际体验以及潜在的错误和问题,从而进一步优化用户体验,帮助产品升级迭代。
背景
•应用复杂性增加:随着单页应用(SPA)和渐进式网页应用(PWA)的流行,前端应用变得越来越复杂。
•网页加载性能要求提高:用户对网页加载速度和交互响应的要求越来越高,性能成为影响用户体验的关键因素。
•多样化的设备和网络环境:用户通过各种设备和网络环境访问应用,这些因素都可能影响应用的性能和用户体验。
•敏捷开发和持续部署:敏捷开发和 CI/CD 的实践要求开发团队能够快速的响应问题,并持续改进产品。
意义
•对应用性能监控,提升用户体验:监控页面的加载时间,交互时间等性能指标,帮助团队优化代码,提升用户体验。
•错误追踪,快速定位并解决问题:捕获前端错误和异常,快速定位问题源头,缩短故障的修复时间。
•用户行为分析,指导产品快速迭代:了解用户如何与应用互动,哪些功能用户喜欢,哪些路径导致转化,从而指导产品迭代升级。
•业务指标监控,保证主流程的稳定性:监控关键业务流程,如:购物车、商详、结算页等黄流,确保业务流程的稳定性。
•告警系统,异常情况能够快速响应:通过定时任务自动化巡检/实时监控,当出现性能异常时,能够快速响应。
监控的类别
从上面可看出,通过监控能够促使我们的系统更加健壮、稳定,体验更好。那我们团队也从去年开始逐步将各个应用接入了监控。到目前为止,我们的监控分为两个部分:
•实时监控:成功集成了 SGM 集团内部监控平台,并引入了相应的 SDK 来对前端应用进行实时监控。目前,我们所涉及的 100+ 个应用程序已经完全接入该系统。通过配置有效的告警机制,我们能够及时捕捉并上报线上环境中的错误和异常,确保业务的稳定运行。
•定时任务巡检:实现了定时任务的自动化设置,用于定期执行自动巡检,并自动上报检测结果,还能激活告警系统。通过这种方式,我们能够确保持续监控系统健康状况,并在发现潜在问题时能够迅速响应。
◦使用 Chrome 插件快速创建 UI 测试脚本,随后在 UI啄木鸟平台 上配置定时执行这些脚本进行系统巡检。这一流程实现了自动化的界面测试,确保应用的用户界面按预期工作,同时及时发现并解决潜在的 UI 问题;
◦自主研发一套脚本,这些脚本通过启动一个 Node.js 服务来进行系统巡检。这种方法使我们能够灵活地监控系统性能和功能,及时发现并处理潜在的问题,从而保障系统的稳定运行;
监控整体架构
监控建设实践
实时监控
通过整合 SGM 监控平台和告警系统,我们实现了对所有接入应用的实时监控。一旦检测到异常,系统会立即通过多种方式(如咚咚、邮件、电话等)通知相关团队成员,确保问题能够被迅速发现和解决。为了提高告警的有效性,我们精心设计了告警策略,确保只有真正的异常情况才会触发告警。以下是我们在优化告警设置过程中积累的一些关键经验和建议:
1.精确度与敏感度的平衡:过于敏感的告警会导致频繁的误报,而设置过于宽松则可能错过关键的异常。找到合适的平衡点至关重要。
2.分级告警机制:根据问题的严重程度设置不同级别的告警,以便采取相应的响应措施。紧急问题可以通过电话直接通知,而较为轻微的问题可以通过邮件或即时消息通知。
3.持续优化告警规则:定期回顾和分析告警的准确性和响应情况,根据实际情况调整告警规则,以提高告警的准确性和有效性。
4.明确责任分配:确保每个告警都有明确的责任人,这样一旦发生异常,能够迅速有人响应。
5.培训和意识提升:对团队成员进行定期的培训,提高他们对告警系统的理解和重视,确保每个人都能正确响应告警。
这些经验和建议是我们在实践中不断摸索和尝试的结果,希望能够帮助你们更有效地管理和响应系统告警,确保应用的稳定运行。
WEB端
用户体验
用户体验是指用户在使用网站过程中的整体感受、情绪和态度,它包括与产品交互的全程。对于开发者而言,打造出色的用户体验意味着关注网站的加载速度、视觉稳定性、交互响应时间、渲染性能等关键因素。Google 提出了一系列 Web 性能标准指标,这些指标旨在量化用户体验的不同层面。SGM 性能监控平台紧跟这些标准,对几项关键指标进行监控并提供反馈,以确保用户能够获得流畅且愉悦的网站使用体验。
•LCP:页面加载过程中最大的内容元素(图片、视频等)渲染完成的时间点。也可近似看作是首屏加载时间。Google 标准是 小于等于 2500ms。
最初,我们遵循了 Google 提出的标准来精确设定我们的告警系统。
配置告警后,我们注意到告警频率较高,主要是因为多数系统的实际网页最大内容绘制(LCP)值普遍超过了预期的 2.5s 标准。然而,这些告警并未实质性影响用户的正常使用体验。为了优化告警机制,我们经过仔细评估后,决定将部分系统的 LCP 阈值暂时调整至 5s,并相应调整了告警级别,以更合理地反映系统性能对用户体验的实际影响。
显然,当前调整的 LCP 阈值 5s 并非我们的最终目标。这一调整是基于应用当前的性能状况所做的临时措施,旨在优化告警的频率和质量。我们计划随着对各个应用性能的持续改进,最终将 LCP 阈值恢复至标准的 2.5s。
•CLS:从页面开始加载到生命周期状态更改为隐藏期间发生的所有意外布局偏移的累计得分。Web 给出的性能指标是 < 0.1
•FCP:从网页开始加载到有内容渲染的耗时。标准性能指标 1.8s
•FID:用户首次发出交互指令到页面可以响应为止的时间差。标准性能指标 100ms
•TTFB:客户端接收到服务器返回的第一个字节响应信息的耗时。标准性能指标 1000ms
从最初启用告警到目前决定关闭大部分告警,我们的决策基于以下考虑:
1.用户体验未受显著影响:即使某些性能指标超出了标准值,我们观察到这并未对用户操作网站造成实质性影响。
2.避免告警疲劳:频繁的告警可能导致开发团队对通知产生麻木感,从而忽视那些真正关键的、影响网站健康的告警。
3.指标作为优化参考:这些性能指标更多地被视为优化网站时的参考点,而对用户的直觉体验影响甚微。
4.有针对性的优化:在网站优化过程中,我们会将这些指标作为健康检查的一部分,进行针对性的改进,而无需依赖告警来频繁提醒。
基于这些理由,我们目前选择只对 LCP 指标保持告警启用,以确保关注到可能影响用户加载体验的关键性能问题。
健康度指标 | 告警开启 | 标准值 | 优化后值 | 告警阀值 | 告警级别 |
---|---|---|---|---|---|
LCP | 开启 | <=2500ms | 针对部分应用 <=5000ms | 1min 内 耗时>=5000ms(持续优化并更新阀值到2500ms) 调用次数:50 连续次数:1次 | 通知 |
FCP | 关闭 | <=1800ms | - | | - |
CLS | 关闭 | <=0.1 | - | | - |
FID | 关闭 | <=100ms | - | | - |
TTFB | 关闭 | <=1000ms | - | | - |
页面性能
网页性能涉及从加载开始到完全可交互的整个过程,包括页面内容的下载、解析和渲染等多个阶段。在 SGM 监控平台上,我们能够追踪到一系列与页面加载性能相关的指标,如页面加载总时长、DNS 解析时长、DOM 加载完成时间、用户的浏览器和地理分布、首次渲染(白屏)时间、用户行为追踪、性能重现和热力图分析等。这些丰富的数据为我们优化页面性能提供了宝贵的参考。特别是首次渲染时间,或称为白屏时间,是我们监控中特别关注的一个关键指标,因为它直接影响用户的首次印象和整体体验。
•白屏时间:这一指标能够向开发者展示哪些页面出现了白屏现象。在利用此指标之前,我们需要为每个应用单独配置白屏时间的监控参数,以确保准确地捕捉到首次内容呈现的时刻。这有助于我们识别并优化那些影响用户首次加载体验的关键页面。
为了监控白屏时间,我们必须在应用的全局配置中的白屏监控项下,指定每个页面的 URL 及其关键元素。同时,我们还需设定监控的起始时间点和超时阈值。值得注意的是,URL 配置支持正则表达式,这为我们提供了灵活性,以匹配和监控一系列相似的页面路径。
•白屏检测的机制:白屏检测机制的核心在于验证页面上的关键元素是否已经被渲染。所谓关键元素,指的是在配置过程中指定的用于检测的 DOM 元素。这些元素的渲染情况是判断页面是否白屏的依据。
•告警设置:白屏告警功能已经启用,但目前还处于初期阶段,一些功能尚待完善。例如,当前的 URL 配置尚未支持正则表达式匹配。
面对当前的局限性,我们采取了双策略应对。一方面,我们利用白屏告警功能直接监控页面的白屏情况;另一方面,我们通过分析页面加载性能指标中的白屏时间来间接监测潜在的白屏问题。在设置平均耗时时间和告警级别时,我们综合考虑了多个因素,包括用户的网络环境、告警的发生频率以及告警的实际适用性,以确保监控方案的有效性和合理性。
网页性能 | 告警开启 | 优化前值 | 优化后值 | 告警阀值 | 告警级别 |
---|---|---|---|---|---|
首包时间 | 关闭 | - | - | - | - |
页面完全加载时间 | 关闭 | - | - | - | - |
白屏时间 | 开启 | <=5000ms | <=10000ms | 1min 内 耗时>=10000ms,后期采用白屏告警替代 调用次数:30 连续次数:5 次 | 紧急 |
•此外,我们的页面追踪功能包括用户行为回溯、页面性能重现以及行为轨迹热力图,这些工具允许我们从多个维度和场景对用户行为进行深入分析,极大地便利了问题的诊断和排查。
JSError监控
我们通过配置错误关键词来匹配控制台的报错信息。报错阈值的设定可以参考各个项目的 QPS,因为在项目实施了恰当的降级策略后,即便控制台出现报错,页面通常仍能正常访问,不会对用户体验造成影响。因此,这个阈值可以适当设置得较高。
这里需要特别注意 “Script error” 的错误,这种错误给不到任何对我们有用的信息。所以需要采用一定的手段避免出现类似的报错:
•这种错误也称之为跨域错误,所以首先,我们需要开启跨域资源共享功能(CORS)。
<script src="http://xxxdomain.com/home.js" crossorigin></script>
•针对 Vue 项目,由于 Vue 重写了 window.onerror 事件,所以我们需要在 Vue 项目中增加 错误处理:
Vue.config.errorHandler = (err, vm, info)=> {
if (err) {
try {
console.error(err);
window.__sgm__.error(err)
} catch (e) {}
}
};
•在某些情况下,“Script error” 可能是无关紧要的,我们可以选择忽略这类特定的错误。为此,可以关闭这些特定错误的监控,具体的错误可以通过它们的 hash 在错误日志中进行识别和过滤。
关键指标 | 关键字(支持正则匹配) | 触发次数 | 告警级别 |
---|---|---|---|
js错误 | null、undefined、error、map、filter、style、length... | 周期:1min ,错误次数:50/100/200(可参考 QPS 值设置),连续次数:1次 | 严重 |
API请求监控
这里我们的告警设置主要关注 HTTP 状态码和业务错误码。这两项指标的异常表明我们的应用可能遇到了问题,需要我们迅速进行检查和处理以确保系统的正常运行。
首先,我们必须在应用的监控配置中设定数据采集参数:
关键指标 | 错误码 | 业务域名 | 触发次数 | 告警级别 |
---|---|---|---|---|
http错误 | !200(Http响应非200报警) | xx1.jd.com xx2.jd.com | 周期:1min 错误次数:1 总调用次数:50 连续次数:1 | 严重 |
业务失败码 | errCode(根据实际业务线设置 -1,-2等) |
针对业务失败码:
1.由于现有应用跨不同业务条线存在异常码的差异,我们需要针对每个业务线收集并配置其特定的异常码。
应用来源 | 标准响应 | 针对性告警 |
---|---|---|
慧采PC 企业购 | { "code":null, "success":true, "msg":"操作成功", "result":{} } | 业务异常码 code ·-1,-2 ·... |
锦礼 | { "code": 000, "data": {}, "msg": "操作成功" } | 业务异常码 code ·! (1000 && 3001等 ) |
color | | ·-1 echo ·1 echo |
其他 | ... | .... |
2、对于新增应用,我们实施了后端服务异常码的标准化。因此,在监控方面,我们只需要配置一套统一的标准来进行监控。
{
"50000X": "程序异常,内部",
"500001": "程序异常,上游",
"500002": "程序异常,xx",
"500003": "程序异常,xx",
...
}
资源错误
这里通常指的是 css、js、图片等资源的加载错误
关键指标 | 告警开启 | 告警阀值 | 告警级别 |
---|---|---|---|
资源错误 | 开启 | 周期:1min 错误次数:200(也可参照QPS进行设置) 连续次数:1 | 严重 |
对于图片加载错误,只需在项目中实施适当的降级方案。在应用的监控配置中,我们可以设置为不收集图片错误相关的数据。
再来举个例子:
在企业业务的封闭场景中,例如慧采平台,我们集成了埋点 JavaScript 脚本。然而,由于某些客户的网络环境,导致我们的埋点相关静态资源延迟加载。
治理方案:
自定义上报
每个业务流程的关键节点或核心功能实施了专门的监控措施,以便对任何异常状况进行跟踪、监控并及时上报。
目前,几条业务线已经实施了自定义上报机制,其主要目的包括:
•利用自定义上报来捕获接口异常的详细信息,如入参和出参,以便在线上出现异常时,能够依据上报的数据快速进行问题的诊断和定位。
•在复杂的环境下,准确追踪用户行为导致的错误,并利用上报的信息进行有效的问题排查和定位。
锦礼酷兜: 用户在选择地址后,系统未能根据所选地址提供正确的信息,导致页面加载出现异常。
由于这个 H5 页面被嵌入到用户的 App 内,开发者难以直接复现用户遇到的问题。因此,我们利用监控平台的自定义上报功能来收集相关信息,以辅助进行问题排查。
•上报地址组件返回值
•上报接口入参
•然后根据自定义上报日志查看具体信息
E卡:外部引用资源异常上报(设备指纹,eid 等)
在结算页面提交订单时,系统需要获取设备ID。为此,我们实施了降级方案,并通过自定义上报机制对此过程进行监控,以确保流程的顺利执行。
降级方案:如果获取不到,后端会生成 uuid 给到前端。
企业购注销pc: 我们需要集成科技 SDK,以便在页面上完成用户注销后自动跳转到指定的 URL。上线后,收到客户反馈指出在完成注销流程后页面未能正确跳转。
接口异常: 在接口异常中,添加自定义监控,查看入参和出参信息。
尽管我们已经在应用中引入了自定义监控以便更好地观察和定位问题,但我们仍需进一步细化和规范化这些监控措施。目前,我们正积极对各业务线的功能点进行梳理,以实现更深入的细化监控。我们的目标是为每个业务线、每个应用的关键链路和功能点定制针对各种异常情况的精细化自定义监控(例如,某页面按钮的显示或点击异常)。
自定义告警 | 告警开启 | 告警阀值 | 告警级别 |
---|---|---|---|
自定义编码 | 开启 | 1min内 调用次数:50 连续次数:1次 | 警告 |
小程序端
与 Web 端相比,小程序的监控存在一些差异,主要是缺少了如 LCP(最大内容绘制)等特定性能指标。然而,性能问题、JavaScript错误、资源加载错误等其他监控指标仍然可以被捕获。此外,小程序官方和开发者工具都提供了性能检测工具,这些工具便于开发者查看和优化小程序应用的性能。本文将不深入介绍 Web 端的监控指标,而是专注于介绍小程序中独有的监控和分析工具。
小程序官方后台
可以分析接口数据、js 分析等。
利用 SGM 的监控功能结合小程序官方的分析工具,对我们的小程序进行综合优化,是一个有效的策略。
原生应用
基础监控:mPaas、烛龙、SGM
mPaaS:崩溃监控。应用崩溃对用户体验有显著影响,是移动端监控中的一个关键指标。通过不断的监控和优化,京东慧采移动端的崩溃率已经降至较低水平,目前的平均用户崩溃率大约为0.03122%。
烛龙:启动耗时、首屏耗时、启动且首焦耗时、卡顿。为了改善首屏加载时间,京东慧采采用了烛龙监控平台并实施了相应的优化措施。优化后,应用的整体性能显著提升,其中 Android 平台的tp95耗时大约为 2764ms,iOS 平台的tp95耗时大约为1791ms,均达到了较低的水平。
SGM:网络、WebView、原生页面等指标。
业务监控
京东慧采在多个业务模块中,其中登录、商详、订单详情 3 个模块接入了业务监控。
登录:登录接入 SGM 监控平台,自定义了整个流程错误码,并配置了告警规则
(1)600:登录正常流程(必要是可白名单开启)
(2)601:登录防刷验证流程异常监控
(3)602:登录魔方验证流程异常监控
(4)603:登录流程异常监控
商详、订单详情:在商品详情和订单详情页面,我们集成了业务监控 SDK。通过移动配置平台,我们下发了监控规则,以便在接口返回的数据不符合预期时,能够上报错误信息。目前,这些监控信息被上报到崩溃分析平台的自定义异常模块中,以便进行进一步的分析和优化。
(1)接口请求是否成功。
(2)banner 楼层:是否为空、楼层类型是否正常(1 原生)、数据/大小图地址是否为空。
(3)商品信息楼层:是否为空、楼层类型是否正常(2 动态)、数据/商品名称/价格是否为空。
(4)服务楼层:是否为空、楼层类型是否正常(1 原生)、数据/服务信息是否为空。
(5)spu 和物流楼层:是否为空、楼层类型是否正常(1 原生)、数据/sup信息是否为空。
(6)其他:其他/按钮数据/按钮名称是否为空、按钮类型是否正常(排除1/2/3/4/5/6/20000)。
订单详情监控信息:
(1)接口请求是否成功。
(2)基础信息楼层:是否为空、楼层类型是否正常(2 动态)、数据/地址信息是否为空。
(3)商品信息楼层:是否为空、楼层类型是否正常(2 动态)、数据/商品列表是否为空。
(4)支付信息楼层:是否为空、楼层类型是否正常(2 动态)、数据是否为空。
(5)价格楼层:是否为空、楼层类型是否正常(2 动态)、数据是否为空。
定时巡检
定时巡检可以通过两种方法实现:
•利用 UI 啄木鸟平台配置定时执行的任务。
•使用团队开发的自定义脚本,并通过自动启动的服务来执行这些检测任务。
UI 啄木鸟
定时巡检的主要目的是确保每个项目的核心流程在每次迭代中保持稳定。通过配置定时任务,我们能够及时发现并解决线上问题,从而维护系统的稳定性。
什么是 UI 啄木鸟
UI啄木鸟平台,由京东集团-京东科技开发,是一个自动化巡检工具,其主要功能包括:
•Chrome插件:用于录制项目的用户交互步骤。
•定时任务平台:用于在服务器端定期执行已录制的脚本进行巡检。
在使用Chrome插件过程中,我们遇到了一些问题。与京东科技团队沟通后,我们获得了共同开发和升级插件的机会。目前,我们已经添加了新功能并修复了一些已知问题。
1.打开录制和停止录制按钮,分别开启监听和关闭拦截页面事件功能;
2.新增点击录制后保留当前录制步骤功能;
3.在执行事件过程中,禁止再次点击执行,避免执行顺序错乱;
4.点击事件可切换操作类型为 focus 事件,便于监听滚动条滑动;
5.在步骤复现情况下,调整判断元素选择器和屏幕位置的顺序,避免位置出入点击位置错位;
6....
使用chrome扩展程序进行安装即可。
怎么使用
•新建录制脚本
•点击详情,开启录制
•监听步骤
•关联到啄木鸟平台
•啄木鸟平台(调试、配置 Cookie,开启定时任务)
自启动巡检工具
自动化巡检工具能够检测页面上的多种元素和链接,包括 a 标签的外链、接口返回的链接、鼠标悬停元素、点击元素,以及跳转后 URL 的有效性。该工具尤其适合用于频道页,这些页面通常通过投放广告和配置通天塔链接来生成。在大型促销活动期间,我们可以运行脚本来验证广告和通天塔链接的有效性。目前,工具的功能还相对有限,并且对于广告组等特定接口的支持不够通用,这也是我们计划逐步改进和优化的方向。
功能检测
•检测所有 a 标签和所有接口响应数据中包含所有通天塔活动外链是否有效。
{
"cookieThor":"", // 是否依赖cookie等登录态,无需请传空
"urlPattern": "pro\.jd\.com",// 匹配的链接,这里只匹配通天塔
"urls": ["https://b.jd.com/s?entry=newuser"] // 将要检测的url,可填多个
}
运行结果:
•检测鼠标 hover 事件,收集用户交互后的接口响应数据,检测所有活动外联是否有效。
{
"cookieThor": "",
"urlPattern": "///pro.jd.com/",
"urls": [
{
"url": "https://b.jd.com/s?entry=newuser",
"hoverElements": [
{
"item": "#focus-category-id .focus-category-item", // css样式选择器
"target": ".focus-category-item-subtitle"
}
]
}
]
}
•检查 click 事件,收集用户交互后的接口响应数据,检测所有通天塔活动外联是否有效。
{
"cookieThor": "",
"urlPattern": "///pro.jd.com/",
"urls": [
{
"url": "https://b.jd.com/s?entry=newuser",
"clickElements": [
{
"item": "#recommendation-floor .drip-tabs-tab"
}
]
}
]
}
•检测点击后,跳转后的链接有效性。
{
"cookieThor": "",
"urlPattern": "///pro.jd.com/",
"urls": [
{
"url": "https://b.jd.com/s?entry=newuser",
"clickElements": [
{
"item": ".recommendation-product-wrapper .jdb-sku-wrapper"
}
]
}
]
}
检测原理
监控后暴露的问题
•慧采 jsError:
用于埋点数据上报的API,但在一些封闭环境中,由于网络环境,客户可能无法及时访问埋点或指纹识别等 SDK,导致频繁的报错。为了解决这个问题,我们可以通过引入 try...catch 语句来捕获异常,并结合使用队列机制,以确保埋点数据能够在网络条件允许时正常上报。这样既避免了错误的频繁发生,也保障了数据上报的完整性和准确性。
public exposure(exposureId: ExposureId, jsonParam = {}, eventParam = '') {
this.execute(() => {
try {
const exposure = new MPing.inputs.Exposure(exposureId);
exposure.eventParam = eventParam; // 设置click事件参数
exposure.jsonParam = serialize(jsonParam); // 设置json格式事件参数,必须是合法的json字符串
console.log('上报 曝光 info >> ', exposure);
new MPing().send(exposure);
} catch (e) {
console.error(e);
}
});
}
private execute(fn: CallbackFunction) {
if (this._MPing === null) {
this._Queue.push(fn);
} else {
fn();
}
}
•企业购订详异常:
总结
接入前
在过去,我们的应用上线后,对线上运行状况了解不足,错误发生时往往依赖于用户或运营团队的反馈,这使我们常常处于被动应对状态,有时甚至会导致严重的线上问题,如白屏、设备兼容性问题和异常报错等。为了改变这一局面,我们开始寻找和实施解决方案。从去年开始,我们逐步将所有 100+ 应用接入了监控系统,现在我们能够实时监控应用状态,及时发现并解决问题,大大提高了我们的响应速度和服务质量。
接入后
自从接入监控系统后,我们的问题发现和处理方式从被动等待用户反馈转变为了主动监测。现在我们能够即时发现并快速解决问题。此外,我们还利用监控平台对若干应用进行了性能优化,并为关键功能点制定了预先的降级策略,以保障应用的稳定运行。
•页面健康度监控表明,我们目前已有 50+ 个项目的性能评分达到或超过 85 分。通过分析监控数据,我们对每个应用的各个页面进行了详细分析和梳理。在确保流程准确无误的基础上,我们对那些性能较差的页面进行了持续的优化。目前,我们仍在对一些关键但性能表现一般的应用进行进一步的优化工作。
•通过配置 JavaScript 错误和资源错误的告警机制,我们在项目迭代过程中及时解决了多个 JavaScript 问题,有效降低了错误率和告警频率。正如前文所述,对于慧采PC 这类封闭环境,客户公司的网络策略可能会阻止埋点 SDK 和设备指纹等 JavaScript 资源的加载,导致全局变量获取时出现错误。我们通过实施异常捕获和队列机制,不仅规避了部分错误,还确保了埋点数据的准确上报。
•通过收集 HTTP 错误码和业务失败码,并设置相应的告警机制,我们能够在接到告警通知的第一时间内分析并解决问题。例如,我们成功地解决了集团内部遇到的 “color404” 问题等。这种做法加快了问题的响应和解决速度,提高了服务的稳定性和用户满意度。
•自定义监控:通过给接口异常做入参、出参的上报、在关键功能点上报有效信息等,能够在应用出现异常时,快速定位问题并及时修复。
•通过实施定时任务巡检,我们有效地避免了迭代更新上线可能对整个流程造成的影响。同时,巡检工具的使用也确保了外部链接的有效性,进一步保障了应用的稳定运行和用户体验。
规划
监控是一个持续长期的过程,我们致力于不断完善,确保系统的稳定性和安全性。基于现有的监控能力,我们计划实施以下几项优化措施:
1.应用性能提升:我们将持续优化我们的应用,目标是让 90% 以上的应用性能评分达到 90 分以上,并对资源错误和 JavaScript 错误进行有效管理。
2.深化监控细节:我们将扩展监控的深度和广度,确保能够捕捉到所有潜在的异常情况。例如,如果一个仅限采购账号使用的按钮错误地显示了,这应该触发自定义异常上报,并在代码层面实施降级处理。
3.巡检工具升级:我们将继续升级我们的 Chrome 巡检插件,提高其智能化程度和覆盖范围,以保持线上主要流程的健壮性和稳定性。
结尾
我们是企业业务大前端团队,会持续针对各端优化升级我们的监控策略,如果您有任何疑问或者有更好的建议,我们非常欢迎您的咨询和交流。
来源:juejin.cn/post/7400271712359186484
快速理解 并发量、吞吐量、日活、QPS、TPS、RPS、RT、PV、UV、DAU、GMV
并发与并行
- 并发:由于CPU数量或核心数量不够,多个任务并不一定是同时进行的,这些任务交替执行(分配不同的CPU时间片,进程或者线程的上下文切换),所以是伪并行。
- 并行:多个任务可以在同一时刻同时执行,通常需要多个或多核处理器,不需要上下文切换,真正的并行。
并发量(Concurrency)
- 概念:并发或并行,是程序和运维本身要考虑的问题。而并发量,通常是不考虑程序并发或并行执行,只考虑一个服务端程序单位时间内同时可接受并响应多少个请求,通常以秒为单位,也可乘以86400,以天为单位。
- 计算方法,通常通过一些压测工具,例如ApiPost压测,或者ab压测来统计,依ab为例:
Window系统:Apache下bin目录有个ab.exe
CentOS系统:yum -y install httpd-tools.x86_64
ab -c 并发数 -n 请求数 网址
ab -c 10 -n 150 127.0.0.1/ 表示对127.0.0.1这个地址,用10个并发一共请求了150次。而不是1500次,
Time taken for tests: 1.249 seconds,说明并发量为 150 / 1.249 ≈ 120 并发,表示系统最多可承载120个并发每秒。
吞吐量(Throughput)
- 概念:吞吐量是指系统在单位时间能够处理多少个请求,TPS、QPS都是吞吐量的量化指标。
相比于QPS这些具有清晰定义的书面用语,吞吐量偏向口语化。
日活
- 概念:每日活跃用户的数量,通常偏向非技术指标用语,这个概念没有清晰的定义,销售运营嘴里的日活,可能是只有一个人1天访问100次,就叫做日活100,也可以说是日活1,中位数日活50,显然意义不大。
QPS(Query Per Second)
- 概念:每秒查询次数,通常是对读操作的压测指标。服务器在一秒的时间内能处理多少量的请求。和并发量概念差不多,并发量高,就能应对更多的请求。
- 计算方法,通常通过一些压测工具,例如ApiPost压测,或者ab压测来统计,依ab为例:
ab -c 10 -n 150 127.0.0.1/
其中返回一行数据:
Requests per second: 120.94 [#/sec] (mean)
表示该接口QPS在120左右。
TPS(Transactions Per Second)
- 概念:每秒处理的事务数目,通常是对写操作的压测指标。这里的事务不是数据库事务,是指服务器接收到请求,再到处理完后响应的过程。TPS表示一秒事件能够完成几次这样的流程。
TPS对比QPS
- QPS:偏向统计查询性能,一般不涉及数据写操作。
- TPS:偏向统计写入性能,如插入、更新、删除等。
RPS(Request Per Second)
- 概念:每秒请求数,和QPS、TPS概念差不多。没有过于清晰的定义,看你怎么用。
RT(Response Time)
- 概念:响应时间间隔,是指用户发起请求,到接收到请求的时间间隔,越少越好,应当控制在0~150毫秒之间。
PV(Page view)
- 概念:浏览次数统计,一般以天为单位。范围可以是单个页面,也可以是整个网站,一千个用户一天对该页面访问一万次,那该页面PV就是一万。
UV(Unique Visitor)
- 概念:唯一访客数。时间单位通常是天,1万个用户一天访问该网站十万次,那么UV是一万。
- 实现方案:已登录的用户可通过会话区分,未登录的用户可让客户端创建一个唯一标识符当做临时的token用于区分用户。
DAU(Daily Active Use)
- 概念:日活跃用户数量,来衡量服务的用户粘性以及服务的衰退周期。统计方案各不相同,这要看对活跃的定义,访问一次算活跃,还是在线时长超10分钟算活跃,还是用户完成某项指标算活跃。
GMV(Gross Merchandise Volume)
- 概念:单位时间内的成交总额。多用于电商行业,一般包含拍下未支付订单金额。
来源:juejin.cn/post/7400281441803403275
丸辣!BigDecimal又踩坑了
丸辣!BigDecimal又踩坑了
前言
小菜之前在国内的一家电商公司自研电商项目,在那个项目中是以人民币的分为最小单位使用Long来进行计算
现在小菜在跨境电商公司也接到了类似的计算需求,在小菜火速完成提交代码后,却被技术leader给叫过去臭骂了一顿
技术leader让小菜将类型改为BigDecimal,小菜苦思不得其解,于是下班后发奋图强,准备搞懂BigDecimal后再对代码进行修改
...
在 Java 中,浮点类型在进行运算时可能会产生精度丢失的问题
尤其是当它们表示非常大或非常小的数,或者需要进行高精度的金融计算时
为了解决这个问题,Java 提供了 BigDecimal
类
BigDecimal 使用各种字段来满足高精度计算,为了后续的描述,这里只需要记住两个字段
precision字段:存储数据十进制的位数,包括小数部分
scale字段:存储小数的位数
BigDecimal的使用方式再后续踩坑中进行描述,最终总结出BigDecimal的最佳实践
BigDecimal的坑
创建实例的坑
错误示例:
在BigDecimal有参构造使用浮点型,会导致精度丢失
BigDecimal d1 = new BigDecimal(6.66);
正确的使用方法应该是在有参构造中使用字符串,如果一定要有浮点数则可以使用BigDecimal.valueOf
private static void createInstance() {
//错误用法
BigDecimal d1 = new BigDecimal(6.66);
//正确用法
BigDecimal d2 = new BigDecimal("6.66");
BigDecimal d3 = BigDecimal.valueOf(6.66);
//6.660000000000000142108547152020037174224853515625
System.out.println(d1);
//6.66
System.out.println(d2);
//6.66
System.out.println(d3);
}
toString方法的坑
当数据量太大时,使用BigDecimal.valueOf
的实例,使用toString方法时会采用科学计数法,导致结果异常
BigDecimal d2 = BigDecimal.valueOf(123456789012345678901234567890.12345678901234567890);
//1.2345678901234568E+29
System.out.println(d2);
如果要打印正常结果就要使用toPlainString
,或者使用字符串进行构造
private static void toPlainString() {
BigDecimal d1 = new BigDecimal("123456789012345678901234567890.12345678901234567890");
BigDecimal d2 = BigDecimal.valueOf(123456789012345678901234567890.12345678901234567890);
//123456789012345678901234567890.12345678901234567890
System.out.println(d1);
//123456789012345678901234567890.12345678901234567890
System.out.println(d1.toPlainString());
//1.2345678901234568E+29
System.out.println(d2);
//123456789012345678901234567890.12345678901234567890
System.out.println(d2.toPlainString());
}
比较大小的坑
比较大小常用的方法有equals
和compareTo
equals
用于判断两个对象是否相等
compareTo
比较两个对象大小,结果为0相等、1大于、-1小于
BigDecimal使用equals时,如果两数小数位数scale不相同,那么就会认为它们不相同,而compareTo则不会比较小数精度
private static void compare() {
BigDecimal d1 = BigDecimal.valueOf(1);
BigDecimal d2 = BigDecimal.valueOf(1.00);
// false
System.out.println(d1.equals(d2));
// 0
System.out.println(d1.compareTo(d2));
}
在BigDecimal的equals方法中能看到,小数位数scale不相等则返回false
public boolean equals(Object x) {
if (!(x instanceof BigDecimal))
return false;
BigDecimal xDec = (BigDecimal) x;
if (x == this)
return true;
//小数精度不相等 返回 false
if (scale != xDec.scale)
return false;
long s = this.intCompact;
long xs = xDec.intCompact;
if (s != INFLATED) {
if (xs == INFLATED)
xs = compactValFor(xDec.intVal);
return xs == s;
} else if (xs != INFLATED)
return xs == compactValFor(this.intVal);
return this.inflated().equals(xDec.inflated());
}
因此,BigDecimal比较时常用compareTo,如果要比较小数精度才使用equals
运算的坑
常见的运算包括加、减、乘、除,如果不了解原理的情况就使用会存在大量的坑
在运算得到结果后,小数位数可能与原始数据发生改变,加、减运算在这种情况下类似
当原始数据为1.00(2位小数位数)和5.555(3位小数位数)相加/减时,结果的小数位数变成3位
private static void calc() {
BigDecimal d1 = BigDecimal.valueOf(1.00);
BigDecimal d2 = BigDecimal.valueOf(5.555);
//1.0
System.out.println(d1);
//5.555
System.out.println(d2);
//6.555
System.out.println(d1.add(d2));
//-4.555
System.out.println(d1.subtract(d2));
}
在加、减运算的源码中,会选择两数中小数位数(scale)最大的当作结果的小数位数(scale)
private static BigDecimal add(final long xs, int scale1, final long ys, int scale2) {
//用差值来判断使用哪个scale
long sdiff = (long) scale1 - scale2;
if (sdiff == 0) {
//scale相等时
return add(xs, ys, scale1);
} else if (sdiff < 0) {
int raise = checkScale(xs,-sdiff);
long scaledX = longMultiplyPowerTen(xs, raise);
if (scaledX != INFLATED) {
//scale2大时用scale2
return add(scaledX, ys, scale2);
} else {
BigInteger bigsum = bigMultiplyPowerTen(xs,raise).add(ys);
//scale2大时用scale2
return ((xs^ys)>=0) ? // same sign test
new BigDecimal(bigsum, INFLATED, scale2, 0)
: valueOf(bigsum, scale2, 0);
}
} else {
int raise = checkScale(ys,sdiff);
long scaledY = longMultiplyPowerTen(ys, raise);
if (scaledY != INFLATED) {
//scale1大用scale1
return add(xs, scaledY, scale1);
} else {
BigInteger bigsum = bigMultiplyPowerTen(ys,raise).add(xs);
//scale1大用scale1
return ((xs^ys)>=0) ?
new BigDecimal(bigsum, INFLATED, scale1, 0)
: valueOf(bigsum, scale1, 0);
}
}
}
再来看看乘法
原始数据还是1.00(2位小数位数)和5.555(3位小数位数),当进行乘法时得到结果的小数位数为5.5550(4位小数)
private static void calc() {
BigDecimal d1 = BigDecimal.valueOf(1.00);
BigDecimal d2 = BigDecimal.valueOf(5.555);
//1.0
System.out.println(d1);
//5.555
System.out.println(d2);
//5.5550
System.out.println(d1.multiply(d2));
}
实际上1.00会被优化成1.0(上面代码示例的结果也显示了),在进行乘法时会将scale进行相加,因此结果为1+3=4位
public BigDecimal multiply(BigDecimal multiplicand) {
//小数位数相加
int productScale = checkScale((long) scale + multiplicand.scale);
if (this.intCompact != INFLATED) {
if ((multiplicand.intCompact != INFLATED)) {
return multiply(this.intCompact, multiplicand.intCompact, productScale);
} else {
return multiply(this.intCompact, multiplicand.intVal, productScale);
}
} else {
if ((multiplicand.intCompact != INFLATED)) {
return multiply(multiplicand.intCompact, this.intVal, productScale);
} else {
return multiply(this.intVal, multiplicand.intVal, productScale);
}
}
}
而除法没有像前面所说的运算方法有规律性,因此使用除法时必须要指定保留小数位数以及舍入方式
进行除法时可以立马指定保留的小数位数和舍入方式(如代码d5)也可以除完再设置保留小数位数和舍入方式(如代码d3、d4)
private static void calc() {
BigDecimal d1 = BigDecimal.valueOf(1.00);
BigDecimal d2 = BigDecimal.valueOf(5.555);
BigDecimal d3 = d2.divide(d1);
BigDecimal d4 = d3.setScale(2, RoundingMode.HALF_UP);
BigDecimal d5 = d2.divide(d1, 2, RoundingMode.HALF_UP);
//5.555
System.out.println(d3);
//5.56
System.out.println(d4);
//5.56
System.out.println(d5);
}
RoundingMode枚举类提供各种各样的舍入方式,RoundingMode.HALF_UP是常用的四舍五入
除了除法必须指定小数位数和舍入方式外,建议其他运算也主动设置进行兜底,以防意外的情况出现
计算价格的坑
在电商系统中,在订单中会有购买商品的价格明细
比如用完优惠卷后总价为10.00,而买了三件商品,要计算每件商品花费的价格
这种情况下10除3是除不尽的,那我们该如何解决呢?
可以将除不尽的余数加到最后一件商品作为兜底
private static void priceCalc() {
//总价
BigDecimal total = BigDecimal.valueOf(10.00);
//商品数量
int num = 3;
BigDecimal count = BigDecimal.valueOf(num);
//每件商品价格
BigDecimal price = total.divide(count, 2, RoundingMode.HALF_UP);
//3.33
System.out.println(price);
//剩余的价格 加到最后一件商品 兜底
BigDecimal residue = total.subtract(price.multiply(count));
//最后一件价格
BigDecimal lastPrice = price.add(residue);
//3.34
System.out.println(lastPrice);
}
总结
普通的计算可以以最小金额作为计算单位并且用Long进行计算,而面对汇率、计算量大的场景可以采用BigDecimal作为计算单位
创建BigDecimal有两种常用的方式,字符串作为构造的参数以及浮点型作为静态方法valueOf��参数,后者在数据大/小的情况下toString方法会采用科学计数法,因此最好使用字符串作为构造器参数的方式
BigDecimal比较大小时,如果需要小数位数精度都相同就采用equals方法,忽略小数位数比较可以使用compareTo方法
BigDecimal进行运算时,加减运算会采用原始两个数据中精度最长的作为结果的精度,乘法运算则是将两个数据的精度相加得到结果的精度,而除法没有规律,必须指定小数位数和舍入模式,其他运算方式也建议主动设置小数位数和舍入模式进行兜底
当遇到商品平摊价格除不尽的情况时,可以将余数加到最后一件商品的价格进行兜底
最后(不要白嫖,一键三连求求拉~)
本篇文章被收入专栏 Java,感兴趣的同学可以持续关注喔
本篇文章笔记以及案例被收入 Gitee-CaiCaiJava、 Github-CaiCaiJava,除此之外还有更多Java进阶相关知识,感兴趣的同学可以starred持续关注喔~
有什么问题可以在评论区交流,如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~
关注菜菜,分享更多技术干货,公众号:菜菜的后端私房菜
来源:juejin.cn/post/7400096469723643956
Java中的双冒号运算符(::)及其应用
在Java 8引入的Lambda表达式和函数式接口之后,双冒号运算符(::)成为了一项重要的功能。它可以将方法或构造函数作为参数传递,简化了编码和提升了代码的可读性。本文将介绍Java中的双冒号运算符及其常见应用场景。
双冒号运算符(::)的语法
双冒号运算符的语法是类名/对象名::方法名
。具体来说,它有三种不同的使用方式:
- 作为静态方法的引用:
ClassName::staticMethodName
- 作为实例方法的引用:
objectReference::instanceMethodName
- 作为构造函数的引用:
ClassName::new
静态方法引用
首先,我们来看一下如何使用双冒号运算符引用静态方法。假设有一个Utils类,其中有一个静态方法processData
:
public class Utils {
public static void processData(String data) {
System.out.println("Processing data: " + data);
}
}
我们可以使用双冒号运算符将该方法作为参数传递给其他方法:
List<String> dataList = Arrays.asList("data1", "data2", "data3");
dataList.forEach(Utils::processData);
上述代码等效于使用Lambda表达式的方式:
dataList.forEach(data -> Utils.processData(data));
通过使用双冒号运算符,我们避免了重复写Lambda表达式,使代码更加简洁和易读。
实例方法引用
双冒号运算符还可以用于引用实例方法。假设我们有一个User类,包含了一个实例方法getUserInfo
:
public class User {
public void getUserInfo() {
System.out.println("Getting user info...");
}
}
我们可以通过双冒号运算符引用该实例方法:
User user = new User();
Runnable getInfo = user::getUserInfo;
getInfo.run();
上述代码中,我们创建了一个Runnable对象,并将user::getUserInfo
作为方法引用赋值给它。然后,通过调用run
方法来执行该方法引用。
构造函数引用
在Java 8之前,要使用构造函数创建对象,需要通过写出完整的类名以及参数列表来调用构造函数。而使用双冒号运算符,我们可以将构造函数作为方法引用,实现更加简洁的对象创建方式。
假设有一个Person类,拥有一个带有name参数的构造函数:
public class Person {
private String name;
public Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
我们可以使用双冒号运算符引用该构造函数并创建对象:
Supplier<Person> personSupplier = Person::new;
Person person = personSupplier.get();
person.getName(); // 调用实例方法
上述代码中,我们使用Person::new
将构造函数引用赋值给Supplier接口,然后通过get
方法创建了Person对象。
总结
本文介绍了Java中双冒号运算符(::)的语法及其常见的应用场景。通过双冒号运算符,我们可以更方便地引用静态方法、实例方法和构造函数,使得代码更加简洁和可读。双冒号运算符是Java 8引入的重要特性,对于函数式编程和Lambda表达式的使用起到了积极的推动作用。
希望本文能够帮助您理解和应用双冒号运算符,提高Java开发的效率和代码质量。如有任何问题或疑惑,欢迎提问!
来源:juejin.cn/post/7316532841923805184
揭秘外卖平台的附近公里设计
背景
相信大家都有点外卖的时候去按照附近公里排序的习惯,那附近的公里是怎么设计的呢?今天shigen
带你一起揭秘。
分析
我们先明确一下需求,每个商家都有一个地址对吧,我们也有一个地址,我们点餐的时候,就是以我们自己所在的位置为圆心,向外辐射,这一圈上有一堆的商家。类似我下方的图展示:
想到了位置,我们自然想到了卫星定位,想到了二维的坐标。那这个需求我们有什么好的设计方案吗?
redis的GEO地理位置坐标这个数据结构刚好能解决我们的需求。
GEO
GEO 是一种地理空间数据结构,它可以存储和处理地理位置信息。它以有序集合(Sorted Set)的形式存储地理位置的经度和纬度,以及与之关联的成员。
以下是 Redis GEO 的一些常见操作:
GEOADD key longitude latitude member [longitude latitude member ...]
:将一个或多个地理位置及其成员添加到指定的键中。 示例:GEOADD cities -122.4194 37.7749 "San Francisco" -74.0059 40.7128 "New York"
GEODIST key member1 member2 [unit]
:计算两个成员之间的距离。 示例:GEODIST cities "San Francisco" "New York" km
GEOPOS key member [member ...]
:获取一个或多个成员的经度和纬度。 示例:GEOPOS cities "San Francisco" "New York"
GEORADIUS key longitude latitude radius unit [WITHCOORD] [WITHDIST] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
:根据给定的经纬度和半径,在指定范围内查找与给定位置相匹配的成员。 示例:GEORADIUS cities -122.4194 37.7749 100 km WITHDIST COUNT 5
Redis 的 GEO 功能可用于许多应用场景,例如:
- 位置服务:可以存储城市、商店、用户等位置信息,并通过距离计算来查找附近的位置。
- 地理围栏:可以存储地理围栏的边界信息,并检查给定的位置是否在围栏内。
- 最短路径:可以将城市或节点作为地理位置,结合图算法,查找两个位置之间的最短路径。
- 热点分析:可以根据位置信息生成热力图,统计热门区域或目标位置的访问频率。
Redis 的 GEO 功能提供了方便且高效的方式来存储和操作地理位置信息,使得处理地理空间数据变得更加简单和快速。
默默的说一句,redis在路径规划下边竟然也这么厉害!
好的,那我们就来开始实现吧。今天我的操作还是用代码来展示,毕竟经纬度在控制台输入可能会出错。
代码实现
今天的案例是将湖北省武汉市各个区的数据存储在redis中,并以我所在的位置计算离别的区距离,以及我最近10km内的区。数据来源
我的测试代码如下,其中的运行结果也在对应的注释上有显示。
因为代码图片的宽度过长,导致代码字体很小,在移动端可尝试横屏观看;在PC端可尝试右键在新标签页打开图片。
以上的代码案例也参考:Redis GEO 常用 RedisTemplate API(Java),感谢作者提供的代码案例支持。
总结
对于需要存储地理数据和需要进行地理计算的需求,可以尝试使用redis进行解决。当然,elasticsearch也提供了对应的数据类型支持。
来源:juejin.cn/post/7275595571733282853
全网显示IP归属地,免费可用,快来看看
前言
经常浏览小视频或各类帖子的朋友们可能已经注意到,目前许多网络平台都会显示作者和评论区用户的IP归属地。那么,这个功能是如何实现的呢?
某些收费平台的API
我们可以利用一些付费平台的API来实现这一功能,比如一些导航软件的开放平台API等。然而,这些服务通常是收费的,而且免费额度有限,适合测试使用,但如果要在生产环境中使用,很可能不够支撑需求。
离线库推荐
那么,有没有免费的离线API库呢?UP现在推荐一个强大的离线库给大家,一个准确率高达99.9%的离线IP地址定位库,查询速度仅需0.0x毫秒,而且数据库仅10兆字节大小。此库提供了Java、PHP、C、Python、Node.js、Golang、C#等多种查询绑定,同时支持Binary、B树和内存三种查询算法。
这个库大家可以在GitHub上搜索:ip2region,即可找到该开源库。
使用
下面使用Java代码给大家演示下如何使用这个IP库,该库目前支持多重主流语言。
1、引入依赖
<dependency>
<groupId>org.lionsoul</groupId>
<artifactId>ip2region</artifactId>
<version>2.7.0</version>
</dependency>
2、下载离线库文件 ip2region.xdb
3、简单使用代码
下面,我们通过Java代码,挑选某个国内的IP进行测试,看看会输出什么样的结果
public class IpTest {
public static void main(String[] args) throws Exception {
// 1、创建 searcher 对象 (修改为离线库路径)
String dbPath = "C:\Users\Administrator\Desktop\ip2region.xdb";
Searcher searcher = null;
try {
searcher = Searcher.newWithFileOnly(dbPath);
} catch (Exception e) {
System.out.printf("failed to create searcher with `%s`: %s\n", dbPath, e);
return;
}
// 2、查询
String ip = "110.242.68.66";
try {
long sTime = System.nanoTime(); // Happyjava
String region = searcher.search(ip);
long cost = TimeUnit.NANOSECONDS.toMicros((long) (System.nanoTime() - sTime));
System.out.printf("{region: %s, ioCount: %d, took: %d μs}\n", region, searcher.getIOCount(), cost);
} catch (Exception e) {
System.out.printf("failed to search(%s): %s\n", ip, e);
}
// 3、关闭资源
searcher.close();
// 备注:并发使用,每个线程需要创建一个独立的 searcher 对象单独使用。
}
}
输出结果为:
{region: 中国|0|河北省|保定市|联通, ioCount: 3, took: 1192 μs}
其中,region的格式为 国家|区域|省份|城市|ISP,缺省的地域信息默认是0。
当然,这个库不只是支持国内的IP,也支持国外的IP。
其他语言可以参考该开源库的说明文档。
总结
这是一个准确率非常高的离线库,如果项目里有IP定位需求的,可以试下该库。
来源:juejin.cn/post/7306334713992708122
如何创建一张被浏览器绝对信任的 https 自签名证书?
在一些前端开发场景中,需要在本地创建 https 服务,Node.js 提供了 https 模块帮助开发者快速创建 https 的服务器,示例代码如下:
const https = require('https')
const fs = require('fs')
const options = {
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem'),
}
const server = https.createServer(options, (req, res) => {
res.writeHead(200)
res.end('hello world\n')
})
server.listen(8080)
与创建 http 服务最大的区别在于:https 服务需要证书。因此需要在 options 选项中提供 key
和 cert
两个字段。大部分前端不知道如何创建 key
和 cert
,虽然网上能查到一些 openssl 命令,但也不知道是什么含义。所谓授人以鱼不如授人以渔,这里先从一些基本概念讲起,然后一步步教大家如何创建一个可以被浏览器绝对信任的自签名证书。
密码学知识
首先要知道加密学中非常重要的一个算法:公开密钥算法(Public Key Cryptography),也称为非对称加密算法(Asymmetrical Cryptography),算法的密钥是一对,分别是公钥(public key)和私钥(private key),一般私钥由密钥对的生成方(比如服务器端)持有,避免泄露,而公钥任何人都可以持有,也不怕泄露。
一句话总结:公钥加密、私钥解密。
公私钥出了用于加解密之外,还能用于数字签名。因为私钥只有密钥对的生成者持有,用私钥签署(注意不是加密)一条消息,然后发送给任意的接收方,接收方只要拥有私钥对应的公钥,就能成功反解被签署的消息。
一句话总结:私钥加签、公钥验证。
由于只有私钥持有者才能“签署”消息,如果不考虑密钥泄露的问题,就不能抵赖说不是自己干的。
数字签名和信任链
基于数字签名技术,假设 A 授权给 B,B 授权给 C,C 授权给 D,那么 D 就相当于拿到了 A 的授权,这就形成了一个完整的信任链。因此:
- 信任链建立了一条从根证书颁发机构(Root CA)到最终证书持有人的信任路径
- 每个证书都有一个签名,验证这个签名需要使用颁发该证书的机构的公钥
- 信任链的作用是确保接收方可以验证数字签名的有效性,并信任签名所代表的身份
以 https 证书在浏览器端被信任为例,整个流程如下:
可以看到,在这套基础设施中,涉及到很多参与方和新概念,例如:
- 服务器实体:需要申请证书的实体(如某个域名的拥有者)
- CA机构:签发证书的机构
- 证书仓库:CA 签发的证书全部保存到仓库中,证书可能过期或被吊销。
- 证书校验方:校验证书真实性的软件,例如浏览器、客户端等。
这些参与方、概念和流程的集合被称为公钥基础设施(Public Key Infrastructure)
X.509 标准
为了能够将这套基础设施跑通,需要遵循一些标准,最常用的标准是 X.509,其内容包括:
- 如何定义证书文件的结构(使用 ANS.1 来描述证书)
- 如何管理证书(申请证书的流程,审核身份的标准,签发证书的流程)
- 如何校验证书(证书签名校验,校验实体属性,比如的域名、证书有效期等)
- 如何对证书进行撤销(包括 CRL 和 OCSP 协议等概念)
X.509标准在网络安全中广泛使用,特别是在 TLS/SSL 协议中用于验证服务器和客户端的身份。在 Node.js 中,当 tls 通道创建之后,可以通过下面两个方法获取客户端和服务端 X.509 证书:
- 获取本地 X.509 证书:getX509Certificate
- 获取对方 X.509 证书:getPeerX509Certificate
生成证书
要想生成证书,首要有一个私钥(private key),私钥的生成方式为:
$ openssl genrsa -out key.pem 2048
会生成一个 key.pem 文件,内容如下:
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCB....
-----END PRIVATE KEY-----
然后,还需要一个与私钥相对应的公钥(public key),生成方式:
$ openssl req -new -sha256 -key key.pem -out csr.pem
按照提示操作即可,下面是示例输入(中国-浙江省-杭州市-西湖-苏堤-keliq):
You are about to be asked to enter information that will be incorporated
int0 your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:CN
State or Province Name (full name) [Some-State]:Zhejiang
Locality Name (eg, city) []:Hangzhou
Organization Name (eg, company) [Internet Widgits Pty Ltd]:West Lake
Organizational Unit Name (eg, section) []:Su Causeway
Common Name (e.g. server FQDN or YOUR name) []:keliq
Email Address []:email@example.com
Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:123456
An optional company name []:XiHu
生成的文件内容如下:
-----BEGIN CERTIFICATE REQUEST-----
MIIDATCCAekCAQAwgY8xCzAJBgNVBAYTAkNOMREw...
-----END CERTIFICATE REQUEST-----
公钥文件创建之后,接下来有两个选择:
- 将其发送给 CA 机构,让其进行签名
- 自签名
自签名
如果是本地开发,我们选择自签名的方式就行了,openssl 同样提供了命令:
$ openssl x509 -req -in csr.pem -signkey key.pem -out cert.pem
Certificate request self-signature ok
subject=C = CN, ST = Zhejiang, L = Hangzhou, O = West Lake, OU = Su Causeway, CN = keliq, emailAddress = email@example.com
最终得到了 cert.pem,内容如下:
-----BEGIN CERTIFICATE-----
MIIDpzCCAo8CFAf7LQmMUweTSW+ECkjc7g1uy3jCMA0...
-----END CERTIFICATE-----
到这里,所有环节都走完了,再来回顾一下,总共生成了三个文件,环环相扣:
- 首先生成私钥文件
key.pem
- 然后生成与私钥对应的公钥文件
csr.pem
- 最后用公私钥生成证书
cert.pem
实战——信任根证书
用上面自签名创建的证书来创建 https 服务:
const options = {
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem'),
}
启动之后,如果你在浏览器中访问,会发现出错了:
命名是 https 服务,为什么浏览器说不是私密连接呢?因为自签名证书默认是不被浏览器信任的,只需要将 cert.pem 拖到钥匙里面即可,然后修改为「始终信任」,过程中需要验证指纹或者输入密码:
如果你觉得上述流程比较繁琐,可以用下面的命令行来完成:
$ sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain cert.pem
实战——指定主题备用名称
然而,即使添加了钥匙串信任,问题似乎并没有解决,报错还是依旧:
仔细看,其实报错信息发生了变化,从原来的 NET::ERR_CERT_AUTHORITY_INVALID
变成了 NET::ERR_CERT_COMMON_NAME_INVALID
,这又是怎么回事呢?我们点开高级按钮看一下详细报错:
这段话的意思是:当前网站的 SSL 证书中的通用名称(Common Name)与实际访问的域名不匹配。
证书中会包含了一个通用名称字段,用于指定证书的使用范围。如果证书中的通用名称与您访问的域名不匹配,浏览器会出现NET::ERR_CERT_COMMON_NAME_INVALID错误。
一句话描述,证书缺少了主题备用名称(subjectAltName),而浏览器校验证书需要此字段。为了更好的理解这一点,我们可以用下面的命令查看证书的完整信息:
$ openssl x509 -in cert.pem -text -noout
输出结果如下:
Certificate:
Data:
Version: 1 (0x0)
Serial Number:
07:fb:2d:09:8c:53:07:93:49:6f:84:0a:48:dc:ee:0d:6e:cb:78:c2
Signature Algorithm: sha256WithRSAEncryption
Issuer: C = CN, ST = Zhejiang, L = Hangzhou, O = West Lake, OU = Su Causeway, CN = keliq, emailAddress = email@example.com
Validity
Not Before: Nov 15 06:29:36 2023 GMT
Not After : Dec 15 06:29:36 2023 GMT
Subject: C = CN, ST = Zhejiang, L = Hangzhou, O = West Lake, OU = Su Causeway, CN = keliq, emailAddress = email@example.com
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (2048 bit)
Modulus:
00:ac:63:b1:f1:7a:69:aa:84:ef:9d:0e:be:c1:f7:
80:3f:6f:59:e1:7d:c5:c6:db:ff:2c:f3:99:12:7f:
...
Exponent: 65537 (0x10001)
Signature Algorithm: sha256WithRSAEncryption
Signature Value:
70:d9:59:10:46:dc:7b:b3:19:c8:bd:4b:c5:70:4f:89:b6:6a:
53:1c:f2:35:27:c8:0a:ed:a8:0a:13:1f:46:3e:e7:a7:ff:1f:
...
我们发现,这个证书并没有 Subject Alternative Name 这个字段,那如何增加这个字段呢?有两种方式:
指定 extfile 选项
$ openssl x509 -req \
-in csr.pem \
-signkey key.pem \
-extfile <(printf "subjectAltName=DNS:localhost") \
-out cert.pem
再次用命令查看证书详情,可以发现 Subject Alternative Name 字段已经有了:
...
X509v3 extensions:
X509v3 Subject Alternative Name:
DNS:localhost
X509v3 Subject Key Identifier:
21:65:8F:93:49:BC:DF:8C:17:1B:6C:43:AC:31:3C:A9:34:3C:CB:77
...
用新生成的 cert.pem 启动 https 服务,再次访问就正常了,可以点击小锁查看证书详细信息:
但是如果把 localhost 换成 127.0.0.1 的话,访问依然被拒绝,因为 subjectAltName 只添加了 localhost 这一个域名,所以非 localhost 域名使用此证书的时候,浏览器就会拒绝。
新建 .cnf 文件
这次我们新建一个 ssl.cnf 文件,并在 alt_names 里面多指定几个域名:
[req]
prompt = no
default_bits = 4096
default_md = sha512
distinguished_name = dn
x509_extensions = v3_req
[dn]
C=CN
ST=Zhejiang
L=Hangzhou
O=West Lake
OU=Su Causeway
CN=keliq
emailAddress=keliq@example.com
[v3_req]
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName=@alt_names
[alt_names]
DNS.1 = localhost
IP.2 = 127.0.0.1
IP.3 = 0.0.0.0
然后一条命令直接生成密钥和证书文件:
$ openssl req -newkey rsa:2048 -new -nodes -x509 -days 3650 \
-config openssl.cnf \
-keyout key.pem \
-out cert.pem
再次查看证书详情,观察 Subject Alternative Name 字段:
...
X509v3 extensions:
X509v3 Key Usage:
Digital Signature, Non Repudiation, Key Encipherment
X509v3 Subject Alternative Name:
DNS:localhost, IP Address:127.0.0.1, IP Address:0.0.0.0
X509v3 Subject Key Identifier:
B6:FC:1E:68:CD:8B:97:D0:80:0E:F1:18:D3:39:86:29:90:0B:9D:1F
...
这样无论是访问 localhost 还是 127.0.0.1 或者 0.0.0.0,浏览器都能够信任。
来源:juejin.cn/post/7301574056720744483
云音乐贵州机房迁移总体方案回顾
一、背景
2023年确定要将云音乐整体服务搬迁至贵州机房,项目需要在各种限制条件下,保障2000+应用、100w+QPS的服务稳定迁移,是云音乐历史上规模最大、人员最多、难度最高的技术项目。在此过程中,解决了大量历史技术债务,同时化解了大量新增系统性风险。以下为总体方案回顾。
二、项目难点
- 迁移规模大
- 此次需要云音乐以及旗下独立App的服务均整体迁移至贵州。涉及2000+应用、100w+QPS的稳定迁移,同时涉及中间件、存储、机房、三方依赖服务等整体的搬迁,搬迁规模大。
- 业务复杂度高
- 场景复杂。迁移规模大,带来更广的业务场景覆盖。而不同的场景对数据一致性要求不同、延迟敏感度不同。迁移方案需要考虑各种场景带来的问题,并提供标准化的解决方案。
- 服务间依赖复杂。此次带来约2000+应用的搬迁,各服务间的调用和依赖情况复杂,在分批迁移方案中需要协调,以及解决迁移期间跨机房30msRT上升带来的问题。
- 历史积弊多
- 贵州迁移前,存在诸多历史技术积弊,影响着全站整体的稳定性。
- 新增风险大
- 贵州迁移带来诸多新增风险,且风险大、解决难度高。
- 部分场景无法做到真实环境全流程预演。
- 在基础技术建设上,也有一些不足的情况,影响整体搬迁执行效率、迁移准确性。
- 限制条件严苛
- 云音乐有着大量的用户基数,此次搬迁要求:不停机迁移、不产生P2及以上事故。除此之外还有机器、网络带宽、网络稳定性、网络RT、迁移方案等限制条件。
- 事项推进&协调难度大
- 此次搬迁规模大,同样,参与人员规模大,整体协调难度大
- 此外带来较多的人因风险。可能因极小的细节未执行到位,就会造成全局事故。
三、重点限制&要求
- 尽可能少采购或不采购额外的机器,贵州和杭州无法完全对等部署。
- 杭州与贵州的长传带宽控制在200Gbps以内,且存在闪断的可能性,各迁移方案需要重点考虑闪断带来的影响。
- 贵州机房与杭州机房之间网络延迟约30ms,各方迁移方案需重点考虑机房延迟带来的影响。
- 业务可用性要求:不影响核心重点业务场景的可用性,不出现P2及以上事故。
- 控制迁移方案对业务代码的侵入。
四、分批方案
1. 分批的原则
1.1 团队/领域间解耦
大团队/领域之间的迁移方案尽可能解耦,分不同批次搬迁。好处:
- 可以将问题拆分、领域清晰。
- 大数据、算法、云音乐技术中心串行搬迁,可以实现机器资源池共享,降低机器采购成本。
- 降低单一团队/领域切流时问题处理复杂度。
1.2 服务端流量自闭环
云音乐服务端需要将流量闭环在同一个机房,避免产生跨区域调用。
云音乐经过微服务之后,目前存在千+服务,各服务间依赖复杂。在贵州机房与杭州机房之间网络延迟约30ms的背景下,每产生一次跨区域调用,则RT上升30ms。
1.3 C端优先
优先迁移C端相关的应用及其资源,其次B端。
关于此处,会有同学认为优先B端可能会更稳,但优先采用B端优先,会有如下问题:
- B端服务搬迁后,腾挪的机器有限。
- B端服务与C端服务相差较大,即使B端服务先行搬迁无问题,也不足以证明C端服务就一定没问题。
对于如何保障C端服务搬迁的稳定性,在文章后续章节展开。
1.4 在可用资源范围内
迁移期间,需要在贵州准备与杭州同等规模的机器资源,因此批次不可能不受到资源的限制。其主要受限制资源为:
- 机器资源
- 贵州&杭州的长传带宽资源
因此,按照以上原则进行分批后,若资源仍不足,再根据团队/领域拆分出第二批
2. 最终分批方案
基于以上原则,最终分批方案如下所示
- 大数据、算法、技术中心串行搬迁。
- 心遇因强依赖云信IM服务,与云信服务独立搬迁
- 技术中心应用基本一批次全部搬迁完成。
- 技术中心的转码、公技侧后台、质量侧系统在第二批次搬迁完成。
五、切流方案
1. 切流的原则
1.1 可灰度
能够按照用户ID、设备ID、IP、流量标几个维度逐步灰度切流。
- 利于预热。在服务启动后,缓存、连接池需要随请求逐步预热,若流量直接全部打过来,可能会将服务打垮。
- 利于测试。能够灰度测试整体功能,避免大面积异常。
1.2 可回滚
尽管做了各种稳定性保障来避免回滚,但是如遇到极端情况,仍有整体回滚的可能性。因此搬迁方案必须可回滚。
1.3 控制长传带宽
在切流过程中,杭州和贵州之间会有大量的服务访问、数据传输,从而可能突破长传带宽200Gbps的限制。因此切流方案中必须减少不必要的跨区域流量。
2. 切流方案
2.1 切流点选择
服务端整体通用架构简化后,如上图所示,因此有如下几个切入点:
- 客户端切流。客户端通过动态切换域名配置,可实现流量的切换。切流算法可以与网关使用保持一致,我们在贵州迁移中就采用了此方案,从而大幅降低贵州与杭州的长传带宽。
- DNS切换。因DNS存在缓存过期,不适合作为流量控制的主要手段。在贵州迁移中,我们主要用其作为长尾流量的切换的手段。
- 四层LB切流、Nginx切流。主要由SA侧负责,因自动化和操作复杂度等因素,在贵州迁移中,四层LB切流只用于辅助切流手段,Nginx因过高的人工操作复杂度,不用于切流。
- 网关切流。网关作为服务端广泛接触的首要流量入口,其系统建设相对完善、自动化程度较高,因此作为主要切流手段。在此次迁移中,网关支持按用户ID、设备ID、IP进行按比例切流。
- 定时任务、MQ切换。主要用于定时任务、MQ的流量切换。
- RPC流量控制。RPC流量路由策略与网关保持一致,依据切流比例,进行RPC流量调用。从而避免跨机房RT的不可控。
- 存储层切换。主要负责存储的切换。
2.2 存储层迁移策略
云音乐业务场景较多,不同场景下对数据一致性的要求也不一样,例如:营收下的订单类场景需要数据强一致性,而点赞需要数据最终一致性即可。
在涉及不同的存储时,也有着多种多样的迁移策略。对此,中间件以及各存储层支持了不同的迁移策略选择,各个业务基于不同的场景,选择正确的策略。迁移策略主要如下:
类型 | 迁移策略 |
---|---|
DB | 读本地写远程、读远程写远程、读本地写本地、禁写 |
Redis | 读写远程+需要禁写、读本地写远程+需要禁写、读写本地 |
Memcached | 异步双写、同步双写、不同步 |
2.3 切流步骤
对以上切入点再次进行分类,可再次简化为流量层切流、存储层切换。在正式切流时,我们按照如下步骤进行切流。
3. 回滚方案
先存储层按序切换,然后流量层按序切换。
六、稳定性保障&治理
1. 全域的稳定性风险
- 全域的稳定性风险。我们在做一般的活动稳定性保障时,一般从活动的主链路出发,再梳理相关依赖,从而整理出稳定性保障&治理的重点。而这种方法确不适用于贵州机房迁移,从前面的分批概览图可得知:此次贵州机房迁移带来全域的稳定性风险。
- 墨菲定律:"如果一件事情有出错的可能性,那么它最终一定会出错。"
- 业界没有类似的经验可参考
因此整个项目组也在摸着石头过河,在此过程中,既有大的方案的设计,也有细枝末节的问题发现和推进处理。总结起来,我们总共从以下几个方面着手进行稳定性保障:
- 信息梳理&摸查
- 新增风险发现&处理
- 历史技术债务处理
- 标准化接入
- 监控告警增强
- 应急预案保障
- 业务侧技术方案保障
- 杭州集群下线保障
2. 信息梳理&摸查
盘点梳理机器资源情况、网络带宽、迁移期间服务可用性要求等全局限制条件,从而确定分批方案、迁移思路。
2.1 机器资源盘点
主要盘点核数、内存。在此过程中,也推进了资源利用率优化、废弃服务下线等事宜。 通过如下公式计算机器资源缺口:搬迁机器缺口 = 搬迁所需数量 -(可用数量+可优化数量)
2.2 长传带宽盘点
需要控制云音乐的长传带宽总量 <= 相对安全的带宽量 相对安全的带宽量 = (长传带宽总量 / 2 x 0.8) - 已被占用带宽量
2.3 迁移期间服务可用性要求
若业务允许全站停服迁移、或仅保障少量核心服务不挂,那么整体迁移方案会简单很多。因此业务对迁移期间的可用性要求,关乎着搬迁方案如何设计。 最终讨论后确定,需要:迁移不产生P2及以上事故
2.4 服务间跨区域调用RT摸查
基于Trace链路,预测分批情况下RT增长情况。
3. 新增系统性风险
此次贵州迁移主要带来的新增系统性风险是:
- 因公网质量问题,带来迁移后用户体验差的风险。
- 因跨机房延迟30ms ,带来的业务侧应用雪崩风险。
- 因跨机房传输网络不稳定,带来的整体系统性风险。
- 因杭州和贵州机房同时部署,带来的服务节点数量、API数量、RPC数量翻倍风险
- 因大规模数据变更,带来的系统性能风险。
- 因新机房建设、搬迁,带来的底层基础设施风险。
- 因全域团队协作、大范围配置变更&发布,带来的人因操作、协作风险。
3.1 因公网质量问题,带来迁移后用户体验差的风险
贵州公网质量如何?迁移至贵州之后是否会因公网质量问题,导致用户体验差?由于云音乐用户基数大,且注重用户体验,这个是必须提前摸清的问题。若公网质量真的存在较大问题,云音乐可能会停止贵州迁移项目。
对此,我们通过如下方式进行了公网质量验证和保障:
- 通过客户端预埋逻辑,抽样检测同时请求杭州和贵州机房的RT差异。
- 通过RT的差异,再下钻分析杭州和贵州机房的差异点。
- 解决或排除机房、客户端、域名配置等差异,最终得出公网质量的差异。
- 在正式切流前,解决完成客户端、机房等差异,保障整体网络请求质量。
- 通过QA侧的整体测试。
3.2 因跨机房延迟30ms ,带来的业务侧应用雪崩风险
云音乐C端服务当前的RT普遍在5~70ms之间,若增加30ms,可能会导致请求堆积、线程池打爆等风险。为避免此风险,我们从如下几个方面入手:
- 尽可能同一批次搬迁,避免长期跨机房调用。
- 同一批次应用,基于用户ID、设备ID、IP进行Hash,实现同机房调用优先。
- 无法同一批次搬迁的应用。
- 确保会只跨一次,避免因循环调用等原因导致的多次跨机房。
- 需提供降级方案,对服务弱依赖。
- 服务需通过QA侧的测试。
3.3 因跨机房传输网络不稳定,带来的整体系统性风险
跨机房网络的现状和参考数据:
- 共计2条线,单条带宽为:100Gbps,但建议保持单条利用率在80%及以下。
- 参考网易北京与杭州的长传带宽质量。
- 可能会出现单条中断的情况,在网络侧的表现为网络抖动。若单条线中断,那么发生故障的请求会重连至另一条线。
- 极低概率出现2条线全部中断的情况。
基于以上现状,需要重点考虑并解决:
- 各中间件、存储在切流期间,长传网络出现问题时的表现、应对和兜底措施。例如ZK重连、重连失败后的重连风暴问题。
- 各服务在切流完成后,若仍长期使用长传网络,若长传网络出现问题的表现、应对和兜底措施。
在贵州迁移项目中,我们对以上重点问题进行了梳理和解决,并制定了各种应急预案和极端情况下的回滚方案。
3.4 因杭州和贵州机房同时部署,带来的服务节点数量、API数量、RPC数量翻倍风险
因杭州和贵州机房同时部署,带来的服务节点数量、API数量、RPC数量翻倍风险
在服务节点数量、API数量、RPC数量翻倍后,主要对底层依赖带来连接、重连上的冲击,以及原有连接数上限的冲击。
在我们实际搬迁中,也因遗漏了这一点,导致线上ZK出现瓶颈,进而ZK挂掉的问题。其主要表现为在网关场景下存在数据推送瓶颈。最终通过网关侧的ZK拆分解决该问题。
除此之外,DB、Memcached、Redis、MQ等资源的连接数也可能会超过原先设定的上限,需要评估后进行调整。
3.5 因大规模数据变更,带来的系统性能风险
大规模数据变更的场景包含但不限于:
- 批量调整配置中心值,因达到配置中心的性能瓶颈,导致配置变更时间过长,或服务挂掉。
- 批量的服务部署、重启,因达到K8S、构建机的性能瓶颈,导致部署、重启时间过长,或服务挂掉。
- 对迁移当晚核心路径上的服务进行集中访问、操作,因达到服务的性能瓶颈,导致访问超时、白屏、数据延迟、或服务挂掉的问题。
针对以上风险,我们重点对配置中心、K8S、贵州迁移管控平台等系统进行了性能优化,以支撑整体迁移。
3.6 因新机房建设、搬迁带来的底层基础设施风险。
因新机房建设、搬迁带来的底层基础设施风险包含但不限于:
- 同城双活能力的缺失。为应对此风险,我们在逻辑上继续保留同城双活的能力,并暂时通过机房不同楼层的部署架构,来尽可能弥补同城双活能力的缺失。
- 机器上架、环境搭建、网络传输等需确保达到验收标准。为应对此风险,运维侧提供相关方案保障整体环境,并最终通过业务侧QA验收。
3.7 因全域团队协作、大范围变更&发布,带来的人因操作、协作风险
在贵州迁移前,已经有多次发生因配置变更错误带来的事故。而此项目带来从未有过的全域迁移,全域协作,大范围变更&发布,风险不可谓不高。在此过程中,通过了许多方式来保障事项的落地,其中比较关键的点,也是项目成功的关键点包括:
- 各部门领导与同事的支持。
- 分工明确。在战略、战术、细节、事项推进等多个点均有相关人员把控,各司其职。
- 各项信息的细化梳理&定位。
- 定期的沟通协作会议,通过敏捷式项目管理,进行滚动式问题发现。
- 问题发现、治理、验证必须闭环。
- 尽可能中心系统化、自动化处理。无法自动化的,则提供标准化实施手册。
- 重点问题,case by case,one by one。
4. 历史技术债务处理
在贵州迁移项目中,比较突出的历史债务处理有:
- ZK强依赖问题
- 在线业务Kafka迁移Nydus。
- 配置硬编码
- 服务间依赖改造
- 资源优化&控制
- 心遇依赖拆分
- 元信息不准确
- 组件版本过于陈旧问题
- 测试环境自动化部署成功率低
- 租户多集群拆分为多应用
4.1 ZK强依赖问题
ZK的不稳定已导致云音乐最高出现P1级事故,在贵州迁移项目中,因网络环境、机房环境、迁移复杂度等因素,ZK服务挂掉的概率极大,因此必须不能对其强依赖。
最终中间件侧对其改造,支持ZK发生故障时,其注册信息降级到本地内存读取。并推进相关依赖方进行升级改造。
4.2 在线业务Kafka迁移Nydus。
Nydus作为云音乐主力MQ产品,相较开源Kafka有更好的监控、运维等能力,Kafka在云音乐在线业务中已不再推荐使用。在贵州迁移中,MQ也需要进行两地切换/切流。
主要收益:
- 在线业务稳定性
- Kafka机器资源回收
- MQ切流特性&历史债务收敛
在推进层面:
- 第一里程碑:生产者完成双写
- 第二里程碑:消费者完成双消费
- 第三里程碑:完成废弃TOPIC下线、代码下线等收尾工作
4.3 配置硬编码
在贵州迁移项目中,需要做大量的配置迁移、变更。其主要为:机房名、集群名、机器IP、机器Ingress域名的变化。而这些在配置中心、代码、自动化脚本、JVM参数中均有存在,此外,IP黑白名单还可能涉及到外部厂商的改造变更。
在具体推进上,采用自动化扫描+人工梳理结合,并辅以标准化改造指引文档。
- 自动化扫描:通过代码扫描、配置中心扫描、JVM参数扫描、连接扫描等方式进行问题发现。
- 人工梳理:外部厂商、不受Git管控的脚本、以及运维侧的配置(例如:存储层访问权限的黑白名单等)、以及自动化扫描可能的遗漏,由各研发、运维人员再次自行梳理。
4.4 服务间依赖改造
核心应对杭州与贵州跨机房30ms RT和长传网络不稳定的风险。对循环调用、不合理依赖、强依赖进行改造。
- 减少不必要依赖。
- 必须不能出现服务跨机房强依赖。
- 不能因循环调用导致跨机房RT飙升。
4.5 资源优化&控制
因贵州需要与杭州同等容量部署,可能存在资源不足的情况。对此需要:
- 统一服务的资源利用率标准,推进资源利用率改造
- 对部分服务进行合并、下线、缩容处理。
4.6 心遇依赖拆分
因心遇强依赖云信,且云信IM为心遇核心业务功能,最终确定心遇为独立批次搬迁。因此心遇依赖的中台服务、存储、算法&大数据相关任务,均需拆分出来,不能与云音乐耦合,否则会产生跨机房调用,影响服务稳定性。
4.7 元信息不准确
在此次迁移中,存在较多的元信息不准确的问题,例如:
不足项 | 解释 |
---|---|
应用的元信息需要补充、更新 | 1. 应用归属的团队信息不准确 2. 应用的废弃、待废弃状态未知 3. 测试应用、非业务应用信息偏杂乱 |
应用团队归属信息多处维护,未统一 | 应用在多个平台均有维护,且均存在维护不准确的问题 |
应用的各项依赖信息不全 | 应用依赖的db、redis、memcached资源,以及在配置中心的key无法全面准确拉取 |
应用的各项依赖信息可视化、系统化建设不足 | 1. 应用依赖的组件版本、依赖的存储资源等,缺乏友好的可视化查询能力。 2. 各项信息之间的关联性建设不足 |
底层中间件、存储元信息不全 | 1. 不同的ZK集群的用处缺乏统一维护。 2. 各项元信息反查调用源IP、集群、应用、团队、负责人的能力不足 |
以上问题在迁移中,通过脚本、1对1沟通确认、手动梳理等多种方式进行了临时处理,在贵州迁移后,仍需再全面的系统性规划。
4.8 组件版本过于陈旧问题
有较多的应用长期不升级,与最新版本跨度较大,存在较多的兼容性问题,需要人工进行升级处理。升级流程大致如下:
在迁移中期,我们进行了自动升级平台建设,基本支持以上升级流程自动化。
4.9 测试环境自动部署成功率低
因此次迁移涉及全部的应用在不同环境的部署,全部人工操作的效率过低,因此我们在非线上环境均由脚本自动化部署,而测试环境由于维护不足,部署成功率较低。
4.10 租户多集群拆分为多应用
当前贵州迁移时整体会按照应用维度进行迁移、切流到贵州。因此对于中台租户型应用、多地域注册类型的应用需要拆分。
5. 标准化接入
除了以上提到的历史技术债务处理和新增系统性风险,公共技术侧大都提供了标准化的接入、改造治理方式。例如:
- 贵州迁移中间件方案汇总。涵盖所有涉及中间件的迁移、切流、迁移策略、接入等指导方案。
- 贵州迁移升级指导。涵盖自动升级与手动升级、脚手架应用与非脚手架应用的升级方案。
- 贵州迁移线上部署指导。涵盖贵州线上部署前的各项必要准备事项,以及特殊应用的注意事项。
- 贵州迁移监控大盘观测指导。涵盖各类迁移监控的观测指导。
- 中台、多地域注册拆分指导。涵盖中台租户、多地域注册类型应用的拆分指导方案,以及整体的拆分流程、验证要点等。
- ddb、redis、memcached、KSchedule等非标治理。涵盖各中间件、存储的非标风险列表、处理办法等。
- 杭州集群下线指导。涵盖杭州集群如何观察、缩容、下线、机器回收的指导方案。
6. 监控告警
在监控告警层面,主要提供了:
- 贵州迁移整体大盘监控。提供了迁移相关全局比例,异常流量,异常比例,能够区分是迁移导致的还是本身杭州服务就有问题导致。同时集成资源层相关指标,判断是单个资源有问题还是全部资源有问题。
- 贵州迁移应用监控。提供了单个应用的贵州迁移监控,应用贵州杭州流量比例,异常流量,异常比例,能够区分是贵州还是杭州的问题。同时有资源相关的指标。
- 杭州集群与贵州集群的哨兵监控对比分析。提供指定应用的杭州和贵州集群在CPU利用率、线程池满、异常比例、RT超时等维度的对比。
- 全局/应用的SLO监控。提供核心指标受损监控。
- 应用层面的系统监控。研发可通过哨兵、APM来查看定位具体的问题。
7. 应急预案
在贵州迁移期间,基于以上风险,主要准备如下应急预案:
- 客户端截流。在开启后,客户端将访问本地或CDN缓存,不再向服务端发送请求。
- 全站服务QPS限流至安全阈值。在开启后,全站的后端服务将限流调整至较低的安全阈值上,在极端情况下,避免因跨机房RT、跨机房传输、跨机房访问等因素的性能瓶颈引起服务端雪崩。
- 长传带宽监控&限流。在开启后,部分离线数据传输任务将会被限流。保障在线业务的带宽在安全水位下。
- 回滚方案。当出现重大问题,且无法快速解决时,逐步将存储、流量切回杭州。
- 外网逃生通道。当出现长传网络完全中断,需要回滚至杭州。通过外网逃生通道实现配置、核心数据的回滚。
- 业务领域内的应急预案。各业务领域内,需要考虑切流前的主动降级预案、切流中的应急预案。
- 批量重启。当出现局部服务必须通过重启才能解决的问题时,将会启用批量重启脚本实现快速重启。当出现全局服务必须通过重启才能解决问题时,需要当场评估问题从而选择全量重启或全量回滚至杭州。
8. 业务技术侧方案
业务技术侧方案重点包含但不限于:
- 应用搬迁范围、搬迁批次梳理明确。当上下游依赖的应用处于不同批次时,需要跨团队沟通协调。
- 明确业务影响,从而确定各应用的中间件、存储迁移策略。
- 历史技术债务处理
- 标准化接入
- 核心场景稳定性保障方案
- 核心指标监控建设完善。
- 切流SOP。包括切流前(前2天、前1天、前5分钟)、切流中、切流后各阶段的执行事项。
- 切流降级方案、应急预案
- 切流停止标准
9. 杭州集群下线
在服务迁移至贵州后,若杭州仍有流量调用,需排查流量来源,并推进流量下线或转移至贵州。先缩容观察,无正常流量、CDN回源等之后,再做集群下线。
七、测试&演练
此次贵州迁移,在各应用标准化治理之后,通过系统批量工具完成贵州各项环境的搭建、测试环境的批量部署。
1. 测试环境演练
1.1 准备事项
在测试演练开始前,我们重点做了如下准备:
- 贵州测试环境批量创建。通过迁移工具,实现贵州测试集群的批量创建、配置批量迁移等。
- 应用自动化升级。通过自动升级平台,实现大规模应用的批量升级,支持了各组件、各应用的多次快速验证、快速升级。
- 测试环境自动化部署。通过自动化部署脚本,为支持测试环境能够多次、高效演练。
- SOP梳理&平台建设。通过SOP平台,将SOP文档沉淀为系统能力,实现各SOP能力的系统化。
- 迁移监控大盘建设。通过细化梳理监控指标,构建监控大盘,掌握各应用、各组件在切流期间的表现。
1.2 执行步骤
在测试环境演练,总体思路是逐步扩大验证范围,最终达到全局基本功能基本验证通过。以下为主要演练顺序,每一步视执行结果,再选择是否重复执行。
顺序 | 验证事项 |
---|---|
1 | 验证中间件内部逻辑是否正确: 1. 网关、RPC、存储层路由策略是否正确。 2.验证监控大盘是否正确 3.验证SOP平台是否正确 4.... |
2 | 验证存储层切换是否正确 |
3 | 逐一对各业务团队进行演练: 1.加深各团队对切流能力的感知。 2.验证收集中间件、存储在各领域的表现。 3.验证各团队、各领域迁移策略的合理性 |
4 | 对BFF、FaaS等特殊应用类型进行演练 |
2. 线上环境演练
因测试环境和线上环境仍存在较大的差异,需要摸清线上真实情况,在演练原则和演练目标上均较测试环境演练有更严格、细致的要求。
2.1 演练原则
- 不对线上数据产生污染;
- 不产生线上 P2 以上事故。
2.2 演练目标
分类 | 目标内容 |
---|---|
公技演练目标 | 1. 切流验证,网关,rpc,贵州迁移大盘监控 2.网关切流比例、快慢,数据库 ddb 贵州跨机房建连对业务影响 3.端上切流,网关切流验证 |
业务演练目标 | 1.流量切换,贵州跨机房对业务影响 2.业务指标和SLO 3.业务预案有效性验证 4.RT变化情况 |
存储演练目标 | 1.ddb 复制延迟,连接数(由于跨机房创建DDB连接非常慢, 主要观察流量到贵州后新建连接对应用和数据库影响及恢复情况) 2.redis数据同步、整体表现 |
网络演练目标 | 1.跨机房延迟情况 2.跨机房带宽实际占用 3.网络带宽占用监控 |
2.3 演练终止条件
- P0、P1 核心场景 SLO 95%以下;
- 用户舆情增长波动明显;
- 跨机房网络大规模异常;
- 大量业务指标或者数据异常;
- 贵州流量达到预定 90%。
3. 独立App迁移验证
在云音乐主站正式切流前,先对云音乐旗下独立App进行了线上搬迁验证,保障云音乐迁移时的稳定性。
八、系统沉淀
1. SOP平台
SOP即标准作业程序(Standard Operating Procedure),源自传统工业领域,强调将某项操作以标准化、流程化的方式固化下来。
SOP平台将标准化、流程化的操作进行系统化呈现,并对接各中间件平台,实现操作效率的提升。在贵州迁移过程中,能够实现多部门信息同步、信息检查,并显著降低批量操作的出错概率、执行效率,降低人因风险。同时也可为后续其他大型项目提供基础支撑。
2. 自动升级平台
自动升级平台串联代码升级变更、测试部署、测试验证、线上发布、线上检测,实现升级生命周期重要节点的自动化。在贵州迁移过程中,显著提升整体升级、验证、部署效率。同时可为后续的大规模组件升级、组件风险治理、组件兼容性摸查、Sidecar式升级提供基础支撑。
九、不足反思
1. 元信息建设仍然不足
精准筛选出每项事宜涉及的范围,是顺利进行各项风险治理的前提条件。在此次贵州机房迁移中也暴露出元信息建设不足的问题。
不足项 | 解释 |
---|---|
应用的元信息需要补充、更新 | 1. 应用归属的团队信息不准确 2. 应用的废弃、待废弃状态未知 3. 测试应用、非业务应用信息偏杂乱 |
应用团队归属信息多处维护,未统一 | 应用在多个平台均有维护,且均存在维护不准确的问题 |
应用的各项依赖信息不全 | 应用依赖的db、redis、memcached资源,以及在配置中心的key无法全面准确拉取 |
应用的各项依赖信息可视化、系统化建设不足 | 1. 应用依赖的组件版本、依赖的存储资源等,缺乏友好的可视化查询能力。 2. 各项信息之间的关联性建设不足 |
底层中间件、存储元信息不全 | 1. 不同的ZK集群的用处缺乏统一维护。 2. 各项元信息反查调用源IP、集群、应用、团队、负责人的能力不足 |
2. 各项元信息的创建、更新、销毁标准化、系统化
在贵州迁移过程中,做了历史技术债务处理、标准化接入方式,后续可针对各项元信息的创建、更新、销毁进行标准化、系统化建设。例如:
- 应用、集群的创建和销毁需要前置校验、审批。以及后期的架构治理扫描。
- 借助组件升级平台,实现组件发布、升级的标准化、系统化。
- DB、Redis、Memcached、ZK的申请、使用、接入等标准化、防劣化。
3. 应用配置标准化
目前应用可做配置的入口有:配置中心、properties文件、props文件、JVM参数、硬编码。不同的中间件提供出的配置方式也各有不同,所以各应用的配置比较五花八门。因此可做如下改进:
- 明确各种配置入口的使用标准。比如:什么时候建议用配置中心?什么时候建议用JVM参数?
- 在组件提供侧、应用研发侧均有一定的宣贯、提示。避免配置方式过于杂乱。
- 提供配置统一上报的能力。助力元信息的建设。
4. 批处理能力需再进一步增强
在贵州机房迁移中,除了SOP平台和自动升级平台的系统沉淀外,业务中间件、Horizon部署平台都提供了一定的工具支撑,从而在一定程度上提升了整体迁移的效率。在之后,随着对效率、系统间融合的要求的提高。需要继续在功能、性能、稳定性等多个层面,继续对批处理、系统间融合进行系统化建设。例如:
- 批量拉取、筛选指定条件的应用以及相关依赖信息。
- 基于指定的环境、团队、应用、集群等维度,进行服务的批量重启、部署。此处需要进一步提升测试环境部署成功率
- 基于指定的应用、集群等维度,进行批量的服务复制、配置复制。
5. ZK稳定性、可维护性优化
在贵州迁移中,ZK的问题相对突出,对此也投入了比较多的人力去排查、解决以及推进风险治理。后续仍需要在ZK的稳定性、可维护性上探讨进一步优化的可能性:
- ZK元信息的维护和使用标准。明确各ZK集群的用处、各ZK Path的用处,ZK集群间隔离、复用的标准,并推进相关标准化治理。
- ZK故障时,因开启降级至内存,业务无法重启服务。若故障期间叠加其他事故,则会导致其他事故被放大。
- 其他稳定性、可维护性梳理
6. 公技侧稳定性保障长效机制和系统化建设
尽管在贵州机房迁移中,做了大量的稳定性保障措施,但依赖每个研发对各自负责领域的理解、运维能力。是否能在团队管理、设施管理、服务管理、稳定性管理、架构设计等多方面,探索出一套可持续的长效保障机制?并进行一定的稳定性系统化建设?从而避免点状问题随机发生。
7. 组件生产、发布、治理能力增强
贵州迁移中涉及大量的组件变更与发布,以及业务侧组件升级与治理。组件可以从生产侧和使用侧进行分析,而组件生命周期主要由2条主线贯穿:
- 组件生产发布线:组件的生产、测试验证、发布。
- 组件风险治理线:风险定义、风险发现、升级推进、升级验证
依据此分类,服务端的组件管理仍有较多可提升空间。
来源:juejin.cn/post/7389952004791894016
网站刚线上就被攻击了,随后我一顿操作。。。
大家好,我是冰河~~
自己搭建的网站刚上线,短信接口就被一直攻击,并且攻击者不停变换IP,导致阿里云短信平台上的短信被恶意刷取了几千条,加上最近工作比较忙,就直接在OpenResty上对短信接口做了一些限制,采用OpenResty+Lua的方案成功动态封禁了频繁刷短信接口的IP。
一、临时解决方案
由于事情比较紧急,所以,当发现这个问题时,就先采用快速的临时方案解决。
(1)查看Nginx日志发现被攻击的IP 和接口
[root@binghe ~]# tail -f /var/log/nginx/access.log
发现攻击者一直在用POST请求 /fhtowers/user/getVerificationCode这个接口
(2)用awk和grep脚本过滤nginx日志,提取攻击短信接口的ip(一般这个接口是用来发注册验证码的,一分钟如果大于10次请求的话就不是正常的访问请求了,大家根据自己的实际情况更改脚本)并放到一个txt文件中去,然后重启nginx
[root@binghe ~]# cat denyip.sh
#!/bin/bash
nginx_home=/usr/local/openresty/nginx
log_path=/var/log/nginx/access.log
tail -n5000 $log_path | grep getVerification | awk '{print $1}' |sort | uniq -c | sort -nr -k1 | head -n 100 |awk '{if($1>10)print ""$2""}' >$nginx_home/denyip/blocksip.txt
/usr/bin/nginx -s reload
(3)设置Nginx去读取用脚本过滤出来的blocksip.txt(注意一下,我这里的Nginx是用的openresty,自带识别lua语法的,下面会有讲openresty的用法)
location = /fhtowers/user/getVerificationCode { #短信接口
access_by_lua '
local f = io.open("/usr/local/openresty/nginx/denyip/blocksip.txt") #黑名单列表
for line in f:lines() do
if ngx.var.http_x_forwarded_for == line then #如果ip在黑名单列表里直接返回403
ngx.exit(ngx.HTTP_FORBIDDEN)
end
end
';
proxy_pass http://appservers; #不在名单里就转发给后台的tomcat服务器
}
(4)把过滤脚本放进crontab任务里,一分钟执行一次
[root@binghe ~]# crontab -e
*/1 * * * * sh /root/denyip.sh
(5)查看一下效果,发现攻击者的请求都被返回403并拒绝了
二、OpenResty+Lua方案
临时方案有效果后,再将其调整成使用OpenResty+Lua脚本的方案,来一张草图。
接下来,就是基于OpenResty和Redis实现自动封禁访问频率过高的IP。
2.1 安装OpenResty
安装使用 OpenResty,这是一个集成了各种 Lua 模块的 Nginx 服务器,是一个以Nginx为核心同时包含很多第三方模块的Web应用服务器,使用Nginx的同时又能使用lua等模块实现复杂的控制。
(1)安装编译工具、依赖库
[root@test1 ~]# yum -y install readline-devel pcre-devel openssl-devel gcc
(2)下载openresty-1.13.6.1.tar.gz 源码包,并解压;下载ngx_cache_purge模块,该模块用于清理nginx缓存;下载nginx_upstream_check_module模块,该模块用于ustream健康检查。
[root@test1 ~]# cd /usr/local/
[root@test1 local]# wget https://openresty.org/download/openresty-1.13.6.1.tar.gz
[root@test1 local]# tar -zxvf openresty-1.13.6.1.tar.gz
[root@test1 local]# cd openresty-1.13.6.1/bundle
[root@test1 local]# wget http://labs.frickle.com/files/ngx_cache_purge-2.3.tar.gz
[root@test1 local]# tar -zxvf ngx_cache_purge-2.3.tar.gz
[root@test1 local]# wget https://github.com/yaoweibin/nginx_upstream_check_module/archive/v0.3.0.tar.gz
[root@test1 local]# tar -zxvf v0.3.0.tar.gz
(3)配置需安装的模块
# ./configure --help可查询需要安装的模块并编译安装
[root@test1 openresty-1.13.6.1]# ./configure --prefix=/usr/local/openresty --with-luajit --with-http_ssl_module --user=root --group=root --with-http_realip_module --add-module=./bundle/ngx_cache_purge-2.3/ --add-module=./bundle/nginx_upstream_check_module-0.3.0/ --with-http_stub_status_module
[root@test1 openresty-1.13.6.1]# make && make install
(4)创建一个软链接方便启动停止
[root@test1 ~]# ln -s /usr/local/openresty/nginx/sbin/nginx /bin/nginx
(5)启动nginx
[root@test1 ~]# nginx #启动
[root@test1 ~]# nginx -s reload #reload配置
如果启动时候报错找不到PID的话就用以下命令解决(如果没有更改过目录的话,让它去读nginx的配置文件就好了)
[root@test1 ~]# /usr/local/openresty/nginx/sbin/nginx -c /usr/local/openresty/nginx/conf/nginx.conf
随后,打开浏览器访问页面。
(6)在Nginx上测试一下能否使用Lua脚本
[root@test1 ~]# vim /usr/local/openresty/nginx/conf/nginx.conf
在server里面加一个
location /lua {
default_type text/plain;
content_by_lua ‘ngx.say(“hello,lua!”)’;
}
加完后重新reload配置。
[root@test1 ~]# nginx -s reload
在浏览器里输入 ip地址/lua,出现下面的字就表示Nginx能够成功使用lua了
2.2 安装Redis
(1)下载、解压、编译安装
[root@test1 ~]# cd /usr/local/
[root@test1 local]# wget http://download.redis.io/releases/redis-6.0.1.tar.gz
[root@test1 local]# tar -zxvf redis-6.0.1.tar.gz
[root@test1 local]# cd redis-6.0.1
[root@test1 redis-6.0.1]# make
[root@test1 redis-6.0.1]# make install
(2)查看是否安装成功
[root@test1 redis-6.0.1]# ls -lh /usr/local/bin/
[root@test1 redis-6.0.1]# redis-server -v
Redis server v=3.2.5 sha=00000000:0 malloc=jemalloc-4.0.3 bits=64 build=dae2abf3793b309d
(3)配置redis 创建dump file、进程pid、log目录
[root@test1 redis-6.0.1]# cd /etc/
[root@test1 etc]# mkdir redis
[root@test1 etc]# cd /var/
[root@test1 var]# mkdir redis
[root@test1 var]# cd redis/
[root@test1 redis]# mkdir data log run
(4)修改配置文件
[root@test1 redis]# cd /usr/local/redis-6.0.1/
[root@test1 redis-6.0.1]# cp redis.conf /etc/redis/6379.conf
[root@test1 redis-6.0.1]# vim /etc/redis/6379.conf
#绑定的主机地址
bind 192.168.1.222
#端口
port 6379
#认证密码(方便测试不设密码,注释掉)
#requirepass
#pid目录
pidfile /var/redis/run/redis_6379.pid
#log存储目录
logfile /var/redis/log/redis.log
#dump目录
dir /var/redis/data
#Redis默认不是以守护进程的方式运行,可以通过该配置项修改,使用yes启用守护进程
daemonize yes
(5)设置启动方式
[root@test1 redis-6.0.1]# cd /usr/local/redis-6.0.1/utils/
[root@test1 utils]# cp redis_init_script /etc/init.d/redis
[root@test1 utils]# vim /etc/init.d/redis #根据自己实际情况修改
/etc/init.d/redis文件的内容如下。
#!/bin/sh
#
# Simple Redis init.d script conceived to work on Linux systems
# as it does use of the /proc filesystem.
REDISPORT=6379
EXEC=/usr/local/bin/redis-server
CLIEXEC=/usr/local/bin/redis-cli
PIDFILE=/var/run/redis_${REDISPORT}.pid
CONF="/etc/redis/${REDISPORT}.conf"
case "$1" in
start)
if [ -f $PIDFILE ]
then
echo "$PIDFILE exists, process is already running or crashed"
else
echo "Starting Redis server..."
$EXEC $CONF
fi
;;
stop)
if [ ! -f $PIDFILE ]
then
echo "$PIDFILE does not exist, process is not running"
else
PID=$(cat $PIDFILE)
echo "Stopping ..."
$CLIEXEC -p $REDISPORT shutdown
while [ -x /proc/${PID} ]
do
echo "Waiting for Redis to shutdown ..."
sleep 1
done
echo "Redis stopped"
fi
;;
*)
echo "Please use start or stop as first argument"
;;
esac
增加执行权限,并启动Redis。
[root@test1 utils]# chmod a+x /etc/init.d/redis #增加执行权限
[root@test1 utils]# service redis start #启动redis
(6)查看redis是否启动
2.3 Lua访问Redis
(1)连接redis,然后添加一些测试参数
[root@test1 utils]# redis-cli -h 192.168.1.222 -p 6379
192.168.1.222:6379> set "123" "456"
OK
(2)编写连接Redis的Lua脚本
[root@test1 utils]# vim /usr/local/openresty/nginx/conf/lua/redis.lua
local redis = require "resty.redis"
local conn = redis.new()
conn.connect(conn, '192.168.1.222', '6379') #根据自己情况写ip和端口号
local res = conn:get("123")
if res==ngx.null then
ngx.say("redis集群中不存在KEY——'123'")
return
end
ngx.say(res)
(3)在nginx.conf配置文件中的server下添加以下location
[root@test1 utils]# vim /usr/local/openresty/nginx/conf/nginx.conf
location /lua_redis {
default_type text/plain;
content_by_lua_file /usr/local/openresty/nginx/conf/lua/redis.lua;
}
随后重新reload配置。
[root@test1 utils]# nginx -s reload #重启一下Nginx
(4)验证Lua访问Redis的正确性
在浏览器输入ip/lua_redis, 如果能看到下图的内容表示Lua可以访问Redis。
准备工作已经完成,现在要实现OpenResty+Lua+Redis自动封禁并解封IP了。3.4
2.4 OpenResty+Lua实现
(1)添加访问控制的Lua脚本(只需要修改Lua脚本中连接Redis的IP和端口即可)
ok, err = conn:connect(“192.168.1.222”, 6379)
注意:如果在Nginx或者OpenResty的上层有用到阿里云的SLB负载均衡的话,需要修改一下脚本里的所有…ngx.var.remote_addr,把remote_addr替换成从SLB获取真实IP的字段即可,不然获取到的IP全都是阿里云SLB发过来的并且是处理过的IP,同时,这些IP全都是一个网段的,根本没有办法起到封禁的效果)。
完整的Lua脚本如下所示。
[root@test1 lua]# vim /usr/local/openresty/nginx/conf/lua/access.lua
local ip_block_time=300 --封禁IP时间(秒)
local ip_time_out=30 --指定ip访问频率时间段(秒)
local ip_max_count=20 --指定ip访问频率计数最大值(秒)
local BUSINESS = ngx.var.business --nginx的location中定义的业务标识符,也可以不加,不过加了后方便区分
--连接redis
local redis = require "resty.redis"
local conn = redis:new()
ok, err = conn:connect("192.168.1.222", 6379)
conn:set_timeout(2000) --超时时间2秒
--如果连接失败,跳转到脚本结尾
if not ok then
goto FLAG
end
--查询ip是否被禁止访问,如果存在则返回403错误代码
is_block, err = conn:get(BUSINESS.."-"..ngx.var.remote_addr)
if is_block == '1' then
ngx.exit(403)
goto FLAG
end
--查询redis中保存的ip的计数器
ip_count, err = conn:get(BUSINESS.."-"..ngx.var.remote_addr)
if ip_count == ngx.null then --如果不存在,则将该IP存入redis,并将计数器设置为1、该KEY的超时时间为ip_time_out
res, err = conn:set(BUSINESS.."-"..ngx.var.remote_addr, 1)
res, err = conn:expire(BUSINESS.."-"..ngx.var.remote_addr, ip_time_out)
else
ip_count = ip_count + 1 --存在则将单位时间内的访问次数加1
if ip_count >= ip_max_count then --如果超过单位时间限制的访问次数,则添加限制访问标识,限制时间为ip_block_time
res, err = conn:set(BUSINESS.."-"..ngx.var.remote_addr, 1)
res, err = conn:expire(BUSINESS.."-"..ngx.var.remote_addr, ip_block_time)
else
res, err = conn:set(BUSINESS.."-"..ngx.var.remote_addr,ip_count)
res, err = conn:expire(BUSINESS.."-"..ngx.var.remote_addr, ip_time_out)
end
end
-- 结束标记
::FLAG::
local ok, err = conn:close()
(2)在需要做访问限制的location里加两段代码即可,这里用刚才的/lua做演示
[root@test1 lua]# vim /usr/local/openresty/nginx/conf/nginx.conf
主要是添加如下配置。
access_by_lua_file /usr/local/openresty/nginx/conf/lua/access.lua;
其中,set $business “lua”
是为了把IP放进Redis的时候标明是哪个location的,可以不加这个配置。
随后,重新reload配置。
[root@test1 lua]# nginx -s reload #修改完后重启nginx
(3)打开浏览器访问192.168.1.222/lua 并一直按F5刷新。
随后,连接Redis,查看IP的访问计数。
[root@test1 ~]# redis-cli -h 192.168.1.222 -p 6379
发现redis已经在统计访问lua这个网页ip的访问次数了
这个key的过期时间是30秒,如果30秒没有重复访问20次这个key就会消失,所以说正常用户一般不会触发这个封禁的脚本。
当30秒内访问超过了20次,发现触发脚本了,变成了403
再次查看Redis的key,发现多了一个lua-block-192.168.1.158,过期时间是300秒,就是说在300秒内这个ip无法继续访问192.168.1.222/lua这个页面了。
过五分钟后再去访问这个页面,又可以访问了。
这个脚本的目的很简单:一个IP如果在30秒内其访问次数达到20次则表明该IP访问频率太快了,因此将该IP封禁5分钟。同时由于计数的KEY在Redis中的超时时间设置成了30秒,所以如果两次访问间隔时间大于30秒将会重新开始计数。
大家也可以将这个脚本优化成,第一次封禁5分钟,第二次封禁半小时,第三次封禁半天,第四次封禁三天,第五次永久封禁等等。
好了,今天就到这儿吧,我是冰河,我们下期见~~
来源:juejin.cn/post/7399109720457543721
既然有了Kubernetes,为什么还需要 Istio?
如果您听说过Service Mesh并尝试过Istio,您可能会有以下问题:
- 为什么 Istio 运行在 Kubernetes 上?
- Kubernetes 和服务网格在云原生应用架构中分别扮演什么角色?
- Istio 在哪些方面对Kubernetes进行了扩展?它解决了什么问题?
- Kubernetes、Envoy 和 Istio 之间是什么关系?
本文将带您了解 Kubernetes 和 Istio 的内部工作原理。另外,我还会介绍 Kubernetes 中的负载均衡方法,并解释为什么有了 Kubernetes 还需要 Istio。
Kubernetes 本质上是通过声明性配置进行应用程序生命周期管理,而服务网格本质上是提供应用程序间流量、安全管理和可观察性。如果你已经使用 Kubernetes 搭建了一个稳定的应用平台,那么如何为服务之间的调用设置负载均衡和流量控制呢?这就是服务网格发挥作用的地方。
Envoy 引入了 xDS 协议
,该协议受到各种开源软件的支持,例如Istio、MOSN等。Envoy 将 xDS 贡献给服务网格或云原生基础设施。Envoy 本质上是一个现代版本的代理,可以通过 API 进行配置,并基于它衍生出许多不同的使用场景——例如 API 网关、服务网格中的 sidecar 代理和边缘代理。
本文包含以下内容:
- kube-proxy 作用的描述。
- Kubernetes 对于微服务管理的局限性。
- 介绍 Istio 服务网格的功能。
- Kubernetes、Envoy 和 Istio 服务网格中一些概念的比较。
Kubernetes 与服务网格
下图展示了 Kubernetes 和 Service Mesh(每个 pod 一个 sidecar 模型)中的服务访问关系。
流量转发
Kubernetes 集群中的每个节点都会部署一个 kube-proxy 组件,该组件与 Kubernetes API Server 通信,获取集群中服务的信息,然后设置 iptables 规则,将服务请求直接发送到对应的 Endpoint(属于该集群的 pod)。同一组服务)。
服务发现
Istio 可以跟随 Kubernetes 中的服务注册,还可以通过控制平面中的平台适配器与其他服务发现系统对接;然后使用数据平面的透明代理生成数据平面配置(使用 CRD,存储在 etcd 中)。数据平面的透明代理作为sidecar容器部署在每个应用服务的pod中,所有这些代理都需要请求控制平面同步代理配置。代理是“透明的”,因为应用程序容器完全不知道代理的存在。该进程中的 kube-proxy 组件也需要拦截流量,只不过 kube-proxy 拦截进出 Kubernetes 节点的流量,而 sidecar 代理拦截进出 pod 的流量。
服务网格的缺点
由于 Kubernetes 每个节点上运行有很多 pod,将原有的 kube-proxy 路由转发功能放在每个 pod 中会增加响应延迟(由于 sidecar 拦截流量时的跳数更多)并消耗更多资源。为了以细粒度的方式管理流量,将添加一系列新的抽象。这会进一步增加用户的学习成本,但随着技术的普及这种情况会慢慢得到缓解。
服务网格的优点
kube-proxy 设置是全局的,无法对每个服务进行精细控制,而服务网格通过 sidecar 代理将流量控制从 Kubernetes 的服务层中取出,从而实现更大的弹性。
Kube-Proxy 的缺点
首先,如果转发的 Pod 无法正常服务,它不会自动尝试另一个 Pod。每个 pod 都有健康检查机制,当 pod 出现健康问题时,kubelet 会重启 pod,kube-proxy 会删除相应的转发规则。此外,nodePort 类型的服务无法添加 TLS 或更复杂的消息路由机制。
Kube-proxy 实现了 Kubernetes 服务的多个 pod 实例之间的流量负载均衡,但是如何对这些服务之间的流量进行细粒度控制——例如将流量按百分比划分到不同的应用程序版本(这些版本都是同一个应用程序的一部分)服务但在不同的部署上),或者进行灰度发布和蓝绿发布?
Kubernetes 社区提供了一种使用 Deployment 进行灰度发布方法,这本质上是一种通过修改 pod 标签将不同 pod 分配给部署服务的方法
。
Kubernetes Ingress 与 Istio 网关
如上所述,kube-proxy 只能在 Kubernetes 集群内路由流量。Kubernetes 集群的 Pod 位于 CNI 创建的网络中。入口(在 Kubernetes 中创建的资源对象)是为了集群外部的通信而创建的。它由位于 Kubernetes 边缘节点上的入口控制器驱动,负责管理南北流量。Ingress 必须对接各种 Ingress Controller,例如nginx ingress 控制器。Ingress仅适用于HTTP流量,使用简单。它只能通过匹配有限数量的字段(例如服务、端口、HTTP 路径等)来路由流量。这使得无法路由 MySQL、Redis 和各种 RPC 等 TCP 流量。这就是为什么你会看到人们在入口资源注释中编写 nginx 配置语言。直接路由南北流量的唯一方法是使用服务的 LoadBalancer 或 NodePort,前者需要云供应商支持,后者需要额外的端口管理。
Istio Gateway 的功能与 Kubernetes Ingress 类似,负责进出集群的南北向流量
。Istio Gateway 描述了一种负载均衡器,用于承载进出网格边缘的连接。该规范描述了一组开放端口以及这些端口使用的协议、用于负载均衡的 SNI 配置等。 Gateway 是一个 CRD 扩展,它也重用了 sidecar 代理的功能;详细配置请参见Istio 网站
。
Envoy
Envoy 是 Istio 中默认的 sidecar 代理。Istio 基于 Enovy 的 xDS 协议扩展了其控制平面。在谈论 Envoy 的 xDS 协议之前,我们需要先熟悉一下 Envoy 的基本术语。以下是 Envoy 中的基本术语及其数据结构列表;请参阅Envoy 文档
了解更多详细信息。
基本术语
以下是您应该了解的 Enovy 基本术语。
- Downstream:下游主机连接 Envoy,发送请求,接收响应;
即发送请求的主机
。 - Upstream:上游主机接收来自 Envoy 的连接和请求并返回响应;
即接收请求的主机
。 - Listener:Listener 是一个命名的网络地址(例如端口、UNIX 域套接字等);下游客户端可以连接到这些侦听器。Envoy 向下游主机公开一个或多个侦听器以进行连接。
- Cluster:集群是 Envoy 连接的一组逻辑上相同的上游主机。Envoy 通过服务发现来发现集群的成员。或者,可以通过主动健康检查来确定集群成员的健康状态。Envoy 通过负载均衡策略决定集群中的哪个成员来路由请求。
Envoy 中可以设置多个监听器,每个监听器可以设置一个过滤器链(过滤器链表),并且过滤器是可扩展的,以便我们可以更轻松地操纵流量的行为——例如设置加密、私有 RPC 等。
xDS 协议由 Envoy 提出,是 Istio 中默认的 sidecar 代理,但只要实现了 xDS 协议,理论上就可以在 Istio 中用作 sidecar 代理——比如蚂蚁集团开源的MOSN。
Istio 是一个功能非常丰富的服务网格,包括以下功能。
- 流量管理:这是Istio最基本的功能。
- 策略控制:启用访问控制系统、遥测捕获、配额管理、计费等。
- 可观察性:在 sidecar 代理中实现。
- 安全身份验证:Citadel 组件执行密钥和证书管理。
Istio 中的流量管理
Istio 中定义了以下 CRD 来帮助用户进行流量管理。
- 网关:网关描述了运行在网络边缘的负载均衡器,用于接收传入或传出的 HTTP/TCP 连接。
- VirtualService:VirtualService 实际上将 Kubernetes 服务连接到 Istio 网关。它还可以执行其他操作,例如定义一组在寻址主机时应用的流量路由规则。
- DestinationRule:DestinationRule 定义的策略决定流量经过路由后的访问策略。简而言之,它定义了流量的路由方式。其中,这些策略可以定义为负载均衡配置、连接池大小和外部检测(用于识别并驱逐负载均衡池中不健康的主机)配置。
- EnvoyFilter:EnvoyFilter 对象描述代理服务的过滤器,可以自定义 Istio Pilot 生成的代理配置。这种配置一般初级用户很少使用。
- ServiceEntry:默认情况下,Istio 服务网格中的服务无法发现网格之外的服务。ServiceEntry 允许将其他条目添加到 Istio 内的服务注册表中,从而允许网格中自动发现的服务访问并路由到这些手动添加的服务。
Kubernetes、xDS、Istio
回顾了 Kubernetes 的 kube-proxy 组件、xDS 和 Istio 中流量管理的抽象之后,现在让我们仅在流量管理方面对这三个组件/协议进行比较(请注意,这三个组件并不完全相同)。
要点
- Kubernetes 的本质是应用程序生命周期管理,特别是部署和管理(伸缩、自动恢复、发布)。
- Kubernetes 为微服务提供了可扩展且高弹性的部署和管理平台。
- 服务网格基于透明代理,通过 sidecar 代理拦截服务之间的流量,然后通过控制平面配置管理它们的行为。
- 服务网格将流量管理与 Kubernetes 解耦,无需 kube-proxy 组件来支持服务网格内的流量;通过提供更接近微服务应用程序层的抽象来管理服务间流量、安全性和可观察性。
- xDS 是服务网格配置的协议标准之一。
- 服务网格是 Kubernetes 中服务的更高级别抽象。
概括
如果说 Kubernetes 管理的对象是 Pod,那么 Service Mesh 管理的对象就是服务
,所以只要用 Kubernetes 来管理微服务,然后应用 Service Mesh 就可以了。如果您甚至不想管理服务,那么可以使用像Knative
这样的无服务器平台z。
来源:juejin.cn/post/7310878133720301604
2024我给公司亏钱了,数据一致性问题真的马虎不得
最近五阳遇到了线上资损问题,我开始重视分布式事务的数据一致性问题,拿我擅长的场景分析下。
举个🌰例子
在付费会员场景,用户购买会员后享受会员权益。在会员售后场景,用户提交售后,系统需要冻结权益并且原路赔付退款。
系统如何保证冻结权益和订单退款的数据一致性呢?当无法保证数据一致时,会导致什么问题呢?
标题 | 业务结果 |
---|---|
订单退款,但未冻结权益 | 平台资金损失 |
订单未退款,但权益冻结 | 用户资金损失 |
订单退款,权益冻结 | 正常 |
通过这个例子可以看到电商场景中,数据不一致可能会导致资金损失。这是电商场景对数据一致性要求高的原因,很多资损(资金损失)问题都是源于数据不一致。
如何理解数据一致性,一致性体现在哪里?
狭义上的数据一致是指:数据完全相同,在数据库主从延迟场景,主从数据一致是指:主数据副本和从数据副本,数据完全相同,客户端查询主库和查询从库得到的结果是相同的,也就是一致的。
除数据多副本场景使用数据一致性的概念之外,扩展后其他场景也使用这个概念。例如分布式事务中,多个事务参与者各自维护一种数据,当多种数据均处于合法状态且符合业务逻辑的情况下,那就可以说整体处于数据一致了。(并不像副本场景要求数据完全相同)
例如会员订单有支付状态和退款状态,会员优惠券有未使用状态和冻结状态。 在一次分布式事务执行前后,订单和优惠券的状态是一致的,即会员订单退款、会员券冻结;会员订单未退款,会员券状态为可使用;
此外还有异构数据一致性,超时一致。异构数据一致性是指同一种数据被异构到多种存储中间件。例如本地缓存、Redis缓存和数据库,即三级缓存的数据一致性。还有搜索场景,需要保证数据库数据和 ElasticSearch数据一致性,这也是分布式事务问题。
一致性和原子性的区别
原子性 指的是事务是一个不可分割的最小工作单元,事务中的操作要么全部成功,要么全部失败。
一致性 指的是事务执行前后,所有数据均处于一致性状态,一致性需要原子性的支持。如果没有实现原子性,一致性也无法实现。一致性在原子性的基础上,还要求实现数据的正确性。例如在同一个事务中实现多商品库存扣减,多个SQL除了保证同时成功同时失败外,还需要保证操作的正确性。如果所有SQL都返回成功了,但是数据是错误的,这无法接受。这就是一致性的要求。
由此可见,数据一致性本身就要求了数据是正确的。
隔离性是指:其他事务并发访问同一份数据时,多个事务之间应该保持隔离性。隔离性级别:如读未提交、读已提交、可重复读和串行化。
隔离性强调的是多个事务之间互不影响的程度,一致性强调的是一个事务前后,数据均处于一致状态。
什么是强一致性
在分布式事务场景,强一致性是指:任何一个时刻,看到各个事务参与者的数据都是一致的。系统不存在不一致的情况。
值得一提的是,CAP理论指出,数据存在多副本情况下,要保证强一致性(在一个绝对时刻,两份数据是完全一致的)需要牺牲可用性。
也就是说系统发现自身处于不一致状态时,将向用户返回失败状态。直至数据一致后,才能返回最新数据,这将牺牲可用性。
为了保证系统是可用的,可以返回旧的数据,但是无法保证强一致性。
会员售后能保证强一致性吗?
会员售后关键的两个动作:权益冻结和订单退款。 两者能保证强一致性吗?答案是不能。假设权益冻结是一个本地事务性操作,但是订单退款包括订单状态流程、支付系统资金流转等等。
如此复杂的流程难以保证任意一个绝对时刻,用户看到权益冻结后,资金一定到账了;这是售后场景 无法达到强一致性的根本原因。
最终一致性和强一致性
最终一致性不要求系统在任意一个时刻,各参与方数据都是一致的,它要求各参与方数据在一定时间后处于一致状态。
最终一致性没有明确这个时间是多长,所以有人说最终一致性就是没有一致性,谁知道多久一定能保证一致呢。
保证最终一致性的手段有哪些?
TCC
TCC 包含 Try、Confirm 和 Cancel 三个操作。
- 确保每个参与者(服务)都实现了 Try、Confirm 和 Cancel 操作。
- 确保在业务逻辑中,如果 Try 操作成功,后续必须执行 Confirm 操作以完成事务;如果 Try 失败或者 Cancel 被调用,则执行 Cancel 操作撤销之前的操作。
需要说明,如果Confirm执行失败,需要不停不重试Confirm,不得执行Cancel。 按照TCC的语义 Try操作已经锁定了、预占了资源。 Confirm在业务上一定是可以成功的。
TCC的问题在于 分布式事务的任意一个操作都应该提供三个接口,每个参与者都需要提供三个接口,整体交互协议复杂,开发成本高。当发生嵌套的分布式事务时,很难保证所有参与者都实现TCC规范。
为什么TCC 方案包含Try
如果没有Try阶段,只有Confirm和 Cancel阶段,如果Confirm失败了,则调用Cancel回滚。为什么Tcc不这样设计呢?
引入Try阶段,为保证不发生状态回跳的情况。
Try阶段是预占资源阶段,还未实际修改资源。设想资金转账场景, A账户向B账户转账100元。 在Try阶段 A账户记录了预扣100元,B 账户记录了预收100元。 如果A账户不足100元,那么Try阶段失败,调用Cancel回滚,这种情况,在任意时刻,A、B用户视角转账是失败的。
Try阶段成功,则调用Confirm接口,最终A账户扣100元,B收到100元。虽然这无法保证在某个时刻,A、B账户资金绝对一致。但是如果没有Try阶段,那么将发生 状态回跳的情况;
状态回跳:即A账户操作成功了,但是B账户操作失败,A账户资金又被回滚了。那么用户A 看到自己的账户状态就是 钱被扣了,但是过一会钱又回来了。
同理B账户也可能遇到收到钱了,但是过一会钱又没了。在转账场景,这种回跳情况几乎是不能接受的。
引入了Try阶段,就能保证不发生状态回跳的情况。
最大努力通知
最大努力通知是指通知方通过一定的机制最大努力将业务处理结果通知到接收方。一般用于最终一致性时间敏感度低的场景,并且接收方的结果不会影响到发起方的结果。即接收方处理失败时,发送方不会跟随回滚。
在电商场景,很多场景使用最大努力通知型作为数据一致性方案。
会员售后如何保证最终一致性?
回到开头的问题,以下是数据不一致的两种情况。
标题 | 业务结果 |
---|---|
订单退款,但未冻结权益 | 平台资金损失 |
订单未退款,但权益冻结 | 用户资金损失 |
订单退款,权益冻结 | 正常 |
平台资损难以追回,当发生容易复现的平台资损时,会引来更多的用户“薅羊毛”,资损问题将进一步放大,所以平台资损一定要避免。
当发生用户资损时,用户可以通过客服向平台追诉,通过人工兜底的方式,可以保证“最终”数据是一致的。
所以基于这个大的原则,大部分系统都是优先冻结会员权益,待用户权益明确冻结后,才根据实际冻结情况,向用户退款。这最大程度上保证了售后系统的资金安全。
会员售后整体上是最大努力通知一致性方案。当权益冻结后,系统通过可重试的消息触发订单退款,且务必保证退款成功(即便是人工介入情况下)。虽说是最大努力通知型,但不代表一致性弱,事实上支付系统的稳定性要求是最高级别的,订单退款成功的可靠性是能得到保证的。
如果权益冻结是分布式事务,如何保证一致性
一开始我们假设权益冻结是一个本地事务,能保证强一致性,这通常与实际不符。权益包含很多玩法,例如优惠券、优惠资格、会员日等等,权益冻结并非是本地事务,而是分布式事务,如何保证一致性呢?
方案1 TCC方案
假设 权益系统包含三个下游系统,身份系统(记录某个时间段是会员)、优惠券系统、优惠资格系统。 TCC方案将要求三方系统均实现 Try、Confirm、Cancel接口。开发成本和上下游交付协议比较复杂。
方案2 无Try 的 TCC方案
假设无Try阶段,直接Confirm修改资源,修改失败则调用Cancel,那么就会出现状态跳回情况,即优惠券被冻结了,但是后面又解冻了。
这种情况下,系统只需要实现扣减资源和回滚资源 两种接口。 系统设计大大简化
方案3 Prepare + Confirm
Prepare: 检查接口,即检查资源是否可以被修改,但是不会锁定资源。
Confirm: 修改资源接口,实际修改资源状态。
如果Prepare失败,则返回执行失败,由于未预占用资源,所以无需回滚资源。在Prepare成功后,则立即调用Confirm。如果Confirm执行失败,则人工介入。
一定需要回滚吗?
在一致性要求高的场景,需要资源回滚能力,保证系统在一定时间后处于一致状态。如果没有回滚,势必导致在某些异常情况下,系统处于不一致状态,且无法自动恢复。
会员售后场景虽然对资金较为敏感,但不需要资源回滚。理由如下
- 将订单退款置为 权益成功冻结之后,可以保证系统不出现平台资损。即权益未完全冻结,订单是不是退款的。
- 用户资损情况可以通过人工客服兜底解决。
在上述方案3中,先通过Prepare阶段验证了参与方都是可以冻结的,在实际Confirm阶段这个状态很难发生改变。所以大概率Confirm不会失败的。
只有极低的概率发生Confirm失败的情况,即用户在权益冻结的一瞬间,使用优惠券,这将导致资源状态发生改变。
解决此类问题,可以在权益冻结后,评估冻结结果,根据实际的冻结结果,决定如何赔付用户,赔付用户多少钱。所以用户并发用券,也不会影响资金安全。
人工兜底与数据一致性
程序员应该在业务收益、开发成本、数据不一致风险等多个角度评估系统设计的合理性。
会员售后场景,看似数据一致性要求高,仿佛数据不一致,就会产生严重的资损问题。但实际分析后,系统并非需要严格的一致性。
越是复杂的系统设计,系统稳定性越差。越是简洁的系统设计,系统稳定性越高。当选择了复杂的系统设计提高数据一致性,必然需要付出更高的开发成本和维护成本。往往适得其反。
当遇到数据一致性挑战时,不妨跳出技术视角,尝试站在产品视角,思考能否适当调整一下产品逻辑,容忍系统在极端情况下,有短暂时间数据不一致。人工兜底处理极端情况。
大多数情况下,产品经理会同意。
来源:juejin.cn/post/7397013935105769523
Docker容器日志过大?有没有比较简单的方式解决?
Docker容器日志过大?有没有比较简单的方式解决?
1. 问题描述
当我们尝试查看特定 Docker 容器的日志时,通常会使用 docker logs <容器名称>
命令。然而,有时候会发现控制台持续输出日志信息,持续时间可能相当长,直到最终打印完成。这种现象往往源自对 Docker 容器日志长时间未进行处理,导致日志积累过多,占用了系统磁盘空间。因此,为了释放磁盘空间并优化系统性能,我们可以采取一些简单而有效的方法来处理这些庞大的日志文件。
2. docker日志处理机制
需要处理问题,那我们肯定要先了解docker的日志处理机制,了解了基本的机制,能够帮助我们更好的理解问题并解决问题。
2.1 日志查看
docker logs <容器名称>
可以查看docker容器的输出日志,但是这里的日志主要包含标准输出和标准错误输出,一些容器可能会把日志输出到某个日志文件中,比如tomcat,这样使用docker logs <容器名称>
命令是无法查看的。
注意docker logs
命令查看的是容器的全部日志,当日志量很大时会对容器的运行造成影响,可以通过docker logs --tail N container name
查看最新N行的数据,N是一个整数。
2.2 处理机制
当我们启动一个docker容器时,实际上时作为docker daemon的一个子进程运行的,docker daemon可以拿到容器里进程的标准输出与标准错误输出,并通过docker的log driver模块来处理,大致图示如下:
上面图中所列举的就是所支持的Log Driver:
- none:容器没有日志,
docker logs
不输出任何内容 - local:日志以自定义格式存储
- json-file:日志以json格式存储,默认的Log Driver
- syslog:将日志写入syslog。syslog守护程序必须在主机上运行
- journald:将日志写入journald。journald守护程序必须在主机上运行
- gelf:将日志写入Graylog Extended Log Format端点,如Graylog或Logstash
- fluentd:将日志写入fluentd。fluentd守护程序必须在主机上运行
- awslogs:将日志写入Amazon CloudWatch Logs
- splunk:通过HTTP Event Collector将日志写入splunk
- etwlogs:将日志作为ETW(Event Tracing for Windows)事件写入。只在Windows平台可用
- gcplogs:将日志写入Google Cloud Platform Logging
- logentries:将日志写入Rapid7 Logentries
可以使用命令docker info | grep "Logging Driver"
2.3 默认的json-file
json-file Log Driver是Docker默认启用的Driver,将容器的STDOUT/STDERR输出以json的格式写到宿主机的磁盘,日志文件路径为 /var/lib/docker/containers/{container_id}/{container_id}-json.log
格式是这样的:
json-file将每一行日志封装到一个json字符串中。
json-file支持如下配置:
- max-size:单个日志文件的最大大小,单位可以为k、m、g,默认是-1,表示日志文件可以无限大。
- max-file:最多可以存多少个日志文件,默认数量是1,当默认数量大于1时,每个日志文件达到最大存储大小,且数量达到设置数量,产生新日志时会删除掉最旧的一个日志文件。
- labels:指定日志所使用到的标签,使用逗号分割。比如traceId,message两个标签。
- env:指定与日志相关的环境变量,使用逗号分割
- env-rejex:一个正则表达式来匹配与日志相关的环境变量
- compress:是否压缩日志文件
3. 如何解决?
3.1 查看日志大小
我们可以通过如下脚本获取当前所有容器的日志大小,这里时使用docker默认的json-file的形式:
#!/bin/sh
echo "======== docker containers logs file size ========"
logs=$(find /var/lib/docker/containers/ -name *-json.log)
for log in $logs
do
ls -lh $log
done
执行脚本:
json-file的命令开头的一小串字符时容器的id。
例如我有一个docker容器id是2de6f164ee11,我们可以适当修改shell脚本,查看某一个容器的日志大小。
logs=$(find /var/lib/docker/containers/ -name *-json.log | grep "2de6f164ee11")
3.2 删除日志
如果docker容器正在运行,使用rm -rf的方式删除日志后,磁盘空间并没有释放。原因是在Linux或者Unix系统中,通过rm -rf或者文件管理器删除文件,将会从文件系统的目录结构上解除链接(unlink)。如果文件是被打开的(有一个进程正在使用),那么进程将仍然可以读取该文件,磁盘空间也一直被占用。
正确的方式是直接使用命令改写日志文件。
cat /dev/null > *-json.log
cat
: 是一个命令,用于连接文件并打印它们的内容到标准输出(通常是终端)。/dev/null
: 是一个特殊的设备文件,向它写入的内容会被丢弃,读取它将会立即返回结束符。>
: 是重定向操作符,将命令的输出重定向到文件。*-json.log
: 是通配符,用于匹配当前目录中所有以-json.log
结尾的文件。
可以使用如下脚本,直接处理所有的日志文件:
#!/bin/sh
echo "======== start clean docker containers logs ========"
logs=$(find /var/lib/docker/containers/ -name *-json.log)
for log in $logs
do
echo "clean logs : $log"
cat /dev/null > $log
done
echo "======== end clean docker containers logs ========"
注意,虽然使用这种方式可以删除日志,释放磁盘,但是过一段时间后,日志又会涨回来,所以要从根本上解决问题,只需要添加两个参数。没错!就是上面所讲到的max-size和max-file。
3.3 治本操作
在运行docker容器时,添加上max-size和max-file可以解决日志一直增长的问题。
docker run -it --log-opt max-size=10m --log-opt max-file=3 alpine ash
这段启动命令表示总共有三个日志文件,每个文件的最大大小时10m,这样就能将该容器的日志大小控制在最大30m。
4. 总结
在运行容器时,我们就应该优先考虑如何处理日志的问题,后面不必为容器运行后所产生的巨大日志而手足无措。
当然需要删除无用日志可以通过3.1,3.2的操作完成,建议在运行容器的时候加上max-size和max-file参数或者至少加上max-size参数。
来源:juejin.cn/post/7343178660069179432
好烦啊,1个SQL干崩核心系统长达12小时!
前言
1个SQL干崩核心系统长达12小时!分享一下这次的故障排查过程
1.故障现象
大周末的接到项目组的电话,反馈应用从凌晨4点开始持续卡顿,起初并未关注,到下午2点左右,核心系统是彻底干绷了,远程接入后发现,数据库后台有大量的异常等待事件
enq:TX -index contention
cursor: pin S wait on X
direct path read
通过监控发现服务器IO和CPU使用率已经高达90%
整个数据库算是夯住了!
根据经验判断应该是性能的问题
2.排查过程
2.1 AWR分析
对于这种性能的问题,首先采集到AWR报告并结合ASH报告分析一下
Direct path read事件尽然排到了第一位!占DB time高达63%,这个等待事件是让一些不常使用的大表数据(冷数据),在全表扫描时,每次都从磁盘读到用户的私有内存(PGA),而不要去挤占有限的、宝贵的、频繁使用的数据(热数据)所在的共享内存(SGA-buffer cache)。
2.2 定位异常SQL
对该TOP SQL分析发现,sql执行频繁,怀疑是执行计划发生变化,如果不把导致问题的根本原因找到,那么很有可能下次还会再发生!
2.3 分析执行计划
通过定位SQL Id,我们去看内存中的执行计划,明显看到了执行计划发生了变化,全表扫占用大量的IO,这里查看执行计划的方法很多。
--该方法是从共享池得到
如果SQL已被age out出share pool,则查找不到
select * from table
(dbms_xplan.display_cursor('&sql_id',null,'typical'));
--该方法是通过awr中得到
select * from table(dbms_xplan.display_awr('&sql_id'));
此时再追踪历史的执行计划发现,从凌晨故障发生开始,执行计划就发生了变化,SQL执行耗费到CPU的平均时间高达上百秒,历史执行计划再次验证了我的判断!
2.4 故障定位
跟业务确认得知,在凌晨业务人员发现,存储空间不够,删除了分区的来释放空间,此处相当于对表结构做了修改,执行计划发生了变化,再加上故障SQL的对应分区,统计信息一直未收集导致这次执行计划发生改变!
3.处理过程
1.定位到SQL的内存地址,从内存中刷出执行计划
select address,hash_value,
executions,parse_calls
from v$sqlarea where
sql_id='4ca86dg34xg62';
--刷出内存
exec sys.dbms_shared_pool.purge('C000000A4C502F40,4103674309','C');
2.收集分区统计信息
BEGIN
-- 为整个表加上统计信息(包括所有分区)
DBMS_STATS.GATHER_TABLE_STATS(
ownname => 'YOUR_SCHEMA', -- 替换为你的模式名
tabname => 'YOUR_PARTITIONED_TABLE', -- 替换为你的分区表名
cascade => TRUE, -- 收集所有分区的统计信息
estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE, -- 自动估算采样百分比
method_opt => 'FOR ALL COLUMNS SIZE AUTO', -- 为所有列自动决定采样大小
degree => DBMS_STATS.DEFAULT_DEGREE -- 使用默认并行度
);
END;
/
此时我们再次查看执行计划,正确了!
4.技能拓扑
分区索引的失效,会引起执行计划的改变
1.TRUNCATE、DROP 操作可以导致该分区表的全局索引失效,
而分区索引依然有效,如果操作的分区没有数据,
那么不会影响索引的状态。
需要注意的是,
对分区表的 ADD 操作对分区索引和全局索引没有影响。
2.如果执行 SPLIT 的目标分区含有数据,
那么在执行 SPLIT 操作后,全局索引和分区索引都会
被被置为 UNUSABLE。
如果执行 SPLIT 的目标分区没有数据,
那么不会影响索引的状态。
3.对分区表执行 MOVE 操作后,
全局索引和分区索引都会被置于无效状态。
4.对于分区表而言,除了 ADD 操作之外,
TRUNCATE、DROP、EXCHANGE 和 SPLIT
操作均会导致全局索引失效,
但是可以加上 UPDATE GLOBAL INDEXES 子句让全局索引不失效。
在 12C 之前的版本,对分区表进行删除分区或者 TRUNCATE 分区,合并或者分裂分区MOVE 分区等 DDL 操作时,分区表上的全局索引会失效,通常要加上 UPDATE GLOBAIINDEXES 或者 ONLINE 关键字,可是加上这些关键字之后,本来很快的 DDL 操作可能就要花费很长的时间,而且还要面临锁的问题。“
Oracle 12C推出了分区表全局索引异步维护特性这个特性有效的解决了这个问题,在对分区表进行上述 DDL 操作时,既能快速完成操作,也能保证全局索引有效,然后通过调度JOB 在固定的时候对全局索引进行维护。“
5.总结
警惕Oracle数据库性能“隐形杀手”——Direct Path Read, 如果不把导致问题的根本原因找到,那么很有可能下次还会再发生!
来源:juejin.cn/post/7387610960159473676
一个高并发项目到落地的心酸路
前言
最近闲来没事,一直在掘金上摸鱼,看了不少高并发相关的文章,突然有感而发,想到了几年前做的一个项目,也多少和高并发有点关系。
这里我一边回忆落地细节一边和大家分享下,兴许能给大家带来点灵感。
正文
需求及背景
先来介绍下需求,首先项目是一个志愿填报系统。
核心功能是两块,一是给考试填报志愿,二是给老师维护考生数据。
本来这个项目不是我们负责,奈何去年公司负责这个项目的组遭到了甲方严重的投诉,说很多考生用起来卡顿,甚至把没填上志愿的责任归到系统上。
甲方明确要求,如果这年再出现这种情况,公司在该省的所有项目将面临被替换的风险。
讨论来讨论去,最后公司将任务落到我们头上时,已经是几个月后的事了,到临危受命阶段,剩下不到半年时间。
虽然直属领导让我们不要有心理负担,做好了表扬,做不好锅也不是我们的,但明显感觉到得到他的压力,毕竟一个不小心就能上新闻。
分析
既然开始做了,再说那些有的没的就没用了,直接开始分析需求。
首先,业务逻辑并不算复杂,难点是在并发和数据准确性上。与客户沟通后,大致了解了并发要求后,于是梳理了下。
- 考生端登录接口、考生志愿信息查询接口需要4W QPS
- 考生保存志愿接口,需要2W TPS
- 报考信息查询4W QPS
- 老师端需要4k QPS
- 导入等接口没限制,可以异步处理,只要保证将全部信息更新一遍在20分钟以内即可,同时故障恢复的时间必须在20分钟以内(硬性要求)
- 考生端数据要求绝对精准,不能出现遗漏、错误等和考生操作不一致的数据
- 数据脱敏,防伪
- 资源是有限的,提供几台物理机
大的要求就这么多,主要是在有限资源下需要达到如此高的并发确实需要思考思考,一般的crud根本达不到要求。
方案研讨
接下来我会从当时我们切入问题的点开始,从前期设计到项目落地整个过程的问题及思考,一步步去展示这个项目如何实现的
首先,我们没有去设计表,没有去设计接口,而是先去测试。测试什么?测试我们需要用到或可能用到的中间件是否满足需求
MySQL
首先是MySQL,单节点MySQL测试它的读和取性能,新建一张user表。
向里面并发插入数据和查询数据,得到的TPS大概在5k,QPS大概在1.2W。
查询的时候是带id查询,索引列的查询不及id查询,差距大概在1k。
insert和update存在细微并发差距,但基本可以忽略,影响更新性能目前最大的问题是索引。
如果表中带索引,将降低1k-1.5k的TPS。
目前结论是,mysql不能达到要求,能不能考虑其他架构,比如mysql主从复制,写和读分开。
测试后,还是放弃,主从复制结构会影响更新,大概下降几百,而且单写的TPS也不能达到要求。
至此结论是,mysql直接上的方案肯定是不可行的
Redis
既然MySQL直接查询和写入不满足要求,自然而然想到加入redis缓存。于是开始测试缓存,也从单节点redis开始测试。
get指令QPS达到了惊人的10w,set指令TPS也有8W,意料之中也惊喜了下,仿佛看到了曙光。
但是,redis容易丢失数据,需要考虑高可用方案
实现方案
既然redis满足要求,那么数据全从redis取,持久化仍然交给mysql,写库的时候先发消息,再异步写入数据库。
最后大体就是redis + rocketMQ + mysql的方案。看上去似乎挺简单,当时我们也这样以为 ,但是实际情况却是,我们过于天真了。
这里主要以最重要也是要求最高的保存志愿信息接口开始攻略
故障恢复
第一个想到的是,这些个节点挂了怎么办?
mysql挂了比较简单,他自己的机制就决定了他即使挂掉,重启后仍能恢复数据,这个可以不考虑。
rocketMQ一般情况下挂掉了可能会丢失数据,经过测试发现,在高并发下,确实存在丢消息的现象。
原因是它为了更加高效,默认采用的是异步落盘的模式,这里为了保证消息的绝对不丢失,修改成同步落盘模式。
然后是最关键的redis,不管哪种模式,redis在高并发下挂掉,都会存在丢失数据的风险。
数据丢失对于这个项目格外致命,优先级甚至高于并发的要求。
于是,问题难点来到了如何保证redis数据正确,讨论过后,决定开启redis事务。
保存接口的流程就变成了以下步骤:
1.redis 开启事务,更新redis数据
2.rocketMQ同步落盘
3.redis 提交事务
4.mysql异步入库
我们来看下这个接口可能存在的问题。
第一步,如果redis开始事务或更新redis数据失败,页面报错,对于数据正确性没有影响
第二步,如果rocketMQ落盘报错,那么就会有两种情况。
情况一,落盘失败,消息发送失败,好像没什么影响,直接报错就可。
情况二,如果发送消息成功,但提示发送失败(无论什么原因),这时候将导致mysql和redis数据的最终不一致。
如何处理?怎么知道是redis的有问题还是mysql的有问题?出现这种情况时,如果考生不继续操作,那么这条错误的数据必定无法被更新正确。
考虑到这个问题,我们决定引入一个时间戳字段,同时启动一个定时任务,比较mysql和redis不一致的情况,并自主修复数据。
首先,redis中记录时间戳,同时在消息中也带上这个时间戳并在入库时记录到表中。
然后,定时任务30分钟执行一次,比较redis中的时间戳是否小于mysql,如果小于,便更新redis中数据。如果大于,则不做处理。
同时,这里再做一层优化,凌晨的时候执行一个定时任务,比较redis中时间戳大于mysql中的时间戳,连续两天这条数据都存在且没有更新操作,将提示给我们手动运维。
然后是第三步,消息提交成功但是redis事务提交失败,和第二步处理结果一致,将被第二个定时任务处理。
这样看下来,即使redis崩掉,也不会丢失数据。
第一轮压测
接口实现后,当时怀着期待,信息满满的去做了压测,结果也是当头棒喝。
首先,数据准确性确实没有问题,不管突然kill掉哪个环节,都能保证数据最终一致性。
但是,TPS却只有4k不到的样子,难道是节点少了?
于是多加了几个节点,但是仍然没有什么起色。问题还是想简单了。
重新分析
经过这次压测,之后一个关键的问题被提了出来,影响接口TPS的到底是什么???
一番讨论过后,第一个结论是:一个接口的响应时间,取决于它最慢的响应时间累加,我们需要知道,这个接口到底慢在哪一步或哪几步?
于是用arthas看了看到底慢在哪里?
结果却是,最慢的竟然是redis修改数据这一步!这和测试的时候完全不一样。于是针对这一步,我们又继续深入探讨。
结论是:
redis本身是一个很优秀的中间件,并发也确实可以,选型时的测试没有问题。
问题出在IO上,我们是将考生的信息用json字符串存储到redis中的(为什么不保存成其他数据结构,因为我们提前测试过几种可用的数据结构,发现redis保存json字符串这种性能是最高的),
而考生数据虽然单条大小不算大,但是在高并发下的上行带宽却是被打满的。
于是针对这种情况,我们在保存到redis前,用gzip压缩字符串后保存到redis中。
为什么使用gzip压缩方式,因为我们的志愿信息是一个数组,很多重复的数据其实都是字段名称,gzip和其他几个压缩算法比较后,综合考虑到压缩率和性能,在当时选择了这种压缩算法。
针对超过限制的字符串,我们同时会将其拆封成多个(实际没有超过三个的)key存储。
继续压测
又一轮压测下来,效果很不错,TPS从4k来到了8k。不错不错,但是远远不够啊,目标2W,还没到它的一半。
节点不够?加了几个节点,有效果,但不多,最终过不了1W。
继续深入分析,它慢在哪?最后发现卡在了rocketMQ同步落盘上。
同步落盘效率太低?于是压测一波发现,确实如此。
因为同步落盘无论怎么走,都会卡在rocketMQ写磁盘的地方,而且因为前面已经对字符串压缩,也没有带宽问题。
问题到这突然停滞,不知道怎么处理rocketMQ这个点。
同时,另一个同事在测试查询接口时也带来了噩耗,查询接口在1W2左右的地方就上不去了,原因还是卡在带宽上,即使压缩了字符串,带宽仍被打满。
怎么办?考虑许久,最后决定采用较常规的处理方式,那就是数据分区,既然单个rocketMQ服务性能不达标,那么就水平扩展,多增加几个rocketMQ。
不同考生访问的MQ不一样,同时redis也可以数据分区,幸运的是正好redis有哈希槽的架构支持这种方式。
而剩下的问题就是如何解决考生分区的方式,开始考虑的是根据id进行求余的分区,但后来发现这种分区方式数据分布及其不均匀。
后来稍作改变,根据正件号后几位取余分区,数据分布才较为均匀。有了大体解决思路,一顿操作后继续开始压测。
一点小意外
压测之后,结果再次不如人意,TPS和QPS双双不增反降,继续通过arthas排查。
最后发现,redis哈希槽访问时会在主节点先计算key的槽位,而后再将请求转到对应的节点上访问,这个计算过程竟然让性能下降了20%-30%。
于是重新修改代码,在java内存中先计算出哈希槽位,再直接访问对应槽位的redis。如此重新压测,QPS达到了惊人的2W,TPS也有1W2左右。
不错不错,但是也只到了2W,在想上去,又有了瓶颈。
不过这次有了不少经验,马上便发现了问题所在,问题来到了nginx,仍然是一样的问题,带宽!
既然知道原因,解决起来也比较方便,我们将唯一有大带宽的物理机上放上两个节点nginx,通过vip代理出去,访问时会根据考生分区信息访问不同的地址。
压测
已经记不清第几轮压测了,不过这次的结果还算满意,主要查询接口QPS已经来到了惊人的4W,甚至个别接口来到6W甚至更高。
胜利已经在眼前,唯一的问题是,TPS上去不了,最高1W4就跑不动了。
什么原因呢?查了每台redis主要性能指标,发现并没有达到redis的性能瓶颈(上行带宽在65%,cpu使用率也只有50%左右)。
MQ呢?MQ也是一样的情况,那出问题的大概率就是java服务了。分析一波后发现,cpu基本跑到了100%,原来每个节点的最大链接数基本占满,但带宽竟然还有剩余。
静下心来继续深入探讨,连接数为什么会满了?原因是当时使用的SpringBoot的内置容器tomcat,无论如何配置,最大连接数最大同时也就支持1k多点。
那么很简单的公式就能出来,如果一次请求的响应时间在100ms,那么1000 * 1000 / 100 = 10000。
也就是说单节点最大支持的并发也就1W,而现在我们保存的接口响应时间却有300ms,那么最大并发也就是3k多,目前4个分区,看来1W4这个TPS也好像找到了出处了。
接下来就是优化接口响应时间的环节,基本是一步一步走,把能优化的都优化了一遍,最后总算把响应时间控制在了100ms以内。
那么照理来说,现在的TPS应该会来到惊人的4W才对。
再再次压测
怀着忐忑又激动的心情,再一次进入压测环节,于是,TPS竟然来到了惊人的2W5。
当时真心激动了一把,但是冷静之后却也奇怪,按正常逻辑,这里的TPS应该能达到3W6才对。
为了找到哪里还有未发现的坑(怕上线后来惊喜),我们又进一步做了分析,最后在日志上找到了些许端倪。
个别请求在链接redis时报了链接超时,存在0.01%的接口响应时间高于平均值。
于是我们将目光投向了redis连接数上,继续一轮监控,最终在业务实现上找到了答案。
一次保存志愿的接口需要执行5次redis操作,分别是获取锁、获取考生信息、获取志愿信息、修改志愿信息、删除锁,同时还有redis的事务。
而与之相比,查询接口只处理了两次操作,所以对于一次保存志愿的操作来看,单节点的redis最多支持6k多的并发。
为了验证这个观点,我们尝试将redis事务和加锁操作去掉,做对照组压测,发现并发确实如预期的一样有所提升(其实还担心一点,就是抢锁超时)。
准备收工
至此,好像项目的高并发需求都已完成,其他的就是完善完善细节即可。
于是又又又一次迎来了压测,这一次不负众望,重要的两个接口均达到了预期。
这之后便开始真正进入业务实现环节,待整个功能完成,在历时一个半月带两周的加班后,终于迎来了提测。
提测后的问题
功能提测后,第一个问题又又又出现在了redis,当高并发下突然kill掉redis其中一个节点。
因为用的是哈希槽的方式,如果挂掉一个节点,在恢复时重新算槽将非常麻烦且效率很低,如果不恢复,那么将严重影响并发。
于是经过讨论之后,决定将redis也进行手动分区,分区逻辑与MQ的一致。
但是如此做,对管理端就带来了一定影响,因为管理端是列表查询,所以管理端获取数据需要从多个节点的redis中同时获取。
于是管理端单独写了一套获取数据分区的调度逻辑。
第二个问题是管理端接口的性能问题,虽然管理端的要求没考生端高,但扛不住他是分页啊,一次查10个,而且还需要拼接各种数据。
不过有了前面的经验,很快就知道问题出在了哪里,关键还是redis的连接数上,为了降低链接数,这里采用了pipeline拼接多个指令。
上线
一切准备就绪后,就准备开始上线。说一下应用布置情况,8+4+1+2个节点的java服务,其中8个节点考生端,4个管理端,1个定时任务,2个消费者服务。
3个ng,4个考生端,1个管理端。
4个RocketMQ。
4个redis。
2个mysql服务,一主一从,一个定时任务服务。
1个ES服务。
最后顺利上线,虽然发生了个别线上问题,但总体有惊无险,
而真是反馈的并发数也远没有到达我们的系统极限,开始准备的水平扩展方案也没有用上,无数次预演过各个节点的宕机和增加分区,一般在10分钟内恢复系统,不过好在没有排上用场。
最后
整个项目做下来感觉越来越偏离面试中的高并发模式,说实在的也是无赖之举,
偏离的主要原因我认为是项目对数据准确性的要求更高,同时需要完成高并发的要求。
但是经过这个项目的洗礼,在其中也收获颇丰,懂得了去监控服务性能指标,然后也加深了中间件和各种技术的理解。
做完之后虽然累,但也很开心,毕竟在有限的资源下去分析性能瓶颈并完成项目要求后,还是挺有成就感的。
再说点题外话,虽然项目成功挽回了公司在该省的形象,也受到了总公司和领导表扬,但最后也就这样了,
实质性的东西一点没有,这也是我离开这家公司的主要原由。不过事后回想,这段经历确实让人难忘,也给我后来的工作带来了很大的帮助。
从以前的crud,变得能去解决接口性能问题。这之前一遇上,可能两眼茫然或是碰运气,现在慢慢的会根据蛛丝马迹去探究优化方案。
不知道我在这个项目的经历是否能引起大家共鸣?希望这篇文章能对你有所帮助。
来源:juejin.cn/post/7346021356679675967
什么是系统的鲁棒性?
嗨,你好啊,我是猿java
现实中,系统面临的异常情况和不确定性因素是不可避免的。例如,网络系统可能会遭受网络攻击、服务器宕机等问题;金融系统可能会受到市场波动、黑天鹅事件等因素的影响;自动驾驶系统可能会遇到天气恶劣、道路状况复杂等情况。
在这些情况下,系统的鲁棒性就显得尤为重要,它能够确保系统能够正确地处理各种异常情况,保持正常运行。因此,这篇文章我们将分析什么是系统的鲁棒性?如何保证系统的鲁棒性?
什么是系统的鲁棒性?
鲁棒性,英文为 Robustness
,它是一个多学科的概念,涉及控制理论、计算机科学、工程学等领域。
在计算机领域,系统的鲁棒性是指系统在面对各种异常情况和不确定性因素时,仍能保持稳定运行和正常功能的能力。
鲁棒性是系统稳定性和可靠性的重要指标,一个具有良好鲁棒性的系统能够在遇到各种异常情况时做出正确的响应,不会因为某些异常情况而导致系统崩溃或失效。
鲁棒性要求系统在在遇到各种异常情况都能正常工作,各种异常很难具像化,这看起来是一种比较理想的情况,那么系统的鲁棒性该如何评估呢?
系统鲁棒性的评估
系统的鲁棒性可以从多个方面来考虑和评估,这里主要从三个方面进行评估:
首先,系统的设计和实现应该考虑到各种可能的异常情况,并采取相应的措施来应对
例如,在网络系统中,可以采用防火墙、入侵检测系统等技术来保护系统免受网络攻击;在金融系统中,可以采用风险管理技术来降低市场波动对系统的影响;在自动驾驶系统中,可以采用传感器融合、路径规划等技术来应对复杂的道路状况。
其次,系统在面临异常情况时应该具有自我修复和自我调整的能力
例如,当网络系统遭受攻击时,系统应该能够及时发现并隔离攻击源,同时自动恢复受影响的服务;当金融系统受到市场波动影响时,系统应该能够自动调整投资组合,降低风险;当自动驾驶系统面临复杂道路状况时,系统应该能够根据实时的道路情况调整行驶策略。
此外,系统的鲁棒性还包括对数据异常和不确定性的处理能力
在现实生活中,数据往往会存在各种异常情况,例如数据缺失、噪声数据等。系统应该能够对这些异常数据进行有效处理,保证系统的正常运行。同时,系统也应该能够对数据的不确定性进行有效处理,例如通过概率模型、蒙特卡洛方法等技术来处理数据不确定性,提高系统的鲁棒性。
鲁棒性的架构策略
对于系统的鲁棒性,有没有一些可以落地的策略?
如下图,展示了一些鲁棒性的常用策略,核心思想是:事前-事中-事后!
预防故障(事前)
对于技术人员来说,要有防范未然的意识,因此,对于系统故障要有预防措施,主要的策略包括:
- 代码质量:绝大部分软件系统是脱离不了代码,因此代码质量是预防故障很核心的一个前提。
- 脱离服务:脱离服务(Removal from service)这种策略指的是将系统元素临时置于脱机状态,以减轻潜在的系统故障。
- 替代:替代(Substitution)这种策略使用更安全的保护机制-通常是基于硬件的-用于被视为关键的软件设计特性。
- 事务:事务(Transactions)针对高可用性服务的系统利用事务语义来确保分布式元素之间交换的异步消息是原子的、一致的、隔离的和持久的。这四个属性被称为“ACID属性”。
- 预测模型:预测模型(Predictive model.)结合监控使用,用于监视系统进程的健康状态,以确保系统在其标称操作参数内运行,并在检测到预测未来故障的条件时采取纠正措施。
- 异常预防:异常预防(Exception prevention)这种策略指的是用于防止系统异常发生的技术。
- 中止:如果确定某个操作是不安全的,它将在造成损害之前被中止(Abort)。这种策略是确保系统安全失败的常见策略。
- 屏蔽:系统可以通过比较几个冗余的上游组件的结果,并在这些上游组件输出的一个或多个值不同时采用投票程序,来屏蔽(Masking)故障。
- 复盘:复盘是对事故的整体分析,发现问题的根本原因,查缺补漏,找到完善的方案。
检测故障(事中)
当故障发生时,在采取任何关于故障的行动之前,必须检测或预测故障的存在,故障检测策略主要包括:
- 监控:监控(Monitor)是用于监视系统的各个其他部分的健康状态的组件:处理器、进程、输入/输出、内存等等。
- **Ping/echo:**Ping/echo是指在节点之间交换的异步请求/响应消息对,用于确定通过相关网络路径的可达性和往返延迟。
- 心跳:心跳(Heartbeat)是一种故障检测机制,它在系统监视器和被监视进程之间进行周期性的消息交换。
- 时间戳:时间戳(Timestamp)这种策略用于检测事件序列的不正确性,主要用于分布式消息传递系统。
- 条件监测:条件检测(Condition monitoring.)这种策略涉及检查进程或设备中的条件或验证设计过程中所做的假设。
- 合理性检查:合理性检查(Sanity checking)这种策略检查特定操作或计算结果的有效性或合理性。
- 投票:投票(Voting)这种策略的最常见实现被称为三模块冗余(或TMR),它使用三个执行相同操作的组件,每个组件接收相同的输入并将其输出转发给投票逻辑,用于检测三个输出状态之间的任何不一致。
- 异常检测:异常检测(Exception detection)这种策略用于检测改变执行正常流程的系统状态。
- 自检测:自检测(Self-test)要求元素(通常是整个子系统)可以运行程序来测试自身的正确运行。自检测程序可以由元素自身启动,或者由系统监视器不时调用。
故障恢复(事后)
故障恢复是指系统出现故障之后如何恢复工作。这是对团队应急能力的一个极大考验,如何在系统故障之后,将故障时间缩小到最短,将事故损失缩减到最小?这往往决定了一个平台,一个公司的声誉,决定了很多技术人员的去留。故障恢复的策略主要包括:
- 冗余备用:冗余备用(Redundant spare)有三种主要表现形式:主动冗余(热备用)、被动冗余(温备用)和备用(冷备用)。
- 回滚:回滚(Rollback)允许系统在检测到故障时回到先前已知良好状态,称为“回滚线”——回滚时间。
- 异常处理:异常处理(Exception handling)要求在检测到异常之后,系统必须以某种方式处理它。
- 软件升级:软件升级(Software upgrade)的目标是在不影响服务的情况下实现可执行代码映像的在线升级。
- 重试:重试(Retry)策略假定导致故障的故障是暂时的,重试操作可能会取得成功。
- 忽略故障行为:当系统确定那些消息是虚假的时,忽略故障行为(Ignore faulty behavior)要求忽略来自特定来源的消息。
- 优雅降级:优雅降级(Graceful degradation)这种策略在元素故障的情况下保持最关键的系统功能,放弃较不重要的功能。
- 重新配置:使用重新配置(Reconfiguration),系统尝试通过将责任重新分配给仍在运行的资源来从系统元素的故障中恢复,同时尽可能保持关键功能。
上述这些策略看起来很高大上,好像离你很远,但是其实很多公司都有对应的措施,比如:系统监控,系统告警,数据备份,分布式,服务器集群,多活,降级策略,熔断机制,复盘等等,这些术语应该就和我们的日常开发息息相关了。
总结
系统的鲁棒性是指系统在面对各种异常情况和不确定性因素时,仍能保持稳定运行和正常功能的能力。系统鲁棒性看似一个理想的状态,却是业界一直追求的终极目标,比如,系统稳定性如何做到 5个9(99.999%),甚至是 6个9(99.9999%),这就要求技术人员时刻保持工匠精神、在自己的本职工作上多走一步,只有在各个相关岗位的共同协作下,才能确保系统的鲁棒性。
学习交流
如果你觉得文章有帮助,请帮忙点个赞呗,关注公众号:猿java
,持续输出硬核文章。
来源:juejin.cn/post/7393312386571370536
身份认证的尽头竟然是无密码 ?
概述
几乎所有的系统都会面临安全认证相关的问题,但是安全相关的问题是一个很麻烦的事情。因为它不产生直接的业务价值,而且处理起来复杂繁琐,所以很多时都容易被忽视。很多后期造成重大的安全隐患,往往都是前期的不重视造成的。但庆幸的是安全问题是普遍存在的,而且大家面临的问题几乎相同,所以可以制定行业标准来规范处理,甚至是可以抽出专门的基础设施(例如:AD、LDAP 等)来专门解决这类共性的问题。总之,关于安全问题非常复杂而且麻烦,对于大多数 99% 的系统来说,不要想着在安全问题领域上搞发明和创新,容易踩坑。而且行业的标准解决方案已经非常成熟了。经过长时间的检验。所以在安全领域,踏踏实实的遵循规范和标准就是最好的安全设计。
HTTP 认证
HTTP 认证协议的最初是在 HTTP/1.1标准中定义的,后续由 IETF 在 RFC 7235 中进行完善。HTTP 协议的主要涉及两种的认证机制。
基本认证
常见的叫法是 HTTP Basic,是一种对于安全性不高,以演示为目的的简单的认证机制(例如你家路由器的登录界面),客户端用户名和密码进行 Base64 编码(注意是编码,不是加密)后,放入 HTTP 请求的头中。服务器在接收到请求后,解码这个字段来验证用户的身份。示例:
GET /some-protected-resource HTTP/1.1
Host: example.com
Authorization: Basic dXNlcjpwYXNzd29yZA==
虽然这种方式简单,但并不安全,因为 base64
编码很容易被解码。建议仅在 HTTPS 协议下使用,以确保安全性。
摘要认证
主要是为了解决 HTTP Basic 的安全问题,但是相对也更复杂一些,摘要认证使用 MD5 哈希函数对用户的密码进行加密,并结合一些盐值(可选)生成一个摘要值,然后将这个值放入请求头中。即使在传输过程中被截获,攻击者也无法直接从摘要中还原出用户的密码。示例:
GET /dir/index.html HTTP/1.1
Host: example.com
Authorization: Digest username="user", realm="example.com", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", uri="/dir/index.html", qop=auth, nc=00000001, cnonce="0a4f113b", response="6629fae49393a05397450978507c4ef1", opaque="5ccc069c403ebaf9f0171e9517f40e41"
**补充:**另在 RFC 7235 规范中还定义当用户没有认证访问服务资源时应返回 401 Unauthorized
状态码,示例:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Restricted Area"
这一规范目前应用在所有的身份认证流程中,并且沿用至今。
Web 认证
表单认证
虽然 HTTP 有标准的认证协议,但目前实际场景中大多应用都还是基于表单认证实现,具体步骤是:
- 前端通过表单收集用户的账号和密码
- 通过协商的方式发送服务端进行验证的方式。
常见的表单认证页面通常如下:
html>
<html>
<head>
<title>Login Pagetitle>
head>
<body>
<h2>Login Formh2>
<form action="/perform_login" method="post">
<div class="container">
<label for="username"><b>Usernameb>label>
<input type="text" placeholder="Enter Username" name="username" required>
<label for="password"><b>Passwordb>label>
<input type="password" placeholder="Enter Password" name="password" required>
<button type="submit">Loginbutton>
div>
form>
body>
html>
为什么表单认证会成为主流 ?主要有以下几点原因:
- 界面美化:开发者可以创建定制化的登录界面,可以与应用的整体设计风格保持一致。而 HTTP 认证通常会弹出一个很丑的模态对话框让用户输入凭证。
- 灵活性:可以在表单里面自定义更多的逻辑和流程,比如多因素认证、密码重置、记住我功能等。这些功能对于提高应用的安全性和便利性非常重要。
- 安全性:表单认证可以更容易地结合现代的安全实践,背后也有 OAuth 2 、Spring Security 等框架的主持。
表单认证传输内容和格式基本都是自定义本没啥规范可言。但是在 2019 年之后 web 认证开始发布标准的认证协议。
WebAuthn
WebAuthn 是一种彻底抛弃传统密码的认证,完全基于生物识别技术和实体密钥作为身份识别的凭证(有兴趣的小伙伴可以在 github 开启 Webauhtn 的 2FA 认证体验一下)。在 2019 年 3 月,W3C 正式发布了 WebAuthn 的第一版规范。
相比于传统的密码,WebAuthn 具有以下优势:
- 减少密码泄露:传统的用户名和密码登录容易受到钓鱼攻击和数据泄露的影响。WebAuthn,不依赖于密码,不存在密码丢失风险。
- 提高用户体验:用户不需要记住复杂的密码,通过使用生物识别等方式可以更快捷、更方便地登录。
- 多因素认证:WebAuthn 可以作为多因素认证过程中的一部分,进一步增强安全性。使用生物识别加上硬件密钥的方式进行认证,比短信验证码更安全。
总的来说,WebAuthn 是未来的身份认证方式,通过提供一个更安全、更方便的认证方式,目的是替代传统的基于密码的登录方法,从而解决了网络安全中的一些长期问题。WebAuthn 目前已经得到流程的浏览器厂商(Chrome、Firefox、Edge、Safari)、操作系统(WIndows、macOS、Linux)的广泛支持。
实现效果
当你的应用接入 WebAuthn 后,用户便可以通过生物识别设备进行认证,效果如下:
实现原理
WebAuthn 实现较为复杂,这里不做详细描述,具体可参看权威的官方文档,大概交互过程可以参考以下时序图:
登录流程大致可以分为以下步骤:
- 用户访问登录页面,填入用户名后即可点击登录按钮。
- 服务器返回随机字符串 Challenge、用户 UserID。
- 浏览器将 Challenge 和 UserID 转发给验证器。
- 验证器提示用户进行认证操作。
- 服务端接收到浏览器转发来的被私钥加密的 Challenge,以此前注册时存储的公钥进行解密,如果解密成功则宣告登录成功。
WebAuthn 采用非对称加密的公钥、私钥替代传统的密码,这是非常理想的认证方案,私钥是保密的,只有验证器需要知道它,连用户本人都不需要知道,也就没有人为泄漏的可能;
备注:你可以通过访问 webauthn.me 了解到更多消息的信息
文章不适合加入过多的演示代码,想要手上体验的可以参考 okta 官方给出基于 Java 17 和 Maven 构建的 webauthn 示例程序,如下:
来源:juejin.cn/post/7354632375446061083
如何优雅的给SpringBoot部署的jar包瘦身?
一、需求背景
我们知道Spring Boot项目,是可以通过java -jar 包名 启动的。
那为什么Spring Boot项目可以通过上述命令启动,而其它普通的项目却不可以呢?
原因在于我们在通过以下命令打包时
mvn clean package
一般的maven项目的打包命令,不会把依赖的jar包也打包进去的,所以这样打出的包一般都很小
但Spring Boot项目的pom.xml文件中一般都会带有spring-boot-maven-plugin插件。
该插件的作用就是会将依赖的jar包全部打包进去。该文件包含了所有的依赖和资源文件。
也就会导致打出来的包比较大。
打完包就可以通过java -jar 包名 启动,确实是方便了。
但当一个系统上线运行后,肯定会有需求迭代和Bug修复,那也就免不了进行重新打包部署。
我们可以想象一种场景,线上有一个紧急致命Bug,你也很快定位到了问题,就改一行代码的事情,当提交代码并完成构建打包并交付给运维。
因为打包的jar很大,一直处于上传中.......
如果你是老板肯定会发火,就改了一行代码却上传几百MB的文件,难道没有办法优化一下吗?
如今迭代发布是常有的事情,每次都上传一个如此庞大的文件,会浪费很多时间。
下面就以一个小项目为例,来演示如何瘦身。
二、瘦身原理
这里有一个最基础 SpringBoot 项目,整个项目代码就一个SpringBoot启动类,单是打包出来的jar就有20多M;
我们通过解压命令,看下jar的组成部分。
tar -zxvf spring-boot-maven-slim-1.0.0.jar
我们可以看出,解压出来的包有三个模块
分为 BOOT-INF,META-INF,org 三个部分
打开 BOOT-INF
classes: 当前项目编译好的代码是放在 classes 里面的,classes 部分是非常小的。
lib: 我们所依赖的 jar 包都是放在 lib 文件夹下,lib部分会很大。
看了这个结构我们该如何去瘦身呢?
项目虽然依赖会很多,但是当版本迭代稳定之后,依赖基本就不会再变动了。
如果可以把这些不变的依赖提前都放到服务器上,打包的时候忽略这些依赖,那么打出来的Jar包就会小很多,直接提升发版效率。
当然这样做你肯定有疑问?
既然打包的时候忽略这些依赖,那通过java -jar 包名 还可以启动吗?
这种方式打的包,在项目启动时,需要通过-Dloader.path指定lib的路径,就可以正常启动
java -Dloader.path=./lib -jar xxx.jar
三、瘦身实例演示
1、依赖拆分配置
只需要在项目pom.xml文件中添加下面的配置:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<executable>true</executable>
<layout>ZIP</layout>
<!--这里是填写需要包含进去的jar,
必须项目中的某些模块,会经常变动,那么就应该将其坐标写进来
如果没有则nothing ,表示不打包依赖 -->
<includes>
<include>
<groupId>nothing</groupId>
<artifactId>nothing</artifactId>
</include>
</includes>
</configuration>
</plugin>
<!--拷贝依赖到jar外面的lib目录-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<!--指定的依赖路径-->
<outputDirectory>
${project.build.directory}/lib
</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
再次打包
mvn clean package
发现target目录中多了个lib文件夹,里面保存了所有的依赖jar。
自己业务相关的jar也只有小小的168kb,相比之前20.2M,足足小了100多倍;
这种方式打的包,在项目启动时,需要通过-Dloader.path指定lib的路径:
java -Dloader.path=./lib -jar spring-boot-maven-slim-1.0.0.jar
虽然这样打包,三方依赖的大小并没有任何的改变,但有个很大的不同就是我们自己的业务包和依赖包分开了;
在不改变依赖的情况下,也就只需要第一次上传lib目录到服务器,后续业务的调整、bug修复,在没调整依赖的情况下,就只需要上传更新小小的业务包即可;
2、自己其它项目的依赖如何处理?
我们在做项目开发时,除了会引用第三方依赖,也会依赖自己公司的其它模块。
比如
这种依赖自己其它项目的工程,也是会经常变动的,所以不宜打到外部的lib,不然就会需要经常上传更新。
那怎么做了?
其实也很简单 只需在上面的插件把你需要打进jar的填写进去就可以了
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<executable>true</executable>
<layout>ZIP</layout>
<!--这里是填写需要包含进去的jar,如果没有则nothing -->
<includes>
<include>
<groupId>com.jincou</groupId>
<artifactId>xiaoxiao-util</artifactId>
</include>
</includes>
</configuration>
</plugin>
这样只有include中所有添加依赖依然会打进当前业务包中。
四、总结
使用瘦身部署,你的业务包确实小了 方便每次的迭代更新,不用每次都上传一个很大的 jar 包,从而节省部署时间。
但这种方式也有一个弊端就是增加了Jar包的管理成本,多人协调开发,构建的时候,还需要专门去关注是否有人更新依赖。
来源:juejin.cn/post/7260772691501301817
不使用代理,我是怎么访问Github的
背景
最近更换了 windows系统的电脑, git clone
项目的时候会连接超时的错误,不管我怎么把环境变量放到终端里尝试走代理都无果,于是开始了排查
以下命令是基于 git bash 终端使用的
检测问题
通过 ssh -T git@github.com
命令查看,会报如下错误:
ssh: connect to host github.com port 22: : Connection timed out
思索了一下,难道是端口的问题吗, 于是从 overflow 上找到回答:
修改 ~/.ssh/config
路径下的内容,增加如下
Host github.com
Hostname ssh.github.com
Port 443
这段配置实际上是让 github.com
走 443 端口去执行,评论上有些说 22端口被占用,某些路由器或者其他程序会占用它,想了一下有道理,于是使用 vim ~/.ssh/config
编辑加上,结果...
ssh: connect to host github.com port 443: : Connection timed out
正当我苦苦思索,为什么 ping github.com
超时的时候,脑子里突然回忆起那道久违的八股文面试题: “url输入网址到浏览器上会发生什么",突然顿悟:是不是DNS解析出了问题,找不到服务器地址?
网上学到一行命令,可以在终端里看DNS服务器的域名解析
nslookup baidu.com
先执行一下 baidu.com
的,得到如下:
Server: 119.6.6.6
Address: 119.6.6.6#53
Non-authoritative answer:
Name: baidu.com
Address: 110.242.68.66
Name: baidu.com
Address: 39.156.66.10
再执行一下 nslookup github.com
,果然发现不对劲了:
Name: github.com
Address: 127.0.0.1
返回了 127.0.0.1
,这不对啊,笔者可是读过书的,这是本地的 IP 地址啊,原来是这一步出了问题..
解决问题
大部分同学应该都改过本地的 DNS 域名映射文件,这也是上面那道八股文题中回答的知识点之一,我们打开资源管理器输入一下路径改一下:
C:\Windows\System32\drivers\etc\hosts
MacOs的同学可以在终端使用 sudo vi /etc/hosts 命令修改
在下面加上下面这一行, 其中 140.82.113.4
是 github 的服务器地址,添加后就可以走本地的域名映射了
140.82.113.4 github.com
保存之后,就可以不使用代理,快乐访问 github.com 了,笔者顺利的完成了梦想第一步: git clone
结语
我是饮东,欢迎点赞关注,我们江湖再会
来源:juejin.cn/post/7328112739335372810
简单聊聊使用lombok 的争议
大家好,我是G探险者。
项目里,因为我使用了Lombok插件,然后代码走查的时候被领导点名了。
我心想,这么好用的插件,为啥不推广呢,整天写那些烦人的setter,getter方法就不嫌烦么?
领导既然不让用,自然有他的道理。
于是我查了一番关于lombok的一些绯闻。就有了这篇文章。
首先呢,Lombok 是一个在 Java 项目中广泛使用的库,旨在通过注解自动生成代码,如 getter 和 setter 方法,以减少重复代码并提高开发效率。然而,Lombok 的使用也带来了一些挑战和争议,特别是关于代码的可读性和与 Java Bean 规范的兼容性。
Lombok 基本使用
示例代码
不使用 Lombok:
public class User {
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
// 其他 getter 和 setter
}
使用 Lombok:
import lombok.Data;
@Data
public class User {
private String name;
private int age;
// 无需显式编写 getter 和 setter
}
Lombok 的争议
- 代码可读性和透明度:Lombok 自动生成的代码在源代码中不直接可见,可能对新开发者造成困扰。
- 工具和 IDE 支持:需要特定的插件或配置,可能引起兼容性问题。
- 与 Java Bean 规范的兼容性:Lombok 在处理属性命名时可能与 Java Bean 规范产生冲突,特别是在属性名以大写字母开头的情况。
下面我就列举一个例子进行说明。
属性命名的例子
假设有这么一个属性,aName;
标准 Java Bean 规范下:
- 属性
aName
的setter getter 方法应为setaName() getaName()
。 - 但是 Lombok 可能生成
getAName()
。
这是因为Lombok 在生成getter和setter方法时,将属性名的首字母也大写,即使它是小写的。所以对于aName属性,Lombok生成的方法可能是getAName()和setAName()。
在处理JSON到Java对象的映射时,JSON解析库(如Jackson或Gson)会尝试根据Java Bean规范匹配JSON键和Java对象的属性。它通常期望属性名的首字母在getter和setter方法中是小写的。因此,如果JSON键为"aName",解析库会寻找setaName()方法。
所以,当你使用Lombok的@Data注解,且Lombok生成的setter方法为setAName()时,JSON解析库可能找不到匹配的方法来设置aName属性,因为它寻找的是setaName()。
这种差异可能在 JSON 到 Java 对象的映射中引起问题。
Java Bean 命名规范
根据 Java Bean 规范,属性名应遵循驼峰式命名法:
- 单个单词的属性名应全部小写。
- 多个单词组成的属性名每个单词的首字母通常大写。
结论
Lombok 是一个有用的工具,可以提高编码效率并减少冗余代码。但是,在使用它时,团队需要考虑其对代码可读性、维护性和与 Java Bean 规范的兼容性。在决定是否使用 Lombok 时,项目的具体需求和团队的偏好应该是主要的考虑因素。
来源:juejin.cn/post/7310786611805863963
token是用来鉴权的,session是用来干什么的?
使用JWT进行用户认证和授权,而Session在一定程度上起到了辅助作用。
让我们详细讨论JWT和Session在这种结合模式中的各自作用以及为什么需要Session。
JWT的作用
- 用户认证:JWT包含了用户的身份信息和权限信息,客户端每次请求时将JWT发送给服务器,服务器通过验证JWT来确认用户身份。
- 无状态性:JWT不需要在服务器端存储用户会话信息,因此服务器可以是无状态的,便于扩展和负载均衡。
Session的作用
- 附加的安全层:即使JWT是无状态的,但在某些应用场景中,仅依赖JWT可能存在一些安全问题,例如Token的泄露或滥用。Session可以作为一个额外的安全层,确保Token即使有效,也必须在服务器的Session管理器中存在对应的会话。
- 管理Token的生命周期:通过Session,可以更方便地管理Token的生命周期,例如强制用户重新登录、手动注销Token等操作。
- 控制“记住我”功能:如果用户选择了“记住我”选项,Session可以记录这个状态,并在JWT过期后,通过Session来决定是否允许继续使用旧的Token。
为什么需要创建Session
尽管JWT可以在无状态环境中使用,但Session的引入带来了以下好处:
- 防止Token滥用:通过在服务器端验证Session,可以确保即使Token有效,也必须是经过服务器端认证的,从而防止Token被恶意使用。
- 支持用户主动注销:当用户选择注销时,可以直接删除服务器端的Session记录,确保Token即使没有过期,也无法再被使用。
- 提供更精细的控制:通过Session,可以实现更精细的权限控制和用户状态管理,例如强制下线、会话过期时间控制等。
- 状态追踪:在某些场景下,追踪用户状态是必要的,例如监控用户的活跃度、登录历史等,这些信息可以通过Session进行管理。
结合JWT和Session的优势
结合使用JWT和Session,可以同时利用两者的优点,实现安全性和扩展性的平衡:
- 无状态认证:JWT可以实现无状态认证,便于系统的水平扩展和负载均衡。
- 状态管理和安全性:Session可以提供额外的状态管理和安全性,确保Token的使用更加安全可靠。
代码示例
以下是一个简化的代码示例,展示了如何在用户登录时创建JWT和Session:
java
Copy code
public LoginResponse login(String username, String password) throws AuthException {
// 验证用户名和密码
User user = userService.authenticate(username, password);
if (user == null) {
throw new AuthException("Invalid username or password");
}
// 生成JWT Token
String token = createJwt(user.getId(), user.getRoles());
// 创建会话
sessionManagerApi.createSession(token, user);
// 返回Token
return new LoginResponse(token);
}
public void createSession(String token, User user) {
LoginUser loginUser = new LoginUser();
loginUser.setToken(token);
loginUser.setUserId(user.getId());
loginUser.setRoles(user.getRoles());
sessionManagerApi.saveSession(token, loginUser);
}
在请求验证时,首先验证JWT的有效性,然后检查Session中是否存在对应的会话:
java
Copy code
@Override
public DefaultJwtPayload validateToken(String token) throws AuthException {
try {
// 1. 先校验jwt token本身是否有问题
JwtContext.me().validateTokenWithException(token);
// 2. 获取jwt的payload
DefaultJwtPayload defaultPayload = JwtContext.me().getDefaultPayload(token);
// 3. 如果是7天免登陆,则不校验session过期
if (defaultPayload.getRememberMe()) {
return defaultPayload;
}
// 4. 判断session里是否有这个token
LoginUser session = sessionManagerApi.getSession(token);
if (session == null) {
throw new AuthException(AUTH_EXPIRED_ERROR);
}
return defaultPayload;
} catch (JwtException jwtException) {
if (JwtExceptionEnum.JWT_EXPIRED_ERROR.getErrorCode().equals(jwtException.getErrorCode())) {
throw new AuthException(AUTH_EXPIRED_ERROR);
} else {
throw new AuthException(TOKEN_PARSE_ERROR);
}
} catch (io.jsonwebtoken.JwtException jwtSelfException) {
throw new AuthException(TOKEN_PARSE_ERROR);
}
}
总结
在这个场景中,JWT用于无状态的用户认证,提供便捷和扩展性;Session作为辅助,提供额外的安全性和状态管理。通过这种结合,可以充分利用两者的优点,确保系统既具备高扩展性,又能提供细致的安全控制。
来源:juejin.cn/post/7383017171180568630
记一种不错的缓存设计思路
之前与同事讨论接口性能问题时听他介绍了一种缓存设计思路,觉得不错,做个记录供以后参考。
场景
假设有个以下格式的接口:
GET /api?keys={key1,key2,key3,...}&types={1,2,3,...}
其中 keys 是业务主键列表,types 是想要取到的信息的类型。
请求该接口需要返回业务主键列表对应的业务对象列表,对象里需要包含指定类型的信息。
业务主键可能的取值较多,千万量级,type 取值范围为 1-10,可以任意组合,每种 type 对应到数据库是 1-N 张表,示意:
现在设想这个接口遇到了性能瓶颈,打算添加 Redis 缓存来改善响应速度,应该如何设计?
设计思路
方案一:
最简单粗暴的方法是直接使用请求的所有参数作为缓存 key,请求的返回内容为 value。
方案二:
如果稍做一下思考,可能就会想到文首我提到的觉得不错的思路了:
- 使用
业务主键:表名
作为缓存 key,表名里对应的该业务主键的记录作为 value; - 查询时,先根据查询参数 keys,以及 types 对应的表,得到所有
key1:tb_1_1
、key1:tb_1_2
这样的组合,使用 Redis 的 mget 命令,批量取到所有缓存中存在的信息,剩下没有命中的,批量到数据库里查询到结果,并放入缓存; - 在某个表的数据有更新时,只需刷新
涉及业务主键:该表名
的缓存,或令其失效即可。
小结
在以上两种方案之间做评估和选择,考虑几个方面:
- 缓存命中率;
- 缓存数量、占用空间大小;
- 刷新缓存是否方便;
稍作思考和计算,就会发现此场景下方案二的优势。
另外,就是需要根据实际业务场景,如业务对象复杂度、读写次数比等,来评估合适的缓存数据的粒度和层次,是对应到某一级组合后的业务对象(缓存值对应存储 + 部分逻辑),还是最基本的数据库表/字段(存储的归存储,逻辑的归逻辑)。
来源:juejin.cn/post/7271597656118394899