2024年11月

引言
  • 背景介绍:在服务器的运维管理中,及时监控系统的登录日志对保障系统的安全至关重要。通过实时监控登录日志,运维人员可以发现潜在的异常登录行为,防止系统被非法访问。
  • 问题引入:如何实现实时监控登录日志,并及时响应潜在的安全风险?
实时监控登录日志的意义
  • 安全性
    :通过监控登录日志,可以迅速发现恶意登录、暴力破解等异常行为。
  • 合规性
    :确保满足各种合规要求,记录所有用户的登录行为。
解决方案概述
  • 监控目标
    :关注登录日志中的关键信息,例如登录时间、IP 地址、用户名、登录方式等。
  • 技术选型
    :通过编写 Bash 脚本,结合
    inotify

    awk

    grep
    等工具,来实现对日志文件的实时监控与分析。
脚本实现原理
  • 实时监控
    :利用
    inotify
    命令动态监控日志文件的变动,并结合
    sed
    命令实时提取和输出新增的登录日志。
  • 日志筛选
    :通过
    grep
    等工具过滤出登录失败、异常登录等相关信息。
  • 报警机制
    :脚本可以配置成在监控到异常行为时,自动发送通知邮件

脚本示例

1 #!/bin/bash2 # 作者: 阿杰3 # 用途: 实时检测登录日志,统计异常登录4 # 脚本名称: watch_secure.sh
5 # 用法: bash watch_seacure.sh
6 
7 # 日志记录8 log_err() {9   printf "[$(date +'%Y-%m-%dT%H:%M:%S')]: \033[31mERROR: \033[0m$@\n"
10 }11 
12 log_info() {13   printf "[$(date +'%Y-%m-%dT%H:%M:%S')]: \033[32mINFO: \033[0m$@\n"
14 }15 
16 log_warning() {17   printf "[$(date +'%Y-%m-%dT%H:%M:%S')]: \033[33mWARNING: \033[0m$@\n"
18 }19 
20 # 初始化Map21 declare -A secureMap22 
23 init() {24 # 行数记录文件25     line_file_name="conf/line_file.txt"    
26 # inode存储文件27     inode_file="conf/inode.txt"
28 # 认证失败文件记录29     ssh_auth_failed_file="conf/ssh_auth_failed.csv"
30 
31 # 文件列表32     file_array=("$line_file_name" "$inode_file" "$ssh_auth_failed_file")33 # inode 文件状态34     inode_file_status=0
35 # 控制是否进行写入 0为可写,1为不可写36     write_status=1
37 
38     oneSecureKey=""
39 
40 {41         if [ ! -d "conf" ];then
42             mkdirconf43         fi
44 # 检查文件是否存在45         for file in ${file_array[@]};do
46             check_file_exists $file
47         done
48         line=$(cat$line_file_name)49         if [ -z "$line" ];then
50             line=0
51         fi
52 # 认证失败文件第一次创建53         if [ $(wc -l < $ssh_auth_failed_file) -eq 0 ];then
54 # 时间以月天为单位(None为空账号或不存在账号)55             echo "登录认证失败时间,源IP地址,登录账号,连接认证失败次数" >$ssh_auth_failed_file56         fi
57 
58 }59 
60     file_name="/var/log/secure"
61     if [ -z "$(rpm -qa | grep 'inotify-tools')" ];then
62         yum install -y inotify-tools > /dev/null 2>&1
63         if [ $? -ne 0 ];then
64             log_err "[init] inotify-tools 安装失败!"
65         fi
66     fi
67 
68 
69 }70 # 检查文件是否存在,不存在则创建71 check_file_exists() {72     local file_name=$1
73     if [ ! -f "$file_name" ];then
74         touch$file_name75         if [ $? -ne 0 ];then
76             log_err "[check_file_exists] file: $file_name 文件创建失败!"
77         fi
78     fi
79 }80 
81 
82 
83 # 监听文件事件84 watch_file() {85     inotifywait -mrq --format '%e' --event create,delete,modify $file_name | while read event ;do
86         case "$event" in
87 MODIFY)88 start_read_file89 ;;90 # 文件被删除或重新创建91         CREATE|DELETE)92 # 重置文件行数93             line=0
94             >$line_file_name95 check96 ;;97         *)98             log_warning "[watch_file] watch file event: $event" 
99 ;;100         esac
101     done
102 }103 
104 # 只读一行105 read_line_file() {106     ((line++))107     echo $line >$line_file_name108 # 不是指定数据退出109     if [ $(sed -n "$line p" $file_name  | grep 'pam_unix(sshd:auth): authentication failure;' | wc -l ) -ne 1 ];then
110 return111     fi
112 # 控制是否进行写入113     write_status=0
114     oneSecureKey=$(sed -n "$line p" $file_name  |awk -v dateNow=$(date +"%Y") '{
115         split($0,rhost,"rhost=")116         split(rhost[2],rhost," ")117         split($0,user,"user=")118         if (length(user[2])==0) {119             user[2]="None"
120 }121         print dateNow":"$1":"$2","rhost[1]","user[2]122     }')
123     log_info "[read_line_file] line: $line data:[$oneSecureKey]"
124     
125 send_map $oneSecureKey126 }127 
128 # 往MAP中塞入数据129 send_map() {130     local key=$1
131     if [ -n ${secureMap[$key]} ];then
132         secureMap[$key]=`expr ${secureMap[$key]} + 1`133     else
134         secureMap[$key]=1
135     fi
136 }137 
138 wirte_all_secure() {139     for key in ${!secureMap[@]};do
140 write_one_secure $key141     done
142 }143 
144 write_one_secure() {145     local key="$@"
146     local data=$(grep -w -n "$key"$ssh_auth_failed_file)147     if [ -n "$data" ];then
148         local i=$(echo $data | awk -F: '{print $1}')149         local a=$(echo $data | awk -F, '{print $NF}')150         sed -i "${i} s#$a#${secureMap[$key]}#"$ssh_auth_failed_file151         if [ $? -ne 0 ];then
152             log_err "[write_secure] 写 $ssh_auth_failed_file 文件失败! data:[$key,${secureMap[$key]}]"
153         fi
154     else
155 # 新数据156         echo "$key,${secureMap[$key]}" >>$ssh_auth_failed_file157         if [ $? -ne 0 ];then
158             log_err "[write_secure] 写 $ssh_auth_failed_file 文件失败! data:[$key,${secureMap[$key]}]"
159         fi
160     fi
161     log_info "[write_secure] line: $line status: $write_status data:[$key,${secureMap[$key]}]"
162 }163 
164 
165 
166 # 启动前应先检查是否读取过167 check() {168 # 检查预存Inode是否一致169 check_secure_file_inode170 }171 
172 # 检查登录日志Inode是否一致173 check_secure_file_inode() {174     inode=$(ls -i $file_name | awk '{print $1}')175     inode_file_data="$(cat $inode_file)"
176     if [ -n "$inode_file_data" ]; then
177         if [ $inode -ne $inode_file_data ];then
178             log_warning "[check_secure_file_inode] secure file inode is inconsistency"
179 # inode不一致,重置180             echo "$inode" >$inode_file181             inode_file_status=1
182         else
183            inode_file_status=0
184         fi
185     else
186 # 第一次读取187         echo "$inode" >$inode_file188         inode_file_status=1
189     fi
190 }191 
192 # 开始读取文件193 start_read_file() {194 # 第一次读取195     if [ $inode_file_status -eq 1 ] ;then
196 # 使用循环将历史内容读取197         while true;do
198             if [ $line -eq $(wc -l < $file_name) ];then
199 break200             fi
201 read_line_file202         done
203 wirte_all_secure204     elif [  $line -ne $(wc -l < $file_name) ];then
205 # 使用循环将行数对齐206         while true;do
207             if [ $line -eq $(wc -l < $file_name) ];then
208 break209             fi
210 read_line_file211             if [ $write_status -eq 0 ];then
212 write_one_secure $oneSecureKey213             fi
214 # 状态设置为1215             write_status=1
216         done        
217     # else
218 #     read_line_file219     #     if [ $write_status -eq 0 ];then
220 #         write_one_secure $oneSecureKey221     #     fi
222 #     # 状态设置为1223     #     write_status=1
224     fi
225 }226 
227 test_main() {228 init229 check_secure_file_inode230 
231 }232 
233 main() {234 # 初始化235 init236 # 内容检查237 check238 start_read_file239     log_info "[main] watch secure startd"
240 watch_file241 }242 
243 main

一、功能

开源的Web漏洞扫描工具,支持以下漏洞

  • XSS漏洞检测 (key: xss)
  • SQL 注入检测 (key: sqldet)
  • 命令/代码注入检测 (key: cmd-injection)
  • 目录枚举 (key: dirscan)
  • 路径穿越检测 (key: path-traversal)
  • XML 实体注入检测 (key: xxe)
  • 文件上传检测 (key: upload)
  • 弱口令检测 (key: brute-force)
  • jsonp 检测 (key: jsonp)
  • ssrf 检测 (key: ssrf)
  • 基线检查 (key: baseline)
  • 任意跳转检测 (key: redirect)
  • CRLF 注入 (key: crlf-injection)
  • Struts2 系列漏洞检测 (高级版,key: struts)
  • Thinkphp系列漏洞检测 (高级版,key: thinkphp)
  • POC 框架 (key: phantasm)

二、安装

首先下载安装包并解压,链接如下

通过网盘分享的文件:扫描器

链接:
https://pan.baidu.com/s/1DIsQSgopqzkK8wGwCqExFg
提取码: jay1

--来自百度网盘超级会员v1的分享

下载解压后只有一个exe文件

打开命令控制面板,进入这个文件夹,输入

xray_windows_amd64.exe genca

生成证书,这时文件夹下会生成一个证书

在火狐浏览器设置里点击隐私,查看证书

点击导入,将刚才的ca证书导入


点击确定

三、使用

输入:

xray_windows_amd64.exe -h

即可查看使用命令,这里我把名字改成了xray.exe,方便使用

1. 基础使用

xray.exe webscan --url url --html-output test1.html

url:输入的地址
test1.html:输出的html文件,点击即可查看相关漏洞扫描结果

2. 基于爬虫模式进行

xray webscan --basic-crawler url --html-output xxx.html

xxx是输出的html名,可以随意修改,最后会在该文件夹下生成

3. 监听端口

xray_window_amd64.exe webscan --listen ip:port --html-output xxx.html

ip:port为你直自己浏览器的代理ip和端口

位置控件使用直观且易懂的通用标识,让用户明确地知道这是一个获取位置信息的按钮。这满足了授权场景需要匹配用户真实意图的需求。只有当用户主观愿意,并且明确了解使用场景后点击位置控件,应用才会获得临时的授权,获取位置信息并完成相应的服务功能。

一旦应用集成了位置控件,用户点击该控件后,无论应用是否已经申请过或被授予精准定位权限,都会在本次前台期间获得精准定位的授权,可以调用位置服务获取精准定位。

对于不是强位置关联应用(例如导航、运动健康等)的应用,只在部分前台场景需要使用位置信息(例如定位城市、打卡、分享位置等)。如果需要长时间使用或是在后台使用位置信息,建议申请位置权限。

位置控件效果如图所示。
img1

约束与限制

  • 当用户首次点击应用中的位置控件,系统将弹窗请求用户授权。如果用户点击“取消”,弹窗消失,应用无授权,用户再次点击位置控件时,将会重新弹窗;如果用户点击“允许”,弹窗消失,应用将被授予临时位置权限,此后点击该应用的位置控件将不会弹窗。
  • 精准定位的临时授权会持续到灭屏、应用切后台、应用退出等任一情况发生,然后恢复到临时授权之前的授权状态(授予/未授予/未申请)
  • 应用在授权期间没有调用次数限制。
  • 为了保障用户的隐私不被恶意应用获取,应用需确保安全控件是可见的且用户能够识别的。开发者需要合理的配置控件的尺寸、颜色等属性,避免视觉混淆的情况,如果发生因控件的样式不合法导致授权失败的情况,请检查设备错误日志。

开发步骤

以在聊天界面发送实时定位信息为例。在当前场景下,应用仅需要在前台期间,短暂地访问当前位置,不需要长时间使用。此时,可以直接使用安全控件中的位置控件,免去权限申请和权限请求等环节,获得临时授权,满足权限最小化,提升用户的隐私体验。

参考以下步骤,实现效果:点击控件“当前位置”获取临时精准定位授权,获取授权后,弹窗提示具体位置信息,效果图请见上文。

  1. 引入位置服务依赖。
import { geoLocationManager } from '@kit.LocationKit';
  1. 添加位置控件和获取当前位置信息。
    安全控件是由图标、文本、背景组成的类似Button的按钮,其中图标、文本两者至少有其一,背景是必选的。图标和文本不支持自定义,仅支持在已有的选项中选择。应用申明安全控件的接口时,分为传参和不传参两种,不传参默认创建图标+文字+背景的按钮,传参根据传入的参数创建,不包含没有配置的元素。

    当前示例使用默认参数,具体请参见LocationButton控件。此外,所有安全控件都继承安全控件通用属性,可用于定制样式。

    在LocationButton的onClick()回调中通过调用geoLocationManager模块提供的方法获取当前位置信息。

import { geoLocationManager } from '@kit.LocationKit';
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';

// 获取当前位置信息
function getCurrentLocationInfo() {
  const requestInfo: geoLocationManager.LocationRequest = {
    'priority': geoLocationManager.LocationRequestPriority.FIRST_FIX,
    'scenario': geoLocationManager.LocationRequestScenario.UNSET,
    'timeInterval': 1,
    'distanceInterval': 0,
    'maxAccuracy': 0
  };
  try {
    geoLocationManager.getCurrentLocation(requestInfo)
      .then((location: geoLocationManager.Location) => {
        promptAction.showToast({ message: JSON.stringify(location) });
      })
      .catch((err: BusinessError) => {
        console.error(`Failed to get current location. Code is ${err.code}, message is ${err.message}`);
      });
  } catch (err) {
    console.error(`Failed to get current location. Code is ${err.code}, message is ${err.message}`);
  }
}

@Entry
@Component
struct Index {
  build() {
    Row() {
      Column({ space: 10 }) {
        LocationButton({
          icon: LocationIconStyle.LINES,
          text: LocationDescription.CURRENT_LOCATION,
          buttonType: ButtonType.Normal
        })
          .padding({top: 12, bottom: 12, left: 24, right: 24})
          .onClick((event: ClickEvent, result: LocationButtonOnClickResult) => {
            if (result === LocationButtonOnClickResult.SUCCESS) {
              // 免去权限申请和权限请求等环节,获得临时授权,获取位置信息授权
              getCurrentLocationInfo();
            } else {
              promptAction.showToast({ message: '获取位置信息失败!' })
            }
          })
      }
      .width('100%')
    }
    .height('100%')
    .backgroundColor(0xF1F3F5)
  }
}

今天开始和大家分享关于Excel最长常用操作封装。

01
、起因

市面上有很多Excel操作库,这些库设计之初的目标是提供对Excel的各种操作功能,包括数据、样式、公式、图表等等。而对于我们平时开发来说,大多时候并不需要那么多强大的功能,可能大部分只涉及两个操作导入和导出。在这种情况下使用任何一种Excel操作库的学习成本相对来说都是比较高的。

如果有个包能提供Excel读取和写入功能,已经能满足大部分常规需求了,而并不需要了解那么多Excel操作细节。

因此有了二次封装Excel操作的想法,用来应对快速开发。

02
、设计思路

整个设计思路围绕两个操作展开——导入和导出。

对于导入我们肯定希望是读完Excel文件直接得到一个对象集合,类似下面伪代码:

var students = Read(excel)

对于导出我们肯定希望传入对象集合直接得到一个Excel文件,类似下面伪代码:

var excel = Write(students)

对于Excel来说关注的是行和列,而对象集合可能很容易表达出行的概念,但是很难表达出列的概念。因此如何想实现上面说到的Excel和对象集合直接转换,还需要进行一些转换,即把对象集合转为成一张类似表格的数据结构,这样就可以和Excel的表对应上了。

而.NET中本身就有这样的数据结构——DataTable。因此我们可以通过DataTable把对象集合和Excel关联起来。

因此我们要想实现Excel导入和导出需要实现以下两部分:

(1)对象集合与DataTable转换;

(2)DataTable与Excel转换;

最后再把两部分合并为最终要的导入导出方法,这样整体思路就比较清晰了。下面我们对两部分设计单独讲解。

03
、对象集合与DataTable转换

把这部分单独拿出来说除了本身需要外,还因为这块功能可以作为公共基础功能,用于其他场景使用,因此这块功能会放入Ideal.Core.Common库中。

要实现对象集合与DataTable转换也可以分为两步:

(1)创建:以对象属性名作为列名创建DataTable;

(2)转换:把对象集合数据填充至DataTable行记录中,或者相反;

1、创建

对于创建表格,考虑到兼容性、扩展性、适用性,我们将考虑支持多种方式创建表格,大致包括以下几种情况。

(1)根据列名创建表格;

(2)根据对象创建表格;

(3)根据对象绑定自定义特性创建表格;

当然还要根据各种细节提供不同的重载方法。

2、转换

对于转换表格主要指对象集合转表格、表格转对象集合、以及其他表格转换,大致包括以下几种情况。

(1)对象集合转为表格;

(2)根据对象绑定自定义特性把集合转为表格;

(3)表格转为对象集合;

(4)根据对象绑定自定义特性把表格转为对象集合;

(5)一维数组转为表格;

(6)行列转置;

04
、DataTable与Excel转换

这块功能也可以说是Excel帮助类的核心功能,因此这块功能将放入Ideal.Core.Document库中。

DataTable与Excel转换可以分为两种情况:

(1)读:把Excel中数据读入DataTable;

(2)写:把DataTable中数据写入Excel;

1、读

读取Excel数据至DataTable,根据通过不同的方式获取到不同格式的Excel数据,可以分为以下两种情况:

(1)根据文件路径读取数据至DataTable;

(2)根据文件流读取数据至DataTable;

2、写

写指把DataTable数据写入Excel中,同样根据不同方式把DataTable数据写入不同格式的Excel中,也可以分为以下几种情况:

(1)把DataTable数据写入Excel文件流;

(2)把DataTable数据写入Excel字节数组;

(3)把DataTable数据写入指定路径Excel;

05
、对象集合与Excel转换

完成上面两部分后,这一步就简单多了,只需要对上面两部分进行整合就行了。因此也可以分为读写两种情况。

1、读

读取Excel数据至对象集合,主要分为两种情况。

(1)读取Excel数据至对象集合;

(2)根据对象自定义特性读取Excel数据至对象集合;

根据不同的Excel数据格式,还有其他组合情况,这里先不细说。

2、写

写指把对象集合写入Excel,主要分为两种情况:

(1)把对象集合写入Excel;

(2)根据对象自定义特性把对象集合写入Excel;

同样,根据不同的Excel数据格式,还有其他组合情况,这里暂不细说。

后面我们将详细讲解具体实现过程。


:测试方法代码以及示例源码都已经上传至代码库,有兴趣的可以看看。
https://gitee.com/hugogoos/Ideal

使用复杂度高的命令

Redis提供了慢日志命令的统计功能

首先设置Redis的慢日志阈值,只有超过阈值的命令才会被记录,这里的单位是微妙,例如设置慢日志的阈值为5毫秒,同时设置只保留最近1000条慢日志记录:

# 命令执行超过5毫秒记录慢日志
CONFIG SET slowlog-log-slower-than 5000
# 只保留最近1000条慢日志
CONFIG SET slowlog-max-len 1000

执行SLOWLOG get 5查询最近5条慢日志

127.0.0.1:6379> SLOWLOG get 5
1) 1) (integer) 32693       # 慢日志ID
   2) (integer) 1593763337  # 执行时间
   3) (integer) 5299        # 执行耗时(微秒)
   4) 1) "LRANGE"           # 具体执行的命令和参数
      2) "user_list_2000"
      3) "0"
      4) "-1"
2) 1) (integer) 32692
   2) (integer) 1593763337
   3) (integer) 5044
   4) 1) "GET"
      2) "book_price_1000"
...

通过查看慢日志记录,就可以知道在什么时间执行哪些命令比较耗时,如果服务请求量并不大,但Redis实例的CPU使用率很高,很有可能就是使用了复杂度高的命令导致的。

比如经常使用O(n)以上复杂度的命令,由于Redis是单线程执行命令,因此这种情况Redis处理数据时就会很耗时。例如

  • sort:对列表(list)、集合(set)、有序集合(sorted set)中的元素进行排序。在最简单的情况下(没有权重、没有模式、没有
    LIMIT
    ),
    SORT
    命令的时间复杂度近似于
    O(n*log(n))

  • sunion:用于计算两个或多个集合的并集。时间复杂度可以描述为
    O(N)
    ,其中
    N
    是所有参与运算集合的元素总数。如果有多个集合,每个集合有不同数量的元素参与运算,那么复杂度会是所有这些集合元素数量的总和。

  • zunionstore:用于计算一个或多个有序集合的并集,并将结果存储到一个新的有序集合中。在最简单的情况下,
    ZUNIONSTORE
    命令的时间复杂度是
    O(N*log(N))
    ,其中
    N
    是所有参与计算的集合中元素的总数。

  • keys * :获取所有的 key 操作;复杂度
    O(n)
    ,数据量越大执行速度越慢;可以使用
    scan
    命令替代

  • Hgetall:返回哈希表中所有的字段和;

  • smembers:返回集合中的所有成员;

解决方案就是,不使用这些复杂度较高的命令,并且一次不要获取太多的数据,每次尽量操作少量的数据,让Redis可以及时处理返回

存储大key

如果查询慢日志发现,并不是复杂度较高的命令导致的,例如都是
SET、DELETE
操作出现在慢日志记录中,那么就要怀疑是否存在Redis写入了大key的情况。

多大才算大

如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。

  • String 类型的 value 超过 1MB
  • 复合类型(List、Hash、Set、Sorted Set 等)的 value 包含的元素超过 5000 个(不过,对于复合类型的 value 来说,不一定包含的元素越多,占用的内存就越多)。

产生原因

  • 程序设计不当,比如直接使用 String 类型存储较大的文件对应的二进制数据。
  • 对于业务的数据规模考虑不周到,比如使用集合类型的时候没有考虑到数据量的快速增长。
  • 未及时清理垃圾数据,比如哈希中冗余了大量的无用键值对。

造成的问题

  • 客户端超时阻塞:由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
  • 网络阻塞:每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
  • 工作线程阻塞:如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
  • 持久化阻塞(磁盘IO):对
    AOF 日志
    的影响
    • 使用Always 策略的时候,
      主线程
      在执行完命令后,会把数据写入到 AOF 日志文件,然后会调用 fsync() 函数,将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。因此当使用 Always 策略的时候,如果写入是一个大 Key,
      主线程
      在执行 fsync() 函数的时候,阻塞的时间会比较久,因为当写入的数据量很大的时候,数据同步到硬盘这个过程是很耗时的。
    • 另外两种策略都不影响主线程

大 key 造成的阻塞问题还会进一步影响到主从同步和集群扩容。

如何发现 bigkey?

  1. 使用 Redis 自带的 --bigkeys 参数来查找:这个命令会扫描(Scan) Redis 中的所有 key ,会对 Redis 的性能有一点影响,最好选择在从节点上执行该命令,因为主节点上执行时,会
    阻塞
    主节点。并且,这种方式只能找出每种数据结构 top 1 bigkey(占用内存最大的 String 数据类型,包含元素最多的复合数据类型)。然而,一个 key 的元素多并不代表占用内存也多,需要我们根据具体的业务情况来进一步判断。

  2. Redis 自带的 SCAN 命令:SCAN 命令可以按照一定的模式和数量返回匹配的 key。获取了 key 之后,可以利用 STRLEN、HLEN、LLEN等命令返回其长度或成员数量。

  3. 借助开源工具分析 RDB 文件:这种方案的前提是Redis 采用的是 RDB 持久化。网上有现成的工具:

    • redis-rdb-tools
      :Python 语言写的用来分析 Redis 的 RDB 快照文件用的工具
    • rdb_bigkeys
      :Go 语言写的用来分析 Redis 的 RDB 快照文件用的工具,性能更好。

如何处理 bigkey?

  • 删除大 key:删除大 key 时建议采用分批次删除和异步删除的方式进行;


    • 因为删除大 key释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,
      操作系统需要把释放掉的内存块插入一个空闲内存块的链表
      ,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会
      阻塞
      当前释放内存的应用程序。

    • 所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis
      主线程的阻塞
      ,如果主线程发生了阻塞,其他所有请求可能都会超时,超时越来越多,会造成 Redis 连接耗尽,产生各种异常。

  • 分割 bigkey:将一个 bigkey 分割为多个小 key。例如,将一个含有上万字段数量的 Hash 按照一定策略(比如二次哈希)拆分为多个 Hash。

  • 手动清理:Redis 4.0+ 可以使用 UNLINK 命令来异步删除一个或多个指定的 key。Redis 4.0 以下可以考虑使用 SCAN 命令结合 DEL 命令来分批次删除。

  • 采用合适的数据结构:例如,文件二进制数据不使用 String 保存、使用 HyperLogLog 统计页面 UV、Bitmap 保存状态信息(0/1)。

  • 开启 lazy-free(惰性删除/延迟释放) :lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。

集中过期

Redis的过期策略采用 定期过期+懒惰过期两种策略:

  • 定期过期:Redis内部维护一个定时任务,默认每秒进行10次(也就是每隔100毫秒一次)过期扫描,从过期字典中随机取出20个key,删除过期的key,如果过期key的比例还超过25%,则继续获取20个key,删除过期的key,循环往复,直到过期key的比例下降到25%或者这次任务的执行耗时超过了25毫秒,才会退出循环
  • 懒惰过期:只有当访问某个key时,才判断这个key是否已过期,如果已经过期,则从实例中删除

Redis的定期删除策略是在Redis
主线程
中执行的,也就是说如果在执行定期删除的过程中,出现了需要大量删除过期key的情况,那么在业务访问时,必须等这个定期删除任务执行结束,才可以处理业务请求。此时就会出现,业务访问延时增大的问题,最大延迟为25毫秒。

为了尽量避免这个问题,在设置过期时间时,可以给过期时间设置一个随机范围,避免同一时刻过期。

伪代码可以这么写:

# 在过期时间点之后的5分钟内随机过期掉
redis.expireat(key, expire_time + random(300))

实例内存达到上限

生产中会给内存设置上限maxmemory,当数据内存达到 maxmemory 时,便会触发redis的内存淘汰策略

那么当实例的内存达到了maxmemory后,就会发现之后每次写入新的数据,就好像变慢了。导致变慢的原因是,当Redis内存达到maxmemory后,每次写入新的数据之前,会先根据内存淘汰策略先踢出一部分数据,让内存维持在maxmemory之下。

而内存淘汰策略就决定这个踢出数据的时间长短:

  • 最常使用的一般是allkeys-lru或volatile-lru策略,Redis 内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值 (此值可配置) ,然后淘汰一个最少访问的key,之后把剩下的key暂存到一个池子中,继续随机取出一批key,并与之前池子中的key比较,再淘汰一个最少访问的key。以此循环,直到内存降到maxmemory之下。
  • 如果使用的是allkeys-random或volatile-random策略,那么就会快很多,因为是随机淘汰,那么就少了比较key访问频率时间的消耗了,随机拿出一批key后直接淘汰即可,因此这个策略要比上面的LRU策略执行快一些。

但以上这些淘汰策略的逻辑都是在访问Redis时,真正命令执行之前执行的,也就是它会影响真正需要执行的命令。

另外,如果此时Redis实例中有存储大key,那么在
淘汰大key释放内存时,这个耗时会更加久,延迟更大

AOF持久化

同步持久化

当 Redis 直接记录 AOF 日志时,如果有大量的写操作,并且配置为
同步持久化

appendfsync always

即每次发生数据变更会被立即记录到磁盘,并且Always写回策略是由
主进程
执行的,而写磁盘比较耗时,性能较差,所以有时会阻塞主线程。

AOF重写

  1. fork 出一条子线程来将文件重写,在执行
    BGREWRITEAOF
    命令时,Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子线程创建新 AOF 文件期间,记录服务器执行的所有写命令。
  2. 当子线程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新的 AOF 文件保存的数据库状态与现有的数据库状态一致。
  3. 最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。

阻塞就是出现在第2步的过程中,将缓冲区中新数据写到新文件的过程中会产生
阻塞

fork耗时

生成RDB和AOF重写都需要父进程fork出一个子进程进行数据的持久化,在fork执行过程中,父进程需要拷贝内存页表给子进程,如果整个实例内存占用很大,那么需要拷贝的内存页表会比较耗时,此过程会消耗大量的CPU资源,在完成fork之前,整个实例会被阻塞住,无法处理任何请求,如果此时CPU资源紧张,那么fork的时间会更长,甚至达到秒级。这会严重影响Redis的性能。

Redis 在进行 RDB 快照的时候,会调用系统函数 fork() ,创建一个子线程来完成临时文件的写入,而触发条件正是配置文件中的 save 配置。当达到配置时,就会触发 bgsave 命令创建快照,这种方式是不会阻塞主线程的,而手动执行 save 命令会在主线程中执行,
阻塞
主线程。

除了因为备份的原因生成RDB之外,在【主从复制】第一次建立连接全量复制时,主节点也会生成RDB文件给从节点进行一次全量同步,这时也会对Redis产生性能影响。

要想避免这种情况,需要规划好数据备份的周期,建议在
从节点上执行备份
,而且最好放在低峰期执行。如果对于丢失数据不敏感的业务,那么不建议开启AOF和AOF重写功能。

集群扩容

Redis 集群可以进行节点的动态扩容缩容,这一过程目前还处于
半自动
状态,需要人工介入。

在扩缩容的时候,需要进行数据迁移。而 Redis 为了保证迁移的一致性,迁移所有操作都是
同步
操作。

执行迁移时,两端的 Redis 均会进入时长不等的
阻塞
状态,对于小Key,该时间可以忽略不计,但如果一旦 Key 的内存使用过大,严重的时候会触发集群内的故障转移,造成不必要的切换。

总结

  1. 使用复杂度高的命令,执行命令时就会耗时
  2. 存储大key:如果一个key写入的数据非常大,Redis在分配内存、删除大key时都会耗时,并且持久化AOF的写回策略是always时会影响Redis性能
  3. 集中过期:Redis的主动过期的定时任务,是在Redis
    主线程
    中执行的,最差的情况下会有25ms的阻塞
  4. 实例内存达到上限时,淘汰策略的逻辑都是在访问Redis时,真正命令执行之前执行的,也就是它会影响真正需要执行的命令。
  5. fork耗时:生成RDB和AOF重写都需要父进程fork出一个子进程进行数据的持久化,如果整个实例内存占用很大,那么需要拷贝的内存页表会比较耗时

额外总结大key的影响:

  1. 如果一个key写入的数据非常大,Redis在分配内存、删除大key时都会耗时。
  2. 当实例内存达到上限时,在淘汰大key释放内存时,内存淘汰策略的耗时会更加久,延迟更大
  3. AOF持久化时,使用always机制,这个操作是在
    主线程
    中执行的,如果写入是一个大 Key,主线程在执行 fsync() 函数的时候,阻塞的时间会更久。
  4. 生成RDB和AOF重写时会fork出一个子进程进行数据的持久化,父进程需要拷贝内存页表给子进程,如果整个实例内存占用很大,那么需要拷贝的内存页表会比较耗时。

面试题专栏

Java面试题专栏
已上线,欢迎访问。

  • 如果你不知道简历怎么写,简历项目不知道怎么包装;
  • 如果简历中有些内容你不知道该不该写上去;
  • 如果有些综合性问题你不知道怎么答;

那么可以私信我,我会尽我所能帮助你。