jpa 公共字段顺序_技术丨利用JPA实现消息落地的一些问题

本文作者: Linkflow首席架构师 – 王鼎,11年软件研发经验,6年SaaS(基于公有云或私有云),熟悉ERP, CDP, omin渠道销售解决方案。参与SaaS产品的大型开发,成员400余人。在一家初创公司从零开始开发新产品。从事SaaS架构和技术管理工作。建立新的开发团队,专注于CDP和Martech SaaS解决方案。

目前我们处理消息的同步,一般是落地到DB后,再同过异步的方式做数据的聚合和处理。至于DB的操作为了简单直接用了Hibernate提供的一套JPA接口,(老实说真的是不喜欢JPA,一是sql log不好分析无法优化,二是必须非常了解JPA的所有关键字含义,不然就要出问题,所以我一直喜欢用mybatis这种更轻量的甚至spring-jdbc)。

那么使用JPA的过程就遇到了一些问题,见招拆招一件一件来。


问题1

遇到的第一个问题就非常的要命,我们的系统是一张单表需要支持multi-tenant多租户,简单说就是表中有个tenantId的字段来区分租户,这是比较常见的设计。那么在对DB做操作的时候,ORM框架应该提供分租户的CURD接口,而不需要开发人员都自己在where中加tenantId=***

  • 解决

这个问题其实没有解决,因为Hibernate还没有实现单表的Multi-tenant(真是相当的坑)。官网文档中说了这样三种情况

SCHEMA
Correlates to the separate schema approach. It is an error to attempt to open a session without a tenant identifier using this strategy. Additionally, a MultiTenantConnectionProvider must be specified.
DATABASE
Correlates to the separate database approach. It is an error to attempt to open a session without a tenant identifier using this strategy. Additionally, a MultiTenantConnectionProvider must be specified.
DISCRIMINATOR
Correlates to the partitioned (discriminator) approach. It is an error to attempt to open a session without a tenant identifier using this strategy. This strategy is not yet implemented in Hibernate as of 4.0 and 4.1. Its support is planned for 5.0.

可以看到最后一种还不支持呢。没办法只有手写where啦。


问题2

由于是处理消息,即使收到DELETE的message也不能真的删除,因为消息是乱序的,如果先来了DELETE再来UPDATE怎么办,实际上是先UPDATE再DELETE,但由于处理效率不一致,所以收到的消息顺序也是无法确定的。基于这点,为了保证数据的最终一致性,所以操作都作为UPDATE处理。删除操作必须是soft delete

解决

可以写一个BaseEntity,都有isactive这个字段,默认都是true

@MappedSuperclass
public class BaseEntity {

    @Column(name="isactive", columnDefinition="boolean DEFAULT true")
    private Boolean active = true;

    public Boolean getActive() {
        return active;
    }

    public void setActive(Boolean active) {
        this.active = active;
    }
}

然后继承一下

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@Table(name = "Product")
@Where(clause="isactive = 1")
public class ProductEntity extends BaseEntity {}

注意@Where就是所有操作都会拼上这个condition从而实现soft delete。


问题3

在处理类似外键关联这种数据的时候,例如Product上有个CategoryId字段,那么数据库设计是一张Category表,一张Product表,Product表上CategoryId字段作为外键关联到Category表的ID字段。那么作为一个JPA的Entity,大家知道Entity是OO的,Product Entity下应该包含一个Category Entity,关系是oneToMany的。

public class ProductEntity extends BaseEntity {

	@ManyToOne(fetch = FetchType.EAGER)
	@JoinColumn(name = "categoryId")
	private CategoryEntity category;
	
}

(这里要插一句,其实如果只是把Category当普通字段,存一个CategoryId也是没有问题的,但是在查询的时候就需要用这个Product.CategoryId再去Category里查一次。用了JPA之后,为了减少一次查询,有时候事情反而会复杂)。

至于消息,比如先收到Product的CREATE事件,这时候会拿消息体里的categoryId去category表查一下有没有这个Category Entity,如果有直接得到后塞到Product的Category属性上去,但是如果没有这个Category怎么办?

解决

如果没有的话,按照JPA的外键关联原则,我们需要建立一个虚拟的Category,也就是说插入一条占位数据到Category表中,只有ID有值。所以对ProductEntity做些改造。

public class ProductEntity extends BaseEntity {

	@ManyToOne(cascade = {CascadeType.PERSIST}, fetch = FetchType.EAGER)
	@NotFound(action= NotFoundAction.IGNORE)
	@JoinColumn(name = "categoryId")
	private CategoryEntity category;
	
}

注意加了两点,一是cascade = {CascadeType.PERSIST},意思是如果Persist了Product的话,发现categoryId不为空而category表中又没有该Category,那么级联插入这条数据(只有ID)。二是@NotFound(action= NotFoundAction.IGNORE),加这条是防止当收到一个Category.DELETE事件后软删除了Category,而读取Product的时候就会Eager地获得Category,一旦获取不到JPA会抛出EntityNotExist的异常。加了这个注解,Product里的category就为null,不会出异常。


问题4

这实际上是问题3的衍生,解决3的时候我们使用了Cascade=PERSIST,那么在发现Category不存在的时候,JPA会发起一个insert,当然数据只有ID,其他的字段等待真正的Category的CREATE事件来了再填充。但是并发的问题就出现了,如果正好就在发起insert之前,Category的CREATE事件来了(另一个Worker在处理),那里也发现表里没有这个Category,所以也随即发起一个insert操作。conflict就这样发生了,主键冲突!这时候怎么办?

解决

我采取了一种比较粗暴的方式,就是retry,首先每次收到事件后的写操作,都是查Entity是否存在,存在就Update,不存在就Insert。当两个Worker同时做写入操作,肯定一个成功一个失败,失败的只要retry一次就会发现Entity有了(另一个Worker写入的),这时候变成Update操作就不会有conflict。

因为项目中依赖Spring,所以恰好有了spring-retry这个包,直接用起来。

public class RetryTemplateBuilder {

    protected RetryTemplate buildable;
    protected RetryTemplateBuilder builder;

    public RetryTemplateBuilder() {
        buildable = createBuildable();
        builder = getBuilder();
    }

    public static RetryTemplateBuilder retryTemplate() {
        return new RetryTemplateBuilder();
    }

    public RetryTemplateBuilder withPolicies(RetryPolicy... policies) {
        CompositeRetryPolicy compositePolicy = new CompositeRetryPolicy();
        compositePolicy.setPolicies(policies);
        buildable.setRetryPolicy(compositePolicy);
        return this;
    }

    public RetryTemplateBuilder withPolicies(RetryPolicy retryPolicy, BackOffPolicy backOffPolicy) {
        buildable.setRetryPolicy(retryPolicy);
        buildable.setBackOffPolicy(backOffPolicy);
        return this;
    }

    public RetryTemplateBuilder withPolicies(BackOffPolicy backOffPolicy) {
        buildable.setBackOffPolicy(backOffPolicy);
        return this;
    }

    public RetryTemplate build() {
        return buildable;
    }

    protected RetryTemplate createBuildable() {
        return new RetryTemplate();
    }

    protected RetryTemplateBuilder getBuilder() {
        return this;
    }

}

这是一个TemplateBuilder,可以理解成retry的模板,一个retryTemplate可以包含多个policy。

public class SimpleRetryPolicyBuilder {

    protected SimpleRetryPolicy buildable;
    protected SimpleRetryPolicyBuilder builder;

    public SimpleRetryPolicyBuilder() {
        buildable = createBuildable();
        builder = getBuilder();
    }

    public static SimpleRetryPolicyBuilder simpleRetryPolicy() {
        return new SimpleRetryPolicyBuilder();
    }

    public static SimpleRetryPolicy simpleRetryPolicyWithRetryableExceptions(int maxAttempts,
                                                                             Map<Class<? extends Throwable>, Boolean> exception) {
        return new SimpleRetryPolicy(maxAttempts, exception);
    }

    public SimpleRetryPolicyBuilder withMaxAttempts(int maxAttempts) {
        buildable.setMaxAttempts(maxAttempts);
        return this;
    }

    public SimpleRetryPolicy build() {
        return buildable;
    }

    protected SimpleRetryPolicy createBuildable() {
        return new SimpleRetryPolicy();
    }

    protected SimpleRetryPolicyBuilder getBuilder() {
        return this;
    }

}

比如这种Policy,就是可以定义需要重试几次,在哪些异常发生的时候重试。

public class FixedBackOffPolicyBuilder {


    protected FixedBackOffPolicy buildable;
    protected FixedBackOffPolicyBuilder builder;

    private FixedBackOffPolicyBuilder() {
        buildable = createBuildable();
        builder = getBuilder();
    }

    public static FixedBackOffPolicyBuilder fixedBackOffPolicy() {
        return new FixedBackOffPolicyBuilder();
    }

    public FixedBackOffPolicyBuilder withDelay(long delay) {
        buildable.setBackOffPeriod(delay);
        return this;
    }

    public FixedBackOffPolicy build() {
        return buildable;
    }

    protected FixedBackOffPolicy createBuildable() {
        return new FixedBackOffPolicy();
    }

    protected FixedBackOffPolicyBuilder getBuilder() {
        return this;
    }
}

还有这种可以定义retry的间隔时间。

最后用起来就手到擒来了,

Map<Class<? extends Throwable>, Boolean> retryFor = new HashMap<>();
// 定义两种异常发生时retry
retryFor.put(DataIntegrityViolationException.class, Boolean.TRUE);
retryFor.put(ConstraintViolationException.class, Boolean.TRUE);
// 定义最大retry次数和间隔时间
RetryTemplate retryTemplate = RetryTemplateBuilder.retryTemplate()
        .withPolicies(
                SimpleRetryPolicyBuilder.simpleRetryPolicyWithRetryableExceptions(MAX_ATTEMPTS, retryFor),
                FixedBackOffPolicyBuilder.fixedBackOffPolicy().withDelay(RETRY_DELAY).build())
        .build();

retryTemplate.execute(new RetryCallback() {
    public Void doWithRetry(RetryContext context) {
        log.info("Attempt times [" + context.getRetryCount() + "]");
        // Your logic code
        return null;
    }
});

在生产环境测试,99%的情况一次retry就可以解决问题,所以我的经验值是设置了3次最大重试次数。


推荐阅读

  • CDP专题
联否Linkflow:CDP科普篇01:客户数据中台(CDP)是什么?​zhuanlan.zhihu.com
5808f905b12a3e89e8e297d67ac2be4f.png
联否Linkflow:CDP科普篇05:客户数据中台(CDP):当代数字化营销的顶梁柱​zhuanlan.zhihu.com
35249aa8a461d1c13ada87a483932957.png
联否Linkflow:CDP科普篇07:CDP如何帮助企业构建数据驱动的文化​zhuanlan.zhihu.com
bb5915398439c4e66329131309903d07.png
联否Linkflow:CDP实操篇01:在部署CDP时,如何评估您的数据需求​zhuanlan.zhihu.com
f863238e4703dcd345d23e23ff21a4f8.png
  • 私域流量专题
联否Linkflow:解读丨如何打造私域流量:流量池思维+中台思维​zhuanlan.zhihu.com
43876e52a8236e55b29cbe8e3bd9069e.png
联否Linkflow:解读丨如何打造私域流量:构建流量池首先需要一个“数据池”​zhuanlan.zhihu.com
e42e7a85de2bae661cfc3e052e3d42c3.png
联否Linkflow:解读丨如何打造私域流量:高效运营流量,让企业拿回主动权​zhuanlan.zhihu.com
7f830f92c7168da95a2a2c27550b8fbb.png

也欢迎大家到小联的BLOG了解更多有关CDP和流量池的干货分享主题沙龙活动,希望小联的见解和经验可以帮到大家。

博客 - Linkflow联否官网​1xl.xyz
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值