Java项目问题

23 篇文章 0 订阅

项目概述:

利用SpringBoot+MySQL/ Redis+ thymeleaf/Vue.js+ Restful架构完成购物商城的基本功能:
商品的分类查询和属性设置+订单状态流转;
CRUD后台各种功能(Mybatis/JPA规范)、事务的使用;
使用Redis对数据进行缓存处理、缓存和数据库的一致性问题、缓存雪崩和缓存穿透问题解决⽅案;
Restful架构实现前后端分离。

开发流程:

1. 表结构的设计

用户相关:用户表、评价表;
产品相关:分类表、属性表、产品表;
订单相关:订单表、订单单项表。

因为表与表之间存在一对多关系,一开始采取的方案是直接在SQL层对应表中建立外键和相应的外键约束,比如订单单项表里有外键分别对应用户表、商品表、订单表里的主键,但是后来发现外键和外键约束在项目开发过程中会带来一些问题,因此后面采取的方案是取消外键约束,将原有的外键只作为对应表中的普通字段来使用,相应的一对多关系在pojo层对应的实体类中使用注释 @ManyToOne @JoinColumn(name="") 来实现。
常见的关系类注释:@OneToOne(一对一)、@OneToMany(一对多)、@ManyToOne(多对一)、@ManyToMany(多对多)

外键和外键约束的优点和缺陷:
优点:
外键,表的外键是另一表的主键,外键是可以有重复的,可以是空值。通过引入外键和外键约束可以保证数据的完整性和一致性,使得级联操作更加方便,此外将数据完整性判断托付给了数据库完成,也减少了程序的代码量。
缺陷
但是,阿里Java规范中强制要求:sql不得使用外键与级联,一切外键概念必须在程序内解决。 这是因为:

  1. 每次对数据进行DELETE或UPDATE操作都必须考虑外键约束,数据库都会判断当前操作是否违反数据完整性,从而导致数据库性能下降。而如果交由程序控制,这种查询过程就可以控制在我们手里,可以省略一些不必要的查询过程。
  2. 并发问题:在使用外键的情况下,每次修改数据都需要去另外一个表检查数据,需要获取额外的锁。若是在高并发大流量事务场景下,使用外键更容易造成死锁。
  3. 扩展性问题:在水平拆分和分库的情况下,外键是无法生效的。将数据间关系的维护,放入应用程序中,可以为将来的分库分表省去很多的麻烦。

2. application.properties、pom.xml

核心配置文件 application.properties:
用于保存相关配置信息,如数据库相关配置信息(用户名、密码、接口等)、Redis相关配置信息等等。
(springboot里除了使用.properties外,还支持 yml格式,只是书写语法略有不同)

配置文件 pom.xml:
在其中添加需要的依赖(dependency):
springboot web、springboot tomcat、redis、 mysql、jpa等。

3. pojo实体层
对应每个数据表建立对应的实体类,主要使用的注释如下:
类注释:
@Entity 表示这是一个实体类;
@Table(name = “category”) 用于标识该实体类对应的数据表的名称;
属性注释:
@Id: 用于声明一个实体类的属性映射为数据库的主键列。

@GeneratedValue: 用于标注主键的生成策略,默认情况下,JPA 自动选择一个最适合底层数据库的主键生成策略。

@Column:用来标识实体类中属性与数据表中字段的对应关系。

@ManyToOne:标识实体之间多对一关联关系。

@JoinColumn :指定与实体类相关联的数据库表中的列字段。由于@ManyToOne等注解只能确定实体之间几对几的关联关系,并不能指定与实体相对应的数据库表中的关联字段,因此,需要与 @JoinColumn 注解来配合使用。

@Transient:实体类需要添加一个属性,但是这个属性又不希望存储至对应的数据库,仅仅是做个临时变量用一下。

此外,如果既没有指明关联到哪个Column,又没有明确要用@Transient忽略,那么就会自动关联到数据表对应的同名字段。

示例如下:

@Entity
@Table(name = "product")
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    int id;
     
    @ManyToOne
    @JoinColumn(name="cid")
    private Category category;
     
    //如果既没有指明 关联到哪个Column,又没有明确要用@Transient忽略,那么就会自动关联到表对应的同名字段
    private String name;
     
    @Transient
    private ProductImage firstProductImage;
    }

4. Dao((Date Access Object) )数据访问层
本项目中的DAO 接口继承了 JpaRepository,就提供了CRUD和分页的各种常见功能。JPA 是不需要写 SQL 语句的,只需要在 dao 接口里按照规范的命名定义对应的方法名,即可达到查询相应字段的效果了。
该接口中没有使用到SpringBoot中的任何注释。
在这里插入图片描述
5. Service层
在该层中定义控制层需要调用的一些具体的方法。

涉及到的相应注释如下:

@Service:标记这个类是 Service类
@Autowired :可以对类成员变量、方法及构造函数进行标注,完成自动装配的工作,通过 @Autowired的使用来消除 set ,get方法。
(@Resource的作用相当于@Autowired,只不过@Autowired按byType自动注入,而@Resource默认按 byName注入。

@Autowired:在启动spring IoC时,容器自动装载了一个AutowiredAnnotationBeanPostProcessor后置处理器,当容器扫描到@Autowied时,就会在IoC容器自动查找需要的bean,并装配给该对象的属性。
@Resource:原理和上面的相同,只是使用的后置处理器为CommonAnnotationBeanPostProcessor。)

@Service
public class CategoryService {
    @Autowired CategoryDAO categoryDAO;
    public List<Category> list() {
        Sort sort = new Sort(Sort.Direction.DESC, "id");
        return categoryDAO.findAll(sort);
    }
}

6. Controller层
本项目是按照前后端分离概念实现的,其中数据部分是通过Restful标准下实体类的相应的Controller类来实现的,而在业务上,除了数据服务要提供,还要提供页面跳转服务,将所有的后台页面跳转实现都放在PageController类中,这样代码更清晰。

对于PageController类:
@Controller :表示这是一个控制器。
@GetMapping(value=""):用于处理请求方法的GET类型

@Controller
public class AdminPageController {
    @GetMapping(value="/admin")
    public String admin(){
        return "redirect:admin_category_list";//重定向路径
    }
    @GetMapping(value="/admin_category_list")
    public String listCategory(){
        return "admin/listCategory";
    }
}

数据相关的Controller类(Restful标准):
@RestController:表示这是一个控制类,并且对类中的每个方法的返回值都会直接转换为 json 数据格式。
@Autowired :自动装配。

Restful标准相关:
url中资源名称用复数,而非单数。即:使用 /categories 而不是用 /category
CRUD的URL就都使用一样的 “/categories”,区别只是在于method不同,即Springboot相应的注解不同,服务器根据method的不同来判断浏览器期望做的业务行为。
增加: @PostMapping
删除: @DeleteMapping
修改: @PutMapping
查询: @GetMapping

@RestController
public class CategoryController {
    @Autowired CategoryService categoryService;
     
    @GetMapping("/categories")
    public List<Category> list() throws Exception {
        return categoryService.list();
    }
}

@RestController 和 @Controller的区别:
Controller:返回⼀个⻚⾯,单独使⽤ @Controller不加@ResponseBody 的话⼀般使⽤在要返回⼀个视图的情况,这种情况属于⽐较传统的Spring MVC 的应⽤,对应于前后端不分离的情况。@ResponseBody 注解的作⽤是将 Controller 的⽅法返回的对象通过适当的转换器转换为指定的格式之后,写⼊到HTTP 响应(Response)对象的 body 中,通常⽤来返回 JSON 数据。

@RestController:返回JSON形式数据,@RestController 只返回对象,对象数据直接以 JSON 形式写⼊ HTTP 响应(Response)中,这种情况属于 RESTful Web服务,这也是⽬前⽇常开发所接触的最常⽤的情况(前后端分离)。

即:@Controller+@ResponseBody =@RestController。

7. Application.java
创建 Application.java,其注解 @SpringBootApplication 表示这是一个SpringBoot应用,运行其主方法就会启动tomcat,默认端口是8080。

8. Redis的部署
这里只介绍前期的准备工作,后面具体的数据处理放在下面项目问题中的第二条中进行讲述。

  1. 在配置文件application.properties里增加 redis的相关配置(服务器地址,端口,密码等),并在pom.xml中添加Redis需要的依赖(dependency)。
  2. 修改 Application, 增加注解: @EnableCaching 用于启动缓存,同时,检查端口6379是否启动。 6379 就是 Redis 服务器使用的端口。如果未启动,设置退出 springboot。
@SpringBootApplication
@EnableCaching
public class Application {
    static {
        PortUtil.checkPort(6379,"Redis 服务端",true);//PortUtil是引入的工具类
    }
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

9. 事务的使用
用户的下单减库存操作是一个事务操作,需要先查询出当前的商品库存,然后插入一条数据至订单表中,最后将商品的库存执行update -1操作。

上述操作可以非常方便的使用SpringBoot中的事务操作来实现。

SpringBoot 使用事务非常简单,首先在启动主类使用注解 @EnableTransactionManagement 开启事务支持后,然后在对应的Service方法上添加注解 @Transactional 即可。

一般常用的是@Transactional(rollbackFor = Exception.class)
如果不配置rollbackFor属性,那么事务只会在遇到运行时异常的时候才会回滚,反之加上rollbackFor=Exception.class,可以让事务在遇到非运行时异常时也回滚,即如果方法抛出异常,数据库里面的数据就会回滚。

当@Transactional注解作用于类上时,该类的所有 public 方法将都具有该类型的事务属性,同时,我们也可以在方法级别使用该标注来覆盖类级别的定义。

此外,如果异常被try{}catch{}了,事务就不回滚了,因为一旦你try{}catch{}了。系统会认为你已经手动处理了异常,就不会进行回滚操作。因此,如果我们在使用事务 @Transactional 的时候,想自己对异常进行处理的话,那么我们可以进行手动回滚事物。在catch中加上 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); 方法进行手动回滚。

10. 前后端分离
html 页面的内容可以简单看成 包含数据部分和不包含数据部分。 所以先准备一个不包含数据的html, 把它传给浏览器,这个速度本身会非常快,因为没有最占时间的数据库操作部分。 然后再通过 Ajax 技术,仅仅从服务器获取“纯数据”,然后把纯数据显示在html上。

这样做的好处:
即便是后台数据库比较花时间,但是用户体验也比前面的方式好,因为用户会先看到部分页面,过一小会儿再看到数据,比在空白页面打圈圈等待体验好。
后端只提供数据,所以前后端开发耦合度降低了很多,整体开发效率可以得到较大提高。

2.项目中遇到了哪些问题?怎么解决的?

1. 外键和外键约束问题
表与表之间存在一对多关系,比如用户和订单单项表,一开始采取的方案是直接在SQL层对应表中建立外键和相应的外键约束,比如订单单项表里有外键分别对应用户表、商品表、订单表里的主键,但是后来发现外键和外键约束在项目开发过程中会带来一些问题,且对于后续的并发处理和扩展而言并不方便,因此后面采取的方案是取消外键约束,将原有的外键只作为对应表中的普通字段来使用,相应的一对多关系在pojo层对应的实体类中使用注释 @ManyToOne @JoinColumn(name="") 来实现。
常见的关系类注释:@OneToOne(一对一)、@OneToMany(一对多)、@ManyToOne(多对一)、@ManyToMany(多对多)

外键和外键约束的优点和缺陷:
优点:
外键,表的外键是另一表的主键,外键是可以有重复的,可以是空值。通过引入外键和外键约束可以保证数据的完整性和一致性,使得级联操作更加方便,此外将数据完整性判断托付给了数据库完成,也减少了程序的代码量。
缺陷
但是,阿里Java规范中强制要求:sql不得使用外键与级联,一切外键概念必须在程序内解决。 这是因为:

  1. 每次对数据进行DELETE或UPDATE操作都必须考虑外键约束,数据库都会判断当前操作是否违反数据完整性,从而导致数据库性能下降。而如果交由程序控制,这种查询过程就可以控制在我们手里,可以省略一些不必要的查询过程。
  2. 并发问题:在使用外键的情况下,每次修改数据都需要去另外一个表检查数据,需要获取额外的锁。若是在高并发大流量事务场景下,使用外键更容易造成死锁。
  3. 扩展性问题:在水平拆分和分库的情况下,外键是无法生效的。将数据间关系的维护,放入应用程序中,可以为将来的分库分表省去很多的麻烦。

2. Redis在SpringBoot中的使用,其和数据库的数据一致性

如何保证缓存与数据库的数据⼀致?

我一开始尝试采取的是一般的更新策略:即数据库更新时缓存对应项也进行更新的方式,使用注释@CachePut,后来发现这样做可能会带来一些问题,即数据库中的数据发生改变的时候,缓存中的数据应该采取删除操作,而不是更新操作。
问:为什么是删除缓存,而不是更新缓存?
(1)当写场景比较多,而读场景比较少时,采用缓存更新方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。
(2)另外,如果写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,比起更新缓存,直接删除缓存更为适合。

确定好删除缓存而不是更新缓存之后,另一个需要确定的问题就是更新数据库和删除缓存的先后问题。通过查询一些资料发现,如果先删除缓存,再更新数据库,会经常发生并发问题。

所以,更新的时候,先更新数据库,成功后,再让删除相应缓存。

问:如果先删除缓存,再更新数据库,会有什么问题?
如果先删缓存,再更新数据库,首先会出现下面的问题:
有一个请求A进行更新操作,同时,另一个请求B进行查询操作。那么会出现如下情形:
(1)请求A进行写操作,删除缓存
(2)请求B查询发现缓存不存在
(3)请求B去数据库查询得到旧值
(4)请求B将旧值写入缓存
(5)请求A将新值写入数据库
这时就会导致数据库和缓存数据不一致的情况。而且,如果不给缓存设置过期时间,该数据永远都是脏数据。而如果采用先更新数据库再删除缓存,由于数据库的读操作的速度远快于写操作的,因此不像先删除缓存再更新数据库那样容易出现并发问题。

但是还是会有其他问题的,如果缓存删除失败了怎么办?
如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据就出现了不一致。
如何解决?提供一个保障的重试机制即可,继续重试删除操作,直到删除成功。

以上即为较为经典的缓存+数据库读写的模式,就是 Cache Aside Pattern。
读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
更新的时候,先更新数据库,成功后,再让删除相应缓存。

采用先更新数据库,再删除缓存思路时,Redis在SpringBoot中的使用:

  1. 在配置文件application.properties里增加 redis的相关配置(服务器地址,端口,密码等),并在pom.xml中添加Redis需要的依赖(dependency)。
  2. 修改 Application, 增加注解: @EnableCaching 用于启动缓存,同时,检查端口6379是否启动。 6379 就是 Redis 服务器使用的端口。如果未启动,设置退出 springboot。
  3. Redis缓存,一般在 Service 这一层上进行实现,以CategoryService 为例。
    首先给CategoryService类上加上注解@CacheConfig,写明cahceNames属性,表示当前类文件中缓存里的keys,都存储在 “categories” 中。
@CacheConfig(cacheNames="categories")

对于查询get方法,使用@Cacheable注解,
第一次访问的时候, redis 是不会有数据的,所以就会通过 jpa 到数据库里去取出来,一旦取出来之后,就会放在 redis里。第二次访问的时候,redis 就有数据了,就不会从数据库里获取了。

@Cacheable(key="'categories-one-'+ #p0")
public Category get(int id) {
	Category c= categoryDAO.findOne(id);
	return c;
}

增加,删除和修改这些涉及到数据库更新的操作一律使用注解@CacheEvict,其参数beforeInvocation设定为默认值false,即在方法执行之后才会删除指定的缓存,且该注释可以保证如果标注的方法因为抛出异常而未能成功返回时不会触发缓存删除操作。

@CacheEvict(allEntries=true)
public void add(Category bean) {
	categoryDAO.save(bean);
}
@CacheEvict(allEntries=true)
public void delete(int id) {
	categoryDAO.delete(id);
}
@CacheEvict(allEntries=true)
public void update(Category bean) {
	categoryDAO.save(bean);
}

补充:@CacheEvict的用法
@CacheEvict是用来标注在需要清除缓存元素的方法或类上的。当标记在一个类上时表示其中所有的方法的执行都会触发缓存的清除操作,使用该注解标志的方法,会清空指定的缓存。如果方法因为抛出异常而未能成功返回时不会触发缓存清除操作。
常用属性:
在这里插入图片描述
3. Redis缓存穿透问题解决⽅案

为了应对Redis缓存穿透问题,提前做了相关预案。

缓存穿透
缓存穿透说简单点就是⼤量请求的 key 根本不存在于缓存中,导致请求直接到了数据库上,根本没有经过缓存这⼀层。
比如,数据库 id 是从 1 开始的,如果请求 id 全部都是负数,此时,缓存中不会有,请求每次都会越过缓存,直接查询数据库。这种恶意攻击场景的缓存穿透就会直接使数据库崩溃。

解决方案:

最基本的就是⾸先做好参数校验,⼀些不合法的参数请求直接抛出异常信息返回给客户端。⽐如查询的数据库 id 不能⼩于 0等等。

另外可以采取缓存⽆效 key的方案,即如果缓存和数据库都查不到某个 key 的数据就写⼀个对应的空值到 redis 中去并设置过期时间。这种⽅式可以解决请求的 key 变化不频繁的情况,如果⿊客恶意攻击,每次构建不同的请求key,会导致 redis 中缓存⼤量⽆效的 key 。很明显,这种⽅案并不能从根本上解决此问题。

我采取的解决方案是利用布隆过滤器。即在缓存层之上加一个布隆过滤器,把所有可能存在的请求的值都存放在布隆过滤器中,当⽤户请求过来,会先判断⽤户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会⾛下⾯的缓存和数据库流程。

尝试了两种方式来实现:

a. 利用Google开源的 Guava中自带的布隆过滤器
较为方便,使用时只需要在项目中引入 Guava的依赖。通过使用BloomFilter.create()命令即可创建一个布隆过滤器,并可以指定其容量和误判概率等参数。

// 创建布隆过滤器对象
BloomFilter<Integer> filter = BloomFilter.create(
        Funnels.integerFunnel(),
        1500,
        0.01);
// 将元素添加进布隆过滤器
filter.put(1);
filter.put(2);
// 判断指定元素是否存在
System.out.println(filter.mightContain(1));
System.out.println(filter.mightContain(2));

Guava 提供的布隆过滤器的实现很方便,其缺点就是只能单机使用,不适用于分布式场景,我也尝试使用了 Redis 中的Bloom Filter布隆过滤器。

b. 给Redis安装Bloom Filter
Redis 4.0 之后有了 Module 功能,可以让 Redis 使用外部模块扩展其功能 。布隆过滤器就是其中的 Module。我使用的是官网推荐的RedisBloom 作为 Redis 布隆过滤器的 Module。下载并编译模块之后,修改其redis.conf文件,增加redisbloom相关的配置,即可实现RedisBloom每次启动自加载:
Redis布隆过滤器与springboot的整合
仿照github上的教程,编写两个lua脚本:
a脚本实现添加数据到指定名称的布隆过滤器,b脚本实现从指定名称的布隆过滤器获取key是否存在。
从而实现springboot和布隆过滤器的整合。

压测:使用jmeter进行测试。

4. 秒杀问题

高并发导致超卖问题
建立了item_kill待秒杀商品表和item_kill_success秒杀成功订单表。

使用雪花算法生成订单编号,它是一个开源的分布式id生成算法,可以快速生成递增id就可以了。

将活动页面上的所有可以静态的元素全部静态化,并尽量减少动态元素。

利用Redisson的分布式锁来实现下单过程,从而解决高并发场景下出现的超卖问题。
(与redis的区别:Redisson可以设置锁的等待时间和过期时间)

@Autowired
private RedissonClient redissonClient;
//redisson的分布式锁
@Override
public Boolean KillItemV4(Integer killId, Integer userId) throws Exception {
    Boolean result=false;
    final String key=new StringBuffer().append(killId).append(userId).toString();
    RLock lock=redissonClient.getLock(key);
    //三个参数、等待时间、锁过期时间、时间单位
    Boolean cacheres=lock.tryLock(30,10,TimeUnit.SECONDS);
    if (cacheres){
        //判断当前用户是否抢购过该商品
        if (itemKillSuccessMapper.countByKillUserId(killId,userId)<=0){
            //获取商品详情
            ItemKill itemKill=itemKillMapper.selectByidV2(killId);
            if (itemKill!=null&&itemKill.getCanKill()==1 && itemKill.getTotal()>0){
                int res=itemKillMapper.updateKillItemV2(killId);
                if (res>0){
                    commonRecordKillSuccessInfo(itemKill,userId);
                    result=true;
                }
            }
        }else {
            System.out.println("您已经抢购过该商品");
        }
        lock.unlock();
    }
    return result;
}

测试:
清空item_kill_success表,将item_kill表中要卖的商品总数设置为一个较小的数,比如10,利用jmeter开启1000个线程开始秒杀测试。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值