1.环境搭建&&数据表设计
- SpringBoot+Mybatis+Druid+Thymeleaf
- Jedis+通用缓存Key封装
- Result结果集封装
通用缓存Key封装: 采用接口+抽象类+实现类的模板模式。这样做能偶避免键重复,导致值被覆盖。
//1.商品表
CREATE TABLE `goods`(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT "商品ID",
`goods_name` VARCHAR(16) DEFAULT NULL COMMENT "商品名称",
`goods_title` VARCHAR(64) DEFAULT NULL COMMENT "商品标题",
`goods_img` VARCHAR(64) DEFAULT NULL COMMENT "商品图片",
`goods_detail` LONGTEXT COMMENT "商品的详情介绍",
`goods_price` DECIMAL(10,2) DEFAULT 0.00 COMMENT "商品单价",
`goods_stock` INT(11) DEFAULT 0 COMMENT "商品库存,-1表示没有限制",
PRIMARY KEY(`id`)
)ENGINE=INNODB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4;
INSERT INTO `goods` VALUES(1,"iphoneX","银色64GB移动联通电信","/img/iphonex.png","移动联通电信",10000.1,500);
//2.秒杀商品表
CREATE TABLE `miaosha_goods`(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT "秒杀商品ID",
`goods_id` BIGINT(20) DEFAULT NULL COMMENT "商品ID",
`miaosha_price` DECIMAL(10,2) DEFAULT 0.00 COMMENT "秒杀价",
`stock_count` INT(11) DEFAULT NULL COMMENT "库存数量",
`start_date` DATETIME DEFAULT NULL COMMENT "秒杀开始时间",
`end_date` DATETIME DEFAULT NULL COMMENT "秒杀结束时间",
PRIMARY KEY(`id`)
)ENGINE=INNODB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4;
insert into `miaosha_goods` values(1,1,0.01,4,"2017-11-05 15:18:00","2017-11-09 15:18:00"),
(2,2,0.03,6,"2017-11-05 15:18:00","2017-11-09 15:18:00");
//3.订单表
CREATE TABLE `order_info`(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`user_id` BIGINT(20) DEFAULT NULL COMMENT "用户ID",
`goods_id` BIGINT(20) DEFAULT NULL COMMENT "商品ID",
`delivery_addr_id` BIGINT(20) DEFAULT NULL COMMENT "收货地址ID",
`goods_name` VARCHAR(16) DEFAULT NULL COMMENT "冗余过来的商品名称",
`goods_count` INT(11) DEFAULT 0 COMMENT "商品数量",
`goods_price` DECIMAL(10,2) DEFAULT 0.00 COMMENT "商品单价",
`order_channel` TINYINT(4) DEFAULT 0 COMMENT "1pc,2android,3ios",
`status` TINYINT(4) DEFAULT 0 COMMENT "订单状态,0新建未支付,1已经支付,2已发货,3已收货,4已经退款,5已经完成",
`create_date` DATETIME DEFAULT NULL COMMENT "订单创建时间",
`pay_date` DATETIME DEFAULT NULL COMMENT "支付时间",
PRIMARY KEY(`id`)
)ENGINE=INNODB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4;
//4.秒杀订单表
CREATE TABLE `miaosha_order`(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`user_id` BIGINT(20) DEFAULT NULL COMMENT "用户ID",
`order_id` BIGINT(20) DEFAULT NULL COMMENT "订单ID",
`goods_id` BIGINT(20) DEFAULT NULL COMMENT "商品ID",
PRIMARY KEY(`id`)
)ENGINE=INNODB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4;
//5.miaosha_user表
CREATE TABLE `miaosha_user` (
`id` bigint(20) NOT NULL COMMENT '用户ID,手机号码',
`nickname` varchar(255) NOT NULL,
`password` varchar(32) DEFAULT NULL COMMENT 'MD5(MD5(pass明文+固定salt),salt)',
`salt` varchar(10) DEFAULT NULL,
`head` varchar(128) DEFAULT NULL COMMENT '头像,云存储ID',
`register_date` datetime DEFAULT NULL COMMENT '注册时',
`last_login_date` datetime DEFAULT NULL COMMENT '上次登陆时间',
`login_count` int(11) DEFAULT '0' COMMENT '登录次数',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2.实现登录功能
1. 明文密码两次MD5加密
用户注册输入的密码提交后台之前先进行一次MD5加密,确保网络上传输的密码是加过密的。服务端收到加密后的密码之后在经过一次MD5加密,将生成的二次加密密码和salt存到数据库。
用户登录业务逻辑:1.用户输入账户密码。2.点击提交,调用js方法第一次MD5加密,然后异步提交。3.服务端根据用户账号查DB,查出结果为空抛出全局异常,查出结果不为空就将前台提交过来的密码加密后和DB取出来的密码比较相等就成功,反之抛全局异常。
2. JSR303数据校验+全局异常处理器
@Valid注解写在输入参数前面,输入参数对应的类,里面的各项成员变量上面还能加注解约束。此外还能自定义注解。当参数校验返回false即校验失败时,那么就会出现一个BindException异常,为了显示友好就写一个全局异常处理器去拦截这个异常。当然其他的异常也能够被拦截。
**怎么实现友好显示的?**当用户登录时,如果后台登录方法查不到用户或者密码不匹配那么就会抛一个全局异常,抛出的这个异常会被我们定义的全局异常处理器拦截,拦截到之后会return一个错误信息,前台ajax就会回调显示这个错误信息,用户能更友好的看到错误信息。
@RequestMapping("/do_login")
@ResponseBody
public Result<String> doLogin(HttpServletResponse response, @Valid LoginVo loginVo) {
log.info(loginVo.toString());
//登录
String token = userService.login(response, loginVo);
return Result.success(token);
}
public class LoginVo {
@NotNull
@IsMobile
private String mobile;
@NotNull
@Length(min=32)
private String password;
}
3.分布式Session
背景:分布式集群,多台服务器。客户端第一次请求落在第一台服务器上,第二次请求落在第二台服务器上。那么第二次Session就会丢失。
解决方案: 1.容器原生的Session同步,就是将一台计算机上的Session同步到其他计算机上,这样性能开销大。
2.分布式Session,实际情况中用的比较多。Session并没有存到容器中来而是存到了缓存中,这就是分布式Session。
分布式Session具体实现: 用户登录成功,会生成一个token。token用于生成键,用户信息作为值,将这对键值对存到redis中,然后实例一个Cookie(“token”,token),
将这个Cookie写进去写到response中,那么下次这个用户再次发请求就会带着这个Cookie。配置参数解析器就能根据Cookie携带的值到redis中查到用户信息,然后注入到方法的请求参数中。
3.实现秒杀功能
- 商品列表页、商品详情页、订单页详情页。
- 秒杀功能
三步:
判断库存、判断是否已经秒杀到了、减库存下订单(事务)。
超卖:
(1)减库存SQL,加上库存是否小于零的条件。
(2)订单表结构增加唯一索引,防止一个用户下多次单。
(3)减库存这个操作的返回值为1的时候才继续后面的下订单,否则会出现生成的订单数量远远多于卖出商品的数量。
倒计时实现:
@RequestMapping("/to_detail/{goodsId}")
public String detail(Model model,MiaoshaUser user,
@PathVariable("goodsId")long goodsId) {
model.addAttribute("user", user);
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
model.addAttribute("goods", goods);
long startAt = goods.getStartDate().getTime();
long endAt = goods.getEndDate().getTime();
long now = System.currentTimeMillis();
int miaoshaStatus = 0;
int remainSeconds = 0;
if(now < startAt ) {//秒杀还没开始,倒计时
miaoshaStatus = 0;
remainSeconds = (int)((startAt - now )/1000);
}else if(now > endAt){//秒杀已经结束
miaoshaStatus = 2;
remainSeconds = -1;
}else {//秒杀进行中
miaoshaStatus = 1;
remainSeconds = 0;
}
model.addAttribute("miaoshaStatus", miaoshaStatus);
model.addAttribute("remainSeconds", remainSeconds);
return "goods_detail";
}
function countDown(){
var remainSeconds = $("#remainSeconds").val();
var timeout;
if(remainSeconds > 0){//秒杀还没开始,倒计时
$("#buyButton").attr("disabled", true);
timeout = setTimeout(function(){
$("#countDown").text(remainSeconds - 1);
$("#remainSeconds").val(remainSeconds - 1);
countDown();
},1000);
}else if(remainSeconds == 0){//秒杀进行中
$("#buyButton").attr("disabled", false);
if(timeout){
clearTimeout(timeout);
}
$("#miaoshaTip").html("秒杀进行中");
}else{//秒杀已经结束
$("#buyButton").attr("disabled", true);
$("#miaoshaTip").html("秒杀已经结束");
}
}
4.JMeter压测、Rredis压测
- JMeter的使用
本地部署压测:
(1)、添加线程组,Thread Group。
(2)、添加HTTP请求默认配置项,HTTP request defaults。
(3)、添加一个Http请求实例,HTTP Request.
(4)、添加聚合报告,Summary Report。
(5)、CSV Data Set Config,配置请求参数(可以模拟多用户)。
linux服务器上部署压测:
(1)、本地录好jmx脚本(“本地要选择保存测试计划为”)、参数文件,上传到服务器。
(2)、压测命令
./apach-jemter-3.3/bin/jmeter.sh -n -t xxx.jmx -l result.jtl
// -n:以NoGUI方式运行脚本
// -t:后面接脚本名称
// -l:后面接日志名称,保存运行结果
(3)、把result.jtl导入到本地,查看压测结果。
注意: 上传到linux服务器后,进入到xxx.jmx中将引用的参数文件的路径改过来。
- Redis压测
//100个并发连接,100000个请求。默认传输的数据包是三个字节大小
redis-benchmark -h 127.0.0.1 -p 6379 -c 100 -n 10000
//传输的数据包是100个字节大小。-q就是简单输出,输出的比较少。这会将每个命令都测试一遍。
redis-benchmark -h 127.0.0.1 -p 6379 -q -d 100
//只测试set和lpush命令的性能
redis-benchmark -t -p 6379 set,lpush -q -n
100000
- SpringBoot打war包
(1)、添加spring-boot-starter-tomcat的provided的依赖、spring-boot-maven-plugin的依赖。
(2)、Main函数继承SpringBootServletInitializer,重写configuer方法。
(3)、DOS进入到项目路径,执行mvn clean package。
(4)、将war包放到tomcat的webapps目录下,启动tomcat.
5.页面优化技术
- 页面缓存、对象缓存、URL缓存
页面缓存适用场景:适合于变化不大的场景,比如商品列表。实际项目中商品列表可能会分页,不可能每页都缓存,只是缓存前两页。
页面缓存:第一次请求过来就将渲染好的页面存到redis中,下次请求就直接从redis中取页面。
@RequestMapping(value="/to_list", produces="text/html")
@ResponseBody
public String list(HttpServletRequest request, HttpServletResponse response, Model model,MiaoshaUser user) {
model.addAttribute("user", user);
//1、取缓存 //一定要先取缓存。
String html = redisService.get(GoodsKey.getGoodsList, "", String.class);
if(!StringUtils.isEmpty(html)) {
return html;
}
List<GoodsVo> goodsList = goodsService.listGoodsVo();
model.addAttribute("goodsList", goodsList);
// return "goods_list";
SpringWebContext ctx = new SpringWebContext(request,response,
request.getServletContext(),request.getLocale(), model.asMap(), applicationContext );
//2、手动渲染
html = thymeleafViewResolver.getTemplateEngine().process("goods_list", ctx);//模板名、上下文对象
if(!StringUtils.isEmpty(html)) {
redisService.set(GoodsKey.getGoodsList, "", html);
}
//3、结果输出,这个html是已经渲染好了的,直接返回给浏览器显示就好了。
return html;
}
对象缓存:实现分布式Session就是,将用户对象缓存到redis中。
- 页面静态化、前后端分离
页面静态化:就是浏览器将HTML页面存在客户端,通过ajax获取数据拿到客户端在渲染页面。(这样就不用下载页面了,只需要下载动态数据就好了。
//商品列表页跳转到商品详情页面,goods_detail.htm放到静态文件夹里面
<td><a th:href="'/goods_detail.htm?goodsId='+${goods.id}">详情</a></td>
//静态页面goods_detail.htm,里面的js
$(function(){
//countDown();
getDetail();
});
function getDetail(){
//这个方法获取请求传过来的参数
var goodsId = g_getQueryString("goodsId");
$.ajax({
url:"/goods/detail/"+goodsId,
type:"GET",
success:function(data){
if(data.code == 0){
//渲染页面的方法
render(data.data);
}else{
layer.msg(data.msg);
}
},
error:function(){
layer.msg("客户端请求有误");
}
});
}
- 静态资源优化
https://www.jianshu.com/p/a32e402fb466 注意:js文件在浏览器本地会有缓存,如果改动了js文件,下次请求加载的还是本地缓存的js文件,导致前端代码跑不通。解决方法引入js文件的链接后面加一个版本参数。代码跑不通就debug,查看数据流是不是对的,这样能尽快锁定哪里出了问题。
<!-- common.js -->
<script type="text/javascript" src="/js/common.js?ver=1"></script>
6接口优化
总目标:减少数据库的访问量。
- Redis预减库存,减少对数据库的访问。
容器初始化的时候将秒杀商品的库存和内存标记加载到Redis中,前面来的请求将redis缓存的库存减完后,后面的请求过来直接返回秒杀结束。
这里有个问题:容器初始化时才将商品库存和内存标记加载进来,那么每次你做秒杀活动都要重新启动项目将数据加载进来,这样会非常麻烦。后台管理系统应该做一个功能,实现商品库存加载和清除的功能。 - 内存标记减少对redis的访问。
比如说前面10个请求已经将redis中缓存的库存减到0了,那么后面的请求会继续将redis中的库存减为负数,显然后面的请求将redis中的库存减成负数是多余的,而且还增加了redis的访问量。那么这里就做一个内存标记,缓存中库存大于零的时候内存标记为false,当缓存中的库存减为0时内存标记就为true。当为false时请求能往下走,反之直接返回秒杀结束。
@RequestMapping(value="/{path}/do_miaosha", method=RequestMethod.POST)
@ResponseBody
public Result<Integer> miaosha(Model model,MiaoshaUser user,
@RequestParam("goodsId")long goodsId,
@PathVariable("path") String path) {
model.addAttribute("user", user);
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
//验证path
boolean check = miaoshaService.checkPath(user, goodsId, path);
if(!check){
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}
//内存标记,减少redis访问
boolean over = localOverMap.get(goodsId);
if(over) {
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//预减库存
long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, ""+goodsId);//10
if(stock < 0) {
localOverMap.put(goodsId, true);
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//判断是否已经秒杀到了
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
if(order != null) {
return Result.error(CodeMsg.REPEATE_MIAOSHA);
}
//入队
MiaoshaMessage mm = new MiaoshaMessage();
mm.setUser(user);
mm.setGoodsId(goodsId);
sender.sendMiaoshaMessage(mm);
return Result.success(0);//排队中
}
- 请求先入队列,异步下单、增强用户体验。
//入队
MiaoshaMessage mm = new MiaoshaMessage();
mm.setUser(user);
mm.setGoodsId(goodsId);
sender.sendMiaoshaMessage(mm);
return Result.success(0);//排队中
public void sendMiaoshaMessage(MiaoshaMessage mm) {
String msg = RedisService.beanToString(mm);
log.info("send message:"+msg);
amqpTemplate.convertAndSend(MQConfig.MIAOSHA_QUEUE, msg);
}
//接收者监听这个MIAOSHA_QUEUE
@RabbitListener(queues=MQConfig.MIAOSHA_QUEUE)
public void receive(String message) {
log.info("receive message:"+message);
MiaoshaMessage mm = RedisService.stringToBean(message, MiaoshaMessage.class);
MiaoshaUser user = mm.getUser();
long goodsId = mm.getGoodsId();
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
int stock = goods.getStockCount();
if(stock <= 0) {
return;
}
//判断是否已经秒杀到了
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
if(order != null) {
return;
}
//减库存 下订单 写入秒杀订单
miaoshaService.miaosha(user, goods);
}
- RabbirMQ四种交换机模式
(1)、direct模式,发送者,接收者。消息不是直接发到接受者的,而是先发给交换机,交换机路由到接收者。
(2)、topic模式,发送者(交换机,路由,消息)、接收者 (监听指定队列)、MQconfig绑定(队列、交换机、路由(能通配)),发送者将消息实体标记投递地址,发送到交换机。交换机收到消息后,检查投递地址,查看这个投递地址和那几个队列匹配,匹配上了就将消息发到这个队列,接收者监听指定的队列接收消息。
(3)、fanout模式,广播模式,MQconfig(广播交换机绑定多个队列)、发送者(消息实体+广播交换机)、接收者(监听指定队列)、广播交换机将接收到的消息,广播给每个绑定的队列。
(4)、Header模式,MQconfig绑定(队列、header交换机、map、判断是否匹配上了)、发送者(封装message里面有消息实体和map,发送给指定的head交换机)、接收者(监听指定队列)。
4.Nginx水平扩展
所有到达10.110.3.62:80的请求,全部交给单项代理proxy_pass,这个代理将请求发到server_pool_miaosha,然后将请求给下面的服务器处理。Weight=1是这台服务器的权重(1/2)。
7.安全优化
秒杀接口业务流程:
- 秒杀接口地址隐藏
//获取动态秒杀路径
function getMiaoshaPath(){
var goodsId = $("#goodsId").val();
g_showLoading();
$.ajax({
url:"/miaosha/path",
type:"GET",
data:{
goodsId:goodsId,
verifyCode:$("#verifyCode").val()
},
success:function(data){
if(data.code == 0){
var path = data.data;
doMiaosha(path);
}else{
layer.msg(data.msg);
}
},
error:function(){
layer.msg("客户端请求有误");
}
});
}
@AccessLimit(seconds=5, maxCount=5, needLogin=true)
@RequestMapping(value="/path", method=RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaPath(HttpServletRequest request, MiaoshaUser user,
@RequestParam("goodsId")long goodsId,
@RequestParam(value="verifyCode", defaultValue="0")int verifyCode
) {
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
boolean check = miaoshaService.checkVerifyCode(user, goodsId, verifyCode);
if(!check) {
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}
String path =miaoshaService.createMiaoshaPath(user, goodsId);
return Result.success(path);
}
- 数学公式验证码
//请求获取验证码,将正确结果存到缓存中
@RequestMapping(value="/verifyCode", method=RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaVerifyCod(HttpServletResponse response,MiaoshaUser user,
@RequestParam("goodsId")long goodsId) {
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
try {
BufferedImage image = miaoshaService.createVerifyCode(user, goodsId);
OutputStream out = response.getOutputStream();
ImageIO.write(image, "JPEG", out);
out.flush();
out.close();
return null;
}catch(Exception e) {
e.printStackTrace();
return Result.error(CodeMsg.MIAOSHA_FAIL);
}
}
//创建验证码
public BufferedImage createVerifyCode(MiaoshaUser user, long goodsId) {
if(user == null || goodsId <=0) {
return null;
}
int width = 80;
int height = 32;
//create the image
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics g = image.getGraphics();
// set the background color
g.setColor(new Color(0xDCDCDC));
g.fillRect(0, 0, width, height);
// draw the border
g.setColor(Color.black);
g.drawRect(0, 0, width - 1, height - 1);
// create a random instance to generate the codes
Random rdm = new Random();
// make some confusion
for (int i = 0; i < 50; i++) {
int x = rdm.nextInt(width);
int y = rdm.nextInt(height);
g.drawOval(x, y, 0, 0);
}
// generate a random code
String verifyCode = generateVerifyCode(rdm);
g.setColor(new Color(0, 100, 0));
g.setFont(new Font("Candara", Font.BOLD, 24));
g.drawString(verifyCode, 8, 24);
g.dispose();
//把验证码存到redis中
int rnd = calc(verifyCode);
redisService.set(MiaoshaKey.getMiaoshaVerifyCode, user.getId()+","+goodsId, rnd);
//输出图片
return image;
}
3.接口限流防刷
需求:设置5秒钟内,最多请求5次,超过这个次数就算为非法请求。
设计:使用拦截器,将这个功能与业务代码分离,能让其他方法形成复用。
//AccessInterceptor
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if(handler instanceof HandlerMethod) {
MiaoshaUser user = getUser(request, response);
UserContext.setUser(user);
HandlerMethod hm = (HandlerMethod)handler;
AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
if(accessLimit == null) {
return true;
}
int seconds = accessLimit.seconds();
int maxCount = accessLimit.maxCount();
boolean needLogin = accessLimit.needLogin();
String key = request.getRequestURI();
if(needLogin) {
if(user == null) {
render(response, CodeMsg.SESSION_ERROR);
return false;
}
key += "_" + user.getId();
}else {
//do nothing
}
//这里为什么要用withExpire()?因为能灵活的根据注解上读取的有效时间,来创建这个对象
//要是不这样写,用之前调用静态方法那样实现,那么就会将有效时间写死了。
AccessKey ak = AccessKey.withExpire(seconds);
Integer count = redisService.get(ak, key, Integer.class);
if(count == null) {
redisService.set(ak, key, 1);
}else if(count < maxCount) {
redisService.incr(ak, key);
}else {
render(response, CodeMsg.ACCESS_LIMIT_REACHED);
return false;
}
}
return true;
}
//自定义访问限制注解
@Retention(RUNTIME)
@Target(METHOD)
public @interface AccessLimit {
int seconds();
int maxCount();
boolean needLogin() default true;
}
@AccessLimit(seconds=5, maxCount=5, needLogin=true)
@RequestMapping(value="/path", method=RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaPath(HttpServletRequest request, MiaoshaUser user,
@RequestParam("goodsId")long goodsId,
@RequestParam(value="verifyCode", defaultValue="0")int verifyCode
) {
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
boolean check = miaoshaService.checkVerifyCode(user, goodsId, verifyCode);
if(!check) {
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}
String path =miaoshaService.createMiaoshaPath(user, goodsId);
return Result.success(path);
}
总结:
- 保证service调用自己的dao或者其他service,不然代码容易混乱。
- 将与代码联系不紧密的功能,尽量单独抽取出来,一方面代码不冗余、看着舒服了,另一方面抽取出来还能形成代码复用。
- 缓存逻辑,先将数据存到数据库,然后再让缓存失效。
例子:
如果将这个逻辑换过来,会怎么样?
考虑这样一个场景,首先A线程先将缓存删除,这时候B线程做了一个读操作,缓存里面保存的是旧数据,这时A又做了一个更新数据操作将数据库里面的数据更新。这样就会导致缓存里存的是旧数据,数据库里面存的是新数据。
更新数据的时候将先更新数据库,再让缓存失效的过程形农场一个事务。
数据库事务四大特性及隔离级别 4.接口+抽象类+实现类的模板模式来封装缓存key。