人的知识就好比一个圆圈,圆圈里面是已知的,圆圈外面是未知的。你知道得越多,圆圈也就越大,你不知道的也就越多。

0%

在一个服务开发框架当中,统一异常处理(Error Handling)可以说是最基础的框架功能。

如果没有统一异常处理,一方面无法规范开发人员的异常处理逻辑,另一方面由于异常处理的不规范,也会创造排查问题时效率低下,所以一般企业级服务框架都会对异常处理进行统一封装,并尽可能地减少开发人员手动去处理异常的机会。

在 Spring 中,通过使用 @RestControllerAdvice 和 @ExceptionHandler 这两个注解,我们可以很容易的实现统一异常处理。

工作原理

下图演示了 @RestControllerAdvice 是如何工作的。

RestControllerAdvice工作原理图

代码示例

GlobalExceptionTranslator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
@Slf4j
@RestControllerAdvice
public class GlobalExceptionTranslator {

/**
* 请求读取错误
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public BaseResponse handleError(HttpMessageNotReadableException e) {
log.error("Message Not Readable", e);
return BaseResponse
.builder()
.code(ResultCode.MSG_NOT_READABLE)
.message(e.getMessage())
.build();
}

/**
* 请求类型不支持错误
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public BaseResponse handleError(HttpRequestMethodNotSupportedException e) {
log.error("Request Method Not Supported", e);
return BaseResponse
.builder()
.code(ResultCode.METHOD_NOT_SUPPORTED)
.message(e.getMessage())
.build();
}

/**
* MIME 类型不支持错误
*/
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public BaseResponse handleError(HttpMediaTypeNotSupportedException e) {
log.error("Media Type Not Supported", e);
return BaseResponse
.builder()
.code(ResultCode.MEDIA_TYPE_NOT_SUPPORTED)
.message(e.getMessage())
.build();
}

/**
* 参数缺少错误
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public BaseResponse handleError(MissingServletRequestParameterException e) {
log.warn("Missing Request Parameter", e);
String message = String.format("Missing Request Parameter: %s", e.getParameterName());
return BaseResponse
.builder()
.code(ResultCode.PARAM_MISS)
.message(message)
.build();
}

/**
* 参数类型错误
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public BaseResponse handleError(MethodArgumentTypeMismatchException e) {
log.warn("Method Argument Type Mismatch", e);
String message = String.format("Method Argument Type Mismatch: %s", e.getName());
return BaseResponse
.builder()
.code(ResultCode.PARAM_TYPE_ERROR)
.message(message)
.build();
}

/**
* 参数绑定错误
*/
@ExceptionHandler(BindException.class)
public BaseResponse handleError(BindException e) {
log.warn("Bind Exception", e);
FieldError error = e.getFieldError();
String message = String.format("%s:%s", error.getField(), error.getDefaultMessage());
return BaseResponse
.builder()
.code(ResultCode.PARAM_BIND_ERROR)
.message(message)
.build();
}

/**
* 参数校验错误
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public BaseResponse handleError(MethodArgumentNotValidException e) {
log.warn("Method Argument Not Valid", e);
BindingResult result = e.getBindingResult();
FieldError error = result.getFieldError();
String message = String.format("%s:%s", error.getField(), error.getDefaultMessage());
return BaseResponse
.builder()
.code(ResultCode.PARAM_VALID_ERROR)
.message(message)
.build();
}

/**
* 参数校验错误
*/
@ExceptionHandler(ConstraintViolationException.class)
public BaseResponse handleError(ConstraintViolationException e) {
log.warn("Constraint Violation", e);
Set<ConstraintViolation<?>> violations = e.getConstraintViolations();
ConstraintViolation<?> violation = violations.iterator().next();
String path = ((PathImpl) violation.getPropertyPath()).getLeafNode().getName();
String message = String.format("%s:%s", path, violation.getMessage());
return BaseResponse
.builder()
.code(ResultCode.PARAM_VALID_ERROR)
.message(message)
.build();
}

/**
* 业务异常
*/
@ExceptionHandler(ServiceException.class)
public BaseResponse handleError(ServiceException e) {
log.error("Service Exception", e);
return BaseResponse
.builder()
.code(e.getResultCode())
.message(e.getMessage())
.build();
}

/**
* 未授权错误
*/
@ExceptionHandler(PermissionDeniedException.class)
public BaseResponse handleError(PermissionDeniedException e) {
log.error("Permission Denied", e);
return BaseResponse
.builder()
.code(e.getResultCode())
.message(e.getMessage())
.build();
}

/**
* 404 错误
*/
@ExceptionHandler(NoHandlerFoundException.class)
public BaseResponse handleError(NoHandlerFoundException e) {
log.error("404 Not Found", e);
return BaseResponse
.builder()
.code(ResultCode.NOT_FOUND)
.message(e.getMessage())
.build();
}


/**
* 500 错误
*/
@ExceptionHandler(Throwable.class)
public BaseResponse handleError(Throwable e) {
log.error("Internal Server Error", e);
return BaseResponse
.builder()
.code(ResultCode.INTERNAL_SERVER_ERROR)
.message(e.getMessage())
.build();
}
}

ResultCode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
@Getter
@AllArgsConstructor
public enum ResultCode {
/**
* 操作成功
*/
SUCCESS(HttpServletResponse.SC_OK, "Operation is Successful"),

/**
* 操作失败
*/
FAILURE(HttpServletResponse.SC_BAD_REQUEST, "Business Exception"),

/**
* 请求读取错误
*/
MSG_NOT_READABLE(HttpServletResponse.SC_BAD_REQUEST, "Message Can't be Read"),

/**
* 请求类型不支持错误
*/
METHOD_NOT_SUPPORTED(HttpServletResponse.SC_METHOD_NOT_ALLOWED, "Method Not Supported"),

/**
* MIME 类型不支持错误
*/
MEDIA_TYPE_NOT_SUPPORTED(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, "Media Type Not Supported"),

/**
* 参数缺少错误
*/
PARAM_MISS(HttpServletResponse.SC_BAD_REQUEST, "Missing Required Parameter"),

/**
* 参数类型错误
*/
PARAM_TYPE_ERROR(HttpServletResponse.SC_BAD_REQUEST, "Parameter Type Mismatch"),

/**
* 参数绑定错误
*/
PARAM_BIND_ERROR(HttpServletResponse.SC_BAD_REQUEST, "Parameter Binding Error"),

/**
* 参数校验错误
*/
PARAM_VALID_ERROR(HttpServletResponse.SC_BAD_REQUEST, "Parameter Validation Error"),

/**
* 未授权错误
*/
UN_AUTHORIZED(HttpServletResponse.SC_UNAUTHORIZED, "Request Unauthorized"),

/**
* 请求拒绝错误
*/
REQ_REJECT(HttpServletResponse.SC_FORBIDDEN, "Request Rejected"),

/**
* 404 错误
*/
NOT_FOUND(HttpServletResponse.SC_NOT_FOUND, "404 Not Found"),

/**
* 500 错误
*/
INTERNAL_SERVER_ERROR(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Internal Server Error");

/**
* 错误码
*/
final int code;

/**
* 错误提示
*/
final String msg;
}

ServiceException:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class ServiceException extends RuntimeException {
private static final long serialVersionUID = 2359767895161832954L;

@Getter
private final ResultCode resultCode;

public ServiceException(String message) {
super(message);
this.resultCode = ResultCode.FAILURE;
}

public ServiceException(ResultCode resultCode) {
super(resultCode.getMsg());
this.resultCode = resultCode;
}

public ServiceException(ResultCode resultCode, String msg) {
super(msg);
this.resultCode = resultCode;
}

public ServiceException(ResultCode resultCode, Throwable cause) {
super(cause);
this.resultCode = resultCode;
}

public ServiceException(String msg, Throwable cause) {
super(msg, cause);
this.resultCode = ResultCode.FAILURE;
}

/**
* for better performance
*/
@Override
public Throwable fillInStackTrace() {
return this;
}

public Throwable doFillInStackTrace() {
return super.fillInStackTrace();
}
}

PermissionDeniedException:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class PermissionDeniedException extends RuntimeException {

@Getter
private final ResultCode resultCode;

public PermissionDeniedException(String message) {
super(message);
this.resultCode = ResultCode.UN_AUTHORIZED;
}

public PermissionDeniedException(ResultCode resultCode) {
super(resultCode.getMsg());
this.resultCode = resultCode;
}

public PermissionDeniedException(ResultCode resultCode, Throwable cause) {
super(cause);
this.resultCode = resultCode;
}

/**
* for better performance
*/
@Override
public Throwable fillInStackTrace() {
return this;
}
}

在企业级服务开发当中,开发人员经常犯的一个错误就是忽略对接口参数做必要的校验,造成调用异常,甚至关键服务挂掉,所以开发人员必须重视参数校验。

因为参数校验和具体业务场景是相关的,一般无法做到自动化,因此参数校验主要是业务开发人员的职责。当然,服务框架应该提供必要的支持,方便开发人员对请求参数添加必要的校验。

在接口参数校验这方面,Spring 提供了完善的支持,我们只需在 Controller 接口参数前添加相关注解,即可实现对应的参数校验。

基础校验

  • NotNull
  • NotEmpty
  • NotBlank
  • Size
  • Digits
  • Max
  • Min
  • DecimalMax
  • DecimalMin
  • Positive
  • PositiveOrZero
  • Negative
  • NegativeOrZero
  • AssertTrue
  • AssertFalse
  • Email
  • Pattern
  • Null
  • Past
  • PastOrPresent
  • Future
  • FutureOrPresent

对象校验

如果参数是对象类型,我们需要在该参数前面添加 @Valid 注解,然后在对象属性上添加前面列出的基础校验注解。

自定义校验

我们也可以手动创建相关注解,已实现自定义校验。下面以创建子表非空检验(逻辑删除)为例进行演示。

  1. 创建注解

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @Constraint(validatedBy = SublistNotEmptyValidator.class)
    @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface SublistNotEmpty {

    String message() default "{javax.validation.constraints.NotEmpty.message}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
    }
  2. 创建注解解析器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class SublistNotEmptyValidator implements ConstraintValidator<SublistNotEmpty, List<? extends LogicDeleteAware>> {

    @Override
    public void initialize(SublistNotEmpty constraintAnnotation) {

    }

    @Override
    public boolean isValid(List<? extends LogicDeleteAware> value, ConstraintValidatorContext context) {
    return !CollectionUtils.isEmpty(value) && !value.stream().allMatch(LogicDeleteAware::isDeleted);
    }
    }

校验示例

基础校验:

1
2
3
4
@GetMapping("account")
public AccountVo getAccount(@RequestParam @NotBlank String userId) {
...
}

对象校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@PostMapping("save")
public AccountVo save(@RequestBody @Valid AccountVo vo) {
...
}

public class AccountVo {
@NotBlank(message = "账号名不能为空")
private String accountName;

@SublistNotEmpty(message = "账号明细不能为空")
@Valid
private List<AccountDetailVo> accountDetails;

...
}

微服务公共关注点

微服务公共关注点

开源框架/平台

目前,微服务主流开源框架/平台有 Dubbo、Spring Cloud 以及 k8s。

横向对比

Dubbo Spring Cloud k8s
配置管理 Diamond->Nacos Spring Cloud Config ConfigMap/Secrets
服务发现与负载均衡 ZooKeeper->Nacos + Client Eureka + Ribbon -> Spring Cloud Consul + Spring Cloud OpenFeign Service
容错限流 Sentinel Hystrix -> Spring Cloud Circuit Breaker(Resilience4j) HealthCheck/Probe/ServiceMesh
API管理
服务安全
日志监控 ELK ELK EFK
Metrics 监控 Dubbo Admin/Monitor Actuator/MicroMeter + Prometheus /Heapster + Prometheus
调用链监控 NA Spring Cloud Sleuth(Zipkin) Jaeger/Zipkin
调度和发布 NA NA Scheduler
自动伸缩和治愈 NA NA Scheduler/AutoScaler
流量治理 ZooKeeper + Client NA ServiceMesh
服务框架 Dubbo RPC SpringBoot Rest 框架无关
应用打包 Jar/War Uber Jar/War Docker Image/Helm
进程隔离 NA NA Docker/Pod
环境管理 NA NA Namespace/Authorization
资源配额 NA NA CPU/Mem Limit, Namespace Quotas

优劣对比

Dubbo Spring Cloud k8s
亮点 阿里背书
成熟稳定
RPC 高性能
流量治理
Netflix/Pivotal 背书
社区活跃
开发体验好
抽象组件化好
谷歌背书
平台抽象
全面覆盖微服务关注点(发布)
语言栈无关
社区活跃
不足 技术较老
耦合性高
JVM only
国外社区小
JVM only
运行耗资源
偏 DevOps 和运维
重量复杂
技术门槛高

建议

  • 理解微服务关注点,根据企业上下文综合考量
  • 尽量不要混搭,保持体系一致性
  • 个人倾向 k8s + Spring Boot

数据建模

  • 尽可能 Denormalize 数据,从而获取最佳性能
    • 使用 Nested 类型的数据,查询数据会慢几倍
    • 使用 Parent/Child 关系,查询速度会慢几百倍
  • 尽量将数据先行计算,然后保存在 Elasticsearch 中,以避查询时的 Script 计算
  • 尽量使用 Filter Context,利用缓存机制,同时减少不必要的算分
  • 结合 profile、explain API 分析慢查询的问题,持续优化数据模型
    • 严禁使用 * 开头的通配符 Terms 查询
  • 聚合查询时,控制聚合的数量,以减少内存的开销

优化分片

  • 避免 Over Sharing
    一个查询需要访问每一个分片,分片过多,会导致不必要的查询开销
  • 结合应用场景,控制单个分片的尺寸
    Search: 200GB Logging: 400GB
  • Force-merge Read-only 索引
    使用基于时间序列的索引,将只读的索引进行 force merge,减少 segment 数量

客户端

  • 多线程写入
  • 使用 Bulk API 进行批量写
    • 单个 Bulk 请求体的数据量不要太大,官方建议大约 5-15 mb
    • 写入端的 bulk 请求超时需要足够长,建议 60s 以上
    • 写入端尽量将数据轮询达到不同的节点上

服务器端

  • 降低 IO 操作

    • 使用 ES 自动生成文档的 id
    • 设置 ES 配置(如 refresh_interval)
  • 降低 CPU 和存储开销

    • 减少不必要的分词
    • 避免不必要的 doc_values(禁用后不能用于排序、聚合以及脚本访问)
    • 文档的字段尽量保证相同的顺序(提高文档的压缩率)
  • 尽可能做到写入和分片的均衡负载,实现水平扩展

    • 使用 Shard Filtering 来控制将分配到哪个节点
    • Write Load Balancer
    • 配置 index.routing.allocation.total_share_per_node,限定每个索引在每个节点上可分配的主分片数
  • 调整 Bulk 线程池和队列

    • 应使用固定大小(CPU 核数 + 1)的线程池,避免过多的上下文切换
    • 队列大小可以适当增加,不要过大,否则占用的内存会成为 GC 的负担

文档建模(关闭无关的功能)

  • 只需要聚合不需要搜索,index 设置成 false
  • 不需要算分,norms 设置成 false
  • 对于指标型数据,关闭 _source,以减少 IO 操作
  • 对于 Text 类型字段,不采用默认的 mapping,应结合实际需求人工设定
  • 不要对字符串使用默认的 dynamic mapping(字段数量过多,会对性能产生较大的影响)
  • 合理设置 index_options: doc |freqs | positions | offsets。(Text 类型默认记录级别为 positions,其它类型默认为 docs。记录内容越多,占用的存储空间越大。)

性能取舍

如果需要追求极致的写入速度,可以牺牲数据可靠性及搜索实时性以换取性能。

  • 牺牲可靠性:将副本分片设置为 0,写入完毕再调整回去
  • 牺牲搜索实时性:增加 refresh_interval 的时间,默认 1s;增大 indices.memory.index_bufer_size,默认是 10%,会导致自动触发 refresh
  • 牺牲可靠性:修改 translog 的配置,默认每次请求都会异步落盘

在平时线上 Redis 维护工作中,有时候需要从 Redis 实例成千上万的 key 中找出特定前缀的 key 列表来手动处理数据,可能是修改它的值,也可能是删除 key。这里就有一个问题,如果从海量的 key 中找出满足特定前缀的 key 列表来?

keys

Redis 提供了一个简单暴力的指令 keys 用来列出所有满足特定正则字符串规则的 key。

1
2
3
4
5
keys *

keys codehole*

keys code*hole

这个指令使用非常简单,提供一个简单的正则字符串即可,但是有很明显的缺点:keys 算法是遍历算法,复杂度是 O(n),且该指令没有 offset、limit 参数,如果实例中有千万级以上的 key,这个指令就会导致 Redis 服务卡顿,所有读写 Redis 的其它指令都会被延后甚至超时报错,因为 Redis 是单线程程序,顺序执行所有指令,其它指令必须等到当前的 keys 指令执行完了才可以继续。

scan

Redis 为了解决这个问题,还提供了 scan 指令, scan 相比 keys 具备以下特点:

  1. 复杂度虽然也是 O(n),但是它是通过游标分布进行的,不会阻塞线程;
  2. 提供 limit 参数,可以控制每次返回结果的最大条数,limit 只是一个 hint,返回的结果可多可少;
  3. 同 keys 一样,它也提供模式匹配功能;
  4. 服务器不需要为游标保存状态,游标的唯一状态就是 scan 返回给客户端的游标整数;
  5. 返回的结果可能会有重复,需要客户端去重,这点非常重要;
  6. 遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的;
  7. 每次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为零。

scan 基础使用

scan 参数提供了 3 个参数,第一个是 cursor 整数值,第二个是 key 的正则模式,第三个是遍历的 limit hint。第一次遍历时,cursor 值为 0,然后将返回结果中第一个整数值作为下一次遍历的 cursor。一直遍历到返回的 cursor 值为 0 时结束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
127.0.0.1:6379> scan 0 match key99* count 1000
1) "13976"
2) 1) "key9911"
2) "key9974"
3) "key9994"
4) "key9910"
5) "key9907"
6) "key9989"
7) "key9971"
8) "key99"
9) "key9966"
10) "key992"
11) "key9903"
12) "key9905"
127.0.0.1:6379> scan 13976 match key99* count 1000
1) "1996"
2) 1) "key9982"
2) "key9997"
3) "key9963"
4) "key996"
5) "key9912"
6) "key9999"
7) "key9921"
8) "key994"
9) "key9956"
10) "key9919"
127.0.0.1:6379> scan 1996 match key99* count 1000
1) "12594"
2) 1) "key9939"
2) "key9941"
3) "key9967"
4) "key9938"
5) "key9906"
6) "key999"
7) "key9909"
8) "key9933"
9) "key9992"
......
127.0.0.1:6379> scan 11687 match key99* count 1000
1) "0"
2) 1) "key9969"
2) "key998"
3) "key9986"
4) "key9968"
5) "key9965"
6) "key9990"
7) "key9915"
8) "key9928"
9) "key9908"
10) "key9929"
11) "key9944"

从上面的过程可以看到虽然提供的 limit 是 1000,但是返回的结果只有 10 个左右。因为这个 limit 不是限定返回结果的数量,而是限定服务器单次遍历的字典槽位数量(约等于)。如果将 limit 设置为 10,会发现返回结果是空的,但是游标值不为零,意味着遍历还没结束。

1
2
3
127.0.0.1:6379> scan 0 match key99* count 10
1) "3072"
2) (empty list or set)

字典的结构

在 Redis 中所有的 key 都存储在一个很大的字典中,这个字典的结构和 Java 中的HashMap 一样,是一维数组 + 二维链表结构,第一维数组的大小总是 2^n(n>=0),扩容一次数组大小空间加倍,也就是 n++。

Redis字典结构

scan 指令返回的游标就是第一维数组的位置索引,我们将这个位置索引称为槽 (slot)。如果不考虑字典的扩容缩容,直接按数组下标挨个遍历就行了。limit 参数就表示需要遍历的槽位数,之所以返回的结果可能多可能少,是因为不是所有的槽位上都会挂接链表,有些槽位可能是空的,还有些槽位上挂接的链表上的元素可能会有多个。每一次遍历都会将 limit 数量的槽位上挂接的所有链表元素进行模式匹配过滤后,一次性返回给客户端。

scan 遍历顺序

scan 的遍历顺序非常特别。它不是从第一维数据的第 0 位一直遍历到末尾,而是采用了高位进位加法来遍历。之所以使用这样特殊的方式进行遍历,是考虑到字典的扩容和缩容时避免槽位的遍历重复和遗漏

首先我们用动画演示以下普通的加法和高位进位加法的区别。

高位进位加法演示

更多的 scan 指令

scan 指令是一系列指令,除了可以遍历所有的 key 之外,还可以对指定的容器集合进行遍历。比如 hscan 遍历 hash 字典的元素、sscan 遍历 set 集合的元素、zscan 遍历 zset 集合的元素。

它们的原理同 scan 都会类似的,因为 hash 底层就是字典,set 也是一个特殊的 hash,zset 内部也使用了字典来存储所有的元素内容,所以这里不再赘述。

大 key 扫描

有时候会因为业务人员使用不当,在 Redis 实例中会形成很大的对象,比如一个很大的 hash,一个很大的 zset 这都是经常出现的。这样的对象对 Redis 的集群数据迁移带来了很大的问题,因为在集群环境下,如果某个 key 太大,会数据导致迁移卡顿。另外在内存分配上,如果一个 key 太大,那么当它需要扩容时,会一次性申请更大的一块内存,这也会导致卡顿。如果这个大 key 被删除,内存会一次性回收,卡顿现象会再一次产生。

如果我们观察到 Redis 的内存大起大落,这极有可能是因为大 key 导致的,这时候就需要定位出具体是那个 key,进一步定位出具体的业务来源,然后再改进相关业务代码设计。

为了避免对线上 Redis 带来卡顿,这就要用到 scan 指令,对于扫描出来的每一个 key,使用 type 指令获得 key 的类型,然后使用相应数据结构的 size 或者 len 方法来得到它的大小,对于每一种类型,保留大小的前 N 名作为扫描结果展示出来。

上面这样的过程需要编写脚本,比较繁琐,不过 Redis 官方已经在 redis-cli 指令中提供了这样的扫描功能,我们可以直接拿来即用。

1
redis-cli -h 127.0.0.1 -p 7001 –-bigkeys

如果你担心这个指令会大幅抬升 Redis 的 ops 导致线上报警,还可以增加一个休眠参数。

1
redis-cli -h 127.0.0.1 -p 7001 –-bigkeys -i 0.1

上面这个指令每隔 100 条 scan 指令就会休眠 0.1s,ops 就不会剧烈抬升,但是扫描的时间会变长。

指令安全

Redis 中有一些非常危险的指令,这些指令会对 Redis 的稳定以及数据安全造成非常严重的影响。比如 keys 指令会导致 Redis 卡顿,flushdbflushall 会让 Redis 中的所有数据全部清空。

Redis 在配置文件中提供了 rename-command 指令用于将某些危险的指令改成特别的名称,用来避免人为误操作。比如在配置文件的 security 块增加以下内容:

1
rename-command keys abckeyabc

如果还想执行 keys 指令,那就不能再敲 keys 指令了,而需要键入 abckeyabc。如果想完全封杀某条命令,可以将指令 rename 成空串,就无法通过任何字符串指令来执行这条指令了。

1
rename-command flushall ""

端口安全

Redis 默认会监听 6379 端口,如果当前的服务器主机有外网地址,Redis 的服务将会直接暴露再公网上,任何一个初级黑客使用适当的工具对 IP 地址进行端口扫描就可以探测出来。

所以,运维人员必须在 Redis 的配置文件中指定监听的 IP 地址,从而避免这样的惨剧发生。

1
bind 10.100.20.13

更进一步,还可以增加 Redis 的密码访问限制,客户端必须使用 auth 指令传入正确的密码才可以访问 Redis,这样即使地址暴露出去了,普通黑客也无法对 Redis 进行任何指令操作。

1
requirepass yoursecurepasswordhereplease

密码控制也会影响到从库复制,从库必须在配置文件里使用 masterauth 指令配置相应的密码才可以进行复制操作。

1
masterauth yoursecurepasswordhereplease

Lua 脚本安全

开发者必须禁止 Lua 脚本由用户输入的内容(UGC)生成,这可能会被黑客利用以植入恶意的攻击代码来得到 Redis 的主机权限。

同时,我们应该让 Redis 以普通用户的身份启动,这样即使存在恶意代码黑客也无法拿到 root 权限。

SSL 代理

Redis 并不支持 SSL 链接,意味着客户端和服务器之间交互的数据不应该直接暴露在公司网上传输,否则会有被窃听的风险。如果必须要用在公网上,可以考虑使用 SSL 代理。

SSL 代理比较常见的有 ssh,不过 Redis 官方推荐使用 spiped 工具,可能是因为 spiped 的功能相对比较单一,使用也比较简单,易于理解。

概念

布隆过滤器(Bloom Filter)是一种空间效率极高的概率型算法和数据结构,主要用来判断一个元数是否在集合中存在。
因为它是一个概率型的算法,所以会存在一定的误差,如果传入一个值去布隆过滤器中检索,可能会出现检测存在的结果实际上是不存在的,但是肯定不会出现实际上不存在却反馈存在的结果。
因此,布隆过滤器不适合那些“零错误”的应用场合。而在能容忍低错误率的应用场合下,布隆过滤器通过极少的错误换取了存储空间的极大节省。

使用

我们需要通过加载 module 来使用 Redis 中的布隆过滤器。使用 Docker 可以直接在 Redis 中体验布隆过滤器。

1
2
docker run -d -p 6379:6379 --name bloomfilter redislabs/rebloom
docker exec -it bloomfilter redis-cli

Redis 布隆过滤器主要就 2 个指令:

  • bf.add 添加元素到布隆过滤器中:bf.add urls https://cdrcool.github.io
  • bf.exists 判断某个元素是否在过滤器中:bf.exists urls https://cdrcool.github.io

如果我们需要一次添加或查询多个元素,还可以使用以下 2 个指令:

  • bf.madd 判断某个元素是否在过滤器中:bf.exists urls https://cdrcool.github.io https://github.com
  • bf.mexists 判断某个元素是否在过滤器中:bf.exists urls https://cdrcool.github.io https://github.com

当我们调用 bd.add 或 bf.madd 指令时,如果此时布隆过滤器不存在,那么 Redis 会帮我们自动创建。Redis 其实还提供了自定义参数的布隆过滤器,需要我们在 add 之前使用 bf.reserve 指令显示创建,如:bf.reserve urls 0.01 100
bf.reservee 有 3 个参数:

  • key 过滤器的名字。
  • error_rate 允许布隆过滤器的错误率,这个值越低过滤器的位数组的大小越大,占用空间也就越大。
  • initial_size 布隆过滤器可以储存的元素个数,当实际存储的元素个数超过这个值之后,过滤器的准确率会下降。

需要注意的是,在执行 bf.reservee 这个命令之前过滤器的名字应该不存在,不然会报错。

原理

每个布隆过滤器对应到 Redis 的数据结构里面就是一个大型的 BitMap 和几个不一样的无偏 hash 函数。所谓无偏就是能够把元素的 hash 值算的比较均匀

向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算的一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算的一个不同的位置,再把位数组的这几个位置都置为 1 就完成了 add 操作。

向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出来,看看位数组中这几个位置是否都为 1,只要有一个位为 0,那么说明布隆过滤器中这个 key 不存在。如果都是 1,这并不能说明这个 key 就一定存在,只是极有可能存在,因为这些位置被置为 1 可能是因为其它的 key 存在所致。如果这个数组比较稀疏,判断正确的概率就会很大。

使用时不要让实际元素远大于初始化大小,当实际元素开始超出初始化大小时,应该对布隆过滤器进行重建,重新分配一个 size 更大的过滤器,再将所有的历史元素批量 add 进去(这就要求我们在其它的存储器中记录所有的历史元素)。

应用场景

  • 爬虫系统中 URL 去重
  • 邮箱系统中垃圾邮件过滤
  • 推荐系统中新闻推荐

参考资料

  1. Redis 中的布隆过滤器

缓存穿透

描述

缓存穿透是指缓存和数据库中都没有数据,而用户却不断在发起请求,如查找 id 为 -1 或特别大等不存在的的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。

解决方案

  1. 接口层增加校验,如用户鉴权校验,id 做基础校验,id <= 的直接拦截。
  2. 对于缓存和数据库中都取不到的数据,可以将对应 key 的 value 设置为 null,同时设置 key 的过期时间,过期时间可以设置断点,如 30 秒,设置过长会导致正常情况下也查找不到数据。
  3. 使用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力。

缓存击穿

描述

缓存击穿是指缓存中没有但数据库中有数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没有读到数据,又同时去数据库中查找数据,引起数据库压力瞬间增大,造成过大压力。

解决方案

  1. 设置热点数据永不过期。
  2. 增加互斥锁,参考代码如下:

互斥锁示例

说明:

1) 缓存中有数据,直接走上述代码 13 行后就返回结果了;
2) 缓存中没有数据,第 1 个进入的线程,获取锁并从数据库去取数据,没释放锁之前,其他并行进入的线程会等待 100ms,再重新去缓存取数据。这样就防止都去数据库重复取数据,重复往缓存中更新数据情况出现。
3) 当然这是简化处理,理论上如果能根据 key 值加锁就更好了,就是线程 A 从数据库取 key1 的数据并不妨碍线程 B 取 key2 的数据,上面代码明显做不到这点。

缓存雪崩

描述

缓存雪崩是值缓存中数据大批量到了过期时间,而查询数据量巨大,引起数据库压力过大甚至宕机。与缓存击穿不同的是缓存击穿是并发查找同一条数据,缓存雪崩是并发查找大量数据。

解决方案

  1. 设置热点数据永远不过期。
  2. 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
  3. 如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中。
  4. 缓存标记:记录缓存数据是否过期,如果过期会触发通知另外的线程在后台去更新实际 key 的缓存。

参考资料

  1. 缓存穿透、缓存击穿、缓存雪崩区别和解决方案
  2. REDIS缓存穿透,缓存击穿,缓存雪崩原因+解决方案

分布式锁本质上要实现的目标就是在 Redis 里面占一个“茅坑”,当别的进程也要来占时,发现已经有人蹲在那里了,就只好放弃或者稍候再试。

占坑一般是使用 setnx(set if not exist) 指令,只允许被一个客户端占用。先来先占,再调用 del 指令释放茅坑。

1
2
3
4
5
setnx lock:codehole true

... do something critical ...

del lock:codehole

但是有个问题,如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用,这样就会陷入死锁,锁永远得不到释放。

于是我们在拿到锁之后,再给锁加上一个过期时间,比如 5s,这样及时中间出现异常也可以保证 5s 之后锁会自动释放。

1
2
3
4
5
6
7
setnx lock:codehole true

expire lock:codehole 5

... do something critical ...

del lock:codehole

但是以上逻辑还有问题。如果再 setnx 和 expire 之间服务进程突然挂掉了,可能是因为机器掉电或者被人为杀掉的,就会导致 expire 得不到执行,也会造成死锁。

这种问题的根源就在于 setnx 和 expire 是两条指令而不是原子指令,所以我们应该使用这两个指定的原子指令。

1
2
3
4
5
set lock:codehole true ex 5 nx

... do something critical ...

del lock:codehole

超时问题

Redis 分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行时间太长,以至于超出了锁的超时限制,就会出现问题。因为这时候第一个线程持有的锁过期了,临界区的逻辑还没有执行完,这个时候第二个线程就提前重新持有了这把锁,导致临界区代码不能得到严格的串行化执行。

为了避免这个问题,Redis 分布式锁不要用于较长时间的任务。如果真的偶尔出现了,数据出现的小波错乱可能需要人工介入结解决。

有一个稍微安全一点的方案是为 set 指令的 value 参数设置为一个随机数,释放锁时先匹配随机数是否一致,然后再删除 key,这是为了确保当前线程占有的锁不会被其它线程释放,除非这个锁时过期了被服务器自动释放的。但是匹配 value 和删除 key 不是一个原子操作,Redis 也没有提供类似于 delifequals 这样的指令,这就需要使用 Lua 脚本来处理了,因为 Lua 脚本可以保证连续多个指令的原子性执行。

1
2
3
4
5
6
# delifequals
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end

但是这也不是一个完美的方案,它只是相对安全一点,因为如果真的超时了,当前线程的逻辑没有执行完,其它线程也会乘虚而入。

可重入性

可重入性是指线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程的多次加锁,那么这个锁就是可重入的。Redis 分布式锁如果要支持可重入,需要对客户端的 set 方法进行包装,使用线程的 Threadlocal 变量存储当前持有锁的计数。

不推荐使用可重入锁,它加重了客户端的复杂性,在编写业务方法时注意在逻辑结构上进行调整完全可以不适用可重入锁。

Readlock

在集群环境下,前面的分布式锁实现是有缺陷的,它不是绝对安全的,

比如在 Sentinel 集群中,主节点挂掉时,从节点会取而代之,客户端上却并没有明显感知。原先第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来得及同步到从节点,主节点突然挂掉了。然后从节点变成了主节点,这个新的节点内部没有这个锁,所以当一个客户端过来请求加锁时,立即就批准了。这样就会导致系统中同样一把锁被两个客户端同时持有,不安全性由此产生。

不过这种不安全也仅仅是在主从发生 failover 的情况下才会产生,而且持续时间极短,业务系统多数情况下可以容忍。

为了解决这个问题,Redis 提供了 Readlock。要使用 Readlock,需要提供多个 Redis 实例,这些实例之前相互独立没有主从关系。同很多分布式算法一样,Readloack 也使用“大多数机制”。

使用 Readlock 也是有代价的,需要更多的 Redis 实例,性能也下降了,代码上还需要引入额外的 library,运维上也需要特殊对待,这些都是需要考虑的成本。