StrongFriends开发笔记

前言

StrongFriends,又称壮壮朋友(๑•̀ㅂ•́)و✧,是我开发的一个举铁爱好者论坛。主要功能包括:发帖、评论、赞踩、私信、RM 计算器、维京系数计算器、力量排行榜、帖子 / 评论 / 用户管理。

前端:BootStraps 后端:SpringBoot / Mybatis DB: MySQL / Redis

以下是个人在实现一些重点功能所做的笔记。

Spring 接收参数的几种方式

1. 通过 @RequestParam 获取请求参数

用注解@RequestParam绑定请求参数 param 到变量 testParam,当请求参数 param 不存在时会有异常发生,可以通过设置属性 required=false 解决,例如 @RequestParam(value="a", required=false)

1
2
3
4
5
6
@RequestMapping(value = "/test", method = RequestMethod.GET, RequestMethod.POST)
@ResponseBody
public String test(@RequestParam("param") String testParam) {
System.out.println("Requst parm is "+testParam);
return "Test";
}

2. 通过 @PathVariable 获取请求参数

1
2
3
4
5
6
@RequestMapping(value = "/test/{parm}", method = RequestMethod.GET, RequestMethod.POST)
@ResponseBody
public String test(@PathVariable("parm") String testParm){
System.out.println("Requst parm is "+testParm);
return "Test";
}

PS:使用 @RequestParam 时,URL 是这样的:http://localhost:8080/path?参数名=参数值
使用 @PathVariable 时,URL是这样的:http://localhost:8080/path/参数值

3. 通过 HttpServletRequest 获取前端发送 JSON 数据

前端示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script>
$(function () {
$.ajax({
type: "POST",
url: "http://localhost:8080/test/",
data: {
"param": "测试参数"
},
async: true,
success: function (data) {
console.log(data);
}
});
})
</script>

使用 HttpServletRequest 获取 JSON :

1
2
3
4
5
6
@RequestMapping(value = "/test", method = RequestMethod.GET, RequestMethod.POST)
@ResponseBody
public String test(HttpServletRequest request){
System.out.println("Requst parm is "+request.getParameter("parm"));
return "Test";
}

4. 通过 @ModelAttribute 将前端发送 JSON 数据映射成目标对象

JS 代码如 3 中所示

Param Class

1
2
3
4
5
public class Param {
private String param;
public String getParam() {return param;}
public void setParam(String param) {this.param = param;}
}

使用 @ModelAttribute 将 JSON 映射成目标对象:

1
2
3
4
5
6
@RequestMapping(value = "/test", method = RequestMethod.GET, RequestMethod.POST)
@ResponseBody
public String test(@ModelAttribute("Param") Param param) {
System.out.println("Requst parm is "+param.getParam());
return "Test";
}

图片上传与读取

图片上传:前端通过 formData 将图片以 POST 请求方式发送给后端,后端使用 MultipartFile file 接收传来的二进制流,然后通过将文件名通过 UUID.randomUUID() 赋为唯一 ID 后直接保存到本地硬盘上。

注意 multipart/form-data 的请求头中的 Content-Type 与普通 POST 请求不同:

普通 POST 请求头中 Content-Type 字段值为:

1
Content-Type: application/x-www-form-urlencoded

multipart/form-data 请求头中 Content-Type 字段值为 multipart/form-data; boundary=xxxxxxx,这里的 xxxxxxx 可以自己定义,但要保证唯一:

1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryZp8W8sOi2HI0TBW7

普通 POST 请求的请求体格式为:

1
param1=123&param2=456&param3=789

上传图片的 multipart/form-data 的 POST 请求体格式为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
------WebKitFormBoundaryjUVXJ3PslTEBh9as
Content-Disposition: form-data; name="param1"


123
------WebKitFormBoundaryjUVXJ3PslTEBh9as
Content-Disposition: form-data; name="param2"


456
------WebKitFormBoundaryjUVXJ3PslTEBh9as
Content-Disposition: form-data; name="file"; filename="test.jpg"
Content-Type: images/jpeg


..................省略的图片二进制流信息

------WebKitFormBoundaryjUVXJ3PslTEBh9as

图片读取:后端接收到图片路径后,从硬盘上读取文件,再将文件流通过 HttpServletResponse response 返回。

具体做法是先设置 ContentType

1
response.setContentType("image");

然后通过 Spring 的 StreamUtils.copy() 读取到的文件流拷贝到 response 中,其中 IMAGE_PATH 即为图片的路径:

1
StreamUtils.copy(new FileInputStream(new File(IMAGE_PATH)), response.getOutputStream());

用户登录与注销

登录

  1. 服务器校验密码,生成 token 关联 userId 并保存至数据库中,同时将 token 返回给客户端(浏览器中存储 Cookie )。
  2. 服务器生成 token 时还需要设置一个过期时间与状态,如果 token 到达过期时间就通过改变 token 状态来失效这个 token。
  3. 下次用户访问时浏览器 Cookie 中会带着这个 token,服务器会通过 token 来找到这个用户(所以 token 必须要是唯一的),并返回用户的具体信息。
  4. 注:token 可以是 session_id,也可以是 Cookie 中的任意一个 key,以下是几个重要方法。
1
2
3
4
5
6
7
8
// 构造 Cookie 并加入 token 字段
Cookie cookie = new Cookie("token", Token.toString());
// Cookie设为全局有效
cookie.setPath("/");
// 浏览器设置Cookie有效时间为一周
cookie.setMaxAge(3600*24*7);
// 将Cookie加入至response中
response.addCookie(cookie);

注销

  1. 服务器端将 token 状态变为失效状态
  2. 清理 session

拦截器 (Interceptor) 的使用

Spring 中的拦截器主要分两种,一个是 MethodInterceptor,它拦截的是方法;一个是 HandlerInterceptor,它拦截的是请求地址,且比 MethodInterceptor 先执行。

实现一个 HandlerInterceptor 拦截器可以直接实现 HandlerInterceptor 接口,也可以继承 HandlerInterceptorAdapter 类,下面是实现接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class DemoInterceptor implements HandlerInterceptor {
// 进入Controller前
@Override
public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception {
return true;
}

// Controller处理后
@Override
public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {
}

@Override
public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
}
}
  • preHandle:该方法将在请求处理之前进行调用。该方法的返回值为 Boolean 类型,当返回为 false 时,表示请求结束,后续的 InterceptorController 都不会再执行;当返回值为 true 时就会继续调用下一个 InterceptorpreHandle 方法,如果已经是最后一个 Interceptor 时候则会调用当前请求的 Controller
  • postHandle:该方法将在请求处理之后进行调用,也就是 Controller 调用之后执行,但它是在 DispatcherServlet 进行视图渲染之前被调用的,所以可以在这个方法中对 Controller 处理之后的 ModelAndView 对象进行操作(如下,我在这里面先加入了当前登录用户的信息)。postHandle 方法被调用的方向跟 preHandle 是相反的,也就是说先声明的 InterceptorpostHandle 方法反而会后执行。
  • afterCompletion:该方法也是需要当前对应的 InterceptorpreHandle 方法的返回值为 true 时才会执行。该方法将在整个请求结束之后,也就是在 DispatcherServlet 渲染了对应的视图之后执行。这个方法的主要作用是用于进行资源清理工作。

搜相关资料时看到一篇国外的技术文章里讲的比较清晰:

  • prehandle – called before the actual handler is executed, but the view is not generated yet

  • postHandle – called after the handler is executed

  • afterCompletion – called after the complete request has finished and view was generated

实现用户登录拦截器

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
@Override
public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception {
String token = null;
// 如果Cookie不为空
if(httpServletRequest.getCookies()!=null){
for(Cookie cookie : httpServletRequest.getCookies() ){
// 找到token字段
if(cookie.getName().equals("token")){
token = cookie.getValue();
break;
}
}
}
if(ticket!=null){
LoginTicket loginTicket = loginTicketDAO.selectByTicket(token);
// 判断Cookie中的token与数据库中的token是否一致且没过期且状态为有效
if (loginTicket == null || loginTicket.getExpired().before(new Date()) || loginTicket.getStatus() != 0) {
return true;
}
// 若数据库中找到了该用户的token
User user = userDAO.selectById(loginTicket.getUserId());
// 用HostHolder保存该用户的信息,这样Controller就可以拿到该用户的信息,是对ThreadLocal的封装
hostHolder.setUser(user);
}
return true;
}

@Override
public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {
// 在modelAndView中加入user信息
if(modelAndView!=null && hostHolder.getUser()!=null){
modelAndView.addObject("user",hostHolder.getUser());
}
}

@Override
public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
// 结束后清除该用户
hostHolder.clear();
}

实现用户权限拦截器

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
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Autowired
private HostHolder hostHolder;

@Override
public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception {
// 管理员ID为99999
if (hostHolder.getUser() == null||hostHolder.getUser().getId()!=99999) {
// 不是管理员就直接跳转回首页
httpServletResponse.sendRedirect("/");
return false;
}
return true;
}

@Override
public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {
}

@Override
public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
}

}

注册拦截器

完成拦截器后就要把拦截器注册到 SpringMVC 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class StrongFriendsWebConfiguration extends WebMvcConfigurerAdapter {

@Autowired
PassportInterceptor passportInterceptor;

@Autowired
AuthInterceptor authInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(passportInterceptor);
// 指定拦截/admin路径下所有请求
registry.addInterceptor(authInterceptor).addPathPatterns("/admin*");
super.addInterceptors(registry);
}
}

ThreadLocal 的使用

在拦截器中使用 ThreadLocal 主要是为了拿到当前的用户信息,并传递给后续的 Controller 中使用,将其封装成一个 HostHolder 以便使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

@Component

public class HostHolder {

private static ThreadLocal<User> users = new ThreadLocal<User>();

public User getUser() {
return users.get();
}

public void setUser(User user) {
users.set(user);
}

public void clear() {
users.remove();;
}

}

多个拦截器的执行顺序

若有两个拦截器 Interceptor1 与 Interceptor2,则执行顺序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

=======> Interceptor1:preHandle()

=======> Interceptor2:preHandle()

.......

=======> Interceptor2:postHandle()

=======> Interceptor1:postHandle()

=======> Interceptor2:afterCompletion()

=======> Interceptor1 :afterCompletion()

Token

  • Token 是服务端生成的一串随机字符串,以在一定时间范围内标识用户的一个令牌
  • 使用 Token 可以减轻服务器的压力,减少频繁查询数据库
  • 存储方式:Cookie 存储在客户端浏览器中
  • 存储容量:有大小限制,不同浏览器的Cookie个数也有不同限制
  • 存储方式:Cookie 中只能存储 ASCII 字符串
  • 安全:Cookie 以明文存储,易被攻破进行 Cookie 欺骗,不安全

Session

  • 存储方式:Session 存放在服务器端
  • 存储容量:没有大小限制,服务器的内存大小有关
  • 存储方式:Session 中能够存储任何类型的数据
  • 安全:用户不可见,不存在泄漏风险,较安全
  • Session 机制可以通过 Cookie 实现,但不一定是 Cookie
  • 当做了负载均衡后,session 保存在某个服务器中,其他服务器是无法识别这个 session 的,除非多个服务器之间对 session 进行复制

PS:当浏览器禁用 Cookie 后,Session 与 Token 仍可以用,不过需要通过其他的方式来获取 Session 与 Token ,比如放在 URL 路径中,或者以表单形式提交给服务器端。

防止 XSS 攻击

这样攻击者通过 JS 脚本将无法读取到 Cookie 信息。

1
response.addHeader("Set-Cookie", "HttpOnly");

使用过滤器 (Filter) 将 HTML 进行转义

Filter 主要用于对用户请求进行预处理,也可以对 HttpServletResponse 进行后处理,是个典型的处理链。使用 Filter 完整的流程是:Filter 对用户请求进行预处理,接着将请求交给 Servlet 进行预处理并生成响应,最后 Filter 再对服务器响应进行后处理。

HttpServletRequest 到达 Servlet 之前,拦截用户的 HttpServletRequest。根据需要检查 HttpServletRequest,也可以修改 HttpServletRequest 头和数据。在 HttpServletResponse 到达客户端之前,拦截 HttpServletResponse。根据需要检查 HttpServletResponse,也可以修改 HttpServletResponse 头和数据。

下面是 Filter 在预防 XSS 攻击的应用:

  1. 实现 XssHttpServletRequestWrapper 类,继承自 HttpServletRequestWrapper,重写 getParameter()getParameterValues() 方法,使用 org.apache.commons.lang3 下的 StringEscapeUtils.escapeHtml4() 方法对所有请求参数与 JSON 进行转义
  2. 实现 Fliter 过滤所有请求,将 HttpServletRequest 强转为 XssHttpServletRequestWrapper 传递下去

拦截器与过滤器的区别

  1. 拦截器基于 JAVA 反射机制 (AOP),而过滤器基于函数回调 (doFilter)
  2. 拦截器不依赖于 Servlet 容器,过滤器依赖于 Servlet 容器
  3. 拦截器只能对 action 请求起作用,而过滤器则可以对几乎所有的请求起作用
  4. 拦截器可以访问 action 上下文、值栈里的对象,而过滤器不能访问
  5. 在 action 的生命周期中,拦截器可以多次被调用,而过滤器只能在容器初始化时被调用一次

执行顺序:过滤器 chain.doFilter() 前 -> 拦截器 preHandle() -> Controller - 拦截器 postHandle() -> 页面渲染 -> 拦截器 afterCompletion() -> 过滤器 chain.doFilter()

异步通用服务模块

这个模块的核心基于生产者-消费者模型,生产者通过向 MQ(由 Redis list 实现,是 FIFO 的)中 Push 需要处理的事件,然后消费者将事件 Pop 出来并通过线程池消费。

其中,生产者产生的事件是类型是多样的,消费者需要判断事件类型并调用对应的 Handler 处理。

首先定义 EventModel :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class EventModel {

// 事件类型
private EventType type;
// 事件触发者
private int actorId;
// 触发对象
private int entityId;
// 触发对象类型
private int entityType;
// 触发对象拥有者
private int entityOwnerId;
// 事件的额外数据
private Map<String, String> exts = new HashMap<>();
// 省略getter/setter方法
...
}

其中 EventType 为枚举:

1
2
3
4
5
6
7
8
9
10
11
12
public enum EventType {
COMMENT(0),
LIKE(1),
DISLIKE(2),
MESSAGE(3),
POST(4);

private int value;
EventType(int value) {this.value = value;}
public int getValue() {return value;}

}

再定义 EventHandler 接口,各类服务的 Handler 都需要实现这个接口:

1
2
3
4
5
6
7
8

public interface EventHandler {
// 具体处理逻辑
void doHandle(EventModel model);
// 获取支持处理的Event类型
List<EventType> getSupportEventTypes();

}

接下来是生产者:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Service
public class EventProducer {

@Autowired
JedisAdapter jedisAdapter;

public boolean produceEvent(EventModel eventModel) {
try {
// 序列化Event
String json = JSONObject.toJSONString(eventModel);
String key = RedisKeyUtil.getEventQueueKey();
// 从左push到list中
jedisAdapter
.lpush(key, json);
return true;
} catch (Exception e) {
return false;
}
}
}

消费者:

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

/**

* Spirng的InitializingBean为bean提供了定义初始化方法的方式。InitializingBean是一个接口,仅包含一个方法:afterPropertiesSet()
* Spring在设置完一个bean所有的信息后,会检查bean是否实现了InitializingBean接口,如果实现就调用bean的afterPropertiesSet方法。
* 装配bean的信息查看bean是否实现InitializingBean接口调用afterPropertiesSet方法
* afterPropertiesSet会在init-method前调用
*
* 实现ApplicationContextAware接口后,可以获得ApplicationContext中的所有bean。
*/

@Service
public class EventConsumer implements InitializingBean, ApplicationContextAware {
// 在config Map中关联EventType与对应处理的EventHandler
private Map<EventType, List<EventHandler>> config = new HashMap<>();
private ApplicationContext applicationContext;

@Autowired
private JedisAdapter jedisAdapter;

@Override
public void afterPropertiesSet() throws Exception {
// 找出所有实现了EventHandler接口的类
Map<String, EventHandler> beans = applicationContext.getBeansOfType(EventHandler.class);
if (beans != null) {
// 遍历所有实现了EventHandler接口的类
for (Map.Entry<String, EventHandler> entry : beans.entrySet()) {
// 找到每个Handler类支持的EventType
List<EventType> eventTypes = entry.getValue().getSupportEventTypes();
// 找到EventType后要与EventHandler关联
for (EventType type : eventTypes) {
if (!config.containsKey(type)) {
// 放入config中,完成关联
config.put(type, new ArrayList<EventHandler>());
}
// 注册每个事件的Handler
config.get(type).add(entry.getValue());
}
}
}

// 初始化线程池
ThreadPool threadPool = new ThreadPool();

Runnable task = new Runnable() {
public void run() {
while (true) {
String key = RedisKeyUtil.getEventQueueKey();
// 弹出最右的事件,如果list没有事件会阻塞list直到等待超时返回nil
List<String> messages = jedisAdapter.brpop(10, key);
// [0]:EVENT(即list key) [1]:事件的具体信息
for (String message : messages) {
// 跳过第一个元素,即redis的list key
if (message.equals(key)) {
continue;
}
// JSON反序列回EventModel
EventModel eventModel = JSON.parseObject(message, EventModel.class);
// 若config中没找到这个事件的处理handler,丢弃该事件
if (!config.containsKey(eventModel.getType())) {
continue;
}
// 找到EventModel事件类型,调用对应Handler处理
for (EventHandler handler : config.get(eventModel.getType())) {
handler.doHandle(eventModel);
}
}
}
}
};
// 执行
threadPool.getThreadPool().execute(task);
}

@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {this.applicationContext = applicationContext;

private class ThreadPool {
// 线程池核心池的大小
private final int CORE_SIZE = 8;
// 线程池的最大线程数
private final int MAX_SIZE = 12;
// 当线程数大于核心时,此为终止前多余的空闲线程等待新任务的最长时间
private final long KEEP_ALIVE_TIME = 30;
// ArrayBlockingQueue的大小
private final int QUEUE_SIZE = 50000;

private ThreadPoolExecutor threadPool = new ThreadPoolExecutor(CORE_SIZE, MAX_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(QUEUE_SIZE), new ThreadPoolExecutor.AbortPolicy());

private ThreadPoolExecutor getThreadPool() {return threadPool;}
}
}

总共有以下五种 Handler:

  1. LikdHandler:Redis 读写 + 系统站内信通知(即私信)
  2. DislikeHandler:Redis 读写 + 系统站内信通知
  3. CommentlikeHandler:MySQL 与 Redis 读写 + 系统站内信通知
  4. MessageHandler:MySQL 写
  5. PostHandler:MySQL 写

杠铃轨迹识别

基于 Flask 搭建服务,具体实现基于 OpenCV 与 Dlib,流程如下:

  1. 用户上传视频文件,Spring Boot 端接收视频并保存,返回视频第一帧图片给浏览器
  2. 用户基于视频的第一帧图片框选杠铃位置,通过 JS 获得框选目标的起点坐标与矩形长宽,返回参数
  3. Spring Boot 接收参数,将参数作为 task Push 进 Redis 的 List 中 ,并将文件名、用户名、视频状态(此时为待处理)等保存至 MySQL 中。
  4. Flask 维护一个进程池,将 List 中的 task 弹出获得具体参数,处理视频。
  5. 处理完成,修改MySQL中的视频状态、处理结果。

防止 SQL 注入攻击

在 Mybatis 中尽量使用 #{} 而不是 ${},因为对于含有 #{} 的 SQL 语句,Mybatis 会通过 JDBC 中的 PreparedStatement 实现预编译,执行时直接使用编译后的 SQL,再用传入的参数替换掉占位符“?”。而${} 未经过预编译,存在 SQL 注入风险。

但是预编译也有一个缺点,就是不允许一个占位符“?”有多个值,在执行有IN子句查询的时候会将多个参数视作一个。例如:

1
select * from user where id in ( 1 , 2 , 3)

此时的 1 , 2, 3 将被整体视作一个参数,解决办法是 foreach,假设参数int[] ids = {1,2,3}

1
2
3
4
5
select * from user where id in
<foreach collection="array" item="id" index="index" collection="list"
open="(" separator="," close=")">
#{id}
</foreach>

使用 Redis 作为缓存

只在赞 / 踩与评论功能中使用了 Redis 作为缓存。

赞 / 踩

  • 写:Redis
  • 读:Redis
  • 注:考虑到赞踩是热点数据,弱数据一致性,只需要将 Redis 缓存定时写入 MySQL 就好
  • Redis 缓存定时写入 MySQL:使用 @EnableScheduling 注解来开启定时任务
  • 即使 Redis 崩了,也能从 MySQL 中恢复上一次的数据,且赞踩数据不是很重要
  • 可以考虑读写分离提高性能

评论

这部分参考了论文《Scaling Memcache at Facebook》 中的 Query cache 策略:

We rely on memcache to lighten the read load on our databases. In particular, we use memcache as a demand-filled look-aside cache as shown in Figure 1. When a web server needs data, it first requests the value from memcache by providing a string key. If the item addressed by that key is not cached, the web server retrieves the data from the database or other back-end service and populates the cache with the key-value pair. For write requests, the web server issues SQL statements to the database and then sends a delete request to memcache that invalidates any stale data. We choose to delete cached data instead of updating it because deletes are idempotent. Memcache is not the authoritative source of the data and is therefore allowed to evict cached data.

简单描述如下:

1.当一个 Web 服务器需要数据时,首先通过一个string key 来向 memcache 请求,如果没有拿到拿到这个 key 的缓存,就会从数据库或者从后台服务中找,再把找到的k-v对存到 memcache 中。

2.对于写的请求,Web 服务器会先发送 SQL 语句到数据库更新,接着发送删除请求到 memcache,失效旧的缓存。选择删除缓存的方式,而不是更新缓存的原因是删除是幂等运算。

我的实现是:

  • 写:先写入MySQL,再删除 Redis 缓存(为了防止缓存删除失败,读到脏数据,还对每个 KEY 都设置了随机失效时间)

  • 读:先读 Redis,若命中直接返回 Redis 数据;若没命中,读 MySQL 数据返回并写入 Redis 缓存

其他功能没采用 Redis 作为缓存的原因

  • 考虑到私信使用频率不是很高,没有用 Redis 做缓存
  • 考虑到如果用户上传的是视频,那么发帖也做缓存的话就会导致占用大量内存,此时用 Redis 做缓存就不是一个明智的选择。而且 Redis 的 Value 不能超过 512MB ,现在一般的视频都超过了这个大小。
  • 本文作者: Marticles
  • 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!