BI 图表管理
图表管理
需求分析
图表管理:基于图表信息的CRUD。可参考此前或者现有项目的一些实现,完善前后端功能
实现步骤
1.后端构建
参考现有项目实现,完成后端接口的CRUD
2.前端构建
(1)页面构建
构建步骤说明
【1】创建路由(修改config/routes.ts文件:配置图表信息列表路由)
【2】新增组件(src/pages新增MyChart组件:新增MyChart文件夹、新建index.tsx文件(可以参考前面创建AddChart))
【3】启动页面访问效果
构建参考
【1】创建路由
{ name: '我的图表', icon: 'pieChart', path: '/my_chart', component: './MyChart' },
【2】新增组件
import { Footer } from '@/components';
import { listChartByPageUsingPost } from '@/services/noob-bi/chartController';
import { useModel } from '@umijs/max';
import React, { useEffect,useState } from 'react';
const MyChart: React.FC = () => {
const [type, setType] = useState<string>('account');
const { setInitialState } = useModel('@@initialState');
useEffect(()=>{
listChartByPageUsingPost({}).then(res=>{
console.error('res',res)
})
});
return (
// 页面信息定义(my-chart)
<div className = "my-chart">
hello my chart
</div>
);
};
export default MyChart;
【3】启动页面访问效果
yarn run dev (启动访问http://localhost:8080)
检查页面是否正常显示,检查接口调用情况
【4】简化代码(清理一些无用的代码引用,只保留最基础的部分,后面按需加载)
import React from 'react';
const MyChart: React.FC = () => {
return (
// 页面信息定义(my-chart)
<div className = "my-chart">
hello my chart
</div>
);
};
export default MyChart;
(2)数据对接
在实际的开发中,前端和后端的职责是需要明确划分的。前端主要负责页面展示和与用户的交互,而后端则负责业务逻辑的实现和数据的处理。尽管前端的逻辑相对较少,但为了提高整个应用的性能和用户体验,应该尽可能地减少前端的计算复杂度,让后端来处理这些复杂的运算。这样,前端只需要调用后端的接口,传递需要的参数即可,后端则负责返回处理好的数据给前端,让前端根据数据进行页面展示。这样的划分可以使得前后端的开发更加高效和有效。
import { listChartByPageUsingPost } from '@/services/noob-bi/chartController';
import React, { useState } from 'react';
const MyChart: React.FC = () => {
// 构建初始条件(便于后面恢复初始条件)
const initSearchParams = {
// 初始情况每页数据返回12条
pageSize:12,
}
/**
* 定义一个状态(searchParams)和其对应的更新函数(setSearchParams),初始化为initSearchParams
* searchParams是发送给后端的查询条件,参数类型是API。ChartQueryRequest
* {...}是展开语法,将initSearchParams中的所有属性展开并复制到一个新对象中(不改变原始对象,可以避免在现有对象直接更改值的对象变异操作)
* React中不推荐直接修改状态或者属性,而是通过创建要一个新对象并将其分配给状态或属性
*/
const [searchParams,setSearchParams] = useState<API.ChartQueryRequest>({...initSearchParams});
// 定义一个获取数据的异步函数
const loadData = async()=>{
/**
* 调用后端接口,传入serchParams请求参数并返回响应结果
* listChartByPageUsingPost是通过openAPI生成的接口
* 当searchParam状态改变时,可通过setSearchParams更新该状态并重新获取数据
*/
const res = await listChartByPageUsingPost(searchParams);
}
return (
// 页面信息定义(my-chart)
<div className = "my-chart">
hello my chart
</div>
);
};
export default MyChart;
继续完善代码(请求接口异步调用、异常处理、数据响应处理)
try...catch...代码块快捷生成:webstorm(ctrl+shift+T)、vscode(直接输入try(自动跳出窗口选择try catch语句块))
处理响应数据和异常情况
import { listChartByPageUsingPost } from '@/services/noob-bi/chartController';
import { message } from 'antd';
import React, { useEffect, useState } from 'react';
const MyChart: React.FC = () => {
// 构建初始条件(便于后面恢复初始条件)
const initSearchParams = {
// 初始情况每页数据返回12条
pageSize:12,
}
/**
* 定义一个状态(searchParams)和其对应的更新函数(setSearchParams),初始化为initSearchParams
* searchParams是发送给后端的查询条件,参数类型是API。ChartQueryRequest
* {...}是展开语法,将initSearchParams中的所有属性展开并复制到一个新对象中(不改变原始对象,可以避免在现有对象直接更改值的对象变异操作)
* React中不推荐直接修改状态或者属性,而是通过创建要一个新对象并将其分配给状态或属性
*/
const [searchParams,setSearchParams] = useState<API.ChartQueryRequest>({...initSearchParams});
// 定义变量存储图表数据
const [chartList,setChartList] = useState<API.Chart[]>();
// 定义数据总数(类型为number、默认为0)
const [total,setTotal] = useState<number>(0);
// 定义一个获取数据的异步函数
const loadData = async()=>{
/**
* 调用后端接口,传入serchParams请求参数并返回响应结果
* listChartByPageUsingPost是通过openAPI生成的接口
* 当searchParam状态改变时,可通过setSearchParams更新该状态并重新获取数据
*/
try {
const res = await listChartByPageUsingPost(searchParams);
// 响应成功,将图表数据进行渲染(如果为空则传入空数组,分页数据则拿到数据列表)
if(res.data){
setChartList(res.data.records ?? []);
// 数据总数:数据列表如果为空则返回0
setTotal(res.data.total ?? 0);
}else{
// 如果后端返回数据为空,抛出异常(获取图表失败)
message.error("获取图表失败");
}
} catch (e:any) {
// 出现异常,提示失败西悉尼
message.error('图表获取失败'+e.message);
}
}
// 页面首次加载,触发加载数据
useEffect(()=>{
// 调用方法加载数据(页面首次渲染以及数组中的搜索条件发生变化的时候执行loadData方法触发搜索)
loadData();
},[searchParams])
return (
// 页面信息定义(my-chart)
<div className = "my-chart">
{/*
* 展示数据列表信息
*/}
{JSON.stringify(chartList)}
<br/>
数据总数:{total}
</div>
);
};
export default MyChart;
构建完成,访问测试(在智能分析中添加数据进行分析)
(3)页面美化
列表构建
构建完成,前端拿到后端响应的数据,下一步是美化页面展示(参考Ant Design Pro组件库)
找一个列表组件进行改造
import React from 'react';
import { LikeOutlined, MessageOutlined, StarOutlined } from '@ant-design/icons';
import { Avatar, List, Space } from 'antd';
const data = Array.from({ length: 23 }).map((_, i) => ({
href: 'https://ant.design',
title: `ant design part ${i}`,
avatar: `https://api.dicebear.com/7.x/miniavs/svg?seed=${i}`,
description:
'Ant Design, a design language for background applications, is refined by Ant UED Team.',
content:
'We supply a series of design principles, practical patterns and high quality design resources (Sketch and Axure), to help people create their product prototypes beautifully and efficiently.',
}));
const IconText = ({ icon, text }: { icon: React.FC; text: string }) => (
<Space>
{React.createElement(icon)}
{text}
</Space>
);
const App: React.FC = () => (
<List
itemLayout="vertical"
size="large"
pagination={{
onChange: (page) => {
console.log(page);
},
pageSize: 3,
}}
dataSource={data}
footer={
<div>
<b>ant design</b> footer part
</div>
}
renderItem={(item) => (
<List.Item
key={item.title}
actions={[
<IconText icon={StarOutlined} text="156" key="list-vertical-star-o" />,
<IconText icon={LikeOutlined} text="156" key="list-vertical-like-o" />,
<IconText icon={MessageOutlined} text="2" key="list-vertical-message" />,
]}
extra={
<img
width={272}
alt="logo"
src="https://gw.alipayobjects.com/zos/rmsportal/mqaQswcyDLcXyDKnZfES.png"
/>
}
>
<List.Item.Meta
avatar={<Avatar src={item.avatar} />}
title={<a href={item.href}>{item.title}</a>}
description={item.description}
/>
{item.content}
</List.Item>
)}
/>
);
export default App;
将上面的List组件放在对应位置,并引入组件(修改部分数据、属性细节、删除一些无关引用,调整为自身项目所需)
import { listChartByPageUsingPost } from '@/services/noob-bi/chartController';
import { message,Avatar, List, Space } from 'antd';
import React, { useEffect, useState } from 'react';
const MyChart: React.FC = () => {
// 构建初始条件(便于后面恢复初始条件)
const initSearchParams = {
// 初始情况每页数据返回12条
pageSize:12,
}
/**
* 定义一个状态(searchParams)和其对应的更新函数(setSearchParams),初始化为initSearchParams
* searchParams是发送给后端的查询条件,参数类型是API。ChartQueryRequest
* {...}是展开语法,将initSearchParams中的所有属性展开并复制到一个新对象中(不改变原始对象,可以避免在现有对象直接更改值的对象变异操作)
* React中不推荐直接修改状态或者属性,而是通过创建要一个新对象并将其分配给状态或属性
*/
const [searchParams,setSearchParams] = useState<API.ChartQueryRequest>({...initSearchParams});
// 定义变量存储图表数据
const [chartList,setChartList] = useState<API.Chart[]>();
// 定义数据总数(类型为number、默认为0)
const [total,setTotal] = useState<number>(0);
// 定义一个获取数据的异步函数
const loadData = async()=>{
/**
* 调用后端接口,传入serchParams请求参数并返回响应结果
* listChartByPageUsingPost是通过openAPI生成的接口
* 当searchParam状态改变时,可通过setSearchParams更新该状态并重新获取数据
*/
try {
const res = await listChartByPageUsingPost(searchParams);
// 响应成功,将图表数据进行渲染(如果为空则传入空数组,分页数据则拿到数据列表)
if(res.data){
setChartList(res.data.records ?? []);
// 数据总数:数据列表如果为空则返回0
setTotal(res.data.total ?? 0);
}else{
// 如果后端返回数据为空,抛出异常(获取图表失败)
message.error("获取图表失败");
}
} catch (e:any) {
// 出现异常,提示失败西悉尼
message.error('图表获取失败'+e.message);
}
}
// 页面首次加载,触发加载数据
useEffect(()=>{
// 调用方法加载数据(页面首次渲染以及数组中的搜索条件发生变化的时候执行loadData方法触发搜索)
loadData();
},[searchParams])
return (
// 页面信息定义(my-chart)
<div className = "my-chart">
<List
itemLayout="vertical"
size="large"
pagination={{
onChange: (page) => {
console.log(page);
},
pageSize: 3,
}}
// 数据源改成图表数据(列表组件会自动渲染)
dataSource={chartList}
footer={
<div>
<b>ant design</b> footer part
</div>
}
renderItem={(item) => (
// List.Item 要展示的每一条数据
<List.Item
// key 对应图表id
key={item.id}
// 要展示的图表信息
extra={
<img
width={272}
alt="logo"
src="https://gw.alipayobjects.com/zos/rmsportal/mqaQswcyDLcXyDKnZfES.png"
/>
}
>
{/* 要展示的图表元素信息 */}
<List.Item.Meta
// 头像(todo)
avatar={<Avatar src={'https://cos.holic-x.com/profile/avatar/avatar02.png'} />}
// 图表名称
title={<a href={item.href}>{item.name}</a>}
// 描述
description={item.chartType?'图表类型'+item.chartType:undefined}
/>
{/* 要展示的内容 */}
{'分析目标:'+item.goal}
</List.Item>
)}
/>
</div>
);
};
export default MyChart;
构建完成,引入对应的图表展示
{/* 图表信息展示 */}
<EChartsReact option={JSON.parse(item.genChart??'{}')} />
如果前端渲染出现JSON解析错误,则可能是数据库中的图表数据出现了脏数据导致渲染失败,需要检查对应chart表中的内容(排查genChart字段,确认其生成的渲染规则是否满足echart的option配置)
(1)排查chart的记录的isdelete状态值
(2)排查chart记录的genChart字段(确认option配置,可以将其在在线echart中进行调试确认定义规则是否正常)
列表美化
美化列表展示:引入搜索框、分页功能、处理数据、统一隐藏图片标题
搜索框引入
{/* 搜索框引入 */}
<div>
{/* <Search placeholder='请输入图表名称' enterButton loading={loading} onSearch={(value)=>{ */}
<Search placeholder='请输入图表名称' enterButton onSearch={(value)=>{
// 设置搜索条件
setSearchParams({
// 原始搜索条件
...initSearchParams,
// 搜索词
name:value,
});
}}></Search>
</div>
数据处理
# 1.数据响应处理(loadData方法修改)
try {
const res = await listChartByPageUsingPost(searchParams);
// 响应成功,将图表数据进行渲染(如果为空则传入空数组,分页数据则拿到数据列表)
if(res.data){
setChartList(res.data.records ?? []);
// 数据总数:数据列表如果为空则返回0
setTotal(res.data.total ?? 0);
// 列表数据处理(有些图表有标题、有些没有,此处过滤掉不展示标题信息)
if(res.data.records){
res.data.records.forEach(data => {
// 将后端返回的图表字符串修改为对象数组,如果后端返回空字符串则返回{}
const chartOption = JSON.parse(data.genChart??'{}');
// 标题设置为undefined
chartOption.title = undefined;
// 将修改后的option重新赋值给原genChart字段
data.genChart = JSON.stringify(chartOption);
});
}
}else{
// 如果后端返回数据为空,抛出异常(获取图表失败)
message.error("获取图表失败");
}
} catch (e:any) {
// 出现异常,提示失败西悉尼
message.error('图表获取失败'+e.message);
}
# 图表展示处理
{/* 图表信息展示 */}
{/* <EChartsReact option={JSON.parse(item.genChart??'{}')} /> */}
<EChartsReact option={item.genChart&&JSON.parse(item.genChart)} />
分页组件修改
# 1.属性定义
// 定义数据总数(类型为number、默认为0)
const [total,setTotal] = useState<number>(0);
# 2.分页组件修改
// 分页组件定义
pagination={{
// 当切换分页,在当前搜索条件的基础上,将页数调整为当前的页数
onChange: (page,pageSize) => {
setSearchParams({
...searchParams,
current:page,
pageSize,
})
},
// 显示当前页数
current: searchParams.current,
// 页面参数、总数修改为自己的
pageSize: searchParams.pageSize,
total: total,
}}
数据展示调整(头像信息调整:将其对应到当前登陆用户)
import {useModel} from '@@/exports';
# 1.获取全局用户登陆状态信息
// 从全局状态中获取登陆用户信息
const {initialState} = useModel('@@initialState');
const {currentUser} = initialState??{};
# 2.修改头像信息
// avatar={<Avatar src={'https://cos.holic-x.com/profile/avatar/avatar02.png'} />}
avatar={<Avatar src={currentUser&¤tUser.userAvatar} />}
todo:loading加载待定:一致不生效
{/* 数据引入 */}
<List
itemLayout="vertical"
size="large"
// 设置组件样式(栅栏格式)
grid={{
gutter: 16,
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2,
}}
...... 其他代码实现 .....
展示美化(样式调整)
List 组件的grid设定
在数据库中多插入几条数据进行测试
// 在元素下方增加16像素的外边距
<div style={{marginBottom:16}}></div>
可以将常用的样式设置为固定的css样式(原子化css):
# 修改global.less(全局样式)
.margin-16 {
// 在元素下方添加16像素的外边距
margin-bottom: 16px;
}
# 引用
<div className='margin-16'/>
创建多个数据,点击搜索查看分页、搜索功能是否正常执行
弱网界面效果
网络=》设置低速3G(刷新页面查看加载效果)
测试完成记得复位
功能扩展
功能扩展
- 扩展编辑、查看图表详情页面
- 网站优化:性能安全、数据存储、限流等
网站思考
- 安全性
如果用户上传一个超大的文件怎么办?比如1000 G?
- 数据存储
目前实现将每个图表的原始数据全部存放在同一个数据表(chart表)中,后期数据量大的情况下,会导致查询图表或查询chart表等操作变得缓慢。
- 限流
在做真正上线的系统中,如果系统需要付费才能使用,比如每次用户调用聪明Al发送一条消息,Al给出一个回答,这背后都需要进行成本的扣除。
1.安全性
只要允许用户自行上传内容,就有可能会受到攻击。例如,如果网站使用了一个图片存储服务器,那么如果某个用户想攻击我们,他可能会在上传原始数据时通过上传一个巨大的文件(如1000G)来利用我们服务器的带宽和存储资源。这样的攻击是很可怕的,因为服务器的负载和网络流量利用率会大幅提高。由于服务器很贵,而家庭宽带成本很便宜,攻击者可能会利用自己的家庭网络攻击我们(攻击者采用低成本的手段对我们进行攻击,却让开发者承担高昂的成本)。所以需要注意,如果网站涉及到用户上传操作,一定要对上传的文件进行校验,以防止攻击,如果不对上传文件进行校验,就要花费更多的资金来进行防御。
用户自主上传:文件校验(fix ChartController智能分析生成图表的接口)
检验文件信息:文件大小、文件后缀
事实上,仅仅校验文件后缀并不能完全保证文件的安全性,因为攻击者可以通过将非法内容更改后缀名来绕过校验。通过修改文件后缀的方式欺骗校验机制,将一些恶意文件伪装成安全的文件类型。现在这个校验的维度是从浅到深,仅仅依靠校验后缀是远远不够的,还需要结合其他严格措施来加强文件的安全性,这是安全性的一个优化点。
todo:文件安全校验(文件安全校验由浅入深 待优化)
2.数据存储
问题分析
目前项目设计实现将每个图表的原始数据全部存放在了同一个数据表(chart表)的字段里,这种设计虽然方便了数据的获取和管理,但是在系统后期数据量大的情况下,会面临一些隐藏的优化问题。
例如,如果允许用户上传100兆(100 MB)的原始数据,那么每一个图表、每一行数据都会存储100M的数据。如果有1000个用户,每个用户有100个图表,那这个数据表的大小将非常巨大,从而导致查询图表或查询chart表等操作变得缓慢
目前实现:我们把每个图表的原始数据全部存放在了同一个数据表(chart表)的字段里。
存在问题:
如果用户上传的原始数据量很大、图表数日益增多,查询chart表就会很慢
对于BI平台,用户是有查看原始数据、对原始数据进行简单查询的需求的。现在如果把所有数据存放在一个字段(列)中,查询时,只能取出这个列的所有内容。
比如下图这张表,如果只想要x,y这两列,就要先把所有的原始数据查出来,然后再去做过滤。或者是从一个小格子中只获取部分内容(可能有这种方式,但是效率可能较低)
解决方案构思
如果将原始数据以表格的形式存储在一个独立的数据表中,而不是放在一个小的格子里,实际上会更方便高效。由于数据表采用了标准的结构方式存储,我们可以通过使用SQL语句进行高效的数据检索,仅查询需要的列或行。此外,还可以利用数据库的索引等高效技术,更快、更精确地对数据进行定位和查询,从而提高查询效率和系统的响应速度。
解决方案:分库分表
把每个图表对应的原始数据单独保存为一个新的数据表,而不是都存在一个字段里。比如:网站数据.xlsx,如果要保存这个数据,就单独保存为一个新的数据表:表名为chart_{图表id}。
新建表,然后填入下图所示的数据,分开查询测试
根据存储和查询场景分析其优点:
(1)存储时:分开存储(数据互不影响)
(2)查询时:使用各种SQL灵活取出所需要的字段
思路构建
使用分开存储的方式可以带来很多好处,其中一个好处就是存储的值相互独立,不会互相影响。例如,如果将一个100 G的数据保存到同一个表中,其他用户在访问这个数据表时会受到很大的影响,甚至在读取这个数据时可能会非常慢。
而通过将每个表单独存储,即使一个用户上传了很大的数据,其他用户在访问时也不会受到影响。这样可以保证数据的安全性和稳定性,同时也能提高系统的处理能力和效率。
以后进行图表数据查询时,可以先根据图表的ID 来查找,然后进行数据查询,方便我们排查问题。甚至返回用户原始数据,通过全标扫描的方式直接捞出所有数据,这比对数据库查询数据进行处理更加快速和高效。
(1)数据存储
在关联存储图表信息的时候,不将数据存储为字段,而是按照指定规则新建一个chart_xxxxx(命名规则:chart_chartId)的数据表,然后通过图表id、数据列名、数据类型等字段生成SQL语句,以上述案例为例子,SQL语法规则参考如下
create table chart_123456789(
日期 int null,
用户数 int null
);
(2)数据查询
基于原有的实现,数据查询是通过查询chart表中的字段(JSON)进行一些数据拆解、过滤等,现在调整为【根据chartId定位到对应的数据表,然后根据数据表读取指定字段即可】
select * from chart_xxxxxxxx;
分库分表
基本概念
分库分表概念
在数据库设计中考虑使用分库分表的思路可以有效地解决大数据量和高并发的问题。可以分水平分表和垂直分库两种方式。
水平分表指在数据量大的情况下,将表按照某个字段的值进行拆分和分散存储,例如拆分出前1万个用户一个表,后1万个用户一个表。
垂直分库则是将不同的业务按照相关性进行划分,例如将用户中心用户相关的内容划分到一个库中,订单、支付信息和订单相关的划分到另一个库中,从而提高系统的可扩展性和稳定性。分库分表是数据库设计中重要的一部分,能有效地优化系统的性能,提高用户体验。
分库分表分类(水平分表、垂直分库)
在大型互联网应用中,为了应对高并发、海量数据等挑战,往往需要对数据库进行拆分。常见的拆分方式有水平分表和垂直分库两种。
水平分表:将同一张表中的数据按一定的规则划分到不同的物理存储位置上,以达到分摊单张表的数据及访问压力的目的。对于SQL分为两类: id-based分表和range-based分表。
垂直分库:指的是根据业务模块的不同,将不同的字段或表分到不同的数据库中。垂直分库基于数据库内核支持,对应用透明,无需额外的开发代码,易于维护升级。
区分 | 水平分表 | 垂直分库 |
---|---|---|
优点 | (1)单个表的数据量减少,查询效率提高 (2)可以通过增加节点,提高系统的扩展性和容错性 | (1)减少单个数据库的数据量,提高系统的查询效率 (2)增加了系统的可扩展性,比水平分表更容易实现 |
缺点 | (1)事务并发处理复杂度增加,需要增加分布式事务的管理,性能和复杂度都有所牺牲 (2)跨节点查询困难,需要设计跨节点的查询模块 | (1)不同数据库之间的维护和同步成本较高 (2)现有系统的改造存在一定的难度 (3)系统的性能会受到数据库之间互相影响的影响 |
需要根据实际的业务场景和技术架构情况,综合考虑各种因素来选择适合自己的分库分表策略。
需要注意SQL注入风险
--查询表chart_12345中id为1或者1 =1 的所有记录。
--由于1 = 1始终成立,因此这条查询语句将返回表chart_12345中的所有记录。--这种查询方式称为SQL注入,是一种常见的安全漏洞,
--攻击者可以通过构造特殊的SQL语句,利用这种漏洞获取敏感信息或者篡改数据。select * from chart_12345 where id = 1 or 1 = 1;
实现说明
实现核心:根据MyBatis的动态SQL构建(根据代码灵活动态生成SQL语句)
测试:在Chart Mapper.xml
(1)ChartMapper定义接口(Alt+Enter快捷键:generate statement生成mapper实现)
public interface ChartMapper extends BaseMapper<Chart> {
List<Map<String,Object>> queryChartData(String querySql);
}
(2)ChartMapper测试
光标移动到ChartMapper,快捷键(Alt+Enter),选择Create Test(创建测试用例)
@SpringBootTest
class ChartMapperTest {
@Resource
private ChartMapper chartMapper;
@Test
void queryChartData() {
String chartId = "1001";
String querySql = String.format("select * from chart_%s", chartId);
List<Map<String, Object>> chartData = chartMapper.queryChartData(querySql);
System.out.println(chartData);
}
}
3.限流
问题分析
需要控制用户使用系统的次数,以避免超支,比如给不同等级的用户分配不同的调用次数,防止用户过度使用系统造成破产(例如鱼聪明目前给普通用户提供50次调用次数,会员用户提供100次)。但限制用户调用次数仍存在一定风险,用户仍有可能通过疯狂调用来刷量,从而导致系统成本过度消耗。
假设系统就一台服务器,能同时处理的用户对话数量是有限的,比如系统最多只能支持10个用户同时对话,如果某个用户一秒内使用10个账号登录,那么其他用户就无法使用系统。就像去自助餐厅吃饭,如果有人一股脑地把所有美食都拿光了,其他人就无法享用了。比如双11这种大促期间阿里巴巴就要去限制,不能说所有的用户想抢购都能成功,在前端随机放行一部分用户,而对于其他用户则进行限制,以确保系统不会被恶意用户占满。
现在要做一个解决方案,就是限流,比如说限制单个用户在每秒只能使用一次,那这里我们怎么去思考这个限流的阈值是多少?多少合适呢?
比如一些AI接口限流:疯狂发消息,超过每秒1次就会提示,正常用户不会触发。
解决方案
(1)控制成本:限制用户调用总次数
(2)限流控制:用户短时间内疯狂调用接口,导致服务器资源被占满,其他用户无法使用(限流)
思考:限流阈值设置为多大?参考正常用户的使用,限制单个用户一秒只能调用一次
限流算法
【场景分析:食堂排队】:
(1)规定窗口限流
去食堂买汉堡,食堂每一小时只允许10个用户买汉堡,汉堡一小时只能做10个, 个人,第60分钟又来10个人,汉堡就不够了。
(2)滑动窗口限流
每10分钟食堂做一个汉堡,这样的话,假如前一个小时汉堡已经被抢光了,然后1小时10分钟来的一个新用户,他又能抢到1小时10分钟得到的那个汉堡。
(3)漏桶限流
大家排好队,一个一个去拿汉堡,前面一个人拿完,后面一个人才能拿。
(4)令牌桶限流
食堂事先做好10个汉堡,假如现在开抢了,前10个人能够同时拿到汉堡,不用排队,但剩下的10个人就只能等下一批汉堡做好才能拿。
限流粒度
(1)针对某个方法限流,即单位时间内最多允许同时XX个操作使用这个方法
(2)针对某个用户限流,比如单个用户单位时间内最多执行XX次操作
(3)针对某个用户×方法限流,比如单个用户单位时间内最多执行XX次这个方法
限流实现
(1)本地限流(单机限流)
每个服务器单独限流(一般适用于单体项目),项目只有一个服务器的场景
举个例子,假设系统有三台服务器,每台服务器限制用户每秒只能请求一次。你可以为每台服务器单独设置限流策略,这样每个服务器都能够独立地控制用户的请求频率。但是这种限流方式并不是很可靠,因为并不知道用户的请求会落在哪台服务器上,它的分布是有一定的偶然性的。即使采用负载均衡技术,让用户请求轮流发送到每台服务器,仍然存在一定的风险。
在Java中,有很多第三方库可以用来实现单机限流:Guava RateLimiter:这是谷歌Guava库提供的限流工具,可以对单位时间内的请求数量进行限制。
(2)分布式限流(多机限流)
如果项目有多个服务器,比如微服务,那么建议使用分布式限流。
1.把用户的使用频率等数据放到一个集中的存储进行统计
比如Redis,这样无论用户的请求落到了哪台服务器,都以集中存储中的数据为准。(Redisson是一个操作Redis的工具库(参考伙伴匹配系统实现))
2.在网关集中进行限流和统计(比如Sentinel、Spring Cloud Gateway)
Redisson限流实现
redis服务端
redis下载,window版本选择下载指定版本(zip)
下载完成,解压redis包,随后启动redis-server.exe(服务端启动),客户端启动可以选择借助其他第三方工具实现操作(RedisManagement)
redisson
Redisson官方仓库内置了一个限流工具类(可帮助利用Redis使用)
引入pom.xml依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.28.0</version>
</dependency>
application.yml配置
spring:
redis:
database: 1
host: localhost
port: 6379
timeout: 5000
password: 123456
redis密码确认:启动redis-server.exe文件,随后启动redis-cli.exe(客户端),输入命令(config get requirepass,获取密码,如果没有设定则为空)
参考下图,2)为空说明没设置密码,则对应application.yml中配置password为空即可
创建RedissonConfig配置类(初始化RedissonClient对象单例)
@Configuration
@ConfigurationProperties(prefix = "spring.redis")
@Data
public class RedissonConfig {
private Integer database;
private String host;
private Integer port;
// 如果redis没有默认密码则不用写
// private String password;
@Bean
public RedissonClient getRedissonClient() {
// 1.创建配置对象
Config config = new Config();
// 添加单机Redisson配置
config.useSingleServer()
.setDatabase(database)
.setAddress("redis://"+host+":"+port);
// 如果没有密码则不需要设定
// 2.创建Redisson实例
RedissonClient redisson = Redisson.create(config);
return redisson;
}
}
创建管理类:RedisLimiterManager
ErrorCode中添加错误码:TOO_MANY_REQUEST(42900,"请求过于频繁")
RedisLimiterManager:专门提供RedisLimiter限流基础服务(通用定义,可适用于一般的项目)
@Service
public class RedisLimiterManager {
@Resource
private RedissonClient redissonClient;
/**
* 限流惭怍
* @param key 区分不同的限流器(例如不同用户的ID分别统计)
*/
public void doRateLimit(String key){
// 创建一个user_limiter限流器(每秒最多访问2次)
RRateLimiter rateLimiter = redissonClient.getRateLimiter(key);
// 限流器的统计规则(每秒2个请求:连续的请求,最多只能1个请求被允许通过)
rateLimiter.trySetRate(RateType.OVERALL,2,1, RateIntervalUnit.SECONDS);
// 每当一个操作访问,请求一个令牌
boolean canOp = rateLimiter.tryAcquire(1);
// 如果没有令牌还想执行操作则抛出异常
if(!canOp){
throw new BusinessException(ErrorCode.TOO_MANY_REQUEST);
}
}
}
扩展:如何跟踪方法参数含义(通过看文档、下载源码进行分析)
测试
光标放在RedisLimiterManager上,点击Alt+Enter键,然后创建测试用例方法
@SpringBootTest
class RedisLimiterManagerTest {
@Resource
private RedisLimiterManager redisLimiterManager;
@Test
void doRateLimit() throws InterruptedException {
// 模拟用户操作
String userId = "1";
// 瞬间执行2次,每成功一次,就打印成功
for(int i=0;i<2;i++){
redisLimiterManager.doRateLimit(userId);
System.out.println("成功");
}
// 睡1s
Thread.sleep(1000);
// 瞬间执行2次,每成功一次,就打印成功
for(int i=0;i<5;i++){
redisLimiterManager.doRateLimit(userId);
System.out.println("成功");
}
}
}
前2s正常请求,后面请求过于频繁超出限制则抛出异常
项目应用
将redis限流器应用到实际项目中:给每个用户分配一个限流器,在genChartAi接口中做限流判断
// 接口限流器
@Resource
private RedisLimiterManager redisLimiterManager;
// 引入限流判断
redisLimiterManager.doRateLimit("genChartByAi_"+loginUser.getId());
由于限流操作可能会抛出异常,因此当请求到达时,如果无法获取到令牌,则将抛出异常并终止请求;反之,如果成功获取到令牌,则请求可以正常继续执行,此时不需要进行其他任何操作。由于限流操作可能会抛出异常,因此当请求到达时,如果无法获取到令牌,则将抛出异常并终止请求;反之,如果成功获取到令牌,则请求可以正常继续执行,此时不需要进行其他任何操作.
4.知识点扩展
【1】文件分片
当我们处理大型文件时,考虑分片上传可以提高上传速度和稳定性。分片上传可以使得当文件上传失败时,不用重新上传整个文件,而是只需要重新上传未完成的那部分分片。然而,对于分片上传的具体实现,建议使用现有的第三方组件,如腾讯云TOS对象存储,而不是自行实现。
因为没有一个标准的实现方式,自行实现可能会导致代码质量不稳定。一般来说,建议对于百兆到几个G的文件,考虑使用分片上传方式。对于大小不到十几兆的文件,可能没有必要进行分片上传。如果能开发一个秒传系统,会起到很大的亮点作用,因为秒传这个功能涉及到技术含量较高的领域,如断点上传、文件校验和数据分片等。此外,还需要注意的是,秒传系统的实现需要考虑很多细节,例如如何保证文件的完整性和隐私安全,如何在高并发环境下实现高效的上传和下载等问题,这些也是最终系统能否得以成功运作的关键。
【2】@Validated推荐使用吗?
在选择技术时,往往需要根据具体的场景来进行判断和决策。当涉及到校验字段的规则时,是否采用@Validated注解并没有一个绝对的答案,而是需要根据具体情况来考虑。如果校验规则相对简单,可以通过@Validated注解中已经提供的一些规则来实现,那么直接使用@Validated注解便是—个非常好的选择。 但如果你的校验规则比较复杂,可能涉及到多个条件和计算,这时候可以直接在业务代码中进行校验并灵活处理。所以说,需要根据具体的情况来选择合适的技术和方法来解决问题。
项目启动说明
【1】启动本地redis
【2】启动api-platform-backend后端项目
【3】启动api-platform-frontend前端项目